Compare commits
57 Commits
latest
...
923f58420d
| Author | SHA1 | Date | |
|---|---|---|---|
| 923f58420d | |||
| 139a4c20ef | |||
| d2a37f21de | |||
| 71a1ef4bb1 | |||
| 1d28388150 | |||
| 5f1d1eae4f | |||
| 49da9190b6 | |||
| 53e4f14b67 | |||
| 33a8e71ed1 | |||
| edbd16464d | |||
| dcb91f587d | |||
| 2f6c55e967 | |||
| 80a758a097 | |||
| 64df3befd5 | |||
| db3416ccee | |||
| cf91020aae | |||
| 444028c91e | |||
| bbf38036a1 | |||
| 387cfc5d6c | |||
| 6b5ed804e3 | |||
| 6499f5ef9d | |||
| dc2c3d177f | |||
| b8bdeddf4c | |||
| 06b2f0ec86 | |||
| 9784b749aa | |||
| 051e5463a9 | |||
| 5222fcf383 | |||
| 6fe1a32687 | |||
| 2834c15d63 | |||
| a73172bfc3 | |||
| f4612e05f0 | |||
| 261755794b | |||
| efebcb111f | |||
| a99c11524b | |||
| b33a849f23 | |||
| 7c21428a28 | |||
| 7a4995823e | |||
| 143abee871 | |||
| e2ffb4f011 | |||
| d577a92eec | |||
| bb3502ed26 | |||
| 34c2a6c4da | |||
| df4d122562 | |||
| 7a04893294 | |||
| 31d05039f0 | |||
| 0812759719 | |||
| 493baee88d | |||
| 27331b20a7 | |||
| 983886e8f1 | |||
| 8928a4edb4 | |||
| 214e12d2e2 | |||
| 68dd0135ab | |||
| 0f62741043 | |||
| 93a1aa249a | |||
| 2c17873ca4 | |||
| 984e2374f8 | |||
| 53d17d467f |
@@ -3,10 +3,13 @@ name: release
|
||||
# På varje push till main: bygg signerad release-APK och lägg den på en
|
||||
# rullande "latest"-release på släppsidan i Gitea.
|
||||
#
|
||||
# Runnern är Pi5 (arm64). Googles aapt2 finns bara för linux x86_64, därför
|
||||
# används en statiskt byggd arm64-aapt2 (lzhiyong/android-sdk-tools) via
|
||||
# gradle-flaggan android.aapt2FromMavenOverride. Resten av Android-bygget
|
||||
# (kotlinc, d8, apksigner) är JVM-baserat och kör fint på arm64.
|
||||
# Byggs ALLTID på brasse-linux01 (label linux-amd64) — Pi5:n OOM:ar på
|
||||
# APK-bygget, och Gitea saknar runner-prioritet så delade labels gav slumpen.
|
||||
# Är huvuddatorn av köas jobbet tills runnern är tillbaka. Första steget
|
||||
# vägrar dessutom köra på arm64 (2026-09-06), som skydd om labels ändras.
|
||||
# På arm64 används en statiskt byggd aapt2 (lzhiyong/android-sdk-tools) —
|
||||
# Googles aapt2 finns bara för linux x86_64. Resten av Android-bygget är
|
||||
# JVM-baserat och arkitekturoberoende.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -18,8 +21,19 @@ env:
|
||||
|
||||
jobs:
|
||||
build-release:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: linux-amd64
|
||||
steps:
|
||||
# Säkerhetsspärr: APK-bygget får ALDRIG köra på Pi5:n (OOM ⇒ hela servern
|
||||
# nere). Om jobbet ändå hamnar på en arm64-runner (t.ex. om Pi-runnern
|
||||
# fått labeln linux-amd64) avbryts det här, innan något tungt startat.
|
||||
- name: Vägra bygga på arm64/Pi5
|
||||
run: |
|
||||
if [ "$(uname -m)" != "x86_64" ]; then
|
||||
echo "::error::Fel runner ($(uname -m), $(hostname)). APK-bygget ska köras på brasse-linux01 (label linux-amd64). Avbryter för att inte ta ner Pi5:n."
|
||||
exit 1
|
||||
fi
|
||||
echo "Runner OK: $(uname -m) på $(hostname)"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -52,7 +66,7 @@ jobs:
|
||||
key: gradle-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
- name: Installera JDK 17 + Android SDK (arm64)
|
||||
- name: Installera JDK 17 + Android SDK
|
||||
run: |
|
||||
set -e
|
||||
echo "== apt: JDK 17 + verktyg =="
|
||||
@@ -70,11 +84,17 @@ jobs:
|
||||
(yes || true) | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
||||
echo "== installerar platform-35 + build-tools =="
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;34.0.0" > /dev/null
|
||||
echo "== hämtar arm64-aapt2 (Googles är x86_64-only) =="
|
||||
mkdir -p "$HOME/ci-tools"
|
||||
wget -nv "$AAPT2_ARM64_URL" -O /tmp/sdk-tools-arm64.zip
|
||||
unzip -qo /tmp/sdk-tools-arm64.zip -d "$HOME/ci-tools"
|
||||
chmod +x "$HOME/ci-tools/build-tools/aapt2"
|
||||
if [ "$(uname -m)" = "aarch64" ]; then
|
||||
echo "== arm64: statisk aapt2 (Googles är x86_64-only) =="
|
||||
mkdir -p "$HOME/ci-tools"
|
||||
wget -nv "$AAPT2_ARM64_URL" -O /tmp/sdk-tools-arm64.zip
|
||||
unzip -qo /tmp/sdk-tools-arm64.zip -d "$HOME/ci-tools"
|
||||
chmod +x "$HOME/ci-tools/build-tools/aapt2"
|
||||
echo "AAPT2_BIN=$HOME/ci-tools/build-tools/aapt2" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "== x86_64: Googles aapt2 ur build-tools =="
|
||||
echo "AAPT2_BIN=$ANDROID_HOME/build-tools/34.0.0/aapt2" >> "$GITHUB_ENV"
|
||||
fi
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "== SDK-setup klar =="
|
||||
|
||||
@@ -82,22 +102,44 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
export JAVA_HOME=$(dirname $(dirname $(readlink -f $(which java))))
|
||||
# Snäll mot Pi5:n — begränsa gradles parallellism
|
||||
nice -n 10 ./gradlew --no-daemon --console=plain \
|
||||
-Dorg.gradle.workers.max=2 \
|
||||
-Pandroid.aapt2FromMavenOverride="$HOME/ci-tools/build-tools/aapt2" \
|
||||
:app:assembleRelease
|
||||
if [ "$(uname -m)" = "aarch64" ]; then
|
||||
# Snäll mot Pi5:n — begränsad parallellism + arm64-aapt2
|
||||
nice -n 10 ./gradlew --no-daemon --console=plain \
|
||||
-Dorg.gradle.workers.max=2 \
|
||||
-Pandroid.aapt2FromMavenOverride="$AAPT2_BIN" \
|
||||
:app:assembleRelease
|
||||
else
|
||||
./gradlew --no-daemon --console=plain :app:assembleRelease
|
||||
fi
|
||||
mkdir -p build
|
||||
cp app/build/outputs/apk/release/app-release.apk build/FitnessDroid.apk
|
||||
(cd build && sha256sum FitnessDroid.apk > checksums.txt && ls -la)
|
||||
# version.json för appens inbyggda uppdateringskoll
|
||||
VN=$("$AAPT2_BIN" dump badging build/FitnessDroid.apk \
|
||||
| sed -n "s/.*versionName='\([^']*\)'.*/\1/p" | head -1)
|
||||
SHA=$(cut -d' ' -f1 build/checksums.txt)
|
||||
jq -n \
|
||||
--argjson code "${{ github.run_number }}" \
|
||||
--arg name "$VN" \
|
||||
--arg sha "$SHA" \
|
||||
--arg url "https://gitea.brasse-pc.eu/${{ github.repository }}/releases/download/latest/FitnessDroid.apk" \
|
||||
'{versionCode:$code, versionName:$name, sha256:$sha, apkUrl:$url}' \
|
||||
> build/version.json
|
||||
cat build/version.json
|
||||
|
||||
- name: Skapa/uppdatera latest-release + ladda upp APK
|
||||
env:
|
||||
API: http://gitea-d:3000/api/v1
|
||||
REPO: ${{ github.repository }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
# gitea-d finns bara på Pi5:ns dockernät; annars publika URL:en
|
||||
if curl -s -m 3 "http://gitea-d:3000/api/v1/version" -o /dev/null 2>/dev/null; then
|
||||
API="http://gitea-d:3000/api/v1"
|
||||
else
|
||||
API="https://gitea.brasse-pc.eu/api/v1"
|
||||
fi
|
||||
echo "API: $API"
|
||||
TAG="latest"
|
||||
BASE="https://gitea.brasse-pc.eu/$REPO/releases/download/$TAG"
|
||||
BODY=$(cat <<EOF
|
||||
@@ -124,7 +166,7 @@ jobs:
|
||||
-d "$(jq -n --arg body "$BODY" '{body:$body}')" -o /dev/null
|
||||
fi
|
||||
echo "release id: $rid"
|
||||
for f in FitnessDroid.apk checksums.txt; do
|
||||
for f in FitnessDroid.apk checksums.txt version.json; do
|
||||
aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" \
|
||||
-H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id")
|
||||
if [ -n "$aid" ] && [ "$aid" != "null" ]; then
|
||||
|
||||
675
LICENSE
Normal file
675
LICENSE
Normal file
@@ -0,0 +1,675 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
{one line to give the program's name and a brief idea of what it does.}
|
||||
Copyright (C) {year} {name of author}
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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 General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
{project} Copyright (C) {year} {fullname}
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
|
||||
46
README.md
46
README.md
@@ -46,6 +46,16 @@ direkt på en ansluten telefon med USB-felsökning på.
|
||||
Logga in med ditt vanliga gym-konto (LDAP). API-url:en går att ändra under
|
||||
"Avancerat" på inloggningsskärmen (standard: `https://gymapi.brasse-pc.eu/graphql`).
|
||||
|
||||
## Rekordstatus: träning / tävling / räknas ej
|
||||
|
||||
Under **Statistik → Personbästa** finns växeln *Alla giltiga · Tävling · Träning*. Tryck på
|
||||
ett rekord så öppnas ett ark med vikt, est. 1RM, datum, pass och anteckning, där lyftet kan
|
||||
märkas som **Träning**, **Tävling** eller **Räknas ej** (fusk, felregistrerat, ej godkänt)
|
||||
med en kommentar. Under arket visas topplistan för samma övning × reps ("näst i tur"),
|
||||
så ett bortplockat lyft kan väljas och återställas. Samma ark öppnas när man trycker på
|
||||
ett set i passhistoriken. "Räknas ej" tas ur rekord, 1RM och PB-höjdpunkter på servern
|
||||
(kod: `ui/common/LiftStatusSheet.kt`, API: `setLiftStatus`/`setSessionSetStatus`).
|
||||
|
||||
## Signeringsnyckeln
|
||||
|
||||
Nyckeln är **inte** incheckad (repot är publikt). Lokalt ligger den i
|
||||
@@ -65,10 +75,38 @@ keytool -genkeypair -keystore signing/fitnessdroid.jks -alias fitnessdroid \
|
||||
|
||||
## CI
|
||||
|
||||
`.gitea/workflows/release.yaml` bygger en signerad APK på Pi5-runnern (arm64)
|
||||
vid varje push till `main` och lägger den på den rullande `latest`-releasen.
|
||||
Eftersom Googles aapt2 bara finns för x86_64 använder bygget en statiskt byggd
|
||||
arm64-aapt2 via `android.aapt2FromMavenOverride`.
|
||||
`.gitea/workflows/release.yaml` bygger en signerad APK vid varje push till
|
||||
`main` och lägger den på den rullande `latest`-releasen. Bygget körs på
|
||||
runnern `brasse-linux01-runner` (x86_64, label `linux-amd64`) — Pi5:n orkar
|
||||
inte APK-bygget. Workflowen är ändå arkitekturvillkorad: på arm64 används en
|
||||
statiskt byggd aapt2 via `android.aapt2FromMavenOverride`.
|
||||
|
||||
## Aktiviteter, kcal-mätare & mål
|
||||
|
||||
Utöver gympass loggar appen **aktiviteter** (promenad, löpning, vandring,
|
||||
fäktning/HEMA m.fl. — ~64 typer ur MET-kompendiet) med GPS-livespårning
|
||||
(osmdroid/OSM-karta, fartgrind mot GPS-hopp, accelerometer-rörelsevakt),
|
||||
RPE-slider för tidsbaserade aktiviteter och offline-kö. Hemskärmen visar
|
||||
**dagens kalorier** (aktivt loggat / telefonens steg via Health Connect /
|
||||
passiv BMR) och **mål** med progress (kcal, pass, steg, aktiviteter, km —
|
||||
per dag/vecka/månad). Statistiken har en konditionssektion ❤️.
|
||||
Plan & detaljer: [`doc/activities-plan.md`](doc/activities-plan.md).
|
||||
|
||||
## Bluetooth-våg
|
||||
|
||||
Appen kan hämta vägningar (vikt + muskel/fett/vatten-%) direkt från en
|
||||
BLE-personvåg med [openScale](https://github.com/oliexdev/openScale)s
|
||||
drivrutiner (vendrade under `app/src/main/java/com/health/openscale/`).
|
||||
Koppla vågen under *Inställningar → Bluetooth-våg* och väg dig från profilen.
|
||||
Först ut: Biltema 84-1002 (PT-727), som är en Exingtech Y1 ("VScale").
|
||||
Detaljer: [`doc/openscale-integration.md`](doc/openscale-integration.md).
|
||||
|
||||
## Licens
|
||||
|
||||
GPL-3.0 (se [`LICENSE`](LICENSE)). Relicensierad från och med BT-vågstödet —
|
||||
openScales drivrutiner är GPL-3.0, vilket kräver att hela appen är det.
|
||||
BLE-biblioteket [Blessed-Kotlin](https://github.com/weliem/blessed-kotlin)
|
||||
är MIT.
|
||||
|
||||
Mer om arkitektur och planer: [`doc/plan.md`](doc/plan.md) och
|
||||
[wikin](https://gitea.brasse-pc.eu/brasse/FitnessDroid/wiki).
|
||||
|
||||
@@ -5,6 +5,7 @@ plugins {
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
// Signeringsnyckeln är inte incheckad. Lokalt ligger den i signing/ (gitignorerad),
|
||||
@@ -28,7 +29,7 @@ android {
|
||||
minSdk = 31
|
||||
targetSdk = 35
|
||||
versionCode = ciRunNumber ?: 1
|
||||
versionName = "0.1.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
|
||||
versionName = "0.12.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -62,6 +63,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +81,12 @@ dependencies {
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.blessed.kotlin)
|
||||
implementation(libs.osmdroid)
|
||||
implementation(libs.health.connect)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<queries>
|
||||
<package android:name="com.google.android.apps.healthdata" />
|
||||
</queries>
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<!-- BT-våg (openScale-drivrutiner). minSdk 31 → bara de nya BLE-permissionerna behövs. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
|
||||
<!-- GPS-spårning av aktiviteter (foreground service med notis) -->
|
||||
<!-- tools:remove="android:maxSdkVersion": blessed-kotlin deklarerar plats-
|
||||
behörigheterna med maxSdkVersion=30 (BLE-skanning behövde plats bara
|
||||
t.o.m. Android 11) och manifest-mergern ärver annars in begränsningen —
|
||||
då försvinner behörigheten helt på Android 12+ och GPS-spårningen
|
||||
kan aldrig fråga. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
tools:remove="android:maxSdkVersion" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||
tools:remove="android:maxSdkVersion" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||
|
||||
<!-- Stegsynk via Health Connect (etapp 5) -->
|
||||
<uses-permission android:name="android.permission.health.READ_STEPS" />
|
||||
<!-- Telefonens stegsensor under spårade aktiviteter -->
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
|
||||
<application
|
||||
android:name=".FitnessDroidApplication"
|
||||
@@ -10,6 +43,21 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.FitnessDroid">
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".data.TrackingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -18,6 +66,14 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- Health Connect: visa varför appen vill läsa steg -->
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Domain events emitted by a [ScaleCommunicator].
|
||||
*
|
||||
* Notes for broadcast-only devices (advertisement parsing, no GATT):
|
||||
* - The adapter emits [Listening] when scanning starts for the target MAC.
|
||||
* - When a final (stabilized) measurement was published and scanning stops, it emits [BroadcastComplete].
|
||||
* - For such devices, [Connected] is typically never emitted.
|
||||
*/
|
||||
sealed class BluetoothEvent {
|
||||
enum class UserInteractionType {
|
||||
CHOOSE_USER,
|
||||
ENTER_CONSENT
|
||||
}
|
||||
|
||||
/** Emitted when scanning starts for a broadcast-only device. */
|
||||
data class Listening(val deviceAddress: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted after a broadcast-only flow has completed (e.g., stabilized measurement parsed). */
|
||||
data class BroadcastComplete(val deviceAddress: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted when a GATT connection has been established. */
|
||||
data class Connected(val deviceName: String, val deviceAddress: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted when an existing GATT connection has been disconnected. */
|
||||
data class Disconnected(val deviceAddress: String, val reason: String? = null) : BluetoothEvent()
|
||||
|
||||
/** Emitted when a connection attempt to a device failed. */
|
||||
data class ConnectionFailed(val deviceAddress: String, val error: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted when a parsed measurement is available. */
|
||||
data class MeasurementReceived(
|
||||
val measurement: ScaleMeasurement,
|
||||
val deviceAddress: String
|
||||
) : BluetoothEvent()
|
||||
|
||||
/** Emitted for generic device-related errors. */
|
||||
data class Error(val deviceAddress: String, val error: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted for miscellaneous device/user-visible messages. */
|
||||
data class DeviceMessage(val message: String, val deviceAddress: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted when user interaction is required (e.g., pick user, enter consent code). */
|
||||
data class UserInteractionRequired(
|
||||
val deviceIdentifier: String,
|
||||
val data: Any?,
|
||||
val interactionType: UserInteractionType,
|
||||
) : BluetoothEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic interface for communicating with Bluetooth scales.
|
||||
* Implementations may be GATT-based or broadcast-only (advertisement parsing).
|
||||
*/
|
||||
interface ScaleCommunicator {
|
||||
|
||||
/** Indicates whether a connection attempt (or scan for broadcast devices) is in progress. */
|
||||
val isConnecting: StateFlow<Boolean>
|
||||
|
||||
/** Indicates whether a GATT connection is active. For broadcast-only devices this is always `false`. */
|
||||
val isConnected: StateFlow<Boolean>
|
||||
|
||||
/** Start communicating with a device identified by [address]. Binds the session to [scaleUser]. */
|
||||
fun connect(address: String, scaleUser: ScaleUser?)
|
||||
|
||||
/** Terminate the current session (disconnect or stop scanning). */
|
||||
fun disconnect()
|
||||
|
||||
/** Request a measurement (if supported; some devices only push asynchronously). */
|
||||
fun requestMeasurement()
|
||||
|
||||
/**
|
||||
* Renders the device-specific configuration UI.
|
||||
* This allows the device handler to inject custom settings fields
|
||||
* (like bind keys or user slots) into the settings screen.
|
||||
*/
|
||||
@Composable
|
||||
fun DeviceConfigurationUi()
|
||||
|
||||
/** Stream of [BluetoothEvent] emitted by the communicator. */
|
||||
fun getEventsFlow(): SharedFlow<BluetoothEvent>
|
||||
|
||||
/** Deliver feedback for a previously requested user interaction. */
|
||||
suspend fun processUserInteractionFeedback(
|
||||
interactionType: BluetoothEvent.UserInteractionType,
|
||||
appUserId: Int,
|
||||
feedbackData: Any
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* FitnessDroid-anpassning: Hilt-DI borttagen (manuell DI), och handler-listan
|
||||
* innehåller än så länge bara de drivrutiner som portats. Fler portas från
|
||||
* openScale allteftersom — behåll upstream-ordningen när listan växer
|
||||
* (första matchande handler vinner).
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth
|
||||
|
||||
import android.content.Context
|
||||
import com.health.openscale.core.bluetooth.scales.DebugGattHandler
|
||||
import com.health.openscale.core.bluetooth.scales.DeviceSupport
|
||||
import com.health.openscale.core.bluetooth.scales.ExingtechY1Handler
|
||||
import com.health.openscale.core.bluetooth.scales.GattScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.scales.BroadcastScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.scales.LinkMode
|
||||
import com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler
|
||||
import com.health.openscale.core.bluetooth.scales.SppScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.scales.TuningProfile
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Factory class responsible for creating appropriate [ScaleCommunicator] instances
|
||||
* for different Bluetooth scale devices.
|
||||
*/
|
||||
class ScaleFactory(
|
||||
private val applicationContext: Context,
|
||||
private val settingsFacade: SettingsFacade,
|
||||
private val measurementFacade: MeasurementFacade,
|
||||
private val userFacade: UserFacade,
|
||||
) {
|
||||
private val TAG = "ScaleHandlerFactory"
|
||||
|
||||
private val modernKotlinHandlers: List<ScaleDeviceHandler> = createHandlers()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Builds the list of modern Kotlin-based device handlers.
|
||||
*
|
||||
* Order matters: [createCommunicator] returns the FIRST handler whose
|
||||
* [ScaleDeviceHandler.supportFor] is non-null.
|
||||
*/
|
||||
internal fun createHandlers(): List<ScaleDeviceHandler> = listOf(
|
||||
// Portade från openScale hittills:
|
||||
ExingtechY1Handler(), // Biltema 84-1002 (PT-727) annonserar som "VScale"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current value of a settings [Flow] from a non-suspending context.
|
||||
*/
|
||||
private fun <T> readSettingBlocking(flow: Flow<T>): T? = runCatching {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
withTimeout(250.milliseconds) { flow.firstOrNull() }
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun createModernCommunicator(
|
||||
handler: ScaleDeviceHandler,
|
||||
support: DeviceSupport
|
||||
): ScaleCommunicator? {
|
||||
val effectiveTuning: TuningProfile = run {
|
||||
val saved: String? = readSettingBlocking(settingsFacade.savedBluetoothTuneProfile)
|
||||
|
||||
saved?.let { runCatching { TuningProfile.valueOf(it) }.getOrNull() }
|
||||
?: support.tuningProfile
|
||||
}
|
||||
|
||||
return when (support.linkMode) {
|
||||
LinkMode.CONNECT_GATT ->
|
||||
GattScaleAdapter(
|
||||
applicationContext,
|
||||
settingsFacade,
|
||||
measurementFacade,
|
||||
userFacade,
|
||||
handler,
|
||||
effectiveTuning
|
||||
)
|
||||
|
||||
LinkMode.BROADCAST_ONLY ->
|
||||
BroadcastScaleAdapter(
|
||||
applicationContext,
|
||||
settingsFacade,
|
||||
measurementFacade,
|
||||
userFacade,
|
||||
handler,
|
||||
effectiveTuning
|
||||
)
|
||||
|
||||
LinkMode.CLASSIC_SPP ->
|
||||
SppScaleAdapter(
|
||||
applicationContext,
|
||||
settingsFacade,
|
||||
measurementFacade,
|
||||
userFacade,
|
||||
handler,
|
||||
effectiveTuning
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the most suitable [ScaleCommunicator] for the given scanned device.
|
||||
*/
|
||||
fun createCommunicator(deviceInfo: ScannedDeviceInfo): ScaleCommunicator? {
|
||||
val primaryIdentifier = deviceInfo.name
|
||||
LogManager.d(TAG, "createCommunicator: Searching for communicator for '${primaryIdentifier}' (${deviceInfo.address}). Handler hint: '${deviceInfo.determinedHandlerDisplayName}'")
|
||||
|
||||
if (readSettingBlocking(settingsFacade.developerModeEnabled) == true) {
|
||||
LogManager.i(TAG, "Developer mode active → routing '$primaryIdentifier' to DebugGattHandler. No measurement will be stored.")
|
||||
return createModernCommunicator(DebugGattHandler(), DebugGattHandler.SUPPORT)
|
||||
}
|
||||
|
||||
for (handler in modernKotlinHandlers) {
|
||||
val support = handler.supportFor(deviceInfo)
|
||||
if (support != null) {
|
||||
LogManager.i(TAG, "Modern handler '${support.displayName}' supports '$primaryIdentifier'.")
|
||||
val modern = createModernCommunicator(handler, support)
|
||||
if (modern != null) {
|
||||
LogManager.i(TAG, "Modern communicator '${modern.javaClass.simpleName}' created for '$primaryIdentifier' with linkMode=${support.linkMode}.")
|
||||
return modern
|
||||
}
|
||||
LogManager.w(TAG, "Modern handler '${support.displayName}' supports '$primaryIdentifier', but no communicator is available.")
|
||||
}
|
||||
}
|
||||
|
||||
LogManager.w(TAG, "No suitable communicator found for device (name: '${deviceInfo.name}', address: '${deviceInfo.address}', handler hint: '${deviceInfo.determinedHandlerDisplayName}').")
|
||||
return null
|
||||
}
|
||||
|
||||
fun getDeviceSupportFor(name: String, address: String): DeviceSupport? {
|
||||
val info = ScannedDeviceInfo(name, address, 0, emptyList(), null)
|
||||
return modernKotlinHandlers.firstNotNullOfOrNull { it.supportFor(info) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any known handler can theoretically support the given device.
|
||||
*/
|
||||
fun getSupportingHandlerInfo(deviceInfo: ScannedDeviceInfo): Pair<Boolean, String?> {
|
||||
for (handler in modernKotlinHandlers) {
|
||||
val support = handler.supportFor(deviceInfo)
|
||||
if (support != null) return true to support.displayName
|
||||
}
|
||||
|
||||
return false to null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.data
|
||||
|
||||
import com.health.openscale.core.data.WeightUnit
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Represents a single measurement record from a scale, potentially combined from multiple BLE packets.
|
||||
*/
|
||||
data class ScaleMeasurement(
|
||||
var userId: Int = 0xFF, // openScale's internal app user ID
|
||||
var dateTime: Date? = null,
|
||||
var weight: Float = 0.0f, // must be in kg
|
||||
var fat: Float = 0.0f, // must be in percentage
|
||||
var water: Float = 0.0f, // must be in percentage
|
||||
var muscle: Float = 0.0f, // must be in percentage
|
||||
var visceralFat: Float = 0.0f, // must be in percentage
|
||||
var bone: Float = 0.0f, // must be in kg
|
||||
var lbm : Float = 0.0f, // must be in kg
|
||||
var bmr: Float = 0.0f, // Basal Metabolic Rate in kcal
|
||||
var heartRate: Int = 0, // must be bpm
|
||||
var impedance: Double = 0.0, // Ohms — high-frequency band when the scale is dual-band
|
||||
var impedanceLow: Double = 0.0, // Ohms — low-frequency band; 0 when not reported
|
||||
var ecw: Float = 0.0f, // Extracellular water, % of body weight
|
||||
var icw: Float = 0.0f, // Intracellular water, % of body weight
|
||||
var protein: Float = 0.0f, // Protein, % of body weight
|
||||
var bcm: Float = 0.0f, // Body cell mass, kg
|
||||
) {
|
||||
|
||||
// --- Utility methods ---
|
||||
|
||||
fun hasWeight(): Boolean = this.weight > 0f
|
||||
|
||||
fun mergeWith(other: ScaleMeasurement) = apply {
|
||||
if (other.weight > 0f && this.weight <= 0f) this.weight = other.weight
|
||||
if (other.fat > 0f && this.fat <= 0f) this.fat = other.fat
|
||||
if (other.water > 0f && this.water <= 0f) this.water = other.water
|
||||
if (other.muscle > 0f && this.muscle <= 0f) this.muscle = other.muscle
|
||||
if (other.visceralFat > 0f && this.visceralFat <= 0f) this.visceralFat = other.visceralFat
|
||||
if (other.bone > 0f && this.bone <= 0f) this.bone = other.bone
|
||||
if (other.lbm > 0f && this.lbm <= 0f) this.lbm = other.lbm
|
||||
if (other.bmr > 0f && this.bmr <= 0f) this.bmr = other.bmr
|
||||
if (other.heartRate > 0f && this.heartRate <= 0f) this.heartRate = other.heartRate
|
||||
if (other.impedance > 0.0 && this.impedance <= 0.0) this.impedance = other.impedance
|
||||
if (other.impedanceLow > 0.0 && this.impedanceLow <= 0.0) this.impedanceLow = other.impedanceLow
|
||||
if (other.ecw > 0f && this.ecw <= 0f) this.ecw = other.ecw
|
||||
if (other.icw > 0f && this.icw <= 0f) this.icw = other.icw
|
||||
if (other.protein > 0f && this.protein <= 0f) this.protein = other.protein
|
||||
if (other.bcm > 0f && this.bcm <= 0f) this.bcm = other.bcm
|
||||
|
||||
if (other.userId != 0xFF &&
|
||||
(this.userId == 0xFF || this.userId == -1)) { // -1 was common init value
|
||||
this.userId = other.userId
|
||||
}
|
||||
|
||||
if (this.dateTime == null && other.dateTime != null) this.dateTime = other.dateTime
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.data
|
||||
|
||||
import com.health.openscale.core.data.ActivityLevel
|
||||
import com.health.openscale.core.data.GenderType
|
||||
import com.health.openscale.core.data.WeightUnit
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
|
||||
data class ScaleUser (
|
||||
var id: Int = 0,
|
||||
var userName: String = "",
|
||||
var birthday: Date = Date(),
|
||||
var bodyHeight: Float = -1f, // always in cm
|
||||
var gender: GenderType = GenderType.MALE,
|
||||
var initialWeight: Float = 0f, // always in kg
|
||||
var goalWeight: Float = 0f, // always in kg
|
||||
var scaleUnit: WeightUnit = WeightUnit.KG,
|
||||
var activityLevel: ActivityLevel = ActivityLevel.SEDENTARY
|
||||
){
|
||||
fun getAge(todayDate: Date?): Int {
|
||||
val calToday = Calendar.getInstance()
|
||||
if (todayDate != null) {
|
||||
calToday.setTime(todayDate)
|
||||
}
|
||||
|
||||
val calBirthday = Calendar.getInstance()
|
||||
calBirthday.setTime(birthday)
|
||||
|
||||
return yearsBetween(calBirthday, calToday)
|
||||
}
|
||||
|
||||
val age: Int
|
||||
get() = getAge(null)
|
||||
|
||||
private fun yearsBetween(start: Calendar, end: Calendar): Int {
|
||||
var years = end.get(Calendar.YEAR) - start.get(Calendar.YEAR)
|
||||
|
||||
val startMonth = start.get(Calendar.MONTH)
|
||||
val endMonth = end.get(Calendar.MONTH)
|
||||
if (endMonth < startMonth
|
||||
|| (endMonth == startMonth
|
||||
&& end.get(Calendar.DAY_OF_MONTH) < start.get(Calendar.DAY_OF_MONTH))
|
||||
) {
|
||||
years -= 1
|
||||
}
|
||||
return years
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.Composable
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.welie.blessed.BluetoothCentralManager
|
||||
import com.welie.blessed.BluetoothCentralManagerCallback
|
||||
import com.welie.blessed.BluetoothPeripheral
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Broadcast adapter (no GATT)
|
||||
// - uses Blessed to scan for a specific address and forwards advertisements to handler.onAdvertisement()
|
||||
// - applies tuning: max scan window, retry/backoff, RSSI filter, packet de-dup, stabilization window
|
||||
// - attaches handler with a no-op transport immediately on start
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
class BroadcastScaleAdapter(
|
||||
context: android.content.Context,
|
||||
settingsFacade: SettingsFacade,
|
||||
measurementFacade: MeasurementFacade,
|
||||
userFacade: UserFacade,
|
||||
handler: ScaleDeviceHandler,
|
||||
profile: TuningProfile = TuningProfile.Balanced
|
||||
) : ModernScaleAdapter(context, settingsFacade, measurementFacade, userFacade, handler) {
|
||||
|
||||
private val tuning: BleBroadcastTuning = profile.forBroadcast()
|
||||
|
||||
private lateinit var central: BluetoothCentralManager
|
||||
private var broadcastAttached = false
|
||||
private var scanTimeoutJob: Job? = null
|
||||
private var attempt = 0
|
||||
private var isScanning = false
|
||||
|
||||
// de-duplication: contentHash -> lastSeenMs
|
||||
private val dedupSeen = LinkedHashMap<Int, Long>(64, 0.75f, true)
|
||||
private var lastForwardAtMs = 0L
|
||||
|
||||
private fun now() = SystemClock.elapsedRealtime()
|
||||
|
||||
private val centralCallback = object : BluetoothCentralManagerCallback() {
|
||||
override fun onDiscovered(peripheral: BluetoothPeripheral, scanResult: ScanResult) {
|
||||
// Filter: only target MAC
|
||||
if (peripheral.address != targetAddress) return
|
||||
|
||||
// Filter: optional RSSI threshold
|
||||
val rssi = scanResult.rssi
|
||||
tuning.minRssiDbm?.let { if (rssi < it) return }
|
||||
|
||||
// De-dup: collapse identical packets within packetDedupWindowMs
|
||||
val bytes = scanResult.scanRecord?.bytes
|
||||
|
||||
LogManager.d(TAG,"Discovered advertisement from ${peripheral.address} RSSI=$rssi ${bytes?.toHexPreview(24)}")
|
||||
|
||||
val hash = contentHash(bytes, rssi)
|
||||
val t = now()
|
||||
val last = dedupSeen[hash]
|
||||
if (last != null && (t - last) <= tuning.packetDedupWindowMs) {
|
||||
LogManager.w(TAG, "Deduplicated packet hash=$hash from ${peripheral.address}")
|
||||
return
|
||||
}
|
||||
dedupSeen[hash] = t
|
||||
trimDedup(t)
|
||||
|
||||
// Attach handler as soon as we see the target device (if not already)
|
||||
ensureAttached(peripheral.address)
|
||||
|
||||
// Optional stabilization: avoid forwarding bursts too quickly
|
||||
if (t - lastForwardAtMs < tuning.stabilizeWindowMs) {
|
||||
LogManager.w(TAG, "Skipping forwarding to handler (stabilize window) from ${peripheral.address}")
|
||||
return
|
||||
}
|
||||
|
||||
val user = selectedUserSnapshot ?: return
|
||||
LogManager.d(TAG,"Forwarding advertisement to handler: ${peripheral.address} RSSI=$rssi ${bytes?.toHexPreview(24)}")
|
||||
val action = handler.onAdvertisement(scanResult, user)
|
||||
LogManager.d(TAG, "Handler returned $action for ${peripheral.address}")
|
||||
|
||||
when (action) {
|
||||
BroadcastAction.IGNORED -> LogManager.d(TAG, "Advertisement IGNORED for ${peripheral.address}")
|
||||
BroadcastAction.CONSUMED_KEEP_SCANNING -> {
|
||||
LogManager.d(TAG, "Advertisement CONSUMED for ${peripheral.address}")
|
||||
lastForwardAtMs = t
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_waiting_for_measurement),
|
||||
peripheral.address
|
||||
)
|
||||
)
|
||||
}
|
||||
BroadcastAction.CONSUMED_STOP -> {
|
||||
lastForwardAtMs = t
|
||||
LogManager.d(TAG, "Measurement stabilized → BroadcastComplete for ${peripheral.address}")
|
||||
_events.tryEmit(BluetoothEvent.BroadcastComplete(peripheral.address))
|
||||
stopScanInternal()
|
||||
cleanup()
|
||||
broadcastAttached = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun DeviceConfigurationUi() {
|
||||
// Delegate to the actual protocol handler
|
||||
handler.DeviceConfigurationUi()
|
||||
}
|
||||
|
||||
private fun ensureCentral() {
|
||||
if (!::central.isInitialized) {
|
||||
central = BluetoothCentralManager(context, centralCallback, mainHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureAttached(address: String) {
|
||||
if (broadcastAttached) return
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
handler.attach(noopTransport, appCallbacks, driverSettings, dataProvider, scope)
|
||||
broadcastAttached = true
|
||||
_events.tryEmit(BluetoothEvent.Listening(address))
|
||||
}
|
||||
|
||||
private fun startScanAttempt(address: String) {
|
||||
// reset per-attempt state
|
||||
isScanning = true
|
||||
lastForwardAtMs = 0
|
||||
dedupSeen.clear()
|
||||
|
||||
// Blessed does not expose ScanSettings directly; we emulate tuning at our level
|
||||
try {
|
||||
central.scanForPeripheralsWithAddresses(setOf(address))
|
||||
LogManager.d(TAG, "Broadcast scan started (attempt ${attempt + 1}/${tuning.common.maxRetries}) for $address")
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Failed to start broadcast scan: ${e.message}", e)
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(address, e.message ?: context.getString(R.string.bt_error_generic)))
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
// Arm scan timeout for this attempt
|
||||
scanTimeoutJob?.cancel()
|
||||
scanTimeoutJob = scope.launch {
|
||||
delay(tuning.maxScanMs.milliseconds)
|
||||
if (!isScanning) return@launch
|
||||
LogManager.w(TAG, "Broadcast scan timed out for $address")
|
||||
stopScanInternal()
|
||||
|
||||
attempt++
|
||||
if (attempt <= tuning.common.maxRetries) {
|
||||
delay(tuning.common.retryBackoffMs.milliseconds)
|
||||
startScanAttempt(address)
|
||||
} else {
|
||||
cleanup()
|
||||
// keep attached? we detach to be consistent with failure
|
||||
runCatching { handler.handleDisconnected() }
|
||||
runCatching { handler.detach() }
|
||||
broadcastAttached = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopScanInternal() {
|
||||
scanTimeoutJob?.cancel(); scanTimeoutJob = null
|
||||
runCatching { if (::central.isInitialized) central.stopScan() }
|
||||
isScanning = false
|
||||
lastDisconnectAtMs = now()
|
||||
}
|
||||
|
||||
private fun trimDedup(t: Long) {
|
||||
// simple time-based eviction
|
||||
val it = dedupSeen.entries.iterator()
|
||||
while (it.hasNext()) {
|
||||
val e = it.next()
|
||||
if (t - e.value > tuning.packetDedupWindowMs) it.remove()
|
||||
else break // map is access-ordered, earliest first
|
||||
}
|
||||
}
|
||||
|
||||
private fun contentHash(bytes: ByteArray?, rssi: Int): Int {
|
||||
if (bytes == null || bytes.isEmpty()) return rssi // fallback
|
||||
// A lightweight rolling hash (faster than Arrays.hashCode in hot path)
|
||||
var h = 1125899907
|
||||
for (b in bytes) h = (h * 131) xor (b.toInt() and 0xFF)
|
||||
// mix in coarse rssi bucket to avoid treating level-only jitter as new data
|
||||
val bucket = (rssi / 3) // bucketize
|
||||
return (h shl 1) xor bucket
|
||||
}
|
||||
|
||||
private val noopTransport = object : ScaleDeviceHandler.Transport {
|
||||
override fun setNotifyOn(service: UUID, characteristic: UUID) {}
|
||||
override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) {}
|
||||
override fun read(service: UUID, characteristic: UUID) {}
|
||||
override fun disconnect() { doDisconnect() }
|
||||
override fun getPeripheral(): BluetoothPeripheral? = null
|
||||
override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = false
|
||||
}
|
||||
|
||||
override fun doConnect(address: String, selectedUser: ScaleUser) {
|
||||
ensureCentral()
|
||||
|
||||
// Attach early so UI can show “Listening…”
|
||||
ensureAttached(address)
|
||||
|
||||
_isConnecting.value = false
|
||||
_isConnected.value = false
|
||||
|
||||
attempt = 0
|
||||
startScanAttempt(address)
|
||||
}
|
||||
|
||||
override fun doDisconnect() {
|
||||
stopScanInternal()
|
||||
runCatching { handler.handleDisconnected() }
|
||||
runCatching { handler.detach() }
|
||||
broadcastAttached = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* ## DebugGattHandler
|
||||
*
|
||||
* A pure **inspection** handler that:
|
||||
*
|
||||
* - Only activates while developer mode is enabled in the Bluetooth settings; it is not part of
|
||||
* the handler registry and never claims a device on its own.
|
||||
* - On connect, **dumps the full GATT table** (all services and characteristics) by
|
||||
* asking the adapter/transport for the current `BluetoothPeripheral`.
|
||||
* - Optionally performs a few safe reads/subscriptions on common services to trigger
|
||||
* some traffic (helpful to verify notifications).
|
||||
* - Logs **every incoming notification** with a compact hex & ASCII preview.
|
||||
*
|
||||
* This handler **never publishes measurements** and is intended solely for diagnostics.
|
||||
*
|
||||
* ### Adapter requirement
|
||||
* The adapter/transport must expose `debugGetPeripheral(): BluetoothPeripheral?`.
|
||||
* In `GattScaleAdapter`, implement it by returning the current `BluetoothPeripheral`.
|
||||
*
|
||||
* ### Why this lives here
|
||||
* We keep all formatting, pretty-printing, and logging **inside this handler**, while
|
||||
* the adapter stays minimal and unopinionated.
|
||||
*/
|
||||
class DebugGattHandler : ScaleDeviceHandler() {
|
||||
companion object {
|
||||
/**
|
||||
* The support descriptor this handler runs with. Exposed because the handler is no longer
|
||||
* part of the registry: [com.health.openscale.core.bluetooth.ScaleFactory] instantiates it
|
||||
* directly when developer mode is on and needs the descriptor to pick the adapter.
|
||||
*/
|
||||
val SUPPORT = DeviceSupport(
|
||||
displayName = "Debug",
|
||||
capabilities = emptySet(), // no functional features
|
||||
implemented = emptySet(),
|
||||
tuningProfile = TuningProfile.Balanced,
|
||||
linkMode = LinkMode.CONNECT_GATT
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Never claims a device by itself. Developer mode is a separate setting
|
||||
* ([com.health.openscale.core.facade.SettingsFacade.developerModeEnabled]); routing it through
|
||||
* the device name used to overwrite the saved scale's identity, which silently broke the
|
||||
* pairing (issue #1478).
|
||||
*/
|
||||
override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? = null
|
||||
|
||||
/**
|
||||
* On connect:
|
||||
* 1) Dump the **entire** GATT service/characteristic tree.
|
||||
* 2) Optionally poke a few common characteristics (best-effort).
|
||||
* 3) Arm light-weight subscriptions to typical measurement characteristics,
|
||||
* if present (best-effort; errors are logged).
|
||||
*/
|
||||
override fun onConnected(user: ScaleUser) {
|
||||
logD("Connected in Debug mode. Dumping full GATT services/characteristics…")
|
||||
dumpAllGatt()
|
||||
|
||||
// --- Optional sanity probes (best-effort; they can fail silently) -----
|
||||
// Generic Access: Device Name
|
||||
readSafe(uuid16(0x1800), uuid16(0x2A00))
|
||||
// Device Information: Manufacturer / Model / FW / SW
|
||||
readSafe(uuid16(0x180A), uuid16(0x2A29))
|
||||
readSafe(uuid16(0x180A), uuid16(0x2A24))
|
||||
readSafe(uuid16(0x180A), uuid16(0x2A26))
|
||||
readSafe(uuid16(0x180A), uuid16(0x2A28))
|
||||
// Battery Level
|
||||
readSafe(uuid16(0x180F), uuid16(0x2A19))
|
||||
|
||||
// Subscribe to common measurement characteristics if present
|
||||
setNotifySafe(uuid16(0x181D), uuid16(0x2A9D)) // Weight Scale -> Weight Measurement
|
||||
setNotifySafe(uuid16(0x181B), uuid16(0x2A9C)) // Body Comp -> Body Composition Measurement
|
||||
|
||||
logD("Debug handler armed. Incoming NOTIFY frames will be logged; no data is stored.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Every incoming notification is logged in a concise form:
|
||||
* - Pretty UUID (16-bit when possible)
|
||||
* - Hex preview (up to 64 bytes)
|
||||
* - ASCII preview (non-printables as '?')
|
||||
*/
|
||||
override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) {
|
||||
val hex = data.toHexPreview(64)
|
||||
val ascii = data.toAsciiPreview(64)
|
||||
logD("NOTIFY chr=${prettyUuid(characteristic)} $hex ascii=$ascii")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Dump utilities
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Dump all discovered GATT services and their characteristics (with property flags).
|
||||
* Uses the transport's `debugGetPeripheral()` hook to access the raw peripheral.
|
||||
*/
|
||||
private fun dumpAllGatt() {
|
||||
val peripheral = getPeripheral()
|
||||
if (peripheral == null) {
|
||||
logD("No peripheral available yet (transport.debugGetPeripheral() returned null).")
|
||||
return
|
||||
}
|
||||
|
||||
val services = peripheral.services
|
||||
logD("=== GATT Service Dump BEGIN ===")
|
||||
if (services.isEmpty()) {
|
||||
logD( "(no services)")
|
||||
logD( "=== GATT Service Dump END ===")
|
||||
return
|
||||
}
|
||||
|
||||
for (svc in services) {
|
||||
logD( "Service ${prettyUuid(svc.uuid)}")
|
||||
val chars = svc.characteristics ?: emptyList()
|
||||
for (ch in chars) {
|
||||
logD(" └─ Char ${prettyUuid(ch.uuid)} props=${propsToString(ch.properties)}"
|
||||
)
|
||||
}
|
||||
}
|
||||
logD("=== GATT Service Dump END ===")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Safe wrappers (never throw; best-effort operations)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
private fun setNotifySafe(service: UUID, characteristic: UUID) {
|
||||
logD("→ setNotifyOn svc=${prettyUuid(service)} chr=${prettyUuid(characteristic)}")
|
||||
runCatching { setNotifyOn(service, characteristic) }
|
||||
.onFailure { logD("setNotifyOn failed: ${it.message ?: it::class.simpleName}") }
|
||||
}
|
||||
|
||||
private fun readSafe(service: UUID, characteristic: UUID) {
|
||||
logD("→ read svc=${prettyUuid(service)} chr=${prettyUuid(characteristic)} (best effort)")
|
||||
runCatching { readFrom(service, characteristic) }
|
||||
.onFailure { logD("read failed: ${it.message ?: it::class.simpleName}") }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Pretty-print helpers
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a standard 128-bit UUID with the Bluetooth base into a compact **0xNNNN** form.
|
||||
* Leaves full UUIDs intact for vendor/custom values.
|
||||
*/
|
||||
private fun prettyUuid(u: UUID): String {
|
||||
val s = u.toString().lowercase(Locale.ROOT)
|
||||
return if (s.startsWith("0000") && s.endsWith("-0000-1000-8000-00805f9b34fb"))
|
||||
"0x" + s.substring(4, 8)
|
||||
else
|
||||
s
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn Android GATT property flags into a readable pipe-separated string.
|
||||
* Example: `READ|WRITE_NR|NOTIFY|INDICATE`
|
||||
*/
|
||||
private fun propsToString(p: Int): String {
|
||||
val flags = mutableListOf<String>()
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_READ) != 0) flags += "READ"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_WRITE) != 0) flags += "WRITE"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0) flags += "WRITE_NR"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) flags += "NOTIFY"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) flags += "INDICATE"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_SIGNED_WRITE) != 0) flags += "SIGNED"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_BROADCAST) != 0) flags += "BROADCAST"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_EXTENDED_PROPS) != 0) flags += "EXT"
|
||||
return if (flags.isEmpty()) "0" else flags.joinToString("|")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import com.health.openscale.core.utils.ConverterUtils
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Handler for Exingtech Y1 scales (often advertising as "VScale").
|
||||
*
|
||||
* Protocol:
|
||||
* - Custom Service: f433bd80-75b8-11e2-97d9-0002a5d5c51b
|
||||
* - Notify Characteristic: 1a2ea400-75b9-11e2-be05-0002a5d5c51b
|
||||
* - Write Characteristic: 29f11080-75b9-11e2-8bf6-0002a5d5c51b
|
||||
*
|
||||
* Flow:
|
||||
* 1) Enable NOTIFY on data characteristic.
|
||||
* 2) Write user block: [0x10, userId, gender(0=male/1=female), age, height(cm)].
|
||||
* 3) Wait for a 20-byte result frame; the first one may only contain weight.
|
||||
* Publish when body composition (fat) is present (data[6] != 0xFF).
|
||||
*/
|
||||
class ExingtechY1Handler : ScaleDeviceHandler() {
|
||||
|
||||
private val SERVICE: UUID =
|
||||
UUID.fromString("f433bd80-75b8-11e2-97d9-0002a5d5c51b")
|
||||
private val CHAR_NOTIFY: UUID =
|
||||
UUID.fromString("1a2ea400-75b9-11e2-be05-0002a5d5c51b")
|
||||
private val CHAR_CMD: UUID =
|
||||
UUID.fromString("29f11080-75b9-11e2-8bf6-0002a5d5c51b")
|
||||
|
||||
override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? {
|
||||
val name = device.name.lowercase(Locale.US)
|
||||
val byName = (name == "vscale")
|
||||
|
||||
val byService = device.serviceUuids.any {
|
||||
it.equals(SERVICE)
|
||||
}
|
||||
|
||||
if (!byName && !byService) return null
|
||||
|
||||
val caps = setOf(
|
||||
DeviceCapability.BODY_COMPOSITION,
|
||||
DeviceCapability.USER_SYNC
|
||||
)
|
||||
|
||||
return DeviceSupport(
|
||||
displayName = "Exingtech Y1 (VScale)",
|
||||
capabilities = caps,
|
||||
implemented = caps,
|
||||
linkMode = LinkMode.CONNECT_GATT
|
||||
)
|
||||
}
|
||||
|
||||
override fun onConnected(user: ScaleUser) {
|
||||
// Enable notifications for result frames
|
||||
setNotifyOn(SERVICE, CHAR_NOTIFY)
|
||||
|
||||
// Send user block (id is truncated to 1 byte like legacy driver)
|
||||
val userIdOneByte = (user.id and 0xFF).toByte()
|
||||
val gender = if (user.gender.isMale()) 0x00 else 0x01
|
||||
val age = (user.age and 0xFF).toByte()
|
||||
val height = (user.bodyHeight.toInt() and 0xFF).toByte()
|
||||
|
||||
val cmd = byteArrayOf(
|
||||
0x10,
|
||||
userIdOneByte,
|
||||
gender.toByte(),
|
||||
age,
|
||||
height
|
||||
)
|
||||
writeTo(SERVICE, CHAR_CMD, cmd, withResponse = true)
|
||||
|
||||
// Prompt user
|
||||
userInfo(R.string.bt_info_step_on_scale)
|
||||
}
|
||||
|
||||
override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) {
|
||||
if (characteristic != CHAR_NOTIFY) return
|
||||
if (data.size != 20) return
|
||||
|
||||
// The first notify can be "weight only"; full composition follows.
|
||||
// In legacy code we waited until fat != 0xFF.
|
||||
val fatHi = data[6]
|
||||
if (fatHi.toInt() and 0xFF == 0xFF) {
|
||||
logD("VScale: weight-only frame, waiting for full composition…")
|
||||
return
|
||||
}
|
||||
|
||||
publish(parseMeasurement(data))
|
||||
}
|
||||
|
||||
// --- Parsing --------------------------------------------------------------
|
||||
|
||||
private fun parseMeasurement(frame: ByteArray): ScaleMeasurement {
|
||||
// Big-endian 16-bit fields, matching legacy ConverterUtils.fromUnsignedInt16Be
|
||||
val weight = ConverterUtils.fromUnsignedInt16Be(frame, 4) / 10.0f
|
||||
val fat = ConverterUtils.fromUnsignedInt16Be(frame, 6) / 10.0f
|
||||
val water = ConverterUtils.fromUnsignedInt16Be(frame, 8) / 10.0f
|
||||
val bone = ConverterUtils.fromUnsignedInt16Be(frame, 10) / 10.0f
|
||||
val muscle = ConverterUtils.fromUnsignedInt16Be(frame, 12) / 10.0f
|
||||
val visceralIndex = (frame[14].toInt() and 0xFF).toFloat()
|
||||
// calorie (offset 15) and BMI (offset 17) exist but are computed by app; skip.
|
||||
|
||||
return ScaleMeasurement().apply {
|
||||
dateTime = Date()
|
||||
this.weight = weight
|
||||
this.fat = fat
|
||||
this.water = water
|
||||
this.muscle = muscle
|
||||
this.bone = bone
|
||||
this.visceralFat = visceralIndex
|
||||
}.also {
|
||||
logD("VScale result kg=${it.weight} fat=${it.fat} water=${it.water} muscle=${it.muscle} bone=${it.bone} visc=${it.visceralFat}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.Composable
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.welie.blessed.BluetoothCentralManager
|
||||
import com.welie.blessed.BluetoothCentralManagerCallback
|
||||
import com.welie.blessed.BluetoothPeripheral
|
||||
import com.welie.blessed.BluetoothPeripheralCallback
|
||||
import com.welie.blessed.ConnectionPriority
|
||||
import com.welie.blessed.GattStatus
|
||||
import com.welie.blessed.HciStatus
|
||||
import com.welie.blessed.WriteType
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// GATT adapter (BLE)
|
||||
// - scans for a specific address and connects via Blessed
|
||||
// - enables notifications, handles read/write with pacing (BleTuning)
|
||||
// - forwards notifications to handler.onNotification()
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
class GattScaleAdapter(
|
||||
context: Context,
|
||||
settingsFacade: SettingsFacade,
|
||||
measurementFacade: MeasurementFacade,
|
||||
userFacade: UserFacade,
|
||||
handler: ScaleDeviceHandler,
|
||||
profile: TuningProfile = TuningProfile.Balanced
|
||||
) : ModernScaleAdapter(context, settingsFacade, measurementFacade, userFacade, handler) {
|
||||
|
||||
private val tuning: BleGattTuning = profile.forGatt()
|
||||
private lateinit var central: BluetoothCentralManager
|
||||
private var currentPeripheral: BluetoothPeripheral? = null
|
||||
|
||||
private val opQueue = Channel<suspend () -> Unit>(Channel.UNLIMITED)
|
||||
|
||||
private data class PendingOp(
|
||||
val id: Long,
|
||||
val deferred: CompletableDeferred<Unit>
|
||||
)
|
||||
|
||||
private val deferredMap = ConcurrentHashMap<UUID, PendingOp>()
|
||||
private var nextOpId = 0L
|
||||
|
||||
private val ioMutex = Mutex()
|
||||
|
||||
private var connectAttempts = 0
|
||||
|
||||
init {
|
||||
// Worker coroutine processes queued BLE operations sequentially
|
||||
scope.launch {
|
||||
for (op in opQueue) {
|
||||
// wait until BLE connection is established
|
||||
while (!_isConnected.value) {
|
||||
delay(10.milliseconds)
|
||||
}
|
||||
|
||||
try {
|
||||
ioMutex.lock()
|
||||
op()
|
||||
} catch (t: Throwable) {
|
||||
LogManager.e(TAG, "BLE operation failed", t)
|
||||
} finally {
|
||||
ioMutex.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun DeviceConfigurationUi() {
|
||||
// Delegate to the actual protocol handler
|
||||
handler.DeviceConfigurationUi()
|
||||
}
|
||||
|
||||
private suspend fun ioGap(ms: Long) {
|
||||
if (ms > 0) delay(ms.milliseconds)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Bluetooth central callbacks
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
private val centralCallback = object : BluetoothCentralManagerCallback() {
|
||||
override fun onDiscovered(peripheral: BluetoothPeripheral, scanResult: ScanResult) {
|
||||
if (peripheral.address != targetAddress) return
|
||||
LogManager.i(TAG, "Found $targetAddress → stop scan + connect")
|
||||
central.stopScan()
|
||||
scope.launch {
|
||||
if (tuning.connectAfterScanDelayMs > 0) delay(tuning.connectAfterScanDelayMs.milliseconds)
|
||||
central.connect(peripheral, peripheralCallback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnected(peripheral: BluetoothPeripheral) {
|
||||
scope.launch {
|
||||
currentPeripheral = peripheral
|
||||
_isConnected.value = true
|
||||
_isConnecting.value = false
|
||||
_events.tryEmit(BluetoothEvent.Connected(peripheral.name, peripheral.address))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionFailed(peripheral: BluetoothPeripheral, status: HciStatus) {
|
||||
scope.launch {
|
||||
LogManager.e(TAG, "Connection failed ${peripheral.address}: $status")
|
||||
if (connectAttempts < tuning.common.maxRetries) {
|
||||
val nextTry = connectAttempts + 1
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_reconnecting_try, nextTry, tuning.common.maxRetries),
|
||||
peripheral.address
|
||||
)
|
||||
)
|
||||
connectAttempts = nextTry
|
||||
delay(tuning.common.retryBackoffMs.milliseconds)
|
||||
runCatching { central.stopScan() }
|
||||
central.scanForPeripheralsWithAddresses(setOf(peripheral.address))
|
||||
_isConnecting.value = true
|
||||
} else {
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(peripheral.address, status.toString()))
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnected(peripheral: BluetoothPeripheral, status: HciStatus) {
|
||||
scope.launch {
|
||||
LogManager.i(TAG, "Disconnected ${peripheral.address}: $status")
|
||||
runCatching { handler.handleDisconnected() }
|
||||
runCatching { handler.detach() }
|
||||
lastDisconnectAtMs = SystemClock.elapsedRealtime()
|
||||
if (peripheral.address == targetAddress) {
|
||||
_events.tryEmit(BluetoothEvent.Disconnected(peripheral.address, status.toString()))
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Peripheral callback receives all GATT events
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
private val peripheralCallback = object : BluetoothPeripheralCallback() {
|
||||
override fun onServicesDiscovered(peripheral: BluetoothPeripheral) {
|
||||
LogManager.d(TAG, "Services discovered for ${peripheral.address}")
|
||||
currentPeripheral = peripheral
|
||||
|
||||
if (tuning.requestHighConnectionPriority) runCatching { peripheral.requestConnectionPriority(ConnectionPriority.HIGH) }
|
||||
if (tuning.requestMtuBytes > 23) runCatching { peripheral.requestMtu(tuning.requestMtuBytes) }
|
||||
|
||||
val user = selectedUserSnapshot ?: run {
|
||||
central.cancelConnection(peripheral); return
|
||||
}
|
||||
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
|
||||
handler.attach(transport, appCallbacks, driverSettings, dataProvider, scope)
|
||||
handler.handleConnected(user)
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
peripheral: BluetoothPeripheral,
|
||||
value: ByteArray,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
LogManager.d(TAG,"\u2190 write response chr=${characteristic.uuid} len=${value.size} status=${status} ${value.toHexPreview(24)}")
|
||||
|
||||
deferredMap[characteristic.uuid]?.let { op ->
|
||||
op.deferred.complete(Unit)
|
||||
deferredMap.remove(characteristic.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationStateUpdate(
|
||||
peripheral: BluetoothPeripheral,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
LogManager.d(TAG,"\u2190 notify state chr=${characteristic.uuid} status=${status}")
|
||||
|
||||
deferredMap[characteristic.uuid]?.let { op ->
|
||||
op.deferred.complete(Unit)
|
||||
deferredMap.remove(characteristic.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicUpdate(
|
||||
peripheral: BluetoothPeripheral,
|
||||
value: ByteArray,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
LogManager.d(TAG,"\u2190 received data chr=${characteristic.uuid} len=${value.size} status=${status} ${value.toHexPreview(24)}")
|
||||
|
||||
handler.handleNotification(characteristic.uuid, value)
|
||||
|
||||
deferredMap[characteristic.uuid]?.let { op ->
|
||||
op.deferred.complete(Unit)
|
||||
deferredMap.remove(characteristic.uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Transport exposed to handler; operations are queued automatically
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
private val transport = object : ScaleDeviceHandler.Transport {
|
||||
|
||||
override fun setNotifyOn(service: UUID, characteristic: UUID) {
|
||||
opQueue.trySend {
|
||||
val p = currentPeripheral ?: return@trySend
|
||||
LogManager.d(TAG, "→ set notify on chr=$characteristic svc=$service")
|
||||
|
||||
val opId = ++nextOpId
|
||||
val deferred = CompletableDeferred<Unit>()
|
||||
deferredMap[characteristic] = PendingOp(opId, deferred)
|
||||
|
||||
val started = p.startNotify(service, characteristic)
|
||||
if (!started) {
|
||||
LogManager.w(TAG, "Failed to initiate notify for $characteristic")
|
||||
// appCallbacks.onWarn(R.string.bt_warn_notify_failed, characteristic.toString())
|
||||
deferred.complete(Unit)
|
||||
deferredMap.remove(characteristic)
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait with timeout from tuning
|
||||
withTimeout(tuning.operationTimeoutMs.milliseconds) {
|
||||
deferred.await()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
LogManager.w(TAG, "Timeout waiting for notify on $characteristic")
|
||||
} finally {
|
||||
val current = deferredMap[characteristic]
|
||||
if (current?.id == opId) {
|
||||
deferredMap.remove(characteristic)
|
||||
}
|
||||
deferred.cancel()
|
||||
}
|
||||
|
||||
ioGap(tuning.notifySetupDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) {
|
||||
opQueue.trySend {
|
||||
val p = currentPeripheral ?: return@trySend
|
||||
val ch = p.getCharacteristic(service, characteristic) ?: return@trySend
|
||||
|
||||
val opId = ++nextOpId
|
||||
val deferred = CompletableDeferred<Unit>()
|
||||
deferredMap[characteristic] = PendingOp(opId, deferred)
|
||||
|
||||
val supportsWriteNoResponse = ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0
|
||||
val supportsWriteResponse = ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0
|
||||
|
||||
val type = when {
|
||||
withResponse && supportsWriteResponse -> WriteType.WITH_RESPONSE
|
||||
!withResponse && supportsWriteNoResponse -> WriteType.WITHOUT_RESPONSE
|
||||
supportsWriteResponse -> {
|
||||
LogManager.w(TAG, "Characteristic $characteristic does not support WITHOUT_RESPONSE, using WITH_RESPONSE instead")
|
||||
WriteType.WITH_RESPONSE
|
||||
}
|
||||
supportsWriteNoResponse -> {
|
||||
LogManager.w(TAG, "Characteristic $characteristic does not support WITH_RESPONSE, using WITHOUT_RESPONSE instead")
|
||||
WriteType.WITHOUT_RESPONSE
|
||||
}
|
||||
else -> {
|
||||
LogManager.w(TAG, "Characteristic $characteristic does not support writing")
|
||||
return@trySend
|
||||
}
|
||||
}
|
||||
|
||||
ioGap(if (withResponse) tuning.writeWithResponseDelayMs else tuning.writeWithoutResponseDelayMs)
|
||||
p.writeCharacteristic(service, characteristic, payload, type)
|
||||
|
||||
LogManager.d(TAG,"\u2192 write to chr=$characteristic svc=$service len=${payload.size} withResp=$withResponse ${payload.toHexPreview(24)}")
|
||||
|
||||
try {
|
||||
withTimeout(tuning.operationTimeoutMs.milliseconds) {
|
||||
deferred.await()
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
LogManager.w(TAG, "Timeout waiting for write on $characteristic")
|
||||
} finally {
|
||||
val current = deferredMap[characteristic]
|
||||
if (current?.id == opId) {
|
||||
deferredMap.remove(characteristic)
|
||||
}
|
||||
deferred.cancel()
|
||||
}
|
||||
|
||||
ioGap(tuning.postWriteDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
override fun read(service: UUID, characteristic: UUID) {
|
||||
opQueue.trySend {
|
||||
val p = currentPeripheral ?: return@trySend
|
||||
p.getCharacteristic(service, characteristic) ?: return@trySend
|
||||
|
||||
val opId = ++nextOpId
|
||||
val deferred = CompletableDeferred<Unit>()
|
||||
deferredMap[characteristic] = PendingOp(opId, deferred)
|
||||
|
||||
p.readCharacteristic(service, characteristic)
|
||||
|
||||
LogManager.d(TAG,"\u2192 read from chr=$characteristic svc=$service")
|
||||
|
||||
try {
|
||||
withTimeout(tuning.operationTimeoutMs.milliseconds) {
|
||||
deferred.await()
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
LogManager.w(TAG, "Timeout waiting for read on $characteristic")
|
||||
} finally {
|
||||
val current = deferredMap[characteristic]
|
||||
if (current?.id == opId) {
|
||||
deferredMap.remove(characteristic)
|
||||
}
|
||||
deferred.cancel()
|
||||
}
|
||||
|
||||
ioGap(tuning.postReadDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
currentPeripheral?.let { central.cancelConnection(it) }
|
||||
}
|
||||
|
||||
override fun getPeripheral(): BluetoothPeripheral? = currentPeripheral
|
||||
|
||||
override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean {
|
||||
val p = currentPeripheral ?: return false
|
||||
return p.getCharacteristic(service, characteristic) != null
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Connection management
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
override fun doConnect(address: String, selectedUser: ScaleUser) {
|
||||
if (!::central.isInitialized) {
|
||||
central = BluetoothCentralManager(context, centralCallback, mainHandler)
|
||||
}
|
||||
|
||||
val sinceLastDisconnect = SystemClock.elapsedRealtime() - lastDisconnectAtMs
|
||||
val waitMs = (tuning.common.reconnectCooldownMs - sinceLastDisconnect).coerceAtLeast(0)
|
||||
|
||||
connectAttempts = 0
|
||||
_isConnected.value = false
|
||||
_isConnecting.value = true
|
||||
|
||||
runCatching { central.stopScan() }
|
||||
|
||||
scope.launch {
|
||||
if (waitMs > 0) delay(waitMs.milliseconds)
|
||||
try {
|
||||
central.scanForPeripheralsWithAddresses(setOf(address))
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Failed to start scan/connect: ${e.message}", e)
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.ConnectionFailed(
|
||||
address,
|
||||
e.message ?: context.getString(R.string.bt_error_generic)
|
||||
)
|
||||
)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun doDisconnect() {
|
||||
runCatching { if (::central.isInitialized) central.stopScan() }
|
||||
currentPeripheral?.let { runCatching { central.cancelConnection(it) } }
|
||||
currentPeripheral = null
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// Stop accepting new BLE operations and release the Blessed central
|
||||
// (its broadcast receivers) before the base cancels the coroutine scope,
|
||||
// which terminates the busy-waiting op-queue worker.
|
||||
runCatching { opQueue.close() }
|
||||
runCatching { if (::central.isInitialized) central.close() }
|
||||
super.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SignalCellularAlt
|
||||
import androidx.compose.material.icons.filled.SignalCellularAlt1Bar
|
||||
import androidx.compose.material.icons.outlined.SignalCellularAlt2Bar
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.ScaleCommunicator
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.data.MeasurementTypeKey
|
||||
import com.health.openscale.core.data.MeasurementValue
|
||||
import com.health.openscale.core.data.UnitType
|
||||
import com.health.openscale.core.data.User
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.health.openscale.core.model.MeasurementWithValues
|
||||
import com.health.openscale.core.utils.ConverterUtils
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.Date
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Shared tuning for BLE pacing & retry (used by GATT adapter).
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
// Common knobs every link can use
|
||||
data class CommonTuning(
|
||||
val reconnectCooldownMs: Long = 2000,
|
||||
val retryBackoffMs: Long = 1500,
|
||||
val maxRetries: Int = 3
|
||||
)
|
||||
|
||||
// GATT-specific
|
||||
data class BleGattTuning(
|
||||
val common: CommonTuning = CommonTuning(),
|
||||
val notifySetupDelayMs: Long = 120,
|
||||
val writeWithResponseDelayMs: Long = 80,
|
||||
val writeWithoutResponseDelayMs: Long = 35,
|
||||
val postWriteDelayMs: Long = 20,
|
||||
val postReadDelayMs: Long = 20,
|
||||
val connectAfterScanDelayMs: Long = 650,
|
||||
val requestHighConnectionPriority: Boolean = false,
|
||||
val requestMtuBytes: Int = 185,
|
||||
val operationTimeoutMs: Long = 1000
|
||||
)
|
||||
|
||||
// Broadcast scanner tuning
|
||||
data class BleBroadcastTuning(
|
||||
val common: CommonTuning = CommonTuning(),
|
||||
val scanMode: Int = ScanSettings.SCAN_MODE_LOW_LATENCY,
|
||||
val maxScanMs: Long = 20_000,
|
||||
val restartBackoffMs: Long = 1500,
|
||||
val packetDedupWindowMs: Long = 750,
|
||||
val stabilizeWindowMs: Long = 1200,
|
||||
val minRssiDbm: Int? = null // e.g. -90
|
||||
)
|
||||
|
||||
// Classic SPP tuning
|
||||
data class BtSppTuning(
|
||||
val common: CommonTuning = CommonTuning(),
|
||||
val connectTimeoutMs: Long = 10_000,
|
||||
val readTimeoutMs: Long = 3000,
|
||||
val writeChunkBytes: Int = 256,
|
||||
val interChunkDelayMs: Long = 10,
|
||||
val soKeepAlive: Boolean = true
|
||||
)
|
||||
|
||||
enum class TuningProfile(
|
||||
@param:StringRes val labelRes: Int,
|
||||
val icon: ImageVector
|
||||
) {
|
||||
Conservative(
|
||||
labelRes = R.string.tuning_conservative,
|
||||
icon = Icons.Filled.SignalCellularAlt1Bar
|
||||
),
|
||||
Balanced(
|
||||
labelRes = R.string.tuning_balanced,
|
||||
icon = Icons.Outlined.SignalCellularAlt2Bar
|
||||
),
|
||||
Aggressive(
|
||||
labelRes = R.string.tuning_aggressive,
|
||||
icon = Icons.Filled.SignalCellularAlt
|
||||
)
|
||||
}
|
||||
fun TuningProfile.forGatt(): BleGattTuning = when (this) {
|
||||
TuningProfile.Balanced -> BleGattTuning(
|
||||
common = CommonTuning(2200, 1500, 3),
|
||||
notifySetupDelayMs = 120,
|
||||
writeWithResponseDelayMs = 80,
|
||||
writeWithoutResponseDelayMs = 35,
|
||||
postWriteDelayMs = 20,
|
||||
postReadDelayMs = 20,
|
||||
connectAfterScanDelayMs = 650,
|
||||
requestHighConnectionPriority = false,
|
||||
requestMtuBytes = 185,
|
||||
operationTimeoutMs = 1000
|
||||
)
|
||||
TuningProfile.Conservative -> BleGattTuning(
|
||||
common = CommonTuning(2500, 1800, 3),
|
||||
notifySetupDelayMs = 160,
|
||||
writeWithResponseDelayMs = 100,
|
||||
writeWithoutResponseDelayMs = 50,
|
||||
postWriteDelayMs = 30,
|
||||
postReadDelayMs = 30,
|
||||
connectAfterScanDelayMs = 800,
|
||||
requestHighConnectionPriority = false,
|
||||
requestMtuBytes = 0,
|
||||
operationTimeoutMs = 2000
|
||||
)
|
||||
TuningProfile.Aggressive -> BleGattTuning(
|
||||
common = CommonTuning(1200, 1200, 2),
|
||||
notifySetupDelayMs = 80,
|
||||
writeWithResponseDelayMs = 60,
|
||||
writeWithoutResponseDelayMs = 25,
|
||||
postWriteDelayMs = 15,
|
||||
postReadDelayMs = 15,
|
||||
connectAfterScanDelayMs = 400,
|
||||
requestHighConnectionPriority = true,
|
||||
requestMtuBytes = 247,
|
||||
operationTimeoutMs = 500
|
||||
)
|
||||
}
|
||||
|
||||
fun TuningProfile.forBroadcast(): BleBroadcastTuning = when (this) {
|
||||
TuningProfile.Balanced -> BleBroadcastTuning(common = CommonTuning(2200,1500,3))
|
||||
TuningProfile.Conservative -> BleBroadcastTuning(
|
||||
common = CommonTuning(2500,1800,3),
|
||||
scanMode = ScanSettings.SCAN_MODE_BALANCED,
|
||||
maxScanMs = 30_000
|
||||
)
|
||||
TuningProfile.Aggressive -> BleBroadcastTuning(
|
||||
common = CommonTuning(1200,1200,2),
|
||||
scanMode = ScanSettings.SCAN_MODE_LOW_LATENCY,
|
||||
maxScanMs = 15_000,
|
||||
stabilizeWindowMs = 900
|
||||
)
|
||||
}
|
||||
|
||||
fun TuningProfile.forSpp(): BtSppTuning = when (this) {
|
||||
TuningProfile.Balanced -> BtSppTuning()
|
||||
TuningProfile.Conservative -> BtSppTuning(connectTimeoutMs = 12_000, interChunkDelayMs = 15)
|
||||
TuningProfile.Aggressive -> BtSppTuning(connectTimeoutMs = 8_000, interChunkDelayMs = 5)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Small persisted driver settings wrapper backed by SettingsFacade (shared by all adapters).
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
class FacadeDriverSettings(
|
||||
private val facade: SettingsFacade,
|
||||
private val scope: CoroutineScope,
|
||||
handlerNamespace: String
|
||||
) : ScaleDeviceHandler.DriverSettings {
|
||||
|
||||
private val prefix = "ble/$handlerNamespace/"
|
||||
private val mem = ConcurrentHashMap<String, String>()
|
||||
|
||||
override fun getInt(key: String, default: Int): Int {
|
||||
val k = prefix + key
|
||||
mem[k]?.toIntOrNull()?.let { return it }
|
||||
val v = runCatching {
|
||||
runBlocking(Dispatchers.IO) { withTimeout(300.milliseconds) { facade.observeSetting(k, default).first() } }
|
||||
}.getOrElse { default }
|
||||
mem[k] = v.toString()
|
||||
return v
|
||||
}
|
||||
|
||||
override fun putInt(key: String, value: Int) {
|
||||
val k = prefix + key
|
||||
mem[k] = value.toString()
|
||||
scope.launch { facade.saveSetting(k, value) }
|
||||
}
|
||||
|
||||
override fun getString(key: String, default: String?): String? {
|
||||
val k = prefix + key
|
||||
mem[k]?.let { return it }
|
||||
val raw = runCatching {
|
||||
runBlocking(Dispatchers.IO) { withTimeout(300.milliseconds) { facade.observeSetting(k, default ?: "").first() } }
|
||||
}.getOrElse { default ?: "" }
|
||||
val result = if (raw.isEmpty() && default == null) null else raw
|
||||
result?.let { mem[k] = it }
|
||||
return result
|
||||
}
|
||||
|
||||
override fun putString(key: String, value: String) {
|
||||
val k = prefix + key
|
||||
mem[k] = value
|
||||
scope.launch { facade.saveSetting(k, value) }
|
||||
}
|
||||
|
||||
override fun remove(key: String) {
|
||||
val k = prefix + key
|
||||
mem.remove(k)
|
||||
scope.launch { facade.saveSetting(k, "") }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// ModernScaleAdapter (abstract base)
|
||||
// - Owns app integration, user/measurements snapshots, event streams, handler wiring.
|
||||
// - Concrete subclasses implement link-specific connect/disconnect logic.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
abstract class ModernScaleAdapter(
|
||||
protected val context: android.content.Context,
|
||||
protected val settingsFacade: SettingsFacade,
|
||||
protected val measurementFacade: MeasurementFacade,
|
||||
protected val userFacade: UserFacade,
|
||||
protected val handler: ScaleDeviceHandler
|
||||
) : ScaleCommunicator, AutoCloseable {
|
||||
|
||||
protected val TAG = this::class.simpleName ?: "ModernScaleAdapter"
|
||||
|
||||
// ---- coroutine & lifecycle -----------------------------------------------------------------
|
||||
protected val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
protected val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// ---- session targeting ---------------------------------------------------------------------
|
||||
protected var targetAddress: String? = null
|
||||
protected var lastDisconnectAtMs: Long = 0L
|
||||
|
||||
// ---- UI streams ----------------------------------------------------------------------------
|
||||
val _events = MutableSharedFlow<BluetoothEvent>(replay = 1, extraBufferCapacity = 8)
|
||||
override fun getEventsFlow(): SharedFlow<BluetoothEvent> = _events.asSharedFlow()
|
||||
|
||||
protected val _isConnecting = MutableStateFlow(false)
|
||||
override val isConnecting: StateFlow<Boolean> = _isConnecting.asStateFlow()
|
||||
|
||||
protected val _isConnected = MutableStateFlow(false)
|
||||
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
// ---- app snapshots for handler.DataProvider -------------------------------------------------
|
||||
@Volatile protected var selectedUserSnapshot: ScaleUser? = null
|
||||
@Volatile protected var usersSnapshot: List<ScaleUser> = emptyList()
|
||||
@Volatile protected var lastSnapshot: Map<Int, ScaleMeasurement> = emptyMap()
|
||||
|
||||
init {
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
handlerNamespace = handler.javaClass.simpleName
|
||||
)
|
||||
handler.attachSettings(driverSettings)
|
||||
|
||||
// Keep a *live* non-blocking snapshot of the current user.
|
||||
scope.launch {
|
||||
userFacade.observeSelectedUser().collect { u ->
|
||||
selectedUserSnapshot = u?.let(::mapUser)
|
||||
}
|
||||
}
|
||||
// Keep a *fresh enough* snapshot of users & their latest measurement.
|
||||
scope.launch {
|
||||
userFacade.observeAllUsers()
|
||||
.flatMapLatest { users ->
|
||||
usersSnapshot = users.map(::mapUser)
|
||||
if (users.isEmpty()) {
|
||||
flowOf(emptyMap())
|
||||
} else {
|
||||
combine(users.map { u -> measurementFacade.getMeasurementsForUser(u.id) }) { lists ->
|
||||
val out = HashMap<Int, ScaleMeasurement>(users.size)
|
||||
users.forEachIndexed { idx, u ->
|
||||
val newest = lists[idx].maxByOrNull { it.measurement.timestamp }
|
||||
mapMeasurement(newest)?.let { out[u.id] = it }
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
.collect { latestMap -> lastSnapshot = latestMap }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ScaleCommunicator entry points ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Template method: base validates input & selected user, then calls [doConnect].
|
||||
* Many handlers expect a selected user from app state;
|
||||
*/
|
||||
final override fun connect(address: String, scaleUser: ScaleUser?) {
|
||||
targetAddress = address
|
||||
_isConnecting.value = true
|
||||
|
||||
scope.launch {
|
||||
val user: ScaleUser? =
|
||||
scaleUser
|
||||
?: selectedUserSnapshot
|
||||
?: withTimeoutOrNull(750.milliseconds) {
|
||||
userFacade.observeSelectedUser().first()
|
||||
}?.let(::mapUser)
|
||||
|
||||
if (user == null) {
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.ConnectionFailed(
|
||||
address,
|
||||
context.getString(R.string.bt_error_no_user_selected)
|
||||
)
|
||||
)
|
||||
_isConnecting.value = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
runCatching {
|
||||
doConnect(address, user)
|
||||
}.onFailure { t ->
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.ConnectionFailed(
|
||||
address,
|
||||
t.message ?: "—"
|
||||
)
|
||||
)
|
||||
_isConnecting.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Template method: calls [doDisconnect] and resets shared state.
|
||||
*/
|
||||
final override fun disconnect() {
|
||||
doDisconnect()
|
||||
cleanup()
|
||||
}
|
||||
|
||||
/**
|
||||
* Default UX helper for devices that only push data via NOTIFY or broadcasts.
|
||||
* Subclasses can override if they can actively trigger measurement on device.
|
||||
*/
|
||||
override fun requestMeasurement() {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_waiting_for_measurement),
|
||||
addr
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun processUserInteractionFeedback(
|
||||
interactionType: BluetoothEvent.UserInteractionType,
|
||||
appUserId: Int,
|
||||
feedbackData: Any
|
||||
) {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
handler.onUserInteractionFeedback(interactionType, appUserId, feedbackData)
|
||||
}.onFailure { t ->
|
||||
val addr = targetAddress ?: "unknown"
|
||||
LogManager.e(TAG, "Delivering user feedback failed: ${t.message}", t)
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_error_delivery_user_feedback, t.message ?: "—"),
|
||||
addr
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- abstract link hooks -------------------------------------------------------------------
|
||||
|
||||
protected abstract fun doConnect(address: String, selectedUser: ScaleUser)
|
||||
protected abstract fun doDisconnect()
|
||||
|
||||
// ---- callbacks & data provider for handlers ------------------------------------------------
|
||||
|
||||
protected val appCallbacks = object : ScaleDeviceHandler.Callbacks {
|
||||
override fun onPublish(measurement: ScaleMeasurement) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(BluetoothEvent.MeasurementReceived(measurement, addr))
|
||||
}
|
||||
|
||||
override fun onInfo(@StringRes resId: Int, vararg args: Any) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(BluetoothEvent.DeviceMessage(context.getString(resId, *args), addr))
|
||||
}
|
||||
|
||||
override fun onWarn(@StringRes resId: Int, vararg args: Any) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(BluetoothEvent.DeviceMessage(context.getString(resId, *args), addr))
|
||||
}
|
||||
|
||||
override fun onError(@StringRes resId: Int, t: Throwable?, vararg args: Any) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
val msg = context.getString(resId, *args)
|
||||
LogManager.e(TAG, msg, t)
|
||||
_events.tryEmit(BluetoothEvent.DeviceMessage(msg, addr))
|
||||
}
|
||||
|
||||
override fun onUserInteractionRequired(interactionType: BluetoothEvent.UserInteractionType, data: Any?) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(BluetoothEvent.UserInteractionRequired(addr, data, interactionType))
|
||||
}
|
||||
|
||||
override fun resolveString(@StringRes resId: Int, vararg args: Any): String =
|
||||
context.getString(resId, *args)
|
||||
}
|
||||
|
||||
protected val dataProvider = object : ScaleDeviceHandler.DataProvider {
|
||||
override fun currentUser(): ScaleUser = selectedUserSnapshot
|
||||
?: error("No selected user snapshot available")
|
||||
override fun usersForDevice(): List<ScaleUser> = usersSnapshot
|
||||
override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = lastSnapshot[userId]
|
||||
}
|
||||
|
||||
protected fun cleanup() {
|
||||
_isConnected.value = false
|
||||
_isConnecting.value = false
|
||||
// keep targetAddress to allow higher layer to retry if wanted
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { scope.cancel() }
|
||||
}
|
||||
|
||||
// ---- mapping helpers (core -> legacy DTOs used by handlers) --------------------------------
|
||||
|
||||
protected fun mapUser(u: User): ScaleUser =
|
||||
ScaleUser().apply {
|
||||
runCatching { id = u.id }
|
||||
runCatching { userName = u.name }
|
||||
when (val b = runCatching { u.birthDate }.getOrNull()) {
|
||||
is Long -> birthday = Date(b)
|
||||
}
|
||||
runCatching { bodyHeight = u.heightCm }
|
||||
runCatching { gender = u.gender }
|
||||
runCatching { activityLevel = u.activityLevel }
|
||||
|
||||
runCatching {
|
||||
runBlocking(scope.coroutineContext) {
|
||||
val userGoals = userFacade.getAllGoalsForUser(u.id).first()
|
||||
|
||||
val goalWeightGoal = userGoals.find { it.measurementTypeId == MeasurementTypeKey.WEIGHT.id }
|
||||
if (goalWeightGoal != null) {
|
||||
val goalType = measurementFacade.getAllMeasurementTypes().first()
|
||||
.find { it.id == goalWeightGoal.measurementTypeId }
|
||||
|
||||
if (goalType != null) {
|
||||
goalWeight = ConverterUtils.convertFloatValueUnit(
|
||||
value = goalWeightGoal.goalValue,
|
||||
fromUnit = goalType.unit,
|
||||
toUnit = UnitType.KG
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val allMeasurements = measurementFacade.getMeasurementsForUser(u.id).first()
|
||||
|
||||
val oldestWeightMeasurementValue = allMeasurements
|
||||
.sortedBy { it.measurement.timestamp }
|
||||
.firstNotNullOfOrNull { measurementWithValues ->
|
||||
measurementWithValues.values.find { it.type.key == MeasurementTypeKey.WEIGHT }
|
||||
}
|
||||
|
||||
if (oldestWeightMeasurementValue != null) {
|
||||
initialWeight = ConverterUtils.convertFloatValueUnit(
|
||||
value = oldestWeightMeasurementValue.value.floatValue ?: 0f,
|
||||
fromUnit = oldestWeightMeasurementValue.type.unit,
|
||||
toUnit = UnitType.KG
|
||||
)
|
||||
}
|
||||
|
||||
val allTypes = measurementFacade.getAllMeasurementTypes().first()
|
||||
|
||||
val weightType = allTypes.find { it.key == MeasurementTypeKey.WEIGHT }
|
||||
if (weightType != null) {
|
||||
scaleUnit = weightType.unit.toWeightUnit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun mapMeasurement(mwv: MeasurementWithValues?): ScaleMeasurement? {
|
||||
if (mwv == null) return null
|
||||
val m = ScaleMeasurement()
|
||||
runCatching { m.userId = mwv.measurement.userId }
|
||||
runCatching { m.dateTime = Date(mwv.measurement.timestamp) }
|
||||
|
||||
fun valueOf(key: MeasurementTypeKey): MeasurementValue? =
|
||||
mwv.values.firstOrNull { it.type.key == key }?.value
|
||||
|
||||
valueOf(MeasurementTypeKey.WEIGHT)?.let { m.weight = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.BODY_FAT)?.let { m.fat = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.WATER)?.let { m.water = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.MUSCLE)?.let { m.muscle = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.VISCERAL_FAT)?.let { m.visceralFat = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.LBM)?.let { m.lbm = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.BONE)?.let { m.bone = it.floatValue ?: 0f }
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
/** Pretty print a few leading bytes of a payload for logs. */
|
||||
fun ByteArray.toHexPreview(limit: Int): String {
|
||||
if (limit <= 0 || isEmpty()) return "(payload ${size}b)"
|
||||
val show = min(size, limit)
|
||||
val sb = StringBuilder("payload=[")
|
||||
for (i in 0 until show) {
|
||||
if (i > 0) sb.append(' ')
|
||||
sb.append(String.format("%02X", this[i]))
|
||||
}
|
||||
if (size > limit) sb.append(" …(+").append(size - limit).append("b)")
|
||||
sb.append(']')
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import android.bluetooth.le.ScanResult
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AutoGraph
|
||||
import androidx.compose.material.icons.filled.FitnessCenter
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.outlined.BatteryStd
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.welie.blessed.BluetoothPeripheral
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import java.util.UUID
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* What a handler declares about a device it supports.
|
||||
*
|
||||
* @property displayName Human-friendly name shown in the UI (e.g., "Yunmai Mini").
|
||||
* @property capabilities Features the device *can* support in theory.
|
||||
* @property implemented Features this handler actually implements today (may be a subset).
|
||||
* @property tuningProfile Optional link timing/retry preferences (see [TuningProfile]).
|
||||
* @property linkMode Whether the device uses GATT or broadcast-only advertisements.
|
||||
*/
|
||||
data class DeviceSupport(
|
||||
val displayName: String,
|
||||
val capabilities: Set<DeviceCapability>,
|
||||
val implemented: Set<DeviceCapability>,
|
||||
val tuningProfile: TuningProfile = TuningProfile.Balanced,
|
||||
val linkMode: LinkMode = LinkMode.CONNECT_GATT
|
||||
)
|
||||
|
||||
/** High-level capabilities a scale might offer. */
|
||||
enum class DeviceCapability(
|
||||
@param:StringRes val labelRes: Int,
|
||||
val icon: ImageVector
|
||||
) {
|
||||
BODY_COMPOSITION( R.string.cap_body_composition, Icons.Filled.FitnessCenter ),
|
||||
TIME_SYNC( R.string.cap_time_sync, Icons.Filled.Schedule ),
|
||||
USER_SYNC( R.string.cap_user_sync, Icons.Filled.Group ),
|
||||
HISTORY_READ( R.string.cap_history_read, Icons.Filled.History ),
|
||||
LIVE_WEIGHT_STREAM(R.string.cap_live_weight, Icons.Filled.AutoGraph ),
|
||||
UNIT_CONFIG( R.string.cap_unit_config, Icons.Filled.Tune ),
|
||||
BATTERY_LEVEL( R.string.cap_battery, Icons.Outlined.BatteryStd )
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines whether a device communicates via a GATT connection
|
||||
* or only via broadcast advertisements.
|
||||
*/
|
||||
enum class LinkMode { CONNECT_GATT, BROADCAST_ONLY, CLASSIC_SPP }
|
||||
|
||||
/**
|
||||
* Signals how the handler consumed an advertisement.
|
||||
* - IGNORED: payload not relevant; adapter keeps scanning silently.
|
||||
* - CONSUMED_KEEP_SCANNING: payload processed, but we want to continue scanning (e.g., waiting for stability).
|
||||
* - CONSUMED_STOP: final payload processed; adapter should stop scanning and finish the session.
|
||||
*/
|
||||
enum class BroadcastAction { IGNORED, CONSUMED_KEEP_SCANNING, CONSUMED_STOP }
|
||||
|
||||
/**
|
||||
* # ScaleDeviceHandler
|
||||
*
|
||||
* Minimal base class for a **device-specific** BLE protocol handler.
|
||||
*
|
||||
* For GATT devices, the app (via `ModernScaleAdapter`) injects a BLE [Transport] and [Callbacks],
|
||||
* then calls [onConnected] and forwards notifications to [onNotification].
|
||||
*
|
||||
* For broadcast-only devices, the adapter attaches a **no-op** transport and forwards
|
||||
* advertisement frames to [onAdvertisement]. The handler can call [publish] to emit results.
|
||||
*
|
||||
* Threading: the adapter serializes and paces BLE I/O. Avoid sleeps or blocking work inside your
|
||||
* handler; just call the helpers in the order your protocol requires.
|
||||
*/
|
||||
abstract class ScaleDeviceHandler {
|
||||
val TAG = this::class.simpleName ?: "ScaleDeviceHandler"
|
||||
|
||||
companion object {
|
||||
// Pseudo UUIDs for Classic/SPP
|
||||
val CLASSIC_DATA_UUID: UUID =
|
||||
UUID.fromString("00000000-0000-0000-0000-00000000C1A5")
|
||||
}
|
||||
/**
|
||||
* Identify whether this handler supports the given scanned device.
|
||||
* Return a [DeviceSupport] description if yes, or `null` if not.
|
||||
*/
|
||||
abstract fun supportFor(device: ScannedDeviceInfo): DeviceSupport?
|
||||
|
||||
/**
|
||||
* Optional UI component for device-specific settings.
|
||||
* Override this in concrete handlers to show custom input fields.
|
||||
*/
|
||||
@Composable
|
||||
open fun DeviceConfigurationUi() {
|
||||
// Default message when no specific configuration is required
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_special_configuration_available),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lifecycle entry points called by the adapter -------------------------
|
||||
|
||||
internal fun attachSettings(settings: DriverSettings) {
|
||||
this.settings = settings
|
||||
}
|
||||
|
||||
internal fun attach(transport: Transport, callbacks: Callbacks, settings: DriverSettings, data: DataProvider, scope: CoroutineScope) {
|
||||
this.transport = transport
|
||||
this.callbacks = callbacks
|
||||
this.settings = settings
|
||||
this.data = data
|
||||
this._scope = scope
|
||||
logD("attach()")
|
||||
}
|
||||
|
||||
internal fun handleConnected(user: ScaleUser) {
|
||||
logD("handleConnected(userId=${user.id}, height=${user.bodyHeight}, age=${user.age})")
|
||||
try {
|
||||
onConnected(user)
|
||||
} catch (t: Throwable) {
|
||||
logE("onConnected failed: ${t.message}", t)
|
||||
callbacks?.onError(
|
||||
R.string.bt_error_handler_connect_failed,
|
||||
t,
|
||||
t.message ?: "—"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun handleNotification(characteristic: UUID, data: ByteArray) {
|
||||
val u = currentAppUser()
|
||||
try {
|
||||
onNotification(characteristic, data, u)
|
||||
} catch (t: Throwable) {
|
||||
logE("onNotification failed for $characteristic: ${t.message}", t)
|
||||
callbacks?.onError(
|
||||
R.string.bt_error_handler_parse_error,
|
||||
t,
|
||||
characteristic.toString(),
|
||||
t.message ?: "—"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun handleDisconnected() {
|
||||
logD("handleDisconnected()")
|
||||
try {
|
||||
onDisconnected()
|
||||
} catch (t: Throwable) {
|
||||
logW("onDisconnected threw: ${t.message}")
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun detach() {
|
||||
logD("detach()")
|
||||
transport = null
|
||||
callbacks = null
|
||||
}
|
||||
|
||||
// --- To be implemented by concrete handlers --------------------------------
|
||||
|
||||
/** Called after services are discovered and the link is ready for I/O (GATT devices only). */
|
||||
protected open fun onConnected(user: ScaleUser) = Unit
|
||||
|
||||
/** Called for each incoming notification (GATT devices only). */
|
||||
protected open fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) = Unit
|
||||
|
||||
/** Optional cleanup hook. */
|
||||
protected open fun onDisconnected() = Unit
|
||||
|
||||
/**
|
||||
* Called for each advertisement seen for the target device (broadcast-only devices).
|
||||
* Default implementation ignores the advertisement.
|
||||
*/
|
||||
open fun onAdvertisement(result: ScanResult, user: ScaleUser): BroadcastAction = BroadcastAction.IGNORED
|
||||
|
||||
// --- Protected helper methods (use these from your handler) ----------------
|
||||
|
||||
/** Enable notifications for a characteristic. */
|
||||
protected fun setNotifyOn(service: UUID, characteristic: UUID) {
|
||||
transport?.setNotifyOn(service, characteristic)
|
||||
?: logW("setNotifyOn called without transport")
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a command to a characteristic.
|
||||
* @param withResponse true for `Write With Response` (default), false for `Write Without Response`.
|
||||
*/
|
||||
protected fun writeTo(
|
||||
service: UUID,
|
||||
characteristic: UUID,
|
||||
payload: ByteArray,
|
||||
withResponse: Boolean = true
|
||||
) {
|
||||
transport?.write(service, characteristic, payload, withResponse)
|
||||
?: logW("writeTo called without transport")
|
||||
}
|
||||
|
||||
/** Read a characteristic (rare for scales; most data comes via NOTIFY). */
|
||||
protected fun readFrom(service: UUID, characteristic: UUID) {
|
||||
transport?.read(service, characteristic)
|
||||
?: logW("readFrom called without transport")
|
||||
}
|
||||
|
||||
/** Publish a fully parsed measurement to the app. */
|
||||
protected fun publish(measurement: ScaleMeasurement) {
|
||||
logI("\u2190 publish measurement to app")
|
||||
callbacks?.onPublish(measurement)
|
||||
?: logW("publish called without callbacks")
|
||||
}
|
||||
|
||||
/** Ask the adapter to terminate the link. */
|
||||
protected fun requestDisconnect() {
|
||||
logD("\u2192 request BLE disconnect")
|
||||
transport?.disconnect()
|
||||
}
|
||||
|
||||
fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean {
|
||||
val hasUUID = transport?.hasCharacteristic(service, characteristic) ?: false
|
||||
if (!hasUUID)
|
||||
logD("hasCharacteristic: $service/$characteristic → false")
|
||||
return hasUUID
|
||||
}
|
||||
|
||||
protected fun getPeripheral(): BluetoothPeripheral? {
|
||||
return transport?.getPeripheral()
|
||||
}
|
||||
|
||||
/** Helper to build a 16-bit Bluetooth Base UUID (e.g., `uuid16(0xFFE4)`). */
|
||||
protected fun uuid16(short: Int): UUID =
|
||||
UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short))
|
||||
|
||||
protected fun resolveString(@StringRes resId: Int, vararg args: Any): String =
|
||||
callbacks?.resolveString(resId, *args) ?: "res:$resId"
|
||||
|
||||
protected fun settingsGetInt(key: String, default: Int = -1): Int = settings.getInt(key, default)
|
||||
protected fun settingsPutInt(key: String, value: Int) { settings.putInt(key, value) }
|
||||
|
||||
protected fun settingsGetString(key: String, default: String? = null): String? = settings.getString(key, default)
|
||||
protected fun settingsPutString(key: String, value: String) { settings.putString(key, value) }
|
||||
|
||||
protected fun currentAppUser(): ScaleUser = data.currentUser()
|
||||
protected fun usersForDevice(): List<ScaleUser> = data.usersForDevice()
|
||||
protected fun lastMeasurementFor(userId: Int): ScaleMeasurement? = data.lastMeasurementFor(userId)
|
||||
|
||||
// --- Logging shortcuts (route to LogManager under a single TAG) ------------
|
||||
|
||||
protected fun logD(msg: String) = LogManager.d(TAG, msg)
|
||||
protected fun logI(msg: String) = LogManager.i(TAG, msg)
|
||||
protected fun logW(msg: String) = LogManager.w(TAG, msg, null)
|
||||
protected fun logE(msg: String, t: Throwable? = null) = LogManager.e(TAG, msg, t)
|
||||
|
||||
// Human-readable messages for users (e.g., snackbars/toasts)
|
||||
protected fun userInfo(@StringRes resId: Int, vararg args: Any) {
|
||||
callbacks?.onInfo(resId, *args) ?: logD("userInfo dropped: res=$resId")
|
||||
}
|
||||
protected fun userWarn(@StringRes resId: Int, vararg args: Any) {
|
||||
callbacks?.onWarn(resId, *args) ?: logW("userWarn dropped: res=$resId")
|
||||
}
|
||||
protected fun userError(@StringRes resId: Int, vararg args: Any, t: Throwable? = null) {
|
||||
callbacks?.onError(resId, t, *args) ?: logE("userError dropped: res=$resId", t)
|
||||
}
|
||||
|
||||
protected fun requestUserInteraction(
|
||||
interactionType: UserInteractionType,
|
||||
data: Any?
|
||||
) {
|
||||
callbacks?.onUserInteractionRequired(interactionType, data)
|
||||
?: logW("requestUserInteraction dropped: $interactionType")
|
||||
}
|
||||
|
||||
open suspend fun onUserInteractionFeedback(
|
||||
interactionType: UserInteractionType,
|
||||
appUserId: Int,
|
||||
feedbackData: Any) { /* no-op */ }
|
||||
|
||||
// --- Wiring provided by the adapter ---------------------------------------
|
||||
|
||||
private var transport: Transport? = null
|
||||
private var callbacks: Callbacks? = null
|
||||
private lateinit var settings: DriverSettings
|
||||
private lateinit var data: DataProvider
|
||||
private var _scope: CoroutineScope? = null
|
||||
|
||||
/**
|
||||
* Lifecycle-bound coroutine scope provided by the adapter (cancelled when the communicator
|
||||
* is closed). Handlers that need timeout/fallback coroutines should use this instead of
|
||||
* creating their own scope. Valid after [attach] — i.e. inside onConnected/onNotification.
|
||||
*/
|
||||
protected val scope: CoroutineScope
|
||||
get() = _scope ?: error("ScaleDeviceHandler.scope accessed before attach()")
|
||||
/**
|
||||
* BLE transport the adapter provides. No threading/queueing implied here—
|
||||
* the adapter already serializes and paces I/O.
|
||||
*/
|
||||
interface Transport {
|
||||
fun setNotifyOn(service: UUID, characteristic: UUID)
|
||||
fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean = true)
|
||||
fun read(service: UUID, characteristic: UUID)
|
||||
fun disconnect()
|
||||
fun getPeripheral(): BluetoothPeripheral? = null
|
||||
fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean
|
||||
}
|
||||
|
||||
// ----- DataProvider: live app data the handler can query -----
|
||||
interface DataProvider {
|
||||
/** Currently selected app user (may be null if none). */
|
||||
fun currentUser(): ScaleUser
|
||||
|
||||
/** Fresh snapshot of app users that are relevant for this device. */
|
||||
fun usersForDevice(): List<ScaleUser>
|
||||
|
||||
/** Latest saved measurement for the given user (or null if none). */
|
||||
fun lastMeasurementFor(userId: Int): ScaleMeasurement?
|
||||
}
|
||||
|
||||
interface DriverSettings {
|
||||
fun getInt(key: String, default: Int = -1): Int
|
||||
fun putInt(key: String, value: Int)
|
||||
|
||||
fun getString(key: String, default: String? = null): String?
|
||||
fun putString(key: String, value: String)
|
||||
|
||||
fun remove(key: String)
|
||||
}
|
||||
|
||||
/** App callbacks to emit parsed results and user-visible messages. */
|
||||
interface Callbacks {
|
||||
fun onPublish(measurement: ScaleMeasurement)
|
||||
fun onInfo(@StringRes resId: Int, vararg args: Any) { /* optional */ }
|
||||
fun onWarn(@StringRes resId: Int, vararg args: Any) { /* optional */ }
|
||||
fun onError(@StringRes resId: Int, t: Throwable? = null, vararg args: Any) { /* optional */ }
|
||||
|
||||
fun onUserInteractionRequired(interactionType: UserInteractionType, data: Any?) { /* optional */ }
|
||||
fun resolveString(@StringRes resId: Int, vararg args: Any): String
|
||||
}
|
||||
|
||||
// --- Small utils -----------------------------------------------------------
|
||||
|
||||
/** Pretty print a few leading bytes of a payload for logs. */
|
||||
fun ByteArray.toHexPreview(limit: Int): String {
|
||||
if (limit <= 0 || isEmpty()) return "(payload ${size}b)"
|
||||
val show = min(size, limit)
|
||||
val sb = StringBuilder("payload=[")
|
||||
for (i in 0 until show) {
|
||||
if (i > 0) sb.append(' ')
|
||||
sb.append(String.format("%02X", this[i]))
|
||||
}
|
||||
if (size > limit) sb.append(" …(+").append(size - limit).append("b)")
|
||||
sb.append(']')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* ASCII preview of the first `max` bytes; non-printable bytes are rendered as '?'.
|
||||
*/
|
||||
fun ByteArray.toAsciiPreview(max: Int = 64): String {
|
||||
if (isEmpty()) return ""
|
||||
val n = min(size, max)
|
||||
val sb = StringBuilder(n)
|
||||
for (i in 0 until n) {
|
||||
val ch = (this[i].toInt() and 0xFF).toChar()
|
||||
sb.append(if (ch.isISOControl()) '?' else ch)
|
||||
}
|
||||
if (size > max) sb.append("…(+").append(size - max).append("b)")
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.core.content.getSystemService
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.welie.blessed.BluetoothPeripheral
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.UUID
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* SPP (Bluetooth Classic / RFCOMM) adapter that plugs a [ScaleDeviceHandler] into a raw byte stream.
|
||||
*
|
||||
* Tuning usage:
|
||||
* - Reconnect cooldown between attempts (common.reconnectCooldownMs)
|
||||
* - Bounded retry on initial connect (common.maxRetries + common.retryBackoffMs)
|
||||
* - Connect timeout (connectTimeoutMs)
|
||||
* - Chunked writes (writeChunkBytes + interChunkDelayMs)
|
||||
* - Small settle delay after connect (derived from interChunkDelayMs)
|
||||
*/
|
||||
class SppScaleAdapter(
|
||||
context: Context,
|
||||
settingsFacade: SettingsFacade,
|
||||
measurementFacade: MeasurementFacade,
|
||||
userFacade: UserFacade,
|
||||
handler: ScaleDeviceHandler,
|
||||
profile: TuningProfile = TuningProfile.Balanced
|
||||
) : ModernScaleAdapter(context, settingsFacade, measurementFacade, userFacade, handler) {
|
||||
|
||||
private val tuning: BtSppTuning = profile.forSpp()
|
||||
|
||||
private var sppSocket: BluetoothSocket? = null
|
||||
private var sppReaderJob: Job? = null
|
||||
private var sppIn: InputStream? = null
|
||||
private var sppOut: OutputStream? = null
|
||||
|
||||
private val writeMutex = Mutex()
|
||||
|
||||
@Composable
|
||||
override fun DeviceConfigurationUi() {
|
||||
// Delegate to the actual protocol handler
|
||||
handler.DeviceConfigurationUi()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun doConnect(address: String, selectedUser: ScaleUser) {
|
||||
val btManager: BluetoothManager? = context.getSystemService()
|
||||
val adapter: BluetoothAdapter? = btManager?.adapter
|
||||
|
||||
if (adapter == null) {
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(address, context.getString(R.string.bt_error_no_bluetooth_adapter)))
|
||||
return
|
||||
}
|
||||
|
||||
val device: BluetoothDevice = try {
|
||||
adapter.getRemoteDevice(address)
|
||||
} catch (_: Throwable) {
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(address, context.getString(R.string.bt_error_no_device_found)))
|
||||
return
|
||||
}
|
||||
|
||||
_isConnecting.value = true
|
||||
_isConnected.value = false
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
// Cooldown between attempts
|
||||
val since = SystemClock.elapsedRealtime() - lastDisconnectAtMs
|
||||
if (since in 1 until tuning.common.reconnectCooldownMs) {
|
||||
delay((tuning.common.reconnectCooldownMs - since).milliseconds)
|
||||
}
|
||||
|
||||
safeCancelDiscovery(adapter)
|
||||
|
||||
var attempt = 0
|
||||
while (isActive) {
|
||||
try {
|
||||
LogManager.i(TAG, "Attempting SPP connection (attempt ${attempt + 1})")
|
||||
val socket = device.createRfcommSocketToServiceRecord(ScaleDeviceHandler.CLASSIC_DATA_UUID)
|
||||
sppSocket = socket
|
||||
|
||||
// --- Connect with manual timeout guard ---
|
||||
var connected = false
|
||||
// Start the blocking connect() in a child job
|
||||
val connectJob = launch(Dispatchers.IO) {
|
||||
socket.connect() // blocking call
|
||||
connected = true
|
||||
LogManager.i(TAG, "SPP connect() succeeded")
|
||||
}
|
||||
// Start a guard that closes the socket if connect takes too long
|
||||
val guardJob = launch(Dispatchers.IO) {
|
||||
val to = tuning.connectTimeoutMs
|
||||
if (to > 0) {
|
||||
delay(to.milliseconds)
|
||||
if (!connected) {
|
||||
LogManager.w(TAG, "Connect timeout reached ($to ms), closing socket")
|
||||
// Force the connect() to abort by closing the socket
|
||||
runCatching { socket.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait until either connect finishes or guard closes the socket
|
||||
connectJob.join()
|
||||
guardJob.cancel()
|
||||
|
||||
// If connect failed, connect() would have thrown and we’d be in catch{}
|
||||
sppIn = socket.inputStream
|
||||
sppOut = socket.outputStream
|
||||
|
||||
// Small settle delay before wiring the handler
|
||||
val settleDelay = maxOf(50L, tuning.interChunkDelayMs * 3)
|
||||
delay(settleDelay.milliseconds)
|
||||
|
||||
_isConnecting.value = false
|
||||
_isConnected.value = true
|
||||
|
||||
val name = safeDeviceName(device)
|
||||
val addr = safeDeviceAddress(device)
|
||||
LogManager.i(TAG, "Connected to device $name [$addr]")
|
||||
_events.tryEmit(BluetoothEvent.Connected(name, addr))
|
||||
|
||||
// Attach handler
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
handler.attach(sppTransport, appCallbacks, driverSettings, dataProvider, scope)
|
||||
handler.handleConnected(selectedUser)
|
||||
|
||||
// Reader loop (idle-timeout via available()+delay)
|
||||
sppReaderJob = launch(Dispatchers.IO) {
|
||||
val buf = ByteArray(1024)
|
||||
var lastRx = SystemClock.elapsedRealtime()
|
||||
try {
|
||||
while (isActive) {
|
||||
val ins = sppIn ?: break
|
||||
val avail = runCatching { ins.available() }.getOrDefault(0)
|
||||
|
||||
if (avail > 0) {
|
||||
val n = ins.read(buf, 0, min(buf.size, avail))
|
||||
if (n <= 0) break
|
||||
lastRx = SystemClock.elapsedRealtime()
|
||||
val payload = buf.copyOf(n)
|
||||
LogManager.d(TAG, "Received $n bytes from SPP ${payload.toHexPreview(24)}")
|
||||
handler.handleNotification(ScaleDeviceHandler.CLASSIC_DATA_UUID, payload)
|
||||
} else {
|
||||
delay(50.milliseconds)
|
||||
val idle = SystemClock.elapsedRealtime() - lastRx
|
||||
if (tuning.readTimeoutMs > 0 && idle >= tuning.readTimeoutMs) {
|
||||
LogManager.w(TAG, "Read idle timeout reached, disconnecting")
|
||||
sppTransport.disconnect()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
LogManager.w(TAG, "SPP read error: ${t.message}", t)
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
val da = safeDeviceAddress(device)
|
||||
LogManager.i(TAG, "Reader loop finished, emitting disconnect")
|
||||
_events.tryEmit(BluetoothEvent.Disconnected(da, "SPP stream closed"))
|
||||
lastDisconnectAtMs = SystemClock.elapsedRealtime()
|
||||
cleanup()
|
||||
doDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// success → exit retry loop
|
||||
break
|
||||
} catch (t: Throwable) {
|
||||
attempt++
|
||||
LogManager.e(TAG, "SPP connect failed (attempt $attempt/${tuning.common.maxRetries}): ${t.message}", t)
|
||||
|
||||
if (attempt <= tuning.common.maxRetries) {
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_reconnecting_try, attempt, tuning.common.maxRetries),
|
||||
address
|
||||
)
|
||||
)
|
||||
delay(tuning.common.retryBackoffMs.milliseconds)
|
||||
safeCancelDiscovery(adapter)
|
||||
continue
|
||||
} else {
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(address, t.message ?: "SPP connect failed"))
|
||||
lastDisconnectAtMs = SystemClock.elapsedRealtime()
|
||||
cleanup()
|
||||
doDisconnect()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun doDisconnect() {
|
||||
sppReaderJob?.cancel(); sppReaderJob = null
|
||||
runCatching { sppIn?.close() }; sppIn = null
|
||||
runCatching { sppOut?.close() }; sppOut = null
|
||||
runCatching { sppSocket?.close() }; sppSocket = null
|
||||
runCatching { handler.handleDisconnected() }
|
||||
runCatching { handler.detach() }
|
||||
_isConnected.value = false
|
||||
_isConnecting.value = false
|
||||
lastDisconnectAtMs = SystemClock.elapsedRealtime()
|
||||
}
|
||||
|
||||
// --- Transport exposed to the handler --------------------------------------------------------
|
||||
|
||||
private val sppTransport = object : ScaleDeviceHandler.Transport {
|
||||
override fun setNotifyOn(service: UUID, characteristic: UUID) {
|
||||
// Not applicable for SPP: stream is always "notifying"
|
||||
}
|
||||
|
||||
override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) {
|
||||
// RFCOMM is a stream; we still pace writes and chunk large payloads
|
||||
scope.launch(Dispatchers.IO) {
|
||||
writeMutex.withLock {
|
||||
try {
|
||||
LogManager.d(TAG, "Starting write of ${payload.size} bytes to SPP: ${payload.toHexPreview(24)}")
|
||||
val chunk = maxOf(1, tuning.writeChunkBytes)
|
||||
var i = 0
|
||||
while (i < payload.size) {
|
||||
val end = min(i + chunk, payload.size)
|
||||
sppOut?.write(payload, i, end - i)
|
||||
sppOut?.flush()
|
||||
val writtenChunk = payload.copyOfRange(i, end)
|
||||
LogManager.d(TAG, "Wrote chunk ${i / chunk + 1}: ${writtenChunk.toHexPreview(16)}")
|
||||
i = end
|
||||
if (i < payload.size && tuning.interChunkDelayMs > 0) {
|
||||
delay(tuning.interChunkDelayMs.milliseconds)
|
||||
}
|
||||
}
|
||||
LogManager.i(TAG, "Finished writing ${payload.size} bytes to SPP")
|
||||
} catch (t: Throwable) {
|
||||
LogManager.e(TAG, "SPP write failed: ${t.message}", t)
|
||||
appCallbacks.onWarn(R.string.bt_warn_write_failed_status,"SPP",t.message ?: "write failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun read(service: UUID, characteristic: UUID) {
|
||||
// Not applicable for SPP; reads are handled by the continuous reader loop
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
doDisconnect()
|
||||
}
|
||||
|
||||
override fun getPeripheral(): BluetoothPeripheral? = null
|
||||
|
||||
override fun hasCharacteristic(
|
||||
service: UUID,
|
||||
characteristic: UUID
|
||||
): Boolean {
|
||||
// Not applicable for SPP
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers with defensive permission handling ---------------------------------------------
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun safeCancelDiscovery(adapter: BluetoothAdapter) {
|
||||
try {
|
||||
if (adapter.isDiscovering) adapter.cancelDiscovery()
|
||||
} catch (se: SecurityException) {
|
||||
LogManager.w(TAG, "cancelDiscovery blocked by missing permission", se)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun safeDeviceName(device: BluetoothDevice): String =
|
||||
try { device.name } catch (_: SecurityException) { null } ?: "Unknown"
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun safeDeviceAddress(device: BluetoothDevice): String =
|
||||
try { device.address } catch (_: SecurityException) { "unknown" }
|
||||
}
|
||||
180
app/src/main/java/com/health/openscale/core/data/Enums.kt
Normal file
180
app/src/main/java/com/health/openscale/core/data/Enums.kt
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* FitnessDroid: nedbantad kopia av openScales Enums.kt — bara det som
|
||||
* bluetooth-drivrutinerna behöver (strängresurser/ikoner urplockade).
|
||||
*/
|
||||
package com.health.openscale.core.data
|
||||
|
||||
enum class GenderType {
|
||||
MALE,
|
||||
FEMALE;
|
||||
|
||||
fun isMale(): Boolean {
|
||||
return this == MALE
|
||||
}
|
||||
}
|
||||
|
||||
enum class ActivityLevel {
|
||||
SEDENTARY, MILD, MODERATE, HEAVY, EXTREME;
|
||||
|
||||
fun toInt(): Int {
|
||||
when (this) {
|
||||
SEDENTARY -> return 0
|
||||
MILD -> return 1
|
||||
MODERATE -> return 2
|
||||
HEAVY -> return 3
|
||||
EXTREME -> return 4
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromInt(unit: Int): ActivityLevel {
|
||||
when (unit) {
|
||||
0 -> return SEDENTARY
|
||||
1 -> return MILD
|
||||
2 -> return MODERATE
|
||||
3 -> return HEAVY
|
||||
4 -> return EXTREME
|
||||
}
|
||||
return SEDENTARY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class WeightUnit {
|
||||
KG, LB, ST;
|
||||
|
||||
override fun toString(): String {
|
||||
when (this) {
|
||||
LB -> return "lb"
|
||||
ST -> return "st"
|
||||
KG -> return "kg"
|
||||
}
|
||||
}
|
||||
|
||||
fun toInt(): Int {
|
||||
when (this) {
|
||||
LB -> return 1
|
||||
ST -> return 2
|
||||
KG -> return 0
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromInt(unit: Int): WeightUnit {
|
||||
when (unit) {
|
||||
1 -> return LB
|
||||
2 -> return ST
|
||||
else -> return KG
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class MeasurementTypeKey(val id: Int) {
|
||||
WEIGHT(1),
|
||||
BMI(2),
|
||||
BODY_FAT(3),
|
||||
WATER(4),
|
||||
MUSCLE(5),
|
||||
LBM(6),
|
||||
BONE(7),
|
||||
WAIST(8),
|
||||
WHR(9),
|
||||
WHTR(10),
|
||||
HIPS(11),
|
||||
VISCERAL_FAT(12),
|
||||
CHEST(13),
|
||||
THIGH(14),
|
||||
BICEPS(15),
|
||||
NECK(16),
|
||||
CALIPER_1(17),
|
||||
CALIPER_2(18),
|
||||
CALIPER_3(19),
|
||||
CALIPER(20),
|
||||
BMR(21),
|
||||
TDEE(22),
|
||||
HEART_RATE(23),
|
||||
CALORIES(24),
|
||||
DATE(25),
|
||||
TIME(26),
|
||||
COMMENT(27),
|
||||
USER(28),
|
||||
IMPEDANCE(29),
|
||||
IMPEDANCE_LOW(30),
|
||||
ECW(31),
|
||||
ICW(32),
|
||||
PROTEIN(33),
|
||||
BCM(34),
|
||||
CUSTOM(99);
|
||||
}
|
||||
|
||||
enum class MeasureUnit {
|
||||
CM, INCH;
|
||||
|
||||
override fun toString(): String {
|
||||
when (this) {
|
||||
CM -> return "cm"
|
||||
INCH -> return "in"
|
||||
}
|
||||
}
|
||||
|
||||
fun toInt(): Int {
|
||||
when (this) {
|
||||
CM -> return 0
|
||||
INCH -> return 1
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromInt(unit: Int): MeasureUnit {
|
||||
when (unit) {
|
||||
1 -> return INCH
|
||||
else -> return CM
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class UnitType(val displayName: String) {
|
||||
KG("kg"),
|
||||
LB("lb"),
|
||||
ST("st"),
|
||||
PERCENT("%"),
|
||||
CM("cm"),
|
||||
INCH("in"),
|
||||
KCAL("kcal"),
|
||||
BPM("bpm"),
|
||||
OHM("Ω"),
|
||||
NONE("");
|
||||
|
||||
fun isWeightUnit(): Boolean {
|
||||
return this == KG || this == LB || this == ST
|
||||
}
|
||||
|
||||
fun toWeightUnit(): WeightUnit {
|
||||
return when (this) {
|
||||
LB -> WeightUnit.LB
|
||||
ST -> WeightUnit.ST
|
||||
else -> WeightUnit.KG
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/src/main/java/com/health/openscale/core/data/User.kt
Normal file
39
app/src/main/java/com/health/openscale/core/data/User.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* FitnessDroid-shim för openScales User-entitet (upstream är en Room-entitet).
|
||||
* Bara fälten som bluetooth-adaptrarnas mapUser() läser.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.health.openscale.core.data
|
||||
|
||||
data class User(
|
||||
val id: Int = 1,
|
||||
val name: String = "",
|
||||
/** Födelsedag i epoch-millis (null = okänd) */
|
||||
val birthDate: Long? = null,
|
||||
/** Längd i cm */
|
||||
val heightCm: Float = -1f,
|
||||
val gender: GenderType = GenderType.MALE,
|
||||
val activityLevel: ActivityLevel = ActivityLevel.SEDENTARY,
|
||||
)
|
||||
|
||||
/** Målvikt m.m. — används av mapUser(); FitnessDroid har inga mål ännu. */
|
||||
data class Goal(
|
||||
val measurementTypeId: Int,
|
||||
val goalValue: Float,
|
||||
)
|
||||
|
||||
/** Mättyp med enhet — används för att avgöra vågens viktenhet. */
|
||||
data class MeasurementType(
|
||||
val id: Int,
|
||||
val key: MeasurementTypeKey,
|
||||
val unit: UnitType,
|
||||
)
|
||||
|
||||
/** Ett enskilt mätvärde. */
|
||||
data class MeasurementValue(
|
||||
val floatValue: Float?,
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* FitnessDroid-shims för openScales facades (upstream pratar med Room +
|
||||
* inställningssystem). Här backas de av DataStore respektive appens profil,
|
||||
* med exakt den yta som bluetooth-paketet använder:
|
||||
*
|
||||
* - SettingsFacade: drivrutinernas key/value-inställningar + tuning-profil
|
||||
* - UserFacade: aktuell användare (längd/ålder/kön) som drivrutinerna behöver
|
||||
* - MeasurementFacade: senaste mätningar (används av vissa drivrutiner för
|
||||
* igenkänning av användare) — tom tills vidare
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.health.openscale.core.facade
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.health.openscale.core.data.Goal
|
||||
import com.health.openscale.core.data.MeasurementType
|
||||
import com.health.openscale.core.data.MeasurementTypeKey
|
||||
import com.health.openscale.core.data.UnitType
|
||||
import com.health.openscale.core.data.User
|
||||
import com.health.openscale.core.model.MeasurementWithValues
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.driverDataStore by preferencesDataStore(name = "openscale_driver")
|
||||
|
||||
class SettingsFacade(private val context: Context) {
|
||||
|
||||
/** Sparad BLE-tuning-profil ("Conservative"/"Balanced"/"Aggressive"), null = handlerns default. */
|
||||
val savedBluetoothTuneProfile: Flow<String?> =
|
||||
context.driverDataStore.data.map { it[stringPreferencesKey("tuning_profile")] }
|
||||
|
||||
/** openScales utvecklarläge (dumpar GATT-trädet i loggen istället för att mäta). */
|
||||
val developerModeEnabled: Flow<Boolean> =
|
||||
context.driverDataStore.data.map { it[stringPreferencesKey("developer_mode")] == "true" }
|
||||
|
||||
fun observeSetting(key: String, default: Int): Flow<Int> =
|
||||
context.driverDataStore.data.map { it[intPreferencesKey(key)] ?: default }
|
||||
|
||||
fun observeSetting(key: String, default: String): Flow<String> =
|
||||
context.driverDataStore.data.map { it[stringPreferencesKey(key)] ?: default }
|
||||
|
||||
suspend fun saveSetting(key: String, value: Int) {
|
||||
context.driverDataStore.edit { it[intPreferencesKey(key)] = value }
|
||||
}
|
||||
|
||||
suspend fun saveSetting(key: String, value: String) {
|
||||
context.driverDataStore.edit { it[stringPreferencesKey(key)] = value }
|
||||
}
|
||||
}
|
||||
|
||||
class UserFacade {
|
||||
|
||||
private val selectedUser = MutableStateFlow<User?>(null)
|
||||
|
||||
/** Appen uppdaterar den aktuella användaren (längd/ålder/kön) härifrån. */
|
||||
fun setSelectedUser(user: User?) {
|
||||
selectedUser.value = user
|
||||
}
|
||||
|
||||
fun observeSelectedUser(): StateFlow<User?> = selectedUser
|
||||
|
||||
fun observeAllUsers(): Flow<List<User>> = selectedUser.map { listOfNotNull(it) }
|
||||
|
||||
fun getAllGoalsForUser(userId: Int): Flow<List<Goal>> = flowOf(emptyList())
|
||||
}
|
||||
|
||||
class MeasurementFacade {
|
||||
|
||||
/** Senaste mätningar per användare — används av vissa drivrutiner för användar-igenkänning. */
|
||||
fun getMeasurementsForUser(userId: Int): Flow<List<MeasurementWithValues>> = flowOf(emptyList())
|
||||
|
||||
fun getAllMeasurementTypes(): Flow<List<MeasurementType>> = flowOf(
|
||||
listOf(MeasurementType(MeasurementTypeKey.WEIGHT.id, MeasurementTypeKey.WEIGHT, UnitType.KG))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* FitnessDroid-shim för openScales MeasurementWithValues (upstream är en
|
||||
* Room-relation). Bara det som bluetooth-adaptrarnas mapMeasurement() läser.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.health.openscale.core.model
|
||||
|
||||
import com.health.openscale.core.data.MeasurementType
|
||||
import com.health.openscale.core.data.MeasurementValue
|
||||
|
||||
data class Measurement(
|
||||
val userId: Int,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
data class ValueWithType(
|
||||
val type: MeasurementType,
|
||||
val value: MeasurementValue,
|
||||
)
|
||||
|
||||
data class MeasurementWithValues(
|
||||
val measurement: Measurement,
|
||||
val values: List<ValueWithType>,
|
||||
)
|
||||
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.SparseArray
|
||||
import com.health.openscale.core.bluetooth.ScaleFactory
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.welie.blessed.BluetoothCentralManager
|
||||
import com.welie.blessed.BluetoothCentralManagerCallback
|
||||
import com.welie.blessed.BluetoothPeripheral
|
||||
import com.welie.blessed.ScanFailure
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import androidx.core.util.isNotEmpty
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Data class to hold information about a scanned Bluetooth LE device.
|
||||
*
|
||||
* @property name The advertised name of the device. Can be null.
|
||||
* @property address The MAC address of the device.
|
||||
* @property rssi The received signal strength indicator (RSSI) in dBm.
|
||||
* @property serviceUuids A list of service UUIDs advertised by the device.
|
||||
* @property manufacturerData Manufacturer-specific data advertised by the device.
|
||||
* @property serviceData Service-data payloads advertised by the device.
|
||||
* @property isSupported Flag indicating whether openScale has a handler for this device.
|
||||
* @property determinedHandlerDisplayName The display name of the handler determined for this device, if any.
|
||||
*/
|
||||
data class ScannedDeviceInfo(
|
||||
var name: String,
|
||||
val address: String,
|
||||
val rssi: Int,
|
||||
val serviceUuids: List<UUID>,
|
||||
val manufacturerData: SparseArray<ByteArray>?,
|
||||
val serviceData: Map<UUID, ByteArray> = emptyMap(),
|
||||
var isSupported: Boolean = false,
|
||||
var determinedHandlerDisplayName: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Manages Bluetooth LE device scanning operations using the Blessed library.
|
||||
*
|
||||
* This class handles starting, stopping, and processing scan results. It exposes
|
||||
* [StateFlow]s for discovered devices, scanning status, and scan errors, allowing
|
||||
* UI components or ViewModels to observe scanning activity.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param externalScope A [CoroutineScope] (typically from a ViewModel) for launching tasks like scan timeouts.
|
||||
* @param scaleFactory An instance of [ScaleFactory] used to determine device support and handler information.
|
||||
*/
|
||||
class BluetoothScannerManager(
|
||||
private val context: Context,
|
||||
private val externalScope: CoroutineScope,
|
||||
private val scaleFactory: ScaleFactory
|
||||
) {
|
||||
private companion object {
|
||||
const val TAG = "BluetoothScannerMgr"
|
||||
}
|
||||
|
||||
// Ensures Blessed library callbacks are executed on the main thread.
|
||||
private val blessedBluetoothHandler = Handler(Looper.getMainLooper())
|
||||
private val centralManager: BluetoothCentralManager by lazy {
|
||||
BluetoothCentralManager(context, centralManagerCallback, blessedBluetoothHandler)
|
||||
}
|
||||
|
||||
private val _scannedDevices = MutableStateFlow<List<ScannedDeviceInfo>>(emptyList())
|
||||
/**
|
||||
* Emits the current list of discovered and processed [ScannedDeviceInfo] objects.
|
||||
* The list is sorted by support status (supported first), then by RSSI (strongest signal first),
|
||||
* and finally by device name.
|
||||
*/
|
||||
val scannedDevices: StateFlow<List<ScannedDeviceInfo>> = _scannedDevices.asStateFlow()
|
||||
|
||||
private val _isScanning = MutableStateFlow(false)
|
||||
/**
|
||||
* Emits `true` if a Bluetooth LE scan is currently active, `false` otherwise.
|
||||
*/
|
||||
val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
|
||||
|
||||
private val _scanError = MutableStateFlow<String?>(null)
|
||||
/**
|
||||
* Emits error messages related to the scanning process.
|
||||
* Emits `null` if there is no current error or an error has been cleared.
|
||||
*/
|
||||
val scanError: StateFlow<String?> = _scanError.asStateFlow()
|
||||
|
||||
private var scanTimeoutJob: Job? = null
|
||||
// Stores unique devices found during a scan, keyed by MAC address, for efficient updates.
|
||||
private val deviceMap = mutableMapOf<String, ScannedDeviceInfo>()
|
||||
|
||||
/**
|
||||
* Starts a Bluetooth LE scan for a specified duration.
|
||||
*
|
||||
* Prerequisites (e.g., Bluetooth enabled, permissions granted) are checked.
|
||||
* If a scan is already in progress, this method returns without action.
|
||||
*
|
||||
* @param scanDurationMs The duration in milliseconds for the scan.
|
||||
* The scan automatically stops after this period if not manually stopped earlier.
|
||||
*/
|
||||
@SuppressLint("MissingPermission") // Permissions are expected to be checked by the calling ViewModel.
|
||||
fun startScan(scanDurationMs: Long) {
|
||||
if (!validateScanPrerequisites()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (_isScanning.value || centralManager.isScanning) {
|
||||
LogManager.d(TAG, "Scan is already in progress.")
|
||||
return
|
||||
}
|
||||
LogManager.i(TAG, "Starting device scan for $scanDurationMs ms.")
|
||||
|
||||
deviceMap.clear()
|
||||
_scannedDevices.value = emptyList()
|
||||
_scanError.value = null // Clear previous errors.
|
||||
_isScanning.value = true
|
||||
|
||||
try {
|
||||
centralManager.scanForPeripherals()
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Exception while starting scan: ${e.message}", e)
|
||||
_scanError.value = "Error starting scan: ${e.localizedMessage ?: "Unknown error"}"
|
||||
_isScanning.value = false
|
||||
return
|
||||
}
|
||||
|
||||
scanTimeoutJob?.cancel()
|
||||
scanTimeoutJob = externalScope.launch {
|
||||
delay(scanDurationMs.milliseconds)
|
||||
if (_isScanning.value) {
|
||||
LogManager.i(TAG, "Scan timeout reached after $scanDurationMs ms.")
|
||||
stopScanInternal(isTimeout = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the currently active Bluetooth LE scan.
|
||||
*/
|
||||
fun stopScan() {
|
||||
stopScanInternal(isTimeout = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation for stopping the scan.
|
||||
* @param isTimeout Indicates if the stop was triggered by a timeout.
|
||||
*/
|
||||
private fun stopScanInternal(isTimeout: Boolean) {
|
||||
if (!_isScanning.value && !centralManager.isScanning) {
|
||||
return // Scan not active.
|
||||
}
|
||||
LogManager.i(TAG, "Stopping device scan. Triggered by timeout: $isTimeout")
|
||||
scanTimeoutJob?.cancel()
|
||||
scanTimeoutJob = null
|
||||
|
||||
try {
|
||||
if (centralManager.isScanning) {
|
||||
centralManager.stopScan()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Exception while stopping scan: ${e.message}", e)
|
||||
// Optionally, an error could be set here, but it's often not critical for a stop action.
|
||||
}
|
||||
_isScanning.value = false
|
||||
|
||||
if (isTimeout && deviceMap.isEmpty()) {
|
||||
_scanError.value = "No devices found."
|
||||
}
|
||||
LogManager.d(TAG, "Scan stopped. Found devices: ${deviceMap.size}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if conditions are met to start a scan (e.g., Bluetooth enabled).
|
||||
* Note: Permission checks are the responsibility of the calling ViewModel.
|
||||
*
|
||||
* @return `true` if prerequisites are met, `false` otherwise.
|
||||
* If `false`, `_scanError` is updated with the reason.
|
||||
*/
|
||||
private fun validateScanPrerequisites(): Boolean {
|
||||
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?
|
||||
if (bluetoothManager?.adapter?.isEnabled != true) {
|
||||
LogManager.w(TAG, "Scan prerequisites not met: Bluetooth is disabled.")
|
||||
_scanError.value = "Bluetooth is disabled. Please enable it to scan."
|
||||
return false
|
||||
}
|
||||
|
||||
if (_isScanning.value) {
|
||||
LogManager.d(TAG, "Scan is already in progress (checked in validate).")
|
||||
return false
|
||||
}
|
||||
_scanError.value = null // Clear errors if prerequisites are met.
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any active scan error message from `scanError` StateFlow.
|
||||
*/
|
||||
fun clearScanError() {
|
||||
if (_scanError.value != null) {
|
||||
_scanError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases resources used by the scanner, including the Blessed [BluetoothCentralManager].
|
||||
* Call this when the scanner is no longer needed (e.g., in ViewModel's `onCleared`).
|
||||
*/
|
||||
fun close() {
|
||||
LogManager.i(TAG, "Closing BluetoothScannerManager.")
|
||||
stopScanInternal(isTimeout = false) // Ensure scan is stopped.
|
||||
try {
|
||||
// Crucial to close BluetoothCentralManager to release system resources
|
||||
// and unregister internal broadcast receivers used by the Blessed library.
|
||||
centralManager.close()
|
||||
LogManager.d(TAG, "Blessed BluetoothCentralManager closed successfully.")
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Error closing Blessed BluetoothCentralManager: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private val centralManagerCallback = object : BluetoothCentralManagerCallback() {
|
||||
@SuppressLint("MissingPermission") // Permissions are handled before scan initiation.
|
||||
override fun onDiscovered(peripheral: BluetoothPeripheral, scanResult: ScanResult) {
|
||||
val deviceName = peripheral.name
|
||||
val deviceAddress = peripheral.address
|
||||
val rssi = scanResult.rssi
|
||||
val serviceUuids: List<UUID> = scanResult.scanRecord?.serviceUuids?.mapNotNull { it?.uuid } ?: emptyList()
|
||||
val manufacturerData: SparseArray<ByteArray>? = scanResult.scanRecord?.manufacturerSpecificData
|
||||
val serviceData: Map<UUID, ByteArray> = scanResult.scanRecord?.serviceData
|
||||
?.mapKeys { it.key.uuid }
|
||||
?.mapValues { it.value.copyOf() }
|
||||
?: emptyMap()
|
||||
|
||||
val newDevice = ScannedDeviceInfo(
|
||||
name = deviceName,
|
||||
address = deviceAddress,
|
||||
rssi = rssi,
|
||||
serviceUuids = serviceUuids,
|
||||
manufacturerData = manufacturerData,
|
||||
serviceData = serviceData,
|
||||
isSupported = false, // will be determined in the next getSupportingHandlerInfo
|
||||
determinedHandlerDisplayName = null // // will be determined in the next getSupportingHandlerInfo
|
||||
)
|
||||
|
||||
val (isSupported, handlerName) = scaleFactory.getSupportingHandlerInfo(newDevice)
|
||||
|
||||
newDevice.isSupported = isSupported
|
||||
newDevice.determinedHandlerDisplayName = handlerName
|
||||
|
||||
val existingDevice = deviceMap[newDevice.address]
|
||||
var listShouldBeUpdated = false
|
||||
|
||||
if (existingDevice != null) {
|
||||
// Update criteria: if RSSI changed, or if key device info (name, support, handler, services, manufacturer data) has improved or changed.
|
||||
val nameChangedToKnown = newDevice.name.isNotEmpty() && existingDevice.name.isEmpty()
|
||||
val supportStatusImproved = !existingDevice.isSupported && newDevice.isSupported
|
||||
val handlerChanged = newDevice.determinedHandlerDisplayName != existingDevice.determinedHandlerDisplayName
|
||||
val serviceUuidsUpdated = newDevice.serviceUuids.isNotEmpty() && newDevice.serviceUuids != existingDevice.serviceUuids
|
||||
val manuDataUpdated = newDevice.manufacturerData != null && !newDevice.manufacturerData.contentEquals(existingDevice.manufacturerData)
|
||||
val serviceDataUpdated = newDevice.serviceData.isNotEmpty() && !newDevice.serviceData.contentEquals(existingDevice.serviceData)
|
||||
|
||||
if (newDevice.rssi != existingDevice.rssi || nameChangedToKnown || supportStatusImproved || handlerChanged || serviceUuidsUpdated || manuDataUpdated || serviceDataUpdated) {
|
||||
deviceMap[newDevice.address] = existingDevice.copy(
|
||||
name = newDevice.name.ifEmpty { existingDevice.name }, // Prefer new name if available.
|
||||
rssi = newDevice.rssi,
|
||||
isSupported = existingDevice.isSupported || newDevice.isSupported, // Retain 'supported' status if ever true.
|
||||
determinedHandlerDisplayName = newDevice.determinedHandlerDisplayName ?: existingDevice.determinedHandlerDisplayName,
|
||||
serviceUuids = if (newDevice.serviceUuids.isNotEmpty()) newDevice.serviceUuids else existingDevice.serviceUuids,
|
||||
manufacturerData = newDevice.manufacturerData ?: existingDevice.manufacturerData,
|
||||
serviceData = newDevice.serviceData.ifEmpty { existingDevice.serviceData }
|
||||
)
|
||||
listShouldBeUpdated = true
|
||||
}
|
||||
} else {
|
||||
// Add new device if it's supported, or has a meaningful name, or provides service/manufacturer data.
|
||||
// This avoids populating the list with devices that have no identifying information and are not supported.
|
||||
if (newDevice.isSupported ||
|
||||
newDevice.name.isNotEmpty() ||
|
||||
newDevice.serviceUuids.isNotEmpty() ||
|
||||
(newDevice.manufacturerData != null && newDevice.manufacturerData.isNotEmpty()) ||
|
||||
newDevice.serviceData.isNotEmpty()
|
||||
) {
|
||||
deviceMap[newDevice.address] = newDevice
|
||||
listShouldBeUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (listShouldBeUpdated) {
|
||||
// Filter ensures only devices that are supported or have a meaningful name (not generic "Unknown Device") are emitted.
|
||||
// Sorting provides a consistent and user-friendly order.
|
||||
_scannedDevices.value = deviceMap.values
|
||||
.filter { it.isSupported || (it.name.isNotEmpty() && it.name != "Unbekanntes Gerät" && it.name != "Unknown Device") }
|
||||
.sortedWith(compareByDescending<ScannedDeviceInfo> { it.isSupported }
|
||||
.thenByDescending { it.rssi }
|
||||
.thenBy { it.name.ifEmpty { "zzzz" }.lowercase() }) // "zzzz" ensures unnamed devices sort last.
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanFailed(scanFailure: ScanFailure) {
|
||||
LogManager.e(TAG, "Bluetooth scan failed: $scanFailure")
|
||||
externalScope.launch {
|
||||
_scanError.value = "Bluetooth Scan Failed: $scanFailure"
|
||||
_isScanning.value = false
|
||||
scanTimeoutJob?.cancel() // Stop scan timeout if scan fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun SparseArray<ByteArray>?.contentEquals(other: SparseArray<ByteArray>?): Boolean {
|
||||
if (this == null || other == null) return this == other
|
||||
if (size() != other.size()) return false
|
||||
for (i in 0 until size()) {
|
||||
val key = keyAt(i)
|
||||
val otherIndex = other.indexOfKey(key)
|
||||
if (otherIndex < 0) return false
|
||||
if (!valueAt(i).contentEquals(other.valueAt(otherIndex))) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun Map<UUID, ByteArray>.contentEquals(other: Map<UUID, ByteArray>): Boolean {
|
||||
if (size != other.size) return false
|
||||
for ((key, value) in this) {
|
||||
val otherValue = other[key] ?: return false
|
||||
if (!value.contentEquals(otherValue)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.utils
|
||||
|
||||
import com.health.openscale.core.data.MeasureUnit
|
||||
import com.health.openscale.core.data.UnitType
|
||||
import com.health.openscale.core.data.WeightUnit
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
object ConverterUtils {
|
||||
private const val KG_LB: Float = 2.20462f
|
||||
private const val KG_ST: Float = 0.157473f
|
||||
private const val CM_IN: Float = 0.393701f
|
||||
|
||||
private const val LB_PER_ST_DOUBLE: Double = 14.0
|
||||
|
||||
@JvmStatic
|
||||
fun toKilogram(value: Float, unit: WeightUnit): Float {
|
||||
when (unit) {
|
||||
WeightUnit.LB -> return value / KG_LB
|
||||
WeightUnit.ST -> return value / KG_ST
|
||||
WeightUnit.KG -> return value
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun fromKilogram(kg: Float, unit: WeightUnit): Float {
|
||||
when (unit) {
|
||||
WeightUnit.LB -> return kg * KG_LB
|
||||
WeightUnit.ST -> return kg * KG_ST
|
||||
WeightUnit.KG -> return kg
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun toCentimeter(value: Float, unit: MeasureUnit): Float {
|
||||
when (unit) {
|
||||
MeasureUnit.INCH -> return value / CM_IN
|
||||
MeasureUnit.CM -> return value
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun fromCentimeter(cm: Float, unit: MeasureUnit): Float {
|
||||
when (unit) {
|
||||
MeasureUnit.INCH -> return cm * CM_IN
|
||||
MeasureUnit.CM -> return cm
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun decimalStToStLb(stDec: Double): Pair<Int, Int> {
|
||||
val totalLb = stDec * LB_PER_ST_DOUBLE
|
||||
var st = floor(totalLb / LB_PER_ST_DOUBLE).toInt()
|
||||
var lb = (totalLb - st * LB_PER_ST_DOUBLE).roundToInt()
|
||||
if (lb == 14) { st += 1; lb = 0 } // normalize carry
|
||||
return st to lb
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun stLbToStDecimal(st: Int, lb: Int): Double =
|
||||
st + (lb / LB_PER_ST_DOUBLE)
|
||||
|
||||
@JvmStatic
|
||||
fun fromSignedInt16Le(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset + 1].toInt() shl 8
|
||||
value += data[offset].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt16Be(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset].toInt() shl 8
|
||||
value += data[offset + 1].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt16Le(data: ByteArray, offset: Int): Int {
|
||||
return fromSignedInt16Le(data, offset) and 0xFFFF
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt16Be(data: ByteArray, offset: Int): Int {
|
||||
return fromSignedInt16Be(data, offset) and 0xFFFF
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt16Le(data: ByteArray, offset: Int, value: Int) {
|
||||
data[offset + 0] = (value and 0xFF).toByte()
|
||||
data[offset + 1] = ((value shr 8) and 0xFF).toByte()
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt16Be(data: ByteArray, offset: Int, value: Int) {
|
||||
data[offset + 0] = ((value shr 8) and 0xFF).toByte()
|
||||
data[offset + 1] = (value and 0xFF).toByte()
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt16Le(value: Int): ByteArray {
|
||||
val data = ByteArray(2)
|
||||
toInt16Le(data, 0, value)
|
||||
return data
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt16Be(value: Int): ByteArray {
|
||||
val data = ByteArray(2)
|
||||
toInt16Be(data, 0, value)
|
||||
return data
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt24Le(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset + 2].toInt() shl 16
|
||||
value += (data[offset + 1].toInt() and 0xFF) shl 8
|
||||
value += data[offset].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt24Be(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset].toInt() shl 16
|
||||
value += (data[offset + 1].toInt() and 0xFF) shl 8
|
||||
value += data[offset + 2].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt24Le(data: ByteArray, offset: Int): Int {
|
||||
return fromSignedInt24Le(data, offset) and 0xFFFFFF
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt24Be(data: ByteArray, offset: Int): Int {
|
||||
return fromSignedInt24Be(data, offset) and 0xFFFFFF
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt32Le(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset + 3].toInt() shl 24
|
||||
value += (data[offset + 2].toInt() and 0xFF) shl 16
|
||||
value += (data[offset + 1].toInt() and 0xFF) shl 8
|
||||
value += data[offset].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt32Be(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset].toInt() shl 24
|
||||
value += (data[offset + 1].toInt() and 0xFF) shl 16
|
||||
value += (data[offset + 2].toInt() and 0xFF) shl 8
|
||||
value += data[offset + 3].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt32Le(data: ByteArray, offset: Int): Long {
|
||||
return fromSignedInt32Le(data, offset).toLong() and 0xFFFFFFFFL
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt32Be(data: ByteArray, offset: Int): Long {
|
||||
return fromSignedInt32Be(data, offset).toLong() and 0xFFFFFFFFL
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt32Le(data: ByteArray, offset: Int, value: Long) {
|
||||
data[offset + 3] = ((value shr 24) and 0xFFL).toByte()
|
||||
data[offset + 2] = ((value shr 16) and 0xFFL).toByte()
|
||||
data[offset + 1] = ((value shr 8) and 0xFFL).toByte()
|
||||
data[offset + 0] = (value and 0xFFL).toByte()
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt32Be(data: ByteArray, offset: Int, value: Long) {
|
||||
data[offset + 0] = ((value shr 24) and 0xFFL).toByte()
|
||||
data[offset + 1] = ((value shr 16) and 0xFFL).toByte()
|
||||
data[offset + 2] = ((value shr 8) and 0xFFL).toByte()
|
||||
data[offset + 3] = (value and 0xFFL).toByte()
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt32Le(value: Long): ByteArray {
|
||||
val data = ByteArray(4)
|
||||
toInt32Le(data, 0, value)
|
||||
return data
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt32Be(value: Long): ByteArray {
|
||||
val data = ByteArray(4)
|
||||
toInt32Be(data, 0, value)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Float value from one UnitType to another, if a conversion is defined.
|
||||
* Returns the original value if no conversion is applicable or units are the same.
|
||||
*
|
||||
* @param value The float value to convert.
|
||||
* @param fromUnit The original UnitType of the value.
|
||||
* @param toUnit The target UnitType for the value.
|
||||
* @return The converted float value, or the original value if no conversion is done.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun convertFloatValueUnit(value: Float, fromUnit: UnitType, toUnit: UnitType): Float {
|
||||
if (fromUnit == toUnit) return value
|
||||
|
||||
// KG -> Andere Gewichtseinheiten
|
||||
if (fromUnit == UnitType.KG) {
|
||||
return when (toUnit) {
|
||||
UnitType.LB -> fromKilogram(value, WeightUnit.LB)
|
||||
UnitType.ST -> fromKilogram(value, WeightUnit.ST)
|
||||
else -> value // Keine Umrechnung zu anderen Typen von KG aus
|
||||
}
|
||||
}
|
||||
// LB -> Andere Gewichtseinheiten (erst zu KG, dann zum Ziel)
|
||||
if (fromUnit == UnitType.LB) {
|
||||
val kgValue = toKilogram(value, WeightUnit.LB)
|
||||
return when (toUnit) {
|
||||
UnitType.KG -> kgValue
|
||||
UnitType.ST -> fromKilogram(kgValue, WeightUnit.ST)
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
// ST -> Andere Gewichtseinheiten (erst zu KG, dann zum Ziel)
|
||||
if (fromUnit == UnitType.ST) {
|
||||
val kgValue = toKilogram(value, WeightUnit.ST)
|
||||
return when (toUnit) {
|
||||
UnitType.KG -> kgValue
|
||||
UnitType.LB -> fromKilogram(kgValue, WeightUnit.LB)
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
// CM -> Andere Längeneinheiten
|
||||
if (fromUnit == UnitType.CM) {
|
||||
return when (toUnit) {
|
||||
UnitType.INCH -> fromCentimeter(value, MeasureUnit.INCH)
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
if (fromUnit == UnitType.INCH) {
|
||||
val cmValue = toCentimeter(value, MeasureUnit.INCH)
|
||||
return when (toUnit) {
|
||||
UnitType.CM -> cmValue
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all non-digit characters from [input] and truncates the result to [maxLen] characters.
|
||||
*
|
||||
* @param input The raw string to sanitize.
|
||||
* @param maxLen The maximum number of digit characters to retain.
|
||||
* @return A string containing only digit characters, at most [maxLen] characters long.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun sanitizeDigits(input: String, maxLen: Int): String =
|
||||
input.filter { it.isDigit() }.take(maxLen)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* FitnessDroid-shim för openScales LogManager.
|
||||
* Upstream har filloggning m.m. — här räcker Androids vanliga logcat.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.health.openscale.core.utils
|
||||
|
||||
import android.util.Log
|
||||
|
||||
object LogManager {
|
||||
fun d(tag: String, msg: String) { Log.d(tag, msg) }
|
||||
fun i(tag: String, msg: String) { Log.i(tag, msg) }
|
||||
fun w(tag: String, msg: String, t: Throwable? = null) { Log.w(tag, msg, t) }
|
||||
fun e(tag: String, msg: String, t: Throwable? = null) { Log.e(tag, msg, t) }
|
||||
}
|
||||
@@ -3,16 +3,38 @@ package eu.brassepc.fitnessdroid
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import eu.brassepc.fitnessdroid.data.AuthRepository
|
||||
import eu.brassepc.fitnessdroid.data.CredentialStore
|
||||
import eu.brassepc.fitnessdroid.data.GraphQlClient
|
||||
import eu.brassepc.fitnessdroid.data.GymApi
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.RestTimerController
|
||||
import eu.brassepc.fitnessdroid.data.ScaleManager
|
||||
import eu.brassepc.fitnessdroid.data.StepsSync
|
||||
import eu.brassepc.fitnessdroid.data.SettingsStore
|
||||
import eu.brassepc.fitnessdroid.data.SyncEngine
|
||||
import eu.brassepc.fitnessdroid.data.TokenStore
|
||||
import eu.brassepc.fitnessdroid.data.UpdateChecker
|
||||
import eu.brassepc.fitnessdroid.data.local.AppDatabase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
/** Enkel manuell DI-container — appen är för liten för Hilt. */
|
||||
class AppContainer(context: Context) {
|
||||
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val tokenStore = TokenStore(context)
|
||||
val settingsStore = SettingsStore(context)
|
||||
val credentialStore = CredentialStore(context)
|
||||
val graphQlClient = GraphQlClient { tokenStore.apiUrl() }
|
||||
val authRepository = AuthRepository(tokenStore, graphQlClient)
|
||||
val authRepository = AuthRepository(tokenStore, graphQlClient, credentialStore, settingsStore)
|
||||
val gymApi = GymApi(graphQlClient, authRepository)
|
||||
val database = AppDatabase.build(context)
|
||||
val syncEngine = SyncEngine(database, graphQlClient, authRepository, appScope)
|
||||
val gymRepository = GymRepository(database, graphQlClient, authRepository, syncEngine, tokenStore, appScope)
|
||||
val restTimer = RestTimerController(context, settingsStore, appScope)
|
||||
val updateChecker = UpdateChecker(context)
|
||||
val scaleManager = ScaleManager(context, settingsStore, appScope)
|
||||
val stepsSync = StepsSync(context, gymApi)
|
||||
}
|
||||
|
||||
class FitnessDroidApplication : Application() {
|
||||
|
||||
179
app/src/main/java/eu/brassepc/fitnessdroid/data/ActivityKcal.kt
Normal file
179
app/src/main/java/eu/brassepc/fitnessdroid/data/ActivityKcal.kt
Normal file
@@ -0,0 +1,179 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Kcal-uppskattning för aktiviteter — Kotlin-port av serverns
|
||||
* ActivityCalorieService så att live-siffran under spårning stämmer med det
|
||||
* som sparas. Netto-kcal (MET−1) eftersom BMR räknas separat i dagsmätaren.
|
||||
*/
|
||||
object ActivityKcal {
|
||||
|
||||
fun estimate(
|
||||
met: Double,
|
||||
category: String,
|
||||
isDistanceBased: Boolean,
|
||||
weightKg: Double?,
|
||||
durationSeconds: Int,
|
||||
distanceMeters: Double?,
|
||||
elevationGainMeters: Double?,
|
||||
rpe: Double?,
|
||||
): Double? {
|
||||
if (weightKg == null || weightKg <= 0 || durationSeconds <= 0) return null
|
||||
|
||||
val hours = durationSeconds / 3600.0
|
||||
var effectiveMet = met
|
||||
val speedKmh = if (distanceMeters != null && distanceMeters > 0) {
|
||||
(distanceMeters / 1000.0) / hours
|
||||
} else null
|
||||
|
||||
if (isDistanceBased && speedKmh != null) {
|
||||
effectiveMet = when (category) {
|
||||
"WALK" -> walkingMet(speedKmh, hiking = false)
|
||||
"HIKE" -> walkingMet(speedKmh, hiking = true)
|
||||
"RUN" -> runningMet(speedKmh)
|
||||
"CYCLE" -> cyclingMet(speedKmh)
|
||||
else -> effectiveMet
|
||||
}
|
||||
} else if (!isDistanceBased && rpe != null) {
|
||||
effectiveMet *= 0.5 + rpe.coerceIn(0.0, 10.0) / 10.0
|
||||
}
|
||||
|
||||
var kcal = (max(effectiveMet, 1.0) - 1.0) * weightKg * hours
|
||||
if (elevationGainMeters != null && elevationGainMeters > 0) {
|
||||
kcal += 0.0067 * weightKg * elevationGainMeters
|
||||
}
|
||||
return kcal
|
||||
}
|
||||
|
||||
private fun walkingMet(kmh: Double, hiking: Boolean): Double {
|
||||
val met = when {
|
||||
kmh < 3.2 -> 2.8
|
||||
kmh < 4.0 -> 3.0
|
||||
kmh < 4.8 -> 3.5
|
||||
kmh < 5.6 -> 4.3
|
||||
kmh < 6.4 -> 5.0
|
||||
kmh < 7.2 -> 7.0
|
||||
else -> 8.3
|
||||
}
|
||||
return if (hiking) max(met + 1.5, 5.3) else met
|
||||
}
|
||||
|
||||
private fun runningMet(kmh: Double): Double = (kmh * 1.02).coerceIn(6.0, 19.0)
|
||||
|
||||
private fun cyclingMet(kmh: Double): Double = when {
|
||||
kmh < 15.0 -> 4.0
|
||||
kmh < 19.0 -> 5.8
|
||||
kmh < 22.5 -> 8.0
|
||||
kmh < 25.7 -> 10.0
|
||||
kmh < 30.0 -> 12.0
|
||||
else -> 15.8
|
||||
}
|
||||
|
||||
/**
|
||||
* Gissa aktivitetstyp (nyckel) för ett spårat distanspass — används för
|
||||
* "Ser ut som X"-förslaget i sammanfattningen.
|
||||
*/
|
||||
fun guessTypeKey(avgKmh: Double, elevationGainPerKm: Double): String = when {
|
||||
avgKmh < 2.0 -> "walking_casual"
|
||||
avgKmh < 6.5 -> if (elevationGainPerKm > 25) "hiking_forest" else "walking_city"
|
||||
avgKmh < 9.0 -> "jogging"
|
||||
avgKmh < 14.0 -> "running"
|
||||
else -> "cycling"
|
||||
}
|
||||
}
|
||||
|
||||
/** Google encoded polyline (precision 1e5) — samma format som webben avkodar. */
|
||||
fun encodePolyline(points: List<Pair<Double, Double>>): String {
|
||||
val sb = StringBuilder()
|
||||
var lastLat = 0L
|
||||
var lastLon = 0L
|
||||
for ((lat, lon) in points) {
|
||||
val iLat = Math.round(lat * 1e5)
|
||||
val iLon = Math.round(lon * 1e5)
|
||||
encodeDiff(iLat - lastLat, sb)
|
||||
encodeDiff(iLon - lastLon, sb)
|
||||
lastLat = iLat
|
||||
lastLon = iLon
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun encodeDiff(diff: Long, sb: StringBuilder) {
|
||||
var v = diff shl 1
|
||||
if (diff < 0) v = v.inv()
|
||||
while (v >= 0x20) {
|
||||
sb.append((((v and 0x1f) or 0x20) + 63).toInt().toChar())
|
||||
v = v shr 5
|
||||
}
|
||||
sb.append((v + 63).toInt().toChar())
|
||||
}
|
||||
|
||||
|
||||
/** Avkoda Google encoded polyline (precision 1e5) → [(lat, lon)]. */
|
||||
fun decodePolyline(encoded: String): List<Pair<Double, Double>> {
|
||||
val points = mutableListOf<Pair<Double, Double>>()
|
||||
var index = 0
|
||||
var lat = 0L
|
||||
var lon = 0L
|
||||
while (index < encoded.length) {
|
||||
for (which in 0..1) {
|
||||
var result = 0L
|
||||
var shift = 0
|
||||
while (true) {
|
||||
val b = (encoded[index].code - 63).toLong()
|
||||
index++
|
||||
result = result or ((b and 0x1f) shl shift)
|
||||
shift += 5
|
||||
if (b < 0x20) break
|
||||
}
|
||||
val delta = if ((result and 1L) != 0L) (result shr 1).inv() else (result shr 1)
|
||||
if (which == 0) lat += delta else lon += delta
|
||||
}
|
||||
points.add(lat / 1e5 to lon / 1e5)
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
/**
|
||||
* Douglas–Peucker-nedbantning av en rutt: tar bort punkter som ligger inom
|
||||
* [epsilonMeters] från linjen mellan sina grannar. Kraftigt färre punkter i
|
||||
* databasen utan att ruttens form ändras nämnvärt.
|
||||
*/
|
||||
fun simplifyRoute(points: List<Pair<Double, Double>>, epsilonMeters: Double = 4.0): List<Pair<Double, Double>> {
|
||||
if (points.size < 3) return points
|
||||
val lat0 = Math.toRadians(points[0].first)
|
||||
// approximativ projektion till meter (räcker gott för korta rutter)
|
||||
fun xy(p: Pair<Double, Double>) = (p.second * 111_320 * Math.cos(lat0)) to (p.first * 110_540)
|
||||
val proj = points.map { xy(it) }
|
||||
val keep = BooleanArray(points.size)
|
||||
keep[0] = true
|
||||
keep[points.size - 1] = true
|
||||
|
||||
val stack = ArrayDeque<Pair<Int, Int>>()
|
||||
stack.addLast(0 to points.size - 1)
|
||||
while (stack.isNotEmpty()) {
|
||||
val (a, b) = stack.removeLast()
|
||||
val (ax, ay) = proj[a]
|
||||
val (bx, by) = proj[b]
|
||||
val dx = bx - ax
|
||||
val dy = by - ay
|
||||
val norm = Math.hypot(dx, dy).coerceAtLeast(1e-9)
|
||||
var worst = -1
|
||||
var worstDist = 0.0
|
||||
for (i in a + 1 until b) {
|
||||
val (px, py) = proj[i]
|
||||
val d = Math.abs(dy * (px - ax) - dx * (py - ay)) / norm
|
||||
if (d > worstDist) {
|
||||
worstDist = d
|
||||
worst = i
|
||||
}
|
||||
}
|
||||
if (worstDist > epsilonMeters && worst > 0) {
|
||||
keep[worst] = true
|
||||
stack.addLast(a to worst)
|
||||
stack.addLast(worst to b)
|
||||
}
|
||||
}
|
||||
return points.filterIndexed { i, _ -> keep[i] }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
@@ -26,6 +27,8 @@ sealed interface AuthState {
|
||||
class AuthRepository(
|
||||
private val store: TokenStore,
|
||||
private val client: GraphQlClient,
|
||||
private val credentials: CredentialStore,
|
||||
private val settings: SettingsStore,
|
||||
) {
|
||||
private val _state = MutableStateFlow<AuthState>(AuthState.Restoring)
|
||||
val state: StateFlow<AuthState> = _state
|
||||
@@ -59,14 +62,40 @@ class AuthRepository(
|
||||
refreshToken = refreshToken,
|
||||
expirationEpochMs = parseExpiration(expiration),
|
||||
)
|
||||
// Spara uppgifterna krypterat för tyst återinloggning (inställbart).
|
||||
if (settings.settings.first().autoRelogin) {
|
||||
runCatching { credentials.save(username.trim(), password) }
|
||||
}
|
||||
_state.value = AuthState.LoggedIn(userName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Servern har avvisat vår session (refresh-token död, session utgången).
|
||||
* Sista utvägen innan riktig utloggning: logga in tyst igen med de
|
||||
* sparade uppgifterna. Returnerar ny JWT eller null.
|
||||
*/
|
||||
private suspend fun trySilentRelogin(): String? {
|
||||
if (!settings.settings.first().autoRelogin) return null
|
||||
val creds = credentials.load() ?: return null
|
||||
return try {
|
||||
login(creds.username, creds.password, store.apiUrl())
|
||||
store.read().token
|
||||
} catch (e: GraphQlException) {
|
||||
// Fel lösenord/användare — uppgifterna är inte giltiga längre.
|
||||
runCatching { credentials.clear() }
|
||||
null
|
||||
}
|
||||
// IOException bubblar uppåt: nätfel ska ge retry, inte utloggning.
|
||||
}
|
||||
|
||||
/** Återställ sessionen vid appstart, som webbens restoreSession(). */
|
||||
suspend fun restoreSession() {
|
||||
val saved = store.read()
|
||||
if (saved.sessionId.isNullOrEmpty() || saved.refreshToken.isNullOrEmpty()) {
|
||||
_state.value = AuthState.LoggedOut
|
||||
// Inga tokens alls — men kanske sparade uppgifter (t.ex. efter
|
||||
// att servern rensat sessioner). Prova tyst innan login-skärmen.
|
||||
val relogged = runCatching { trySilentRelogin() }.getOrNull()
|
||||
if (relogged == null) _state.value = AuthState.LoggedOut
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -78,18 +107,22 @@ class AuthRepository(
|
||||
?.get("isValid")?.jsonPrimitive?.booleanOrNull == true
|
||||
if (valid) {
|
||||
_state.value = AuthState.LoggedIn(saved.username.orEmpty())
|
||||
} else if (trySilentRelogin() != null) {
|
||||
// login() har redan satt LoggedIn — utloggningen märktes aldrig.
|
||||
} else {
|
||||
store.clearSession()
|
||||
_state.value = AuthState.LoggedOut
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Nätverksfel vid uppstart: släpp in användaren om JWT:n inte hunnit
|
||||
// gå ut, annars visa inloggningen igen.
|
||||
// gå ut. Har den gått ut: prova tyst återinloggning (kan också
|
||||
// faila på nätet — då login-skärmen, synken tar det sen).
|
||||
val stillValid = (saved.expirationEpochMs ?: 0) > System.currentTimeMillis()
|
||||
_state.value = if (stillValid) {
|
||||
AuthState.LoggedIn(saved.username.orEmpty())
|
||||
} else {
|
||||
AuthState.LoggedOut
|
||||
_state.value = when {
|
||||
stillValid -> AuthState.LoggedIn(saved.username.orEmpty())
|
||||
runCatching { trySilentRelogin() }.getOrNull() != null ->
|
||||
AuthState.LoggedIn(store.read().username.orEmpty())
|
||||
else -> AuthState.LoggedOut
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +143,7 @@ class AuthRepository(
|
||||
val sessionId = saved.sessionId
|
||||
val refreshToken = saved.refreshToken
|
||||
if (sessionId == null || refreshToken == null) {
|
||||
trySilentRelogin()?.let { return it }
|
||||
forceLogout()
|
||||
throw GraphQlException(listOf("Sessionen har gått ut, logga in igen"))
|
||||
}
|
||||
@@ -127,12 +161,17 @@ class AuthRepository(
|
||||
val newRefresh = result?.get("newRefreshToken")?.jsonPrimitive?.contentOrNullSafe()
|
||||
val newExpiration = result?.get("accessTokenExpiration")?.jsonPrimitive?.contentOrNullSafe()
|
||||
if (!success || newToken == null || newRefresh == null) {
|
||||
trySilentRelogin()?.let { return it }
|
||||
forceLogout()
|
||||
throw GraphQlException(listOf("Sessionen har gått ut, logga in igen"))
|
||||
}
|
||||
store.updateTokens(newToken, newRefresh, parseExpiration(newExpiration))
|
||||
newToken
|
||||
} catch (e: GraphQlException) {
|
||||
// Servern avvisade refresh-tokenen. Försök logga in tyst igen med
|
||||
// sparade uppgifter innan riktig utloggning. Nätfel (IOException)
|
||||
// bubblar istället uppåt så synkmotorn kan försöka igen.
|
||||
trySilentRelogin()?.let { return it }
|
||||
forceLogout()
|
||||
throw GraphQlException(listOf("Sessionen har gått ut, logga in igen"))
|
||||
}
|
||||
@@ -145,6 +184,8 @@ class AuthRepository(
|
||||
client.execute(LOGOUT, buildJsonObject { put("s", saved.sessionId) })
|
||||
}
|
||||
}
|
||||
// Manuell utloggning = släng även de sparade uppgifterna.
|
||||
runCatching { credentials.clear() }
|
||||
forceLogout()
|
||||
}
|
||||
|
||||
|
||||
20
app/src/main/java/eu/brassepc/fitnessdroid/data/Bmr.kt
Normal file
20
app/src/main/java/eu/brassepc/fitnessdroid/data/Bmr.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Basalmetabolism (kcal/dygn) enligt Mifflin–St Jeor:
|
||||
* 10·vikt + 6.25·längd − 5·ålder + 5 (man) / −161 (kvinna).
|
||||
* null om något av underlagen saknas.
|
||||
*/
|
||||
fun bmrKcalPerDay(
|
||||
weightKg: Double?,
|
||||
heightCm: Double?,
|
||||
birthYear: Int?,
|
||||
isFemale: Boolean?,
|
||||
): Double? {
|
||||
if (weightKg == null || heightCm == null || birthYear == null || isFemale == null) return null
|
||||
val age = (LocalDate.now().year - birthYear).coerceIn(0, 120)
|
||||
val k = if (isFemale) -161.0 else 5.0
|
||||
return 10.0 * weightKg + 6.25 * heightCm - 5.0 * age + k
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
private val Context.credDataStore by preferencesDataStore(name = "credentials")
|
||||
|
||||
/**
|
||||
* Sparar inloggningsuppgifterna för tyst återinloggning. Lösenordet
|
||||
* AES/GCM-krypteras med en nyckel i Android Keystore — nyckeln lämnar
|
||||
* aldrig enheten och ciphertexten är värdelös utan den.
|
||||
*/
|
||||
class CredentialStore(private val context: Context) {
|
||||
|
||||
data class Credentials(val username: String, val password: String)
|
||||
|
||||
suspend fun save(username: String, password: String) {
|
||||
val cipher = Cipher.getInstance(TRANSFORM)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key())
|
||||
val ct = cipher.doFinal(password.toByteArray(Charsets.UTF_8))
|
||||
val blob = Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + ":" +
|
||||
Base64.encodeToString(ct, Base64.NO_WRAP)
|
||||
context.credDataStore.edit {
|
||||
it[KEY_USER] = username
|
||||
it[KEY_PW] = blob
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun load(): Credentials? {
|
||||
val prefs = context.credDataStore.data.first()
|
||||
val user = prefs[KEY_USER] ?: return null
|
||||
val blob = prefs[KEY_PW] ?: return null
|
||||
return runCatching {
|
||||
val (ivB64, ctB64) = blob.split(":", limit = 2)
|
||||
val cipher = Cipher.getInstance(TRANSFORM)
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
key(),
|
||||
GCMParameterSpec(128, Base64.decode(ivB64, Base64.NO_WRAP)),
|
||||
)
|
||||
Credentials(
|
||||
user,
|
||||
cipher.doFinal(Base64.decode(ctB64, Base64.NO_WRAP)).toString(Charsets.UTF_8),
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
context.credDataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
private fun key(): SecretKey {
|
||||
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(ks.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||
generator.init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.build()
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_ALIAS = "fitnessdroid-credentials"
|
||||
private const val TRANSFORM = "AES/GCM/NoPadding"
|
||||
private val KEY_USER = stringPreferencesKey("username")
|
||||
private val KEY_PW = stringPreferencesKey("password_encrypted")
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,11 @@ class GraphQlClient(private val urlProvider: suspend () -> String) {
|
||||
|
||||
http.newCall(request).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
// 5xx = servern/proxyn nere — behandla som nätfel (retry) och inte
|
||||
// som ett logiskt API-fel (som t.ex. loggar ut användaren).
|
||||
if (response.code >= 500) {
|
||||
throw java.io.IOException("HTTP ${response.code} från servern")
|
||||
}
|
||||
val root = runCatching { json.parseToJsonElement(text).jsonObject }.getOrNull()
|
||||
?: throw GraphQlException(listOf("HTTP ${response.code}: oväntat svar från servern"))
|
||||
|
||||
|
||||
@@ -1,8 +1,62 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
@Serializable
|
||||
data class ActivityType(
|
||||
val id: Int,
|
||||
val key: String,
|
||||
val nameSv: String,
|
||||
val nameEn: String = "",
|
||||
val met: Double = 4.0,
|
||||
val category: String = "OTHER",
|
||||
val isDistanceBased: Boolean = false,
|
||||
val isCardio: Boolean = false,
|
||||
val iconKey: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Activity(
|
||||
val id: String,
|
||||
val startedAt: String,
|
||||
val durationSeconds: Int,
|
||||
val distanceMeters: Double? = null,
|
||||
val elevationGainMeters: Double? = null,
|
||||
val estimatedKcal: Double? = null,
|
||||
val rpe: Double? = null,
|
||||
val source: String = "manual",
|
||||
val notes: String? = null,
|
||||
val steps: Int? = null,
|
||||
val routePolyline: String? = null,
|
||||
val activityType: ActivityType,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DailySummary(
|
||||
val gymKcal: Double = 0.0,
|
||||
val activityKcal: Double = 0.0,
|
||||
val activeKcal: Double = 0.0,
|
||||
val gymSessionCount: Int = 0,
|
||||
val activityCount: Int = 0,
|
||||
val distanceMeters: Double = 0.0,
|
||||
val bmrKcalPerDay: Double? = null,
|
||||
val phoneKcal: Double? = null,
|
||||
val steps: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GoalProgress(
|
||||
val id: Int,
|
||||
val metric: String,
|
||||
val period: String,
|
||||
val targetValue: Double,
|
||||
val currentValue: Double,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Profile(
|
||||
@@ -10,6 +64,10 @@ data class Profile(
|
||||
val displayName: String? = null,
|
||||
val email: String? = null,
|
||||
val bodyWeightKg: Double? = null,
|
||||
val heightCm: Double? = null,
|
||||
val birthYear: Int? = null,
|
||||
/** "male"/"female" — serverns konvention */
|
||||
val sex: String? = null,
|
||||
)
|
||||
|
||||
/** Autentiserade anrop mot gym-API:t. Query-strängarna speglar gymTrackerApi.js. */
|
||||
@@ -18,14 +76,182 @@ class GymApi(
|
||||
private val auth: AuthRepository,
|
||||
) {
|
||||
suspend fun myProfile(): Profile {
|
||||
val data = client.execute(MY_PROFILE, token = auth.bearerToken())
|
||||
// Fallback till gamla fältuppsättningen mot en äldre server.
|
||||
val data = try {
|
||||
client.execute(MY_PROFILE, token = auth.bearerToken())
|
||||
} catch (e: GraphQlException) {
|
||||
client.execute(MY_PROFILE_LEGACY, token = auth.bearerToken())
|
||||
}
|
||||
val profile = data["myProfile"]?.jsonObject
|
||||
?: throw GraphQlException(listOf("Kunde inte hämta profilen"))
|
||||
return client.json.decodeFromJsonElement(profile)
|
||||
}
|
||||
|
||||
/** Kroppsdata till servern. null = rör ej, -1/"" = rensa (serverns konvention). */
|
||||
suspend fun updateProfile(heightCm: Double?, birthYear: Int?, sex: String?): Profile {
|
||||
val data = client.execute(
|
||||
"mutation(\$h: Float,\$y: Int,\$s: String){updateProfile(heightCm:\$h,birthYear:\$y,sex:\$s){username displayName email bodyWeightKg heightCm birthYear sex}}",
|
||||
buildJsonObject {
|
||||
heightCm?.let { put("h", it) }
|
||||
birthYear?.let { put("y", it) }
|
||||
sex?.let { put("s", it) }
|
||||
},
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val profile = data["updateProfile"]?.jsonObject
|
||||
?: throw GraphQlException(listOf("Kunde inte spara kroppsdata"))
|
||||
return client.json.decodeFromJsonElement(profile)
|
||||
}
|
||||
|
||||
/* ---------- Aktiviteter ---------- */
|
||||
|
||||
suspend fun activityTypes(): List<ActivityType> {
|
||||
val data = client.execute(
|
||||
"query{activityTypes{id key nameSv nameEn met category isDistanceBased isCardio iconKey}}",
|
||||
token = auth.bearerToken(),
|
||||
)
|
||||
return data["activityTypes"]!!.jsonArray.map { client.json.decodeFromJsonElement<ActivityType>(it) }
|
||||
}
|
||||
|
||||
suspend fun activities(limit: Int = 50): List<Activity> {
|
||||
val data = client.execute(
|
||||
"query(\$l:Int){activities(limit:\$l){$ACTIVITY_FIELDS}}",
|
||||
buildJsonObject { put("l", limit) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
return data["activities"]!!.jsonArray.map { client.json.decodeFromJsonElement<Activity>(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Logga aktivitet. Servern beräknar kcal (MET/tempo/RPE) om estimatedKcal
|
||||
* utelämnas. null = utelämna fält.
|
||||
*/
|
||||
suspend fun addActivity(
|
||||
activityTypeId: Int,
|
||||
startedAtIso: String,
|
||||
durationSeconds: Int,
|
||||
distanceMeters: Double? = null,
|
||||
elevationGainMeters: Double? = null,
|
||||
rpe: Double? = null,
|
||||
estimatedKcal: Double? = null,
|
||||
source: String? = null,
|
||||
routePolyline: String? = null,
|
||||
notes: String? = null,
|
||||
steps: Int? = null,
|
||||
): Activity {
|
||||
val data = client.execute(
|
||||
"mutation(\$input: AddActivityInput!){addActivity(input:\$input){$ACTIVITY_FIELDS}}",
|
||||
buildJsonObject {
|
||||
put("input", buildJsonObject {
|
||||
put("activityTypeId", activityTypeId)
|
||||
put("startedAt", startedAtIso)
|
||||
put("durationSeconds", durationSeconds)
|
||||
distanceMeters?.let { put("distanceMeters", it) }
|
||||
elevationGainMeters?.let { put("elevationGainMeters", it) }
|
||||
rpe?.let { put("rpe", it) }
|
||||
steps?.let { put("steps", it) }
|
||||
estimatedKcal?.let { put("estimatedKcal", it) }
|
||||
source?.let { put("source", it) }
|
||||
routePolyline?.let { put("routePolyline", it) }
|
||||
notes?.let { put("notes", it) }
|
||||
})
|
||||
},
|
||||
auth.bearerToken(),
|
||||
)
|
||||
return client.json.decodeFromJsonElement(data["addActivity"]!!)
|
||||
}
|
||||
|
||||
/** null = rör ej, -1/"" = rensa (serverns konvention). */
|
||||
suspend fun updateActivity(
|
||||
id: String,
|
||||
activityTypeId: Int? = null,
|
||||
startedAtIso: String? = null,
|
||||
durationSeconds: Int? = null,
|
||||
distanceMeters: Double? = null,
|
||||
elevationGainMeters: Double? = null,
|
||||
rpe: Double? = null,
|
||||
notes: String? = null,
|
||||
): Activity {
|
||||
val data = client.execute(
|
||||
"mutation(\$input: UpdateActivityInput!){updateActivity(input:\$input){$ACTIVITY_FIELDS}}",
|
||||
buildJsonObject {
|
||||
put("input", buildJsonObject {
|
||||
put("id", id)
|
||||
activityTypeId?.let { put("activityTypeId", it) }
|
||||
startedAtIso?.let { put("startedAt", it) }
|
||||
durationSeconds?.let { put("durationSeconds", it) }
|
||||
distanceMeters?.let { put("distanceMeters", it) }
|
||||
elevationGainMeters?.let { put("elevationGainMeters", it) }
|
||||
rpe?.let { put("rpe", it) }
|
||||
notes?.let { put("notes", it) }
|
||||
})
|
||||
},
|
||||
auth.bearerToken(),
|
||||
)
|
||||
return client.json.decodeFromJsonElement(data["updateActivity"]!!)
|
||||
}
|
||||
|
||||
suspend fun deleteActivity(id: String) {
|
||||
client.execute(
|
||||
"mutation(\$id: UUID!){deleteActivity(id:\$id)}",
|
||||
buildJsonObject { put("id", id) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Dagens sammanställning; [dayStartIso] = lokala midnatt som instant. */
|
||||
suspend fun dailySummary(dayStartIso: String): DailySummary {
|
||||
val data = client.execute(
|
||||
"query(\$d:DateTime){dailySummary(date:\$d){gymKcal activityKcal activeKcal gymSessionCount activityCount distanceMeters bmrKcalPerDay phoneKcal steps}}",
|
||||
buildJsonObject { put("d", dayStartIso) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
return client.json.decodeFromJsonElement(data["dailySummary"]!!)
|
||||
}
|
||||
|
||||
/* ---------- Mål ---------- */
|
||||
|
||||
/** Mål med progress; klienten skickar sina lokala periodstarter som instants. */
|
||||
suspend fun goalsWithProgress(
|
||||
dayStartIso: String,
|
||||
weekStartIso: String,
|
||||
monthStartIso: String,
|
||||
): List<GoalProgress> {
|
||||
val data = client.execute(
|
||||
"query(\$d:DateTime!,\$w:DateTime!,\$m:DateTime!){goalsWithProgress(dayStart:\$d,weekStart:\$w,monthStart:\$m){id metric period targetValue currentValue}}",
|
||||
buildJsonObject {
|
||||
put("d", dayStartIso); put("w", weekStartIso); put("m", monthStartIso)
|
||||
},
|
||||
auth.bearerToken(),
|
||||
)
|
||||
return data["goalsWithProgress"]!!.jsonArray.map { client.json.decodeFromJsonElement<GoalProgress>(it) }
|
||||
}
|
||||
|
||||
/** Synka upp dagens/gårdagens steg; [dayStartIso] = lokalt dygn 00:00 som instant. */
|
||||
suspend fun upsertDailySteps(dayStartIso: String, steps: Int) {
|
||||
client.execute(
|
||||
"mutation(\$d:DateTime!,\$s:Int!){upsertDailySteps(date:\$d,steps:\$s)}",
|
||||
buildJsonObject { put("d", dayStartIso); put("s", steps) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
|
||||
/** targetValue <= 0 tar bort målet. */
|
||||
suspend fun setGoal(metric: String, period: String, targetValue: Double) {
|
||||
client.execute(
|
||||
"mutation(\$m:String!,\$p:String!,\$t:Float!){setGoal(metric:\$m,period:\$p,targetValue:\$t)}",
|
||||
buildJsonObject { put("m", metric); put("p", period); put("t", targetValue) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTIVITY_FIELDS =
|
||||
"id startedAt durationSeconds distanceMeters elevationGainMeters estimatedKcal rpe source notes steps routePolyline " +
|
||||
"activityType{id key nameSv nameEn met category isDistanceBased isCardio iconKey}"
|
||||
private const val MY_PROFILE =
|
||||
"query{myProfile{username displayName email bodyWeightKg heightCm birthYear sex}}"
|
||||
private const val MY_PROFILE_LEGACY =
|
||||
"query{myProfile{username displayName email bodyWeightKg}}"
|
||||
}
|
||||
}
|
||||
|
||||
989
app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt
Normal file
989
app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt
Normal file
@@ -0,0 +1,989 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import eu.brassepc.fitnessdroid.data.local.AppDatabase
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedExerciseType
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedMuscle
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedMuscleGroup
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedStartCard
|
||||
import eu.brassepc.fitnessdroid.data.local.LocalExercise
|
||||
import eu.brassepc.fitnessdroid.data.local.LocalSession
|
||||
import eu.brassepc.fitnessdroid.data.local.LocalSet
|
||||
import eu.brassepc.fitnessdroid.data.local.PendingOp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Lokal-först-repository. Allt UI läser Room-flows; alla skrivningar sker
|
||||
* lokalt först och läggs på op-kön som [SyncEngine] driver mot servern.
|
||||
*/
|
||||
private const val LIFT_DETAIL_FIELDS =
|
||||
"id exerciseTypeId exerciseTypeName weight reps date notes status statusNote gymSessionId sessionName gymSessionSetId estimated1Rm isCurrentRecord"
|
||||
|
||||
class GymRepository(
|
||||
private val db: AppDatabase,
|
||||
private val client: GraphQlClient,
|
||||
private val auth: AuthRepository,
|
||||
private val sync: SyncEngine,
|
||||
private val store: TokenStore,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
val activeSession = db.sessionDao().activeSession()
|
||||
val exerciseTypes = db.cacheDao().exerciseTypes()
|
||||
val muscleGroups = db.cacheDao().muscleGroups()
|
||||
val muscles = db.cacheDao().muscles()
|
||||
val startCards = db.cacheDao().startCards()
|
||||
val pendingCount = db.opDao().pendingCount()
|
||||
val syncStatus = sync.status
|
||||
|
||||
fun exercises(sessionId: Long) = db.sessionDao().exercises(sessionId)
|
||||
fun setsForSession(sessionId: Long) = db.sessionDao().setsForSession(sessionId)
|
||||
|
||||
private suspend fun enqueue(kind: String, targetLocalId: Long) {
|
||||
db.opDao().enqueue(
|
||||
PendingOp(kind = kind, targetLocalId = targetLocalId, createdAtEpochMs = System.currentTimeMillis())
|
||||
)
|
||||
sync.kick()
|
||||
}
|
||||
|
||||
/* ---------- Passhantering ---------- */
|
||||
|
||||
suspend fun startFreeSession(name: String? = null): Long {
|
||||
val id = db.sessionDao().insertSession(
|
||||
LocalSession(name = name, startedAtEpochMs = System.currentTimeMillis())
|
||||
)
|
||||
enqueue(OpKind.START_SESSION, id)
|
||||
return id
|
||||
}
|
||||
|
||||
/** Starta från favoritpass/mall-kort: skapar passet + alla övningar lokalt. */
|
||||
suspend fun startFromCard(card: CachedStartCard): Long {
|
||||
val sessionId = db.sessionDao().insertSession(
|
||||
LocalSession(name = card.title, startedAtEpochMs = System.currentTimeMillis())
|
||||
)
|
||||
enqueue(OpKind.START_SESSION, sessionId)
|
||||
card.exerciseTypeIds.split(",").mapNotNull { it.trim().toIntOrNull() }
|
||||
.forEachIndexed { index, typeId ->
|
||||
val exId = db.sessionDao().insertExercise(
|
||||
LocalExercise(sessionId = sessionId, exerciseTypeId = typeId, order = index + 1)
|
||||
)
|
||||
enqueue(OpKind.ADD_EXERCISE, exId)
|
||||
}
|
||||
return sessionId
|
||||
}
|
||||
|
||||
suspend fun addExercise(sessionId: Long, exerciseTypeId: Int): Long {
|
||||
val order = db.sessionDao().exerciseCount(sessionId) + 1
|
||||
val id = db.sessionDao().insertExercise(
|
||||
LocalExercise(sessionId = sessionId, exerciseTypeId = exerciseTypeId, order = order)
|
||||
)
|
||||
enqueue(OpKind.ADD_EXERCISE, id)
|
||||
refreshLastPerformance(exerciseTypeId)
|
||||
return id
|
||||
}
|
||||
|
||||
suspend fun logSet(
|
||||
exerciseId: Long,
|
||||
reps: Int?,
|
||||
weight: Double?,
|
||||
distanceMeters: Double?,
|
||||
durationSeconds: Int?,
|
||||
rpe: Double?,
|
||||
isWarmup: Boolean,
|
||||
): Long {
|
||||
val order = db.sessionDao().setCount(exerciseId) + 1
|
||||
val id = db.sessionDao().insertSet(
|
||||
LocalSet(
|
||||
exerciseId = exerciseId,
|
||||
order = order,
|
||||
reps = reps,
|
||||
weight = weight,
|
||||
distanceMeters = distanceMeters,
|
||||
durationSeconds = durationSeconds,
|
||||
rpe = rpe,
|
||||
isWarmup = isWarmup,
|
||||
loggedAtEpochMs = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
enqueue(OpKind.ADD_SET, id)
|
||||
// Uppdatera cachens "senaste prestation" för förifyllnad nästa gång.
|
||||
db.sessionDao().exercise(exerciseId)?.let { ex ->
|
||||
db.cacheDao().updateLastPerformance(ex.exerciseTypeId, weight, reps, durationSeconds, distanceMeters)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
suspend fun updateSet(set: LocalSet) {
|
||||
db.sessionDao().updateSet(set)
|
||||
// Inte synkad än? Då bär den väntande ADD_SET-opsen de nya värdena
|
||||
// automatiskt (den läser raden när den körs). Annars: egen update-op.
|
||||
if (set.serverId != null) enqueue(OpKind.UPDATE_SET, set.id)
|
||||
}
|
||||
|
||||
suspend fun deleteSet(setId: Long) {
|
||||
val set = db.sessionDao().set(setId) ?: return
|
||||
db.sessionDao().deleteSet(setId)
|
||||
// Osynkat set: den väntande ADD-opsen blir en no-op när raden är borta.
|
||||
// Synkat set: ta bort på servern också (server-id:t i op:en, raden finns inte kvar).
|
||||
set.serverId?.let { serverId ->
|
||||
db.opDao().enqueue(
|
||||
PendingOp(
|
||||
kind = OpKind.REMOVE_SET,
|
||||
targetLocalId = serverId.toLong(),
|
||||
createdAtEpochMs = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
sync.kick()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun completeSession(
|
||||
sessionId: Long,
|
||||
durationSecondsOverride: Int? = null,
|
||||
editedStartEpochMs: Long? = null,
|
||||
) {
|
||||
val session = db.sessionDao().session(sessionId) ?: return
|
||||
db.sessionDao().updateSession(
|
||||
session.copy(
|
||||
status = "completed",
|
||||
durationSecondsOverride = durationSecondsOverride,
|
||||
editedStartEpochMs = editedStartEpochMs,
|
||||
startedAtEpochMs = editedStartEpochMs ?: session.startedAtEpochMs,
|
||||
)
|
||||
)
|
||||
enqueue(OpKind.COMPLETE_SESSION, sessionId)
|
||||
}
|
||||
|
||||
suspend fun bodyWeightKg(): Double? = store.bodyWeightKg()
|
||||
suspend fun displayName(): String? = store.displayName()
|
||||
|
||||
/* ---------- Aktiviteter (offline-kö) ---------- */
|
||||
|
||||
/** Aktiviteter som väntar på synk — visas överst i aktivitetslistan. */
|
||||
val pendingActivities = db.opDao().pendingActivities()
|
||||
|
||||
/**
|
||||
* Köa en aktivitet lokalt och låt synk-kön skicka den när nätet är
|
||||
* tillbaka (samma mönster som passen). Servern räknar ut kcal vid synk.
|
||||
*/
|
||||
suspend fun queueActivity(activity: eu.brassepc.fitnessdroid.data.local.PendingActivity) {
|
||||
val id = db.opDao().enqueueActivity(activity)
|
||||
enqueue(OpKind.ADD_ACTIVITY, id)
|
||||
}
|
||||
|
||||
/* ---------- Kroppsmätningar ---------- */
|
||||
|
||||
suspend fun fetchBodyMeasurements(): List<BodyMeasurement> {
|
||||
val data = client.execute(
|
||||
"query{bodyMeasurements{id date weightKg musclePercent fatPercent waterPercent}}",
|
||||
token = auth.bearerToken(),
|
||||
)
|
||||
return data["bodyMeasurements"]!!.jsonArray.map { m ->
|
||||
val o = m.jsonObject
|
||||
BodyMeasurement(
|
||||
id = o["id"]!!.jsonPrimitive.content,
|
||||
date = o["date"]?.jsonPrimitive?.contentOrNull() ?: "",
|
||||
weightKg = o["weightKg"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
||||
musclePercent = o["musclePercent"]?.jsonPrimitive?.doubleOrNull,
|
||||
fatPercent = o["fatPercent"]?.jsonPrimitive?.doubleOrNull,
|
||||
waterPercent = o["waterPercent"]?.jsonPrimitive?.doubleOrNull,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ny mätning. Faller tillbaka på gamla updateBodyWeight mot äldre API. */
|
||||
suspend fun addBodyMeasurement(
|
||||
weightKg: Double,
|
||||
dateIso: String? = null,
|
||||
musclePercent: Double? = null,
|
||||
fatPercent: Double? = null,
|
||||
waterPercent: Double? = null,
|
||||
) {
|
||||
try {
|
||||
client.execute(
|
||||
"mutation(\$input: AddBodyMeasurementInput!){addBodyMeasurement(input:\$input){id}}",
|
||||
buildJsonObject {
|
||||
put("input", buildJsonObject {
|
||||
put("weightKg", weightKg)
|
||||
dateIso?.let { put("date", it) }
|
||||
musclePercent?.let { put("musclePercent", it) }
|
||||
fatPercent?.let { put("fatPercent", it) }
|
||||
waterPercent?.let { put("waterPercent", it) }
|
||||
})
|
||||
},
|
||||
auth.bearerToken(),
|
||||
)
|
||||
} catch (e: GraphQlException) {
|
||||
client.execute(
|
||||
"mutation(\$w: Float){updateBodyWeight(bodyWeightKg:\$w){bodyWeightKg}}",
|
||||
buildJsonObject { put("w", weightKg) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
store.saveBodyWeight(weightKg)
|
||||
}
|
||||
|
||||
/** null = rör inte fältet, -1 = rensa (serverns konvention). */
|
||||
suspend fun updateBodyMeasurement(
|
||||
id: String,
|
||||
weightKg: Double,
|
||||
dateIso: String?,
|
||||
musclePercent: Double? = null,
|
||||
fatPercent: Double? = null,
|
||||
waterPercent: Double? = null,
|
||||
) {
|
||||
client.execute(
|
||||
"mutation(\$input: UpdateBodyMeasurementInput!){updateBodyMeasurement(input:\$input){id}}",
|
||||
buildJsonObject {
|
||||
put("input", buildJsonObject {
|
||||
put("id", id)
|
||||
put("weightKg", weightKg)
|
||||
dateIso?.let { put("date", it) }
|
||||
musclePercent?.let { put("musclePercent", it) }
|
||||
fatPercent?.let { put("fatPercent", it) }
|
||||
waterPercent?.let { put("waterPercent", it) }
|
||||
})
|
||||
},
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun deleteBodyMeasurement(id: String) {
|
||||
client.execute(
|
||||
"mutation(\$id: UUID!){deleteBodyMeasurement(id:\$id)}",
|
||||
buildJsonObject { put("id", id) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Cachade personbästa som (exerciseTypeId, reps) → bästa vikt. */
|
||||
suspend fun personalBests(): Map<Pair<Int, Int>, Double> =
|
||||
db.cacheDao().personalBests().associate { (it.exerciseTypeId to it.reps) to it.weight }
|
||||
|
||||
|
||||
/** PB-cachen (för 🏆-märkning av set under pass) – bara giltiga lyft, alla lägen. */
|
||||
suspend fun refreshPbCache(token: String? = null) {
|
||||
val tok = token ?: auth.bearerToken()
|
||||
|
||||
val pbs = client.execute(
|
||||
"query{personalBests{exerciseTypeId repRecords{reps weight}}}",
|
||||
token = tok,
|
||||
)
|
||||
val rows = pbs["personalBests"]!!.jsonArray.flatMap { p ->
|
||||
val po = p.jsonObject
|
||||
val typeId = po["exerciseTypeId"]!!.jsonPrimitive.int
|
||||
po["repRecords"]!!.jsonArray.mapNotNull { r ->
|
||||
val ro = r.jsonObject
|
||||
val reps = ro["reps"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null
|
||||
val weight = ro["weight"]?.jsonPrimitive?.doubleOrNull ?: return@mapNotNull null
|
||||
eu.brassepc.fitnessdroid.data.local.CachedPb(
|
||||
key = "$typeId-$reps",
|
||||
exerciseTypeId = typeId,
|
||||
reps = reps,
|
||||
weight = weight,
|
||||
)
|
||||
}
|
||||
}
|
||||
db.cacheDao().clearPersonalBests()
|
||||
db.cacheDao().upsertPersonalBests(rows)
|
||||
|
||||
}
|
||||
|
||||
/* ---------- Rekordstatus (träning / tävling / räknas ej) ---------- */
|
||||
|
||||
/** PB-lista med detaljer per rekord. mode: all | competition | training. */
|
||||
suspend fun personalBestsDetailed(mode: String = "all"): List<PbEntry> {
|
||||
val pb = client.execute(
|
||||
"query(\$m:String){personalBests(mode:\$m){exerciseTypeId exerciseTypeName repRecords{reps weight date liftId notes status statusNote sessionName estimated1Rm}}}",
|
||||
buildJsonObject { put("m", mode) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
return pb["personalBests"]!!.jsonArray.map { p ->
|
||||
val po = p.jsonObject
|
||||
PbEntry(
|
||||
exerciseName = po["exerciseTypeName"]?.jsonPrimitive?.contentOrNull() ?: "Övning",
|
||||
exerciseTypeId = po["exerciseTypeId"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
records = po["repRecords"]!!.jsonArray.mapNotNull { r ->
|
||||
val ro = r.jsonObject
|
||||
PbRecord(
|
||||
reps = ro["reps"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null,
|
||||
weight = ro["weight"]?.jsonPrimitive?.doubleOrNull ?: return@mapNotNull null,
|
||||
date = ro["date"]?.jsonPrimitive?.contentOrNull()?.take(10) ?: "",
|
||||
liftId = ro["liftId"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
status = ro["status"]?.jsonPrimitive?.contentOrNull() ?: "training",
|
||||
statusNote = ro["statusNote"]?.jsonPrimitive?.contentOrNull(),
|
||||
notes = ro["notes"]?.jsonPrimitive?.contentOrNull(),
|
||||
sessionName = ro["sessionName"]?.jsonPrimitive?.contentOrNull(),
|
||||
est1Rm = ro["estimated1Rm"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
||||
)
|
||||
}.sortedBy { it.reps },
|
||||
)
|
||||
}.sortedBy { it.exerciseName }
|
||||
}
|
||||
|
||||
/** Topplista för övning × reps, alla statusar (så bortplockade kan återställas). */
|
||||
suspend fun pbCandidates(exerciseTypeId: Int, reps: Int, mode: String = "all", limit: Int = 8): List<LiftDetail> {
|
||||
val d = client.execute(
|
||||
"query(\$e:Int!,\$r:Int!,\$l:Int,\$m:String){pbCandidates(exerciseTypeId:\$e,reps:\$r,limit:\$l,mode:\$m){$LIFT_DETAIL_FIELDS}}",
|
||||
buildJsonObject { put("e", exerciseTypeId); put("r", reps); put("l", limit); put("m", mode) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
return d["pbCandidates"]!!.jsonArray.map { parseLiftDetail(it.jsonObject) }
|
||||
}
|
||||
|
||||
suspend fun liftDetail(id: Int): LiftDetail? {
|
||||
val d = client.execute(
|
||||
"query(\$id:Int!){liftDetail(id:\$id){$LIFT_DETAIL_FIELDS}}",
|
||||
buildJsonObject { put("id", id) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
return d["liftDetail"]?.takeIf { it !is kotlinx.serialization.json.JsonNull }?.let { parseLiftDetail(it.jsonObject) }
|
||||
}
|
||||
|
||||
/** Sätter status på ett lyft (och dess set). Uppdaterar PB-cachen efteråt. */
|
||||
suspend fun setLiftStatus(id: Int, status: String, note: String?) {
|
||||
client.execute(
|
||||
"mutation(\$input:SetLiftStatusInput!){setLiftStatus(input:\$input){id status statusNote}}",
|
||||
buildJsonObject { put("input", buildJsonObject { put("id", id); put("status", status); note?.let { put("note", it) } }) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
runCatching { refreshPbCache() }
|
||||
}
|
||||
|
||||
/** Sätter status på ett set i ett pass (och dess lyft om passet är avslutat). */
|
||||
suspend fun setSessionSetStatus(setId: Int, status: String, note: String?) {
|
||||
client.execute(
|
||||
"mutation(\$input:SetSessionSetStatusInput!){setSessionSetStatus(input:\$input){id status statusNote}}",
|
||||
buildJsonObject { put("input", buildJsonObject { put("setId", setId); put("status", status); note?.let { put("note", it) } }) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
runCatching { refreshPbCache() }
|
||||
}
|
||||
|
||||
private fun parseLiftDetail(o: kotlinx.serialization.json.JsonObject) = LiftDetail(
|
||||
id = o["id"]!!.jsonPrimitive.int,
|
||||
exerciseTypeId = o["exerciseTypeId"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
exerciseName = o["exerciseTypeName"]?.jsonPrimitive?.contentOrNull() ?: "",
|
||||
weight = o["weight"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
||||
reps = o["reps"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
date = o["date"]?.jsonPrimitive?.contentOrNull()?.take(10) ?: "",
|
||||
notes = o["notes"]?.jsonPrimitive?.contentOrNull(),
|
||||
status = o["status"]?.jsonPrimitive?.contentOrNull() ?: "training",
|
||||
statusNote = o["statusNote"]?.jsonPrimitive?.contentOrNull(),
|
||||
sessionId = o["gymSessionId"]?.jsonPrimitive?.intOrNull,
|
||||
sessionName = o["sessionName"]?.jsonPrimitive?.contentOrNull(),
|
||||
setId = o["gymSessionSetId"]?.jsonPrimitive?.intOrNull,
|
||||
est1Rm = o["estimated1Rm"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
||||
isCurrentRecord = o["isCurrentRecord"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
)
|
||||
|
||||
/* ---------- Referensdata / cache ---------- */
|
||||
|
||||
/** Hämta om all referensdata. Tyst vid nätfel — cachen gäller tills vidare. */
|
||||
fun refreshAllAsync() {
|
||||
scope.launch {
|
||||
runCatching { refreshReferenceData() }
|
||||
runCatching { adoptServerActiveSession() }
|
||||
sync.kick()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshReferenceData() {
|
||||
val token = auth.bearerToken()
|
||||
|
||||
// Varje sektion för sig, och de nya API-fälten (iconKey,
|
||||
// defaultRestSeconds) med fallback — en äldre server ska aldrig
|
||||
// kunna lämna hela cachen tom.
|
||||
runCatching {
|
||||
val groups = try {
|
||||
client.execute("query{muscleGroups{id name iconKey}}", token = token)
|
||||
} catch (e: GraphQlException) {
|
||||
client.execute("query{muscleGroups{id name}}", token = token)
|
||||
}
|
||||
db.cacheDao().upsertMuscleGroups(
|
||||
groups["muscleGroups"]!!.jsonArray.map {
|
||||
val o = it.jsonObject
|
||||
CachedMuscleGroup(
|
||||
o["id"]!!.jsonPrimitive.int,
|
||||
o["name"]!!.jsonPrimitive.content,
|
||||
o["iconKey"]?.jsonPrimitive?.contentOrNull(),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val muscles = client.execute("query{muscles{id name muscleGroupId}}", token = token)
|
||||
db.cacheDao().upsertMuscles(
|
||||
muscles["muscles"]!!.jsonArray.map {
|
||||
val o = it.jsonObject
|
||||
CachedMuscle(
|
||||
o["id"]!!.jsonPrimitive.int,
|
||||
o["name"]!!.jsonPrimitive.content,
|
||||
o["muscleGroupId"]?.jsonPrimitive?.intOrNull,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
runCatching { refreshPbCache(token) }
|
||||
|
||||
runCatching {
|
||||
val prof = client.execute("query{myProfile{bodyWeightKg displayName}}", token = token)
|
||||
val p = prof["myProfile"]?.jsonObject
|
||||
store.saveBodyWeight(p?.get("bodyWeightKg")?.jsonPrimitive?.doubleOrNull)
|
||||
store.saveDisplayName(p?.get("displayName")?.jsonPrimitive?.contentOrNull())
|
||||
}
|
||||
|
||||
val favIds: Set<Int> = runCatching {
|
||||
client.execute("query{favoriteExercises{id}}", token = token)["favoriteExercises"]!!
|
||||
.jsonArray.map { it.jsonObject["id"]!!.jsonPrimitive.int }.toSet()
|
||||
}.getOrDefault(emptySet())
|
||||
|
||||
val typeFields =
|
||||
"id name description metValue tracksWeight tracksReps tracksDistance tracksDuration isBodyweight"
|
||||
val types = try {
|
||||
client.execute(
|
||||
"query{exerciseTypes{$typeFields defaultRestSeconds exerciseTypeMuscles{muscle{id}}}}",
|
||||
token = token,
|
||||
)
|
||||
} catch (e: GraphQlException) {
|
||||
client.execute(
|
||||
"query{exerciseTypes{$typeFields exerciseTypeMuscles{muscle{id}}}}",
|
||||
token = token,
|
||||
)
|
||||
}
|
||||
db.cacheDao().replaceExerciseTypes(
|
||||
types["exerciseTypes"]!!.jsonArray.map {
|
||||
val o = it.jsonObject
|
||||
CachedExerciseType(
|
||||
id = o["id"]!!.jsonPrimitive.int,
|
||||
name = o["name"]!!.jsonPrimitive.content,
|
||||
description = o["description"]?.jsonPrimitive?.contentOrNull(),
|
||||
metValue = o["metValue"]?.jsonPrimitive?.doubleOrNull ?: 5.0,
|
||||
tracksWeight = o["tracksWeight"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
tracksReps = o["tracksReps"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
tracksDistance = o["tracksDistance"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
tracksDuration = o["tracksDuration"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
isBodyweight = o["isBodyweight"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
defaultRestSeconds = o["defaultRestSeconds"]?.jsonPrimitive?.intOrNull,
|
||||
muscleIds = o["exerciseTypeMuscles"]?.jsonArray
|
||||
?.mapNotNull { m -> m.jsonObject["muscle"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
|
||||
?.joinToString(",") ?: "",
|
||||
isFavorite = o["id"]!!.jsonPrimitive.int in favIds,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Startkort: mallar + favoritpass
|
||||
val cards = mutableListOf<CachedStartCard>()
|
||||
runCatching {
|
||||
val templates = client.execute(
|
||||
"query{workoutTemplates{id name exercises{order exerciseType{id}}}}",
|
||||
token = token,
|
||||
)
|
||||
templates["workoutTemplates"]!!.jsonArray.forEachIndexed { i, t ->
|
||||
val o = t.jsonObject
|
||||
val ids = o["exercises"]!!.jsonArray
|
||||
.sortedBy { e -> e.jsonObject["order"]?.jsonPrimitive?.intOrNull ?: 0 }
|
||||
.mapNotNull { e -> e.jsonObject["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
|
||||
cards += CachedStartCard(
|
||||
key = "template-${o["id"]!!.jsonPrimitive.int}",
|
||||
title = o["name"]!!.jsonPrimitive.content,
|
||||
subtitle = "Mall · ${ids.size} övningar",
|
||||
exerciseTypeIds = ids.joinToString(","),
|
||||
templateId = o["id"]!!.jsonPrimitive.int,
|
||||
sortOrder = i,
|
||||
)
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
val favs = client.execute(
|
||||
"query{favoriteGymSessions{id name gymSessionExercises{order exerciseType{id}}}}",
|
||||
token = token,
|
||||
)
|
||||
favs["favoriteGymSessions"]!!.jsonArray.forEachIndexed { i, s ->
|
||||
val o = s.jsonObject
|
||||
val ids = o["gymSessionExercises"]!!.jsonArray
|
||||
.sortedBy { e -> e.jsonObject["order"]?.jsonPrimitive?.intOrNull ?: 0 }
|
||||
.mapNotNull { e -> e.jsonObject["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
|
||||
cards += CachedStartCard(
|
||||
key = "favsession-${o["id"]!!.jsonPrimitive.int}",
|
||||
title = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Favoritpass",
|
||||
subtitle = "Favorit · ${ids.size} övningar",
|
||||
exerciseTypeIds = ids.joinToString(","),
|
||||
sortOrder = 100 + i,
|
||||
)
|
||||
}
|
||||
}
|
||||
db.cacheDao().clearStartCards()
|
||||
db.cacheDao().upsertStartCards(cards)
|
||||
}
|
||||
|
||||
/** Om servern har ett aktivt pass men appen saknar ett: ta över det lokalt. */
|
||||
private suspend fun adoptServerActiveSession() {
|
||||
if (db.sessionDao().activeSessionIdNow() != null) return
|
||||
val token = auth.bearerToken()
|
||||
val data = client.execute(
|
||||
"query{activeGymSession{id name notes startTime gymSessionExercises{id order notes exerciseType{id} sets{id order reps weight distanceMeters durationSeconds rpe isWarmup isCompleted}}}}",
|
||||
token = token,
|
||||
)
|
||||
val s = data["activeGymSession"] ?: return
|
||||
if (s is kotlinx.serialization.json.JsonNull) return
|
||||
val o = s.jsonObject
|
||||
val sessionId = db.sessionDao().insertSession(
|
||||
LocalSession(
|
||||
serverId = o["id"]!!.jsonPrimitive.int,
|
||||
name = o["name"]?.jsonPrimitive?.contentOrNull(),
|
||||
notes = o["notes"]?.jsonPrimitive?.contentOrNull(),
|
||||
startedAtEpochMs = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
o["gymSessionExercises"]?.jsonArray?.forEach { e ->
|
||||
val eo = e.jsonObject
|
||||
val exId = db.sessionDao().insertExercise(
|
||||
LocalExercise(
|
||||
sessionId = sessionId,
|
||||
serverId = eo["id"]!!.jsonPrimitive.int,
|
||||
exerciseTypeId = eo["exerciseType"]!!.jsonObject["id"]!!.jsonPrimitive.int,
|
||||
order = eo["order"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
notes = eo["notes"]?.jsonPrimitive?.contentOrNull(),
|
||||
)
|
||||
)
|
||||
eo["sets"]?.jsonArray?.forEach { st ->
|
||||
val so = st.jsonObject
|
||||
db.sessionDao().insertSet(
|
||||
LocalSet(
|
||||
exerciseId = exId,
|
||||
serverId = so["id"]!!.jsonPrimitive.int,
|
||||
order = so["order"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
reps = so["reps"]?.jsonPrimitive?.intOrNull,
|
||||
weight = so["weight"]?.jsonPrimitive?.doubleOrNull,
|
||||
distanceMeters = so["distanceMeters"]?.jsonPrimitive?.doubleOrNull,
|
||||
durationSeconds = so["durationSeconds"]?.jsonPrimitive?.intOrNull,
|
||||
rpe = so["rpe"]?.jsonPrimitive?.doubleOrNull,
|
||||
isWarmup = so["isWarmup"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
isCompleted = so["isCompleted"]?.jsonPrimitive?.booleanOrNull ?: true,
|
||||
loggedAtEpochMs = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshLastPerformance(exerciseTypeId: Int) {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val data = client.execute(
|
||||
"query(\$id:Int!){lastExercisePerformance(exerciseTypeId:\$id){sets{reps weight distanceMeters durationSeconds}}}",
|
||||
buildJsonObject { put("id", exerciseTypeId) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val perf = data["lastExercisePerformance"] ?: return@launch
|
||||
if (perf is kotlinx.serialization.json.JsonNull) return@launch
|
||||
val best = perf.jsonObject["sets"]?.jsonArray?.lastOrNull()?.jsonObject ?: return@launch
|
||||
db.cacheDao().updateLastPerformance(
|
||||
exerciseTypeId,
|
||||
best["weight"]?.jsonPrimitive?.doubleOrNull,
|
||||
best["reps"]?.jsonPrimitive?.intOrNull,
|
||||
best["durationSeconds"]?.jsonPrimitive?.intOrNull,
|
||||
best["distanceMeters"]?.jsonPrimitive?.doubleOrNull,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFavoriteExercise(exerciseTypeId: Int, favorite: Boolean) {
|
||||
scope.launch {
|
||||
db.cacheDao().setExerciseFavorite(exerciseTypeId, favorite)
|
||||
runCatching {
|
||||
val mutation = if (favorite) {
|
||||
"mutation(\$id:Int!){addFavoriteExercise(exerciseTypeId:\$id){id}}"
|
||||
} else {
|
||||
"mutation(\$id:Int!){removeFavoriteExercise(exerciseTypeId:\$id)}"
|
||||
}
|
||||
client.execute(mutation, buildJsonObject { put("id", exerciseTypeId) }, auth.bearerToken())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Full detalj för ett avslutat pass. */
|
||||
suspend fun fetchSessionDetail(id: Int): SessionDetail {
|
||||
val data = client.execute(
|
||||
"query(\$id:Int!){gymSession(id:\$id){id name notes date startTime endTime durationSecondsOverride estimatedCalories gymSessionExercises{order exerciseType{id name} sets{id order reps weight distanceMeters durationSeconds rpe isWarmup notes status statusNote}}}}",
|
||||
buildJsonObject { put("id", id) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val o = data["gymSession"]!!.jsonObject
|
||||
return SessionDetail(
|
||||
id = o["id"]!!.jsonPrimitive.int,
|
||||
name = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Pass",
|
||||
date = (o["date"]?.jsonPrimitive?.contentOrNull()
|
||||
?: o["startTime"]?.jsonPrimitive?.contentOrNull()).orEmpty(),
|
||||
notes = o["notes"]?.jsonPrimitive?.contentOrNull(),
|
||||
calories = o["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
|
||||
durationMinutes = durationMinutes(
|
||||
o["durationSecondsOverride"]?.jsonPrimitive?.intOrNull,
|
||||
o["startTime"]?.jsonPrimitive?.contentOrNull(),
|
||||
o["endTime"]?.jsonPrimitive?.contentOrNull(),
|
||||
),
|
||||
exercises = o["gymSessionExercises"]!!.jsonArray
|
||||
.sortedBy { it.jsonObject["order"]?.jsonPrimitive?.intOrNull ?: 0 }
|
||||
.map { e ->
|
||||
val eo = e.jsonObject
|
||||
SessionDetailExercise(
|
||||
name = eo["exerciseType"]?.jsonObject?.get("name")?.jsonPrimitive?.contentOrNull()
|
||||
?: "Övning",
|
||||
sets = eo["sets"]!!.jsonArray.map(::parseHistorySet)
|
||||
.sortedBy { it.order },
|
||||
exerciseTypeId = eo["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull ?: 0,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Historik för en övning: tidigare pass med dess set, plus tyngsta
|
||||
* lyften de senaste tre månaderna. Filtreras klient-side ur de
|
||||
* senaste avslutade passen.
|
||||
*/
|
||||
suspend fun fetchExerciseHistory(exerciseTypeId: Int, limit: Int = 40): ExerciseHistory {
|
||||
val data = client.execute(
|
||||
"query(\$s:String,\$l:Int){gymSessions(status:\$s,limit:\$l){name date startTime gymSessionExercises{exerciseType{id} sets{order reps weight distanceMeters durationSeconds rpe isWarmup}}}}",
|
||||
buildJsonObject { put("s", "completed"); put("l", limit) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val entries = data["gymSessions"]!!.jsonArray.mapNotNull { s ->
|
||||
val o = s.jsonObject
|
||||
val sets = o["gymSessionExercises"]!!.jsonArray
|
||||
.filter {
|
||||
it.jsonObject["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull == exerciseTypeId
|
||||
}
|
||||
.flatMap { it.jsonObject["sets"]!!.jsonArray }
|
||||
.map(::parseHistorySet)
|
||||
.sortedBy { it.order }
|
||||
if (sets.isEmpty()) return@mapNotNull null
|
||||
ExerciseHistoryEntry(
|
||||
date = (o["date"]?.jsonPrimitive?.contentOrNull()
|
||||
?: o["startTime"]?.jsonPrimitive?.contentOrNull()).orEmpty().take(10),
|
||||
sessionName = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Pass",
|
||||
sets = sets,
|
||||
)
|
||||
}.sortedByDescending { it.date }
|
||||
|
||||
val cutoff = java.time.LocalDate.now().minusMonths(3).toString()
|
||||
val topLifts = entries
|
||||
.filter { it.date >= cutoff }
|
||||
.flatMap { entry ->
|
||||
entry.sets.filter { !it.isWarmup && it.weight != null }
|
||||
.map { TopLift(entry.date, it.weight!!, it.reps) }
|
||||
}
|
||||
.sortedWith(compareByDescending<TopLift> { it.weight }.thenByDescending { it.reps ?: 0 })
|
||||
.take(10)
|
||||
|
||||
return ExerciseHistory(entries = entries, topLifts = topLifts)
|
||||
}
|
||||
|
||||
private fun parseHistorySet(element: kotlinx.serialization.json.JsonElement): HistorySet {
|
||||
val so = element.jsonObject
|
||||
return HistorySet(
|
||||
order = so["order"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
weight = so["weight"]?.jsonPrimitive?.doubleOrNull,
|
||||
reps = so["reps"]?.jsonPrimitive?.intOrNull,
|
||||
distanceMeters = so["distanceMeters"]?.jsonPrimitive?.doubleOrNull,
|
||||
durationSeconds = so["durationSeconds"]?.jsonPrimitive?.intOrNull,
|
||||
rpe = so["rpe"]?.jsonPrimitive?.doubleOrNull,
|
||||
isWarmup = so["isWarmup"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
id = so["id"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
notes = so["notes"]?.jsonPrimitive?.contentOrNull(),
|
||||
status = so["status"]?.jsonPrimitive?.contentOrNull() ?: "training",
|
||||
statusNote = so["statusNote"]?.jsonPrimitive?.contentOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Statistik för en period: KPI:er + muskelvolym + PB-lista. */
|
||||
suspend fun fetchStats(period: String, referenceDate: String? = null): StatsBundle {
|
||||
val token = auth.bearerToken()
|
||||
|
||||
val summaryData = client.execute(
|
||||
"query(\$p:String!,\$d:String){gymSessionSummary(period:\$p,referenceDate:\$d){" +
|
||||
"sessionCount totalDurationMinutes avgDurationMinutes totalCalories totalVolumeKg totalSets totalReps " +
|
||||
"sessionsPerWeek currentStreakWeeks " +
|
||||
"previous{sessionCount totalDurationMinutes totalCalories totalVolumeKg totalSets} " +
|
||||
"trend{label totalVolumeKg sessionCount} " +
|
||||
"highlights{heaviestLift{exerciseName weight reps date} newPrCount}}}",
|
||||
buildJsonObject { put("p", period); referenceDate?.let { put("d", it) } },
|
||||
token,
|
||||
)
|
||||
val s = summaryData["gymSessionSummary"]!!.jsonObject
|
||||
val prev = s["previous"]?.takeIf { it !is kotlinx.serialization.json.JsonNull }?.jsonObject
|
||||
val highlights = s["highlights"]?.takeIf { it !is kotlinx.serialization.json.JsonNull }?.jsonObject
|
||||
val heaviest = highlights?.get("heaviestLift")
|
||||
?.takeIf { it !is kotlinx.serialization.json.JsonNull }?.jsonObject
|
||||
|
||||
val muscleVolumes: Map<String, Double> = runCatching {
|
||||
val ws = client.execute(
|
||||
"query(\$p:String!,\$d:String){workoutStats(period:\$p,referenceDate:\$d){muscleGroupStats{muscleGroupName totalVolume}}}",
|
||||
buildJsonObject { put("p", period); referenceDate?.let { put("d", it) } },
|
||||
token,
|
||||
)
|
||||
ws["workoutStats"]!!.jsonObject["muscleGroupStats"]!!.jsonArray.associate {
|
||||
val o = it.jsonObject
|
||||
(o["muscleGroupName"]?.jsonPrimitive?.contentOrNull() ?: "?") to
|
||||
(o["totalVolume"]?.jsonPrimitive?.doubleOrNull ?: 0.0)
|
||||
}
|
||||
}.getOrDefault(emptyMap())
|
||||
|
||||
val pbs: List<PbEntry> = runCatching { personalBestsDetailed("all") }.getOrDefault(emptyList())
|
||||
|
||||
fun kpi(key: String) = s[key]?.jsonPrimitive?.doubleOrNull ?: 0.0
|
||||
fun prevKpi(key: String) = prev?.get(key)?.jsonPrimitive?.doubleOrNull
|
||||
|
||||
return StatsBundle(
|
||||
sessionCount = kpi("sessionCount").toInt(),
|
||||
prevSessionCount = prevKpi("sessionCount")?.toInt(),
|
||||
totalDurationMinutes = kpi("totalDurationMinutes").toInt(),
|
||||
prevDurationMinutes = prevKpi("totalDurationMinutes")?.toInt(),
|
||||
totalCalories = kpi("totalCalories"),
|
||||
prevCalories = prevKpi("totalCalories"),
|
||||
totalVolumeKg = kpi("totalVolumeKg"),
|
||||
prevVolumeKg = prevKpi("totalVolumeKg"),
|
||||
totalSets = kpi("totalSets").toInt(),
|
||||
totalReps = kpi("totalReps").toInt(),
|
||||
sessionsPerWeek = kpi("sessionsPerWeek"),
|
||||
streakWeeks = kpi("currentStreakWeeks").toInt(),
|
||||
trend = s["trend"]?.jsonArray?.map {
|
||||
val o = it.jsonObject
|
||||
TrendBucket(
|
||||
label = o["label"]?.jsonPrimitive?.contentOrNull() ?: "",
|
||||
volumeKg = o["totalVolumeKg"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
||||
sessionCount = o["sessionCount"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
)
|
||||
}.orEmpty(),
|
||||
heaviestLift = heaviest?.let {
|
||||
"${it["exerciseName"]?.jsonPrimitive?.contentOrNull()}: " +
|
||||
"${it["weight"]?.jsonPrimitive?.doubleOrNull ?: 0.0} kg × " +
|
||||
"${it["reps"]?.jsonPrimitive?.intOrNull ?: 0}"
|
||||
},
|
||||
newPrCount = highlights?.get("newPrCount")?.jsonPrimitive?.intOrNull ?: 0,
|
||||
muscleVolumes = muscleVolumes,
|
||||
personalBests = pbs,
|
||||
)
|
||||
}
|
||||
|
||||
/** Serverns kaloriberäkning och tidsfördelning för ett pass. */
|
||||
suspend fun fetchSessionStats(id: Int): SessionStats {
|
||||
val data = client.execute(
|
||||
"query(\$id:Int!){gymSessionStats(id:\$id){durationMinutes estimatedCalories totalSets totalReps totalVolumeKg exerciseBreakdown{exerciseTypeId exerciseName allocatedSeconds estimatedCalories totalSets totalReps maxWeight volumeKg}}}",
|
||||
buildJsonObject { put("id", id) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val o = data["gymSessionStats"]!!.jsonObject
|
||||
return SessionStats(
|
||||
durationMinutes = o["durationMinutes"]?.jsonPrimitive?.intOrNull,
|
||||
estimatedCalories = o["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
|
||||
totalSets = o["totalSets"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
totalReps = o["totalReps"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
totalVolumeKg = o["totalVolumeKg"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
||||
breakdown = o["exerciseBreakdown"]?.jsonArray?.map { e ->
|
||||
val eo = e.jsonObject
|
||||
val typeId = eo["exerciseTypeId"]?.jsonPrimitive?.intOrNull
|
||||
ExerciseStats(
|
||||
exerciseTypeId = typeId,
|
||||
name = eo["exerciseName"]?.jsonPrimitive?.contentOrNull() ?: "Övning",
|
||||
allocatedSeconds = eo["allocatedSeconds"]?.jsonPrimitive?.intOrNull,
|
||||
estimatedCalories = eo["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
|
||||
metValue = typeId?.let { db.cacheDao().exerciseType(it)?.metValue },
|
||||
)
|
||||
}.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Historik hämtas direkt (ingen offline-garanti i v1). */
|
||||
suspend fun fetchHistory(limit: Int = 30): List<HistoryItem> {
|
||||
val data = client.execute(
|
||||
"query(\$s:String,\$l:Int){gymSessions(status:\$s,limit:\$l){id name date startTime endTime durationSecondsOverride estimatedCalories gymSessionExercises{id sets{id weight reps}}}}",
|
||||
buildJsonObject { put("s", "completed"); put("l", limit) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
return data["gymSessions"]!!.jsonArray.map { s ->
|
||||
val o = s.jsonObject
|
||||
val sets = o["gymSessionExercises"]!!.jsonArray.flatMap { it.jsonObject["sets"]!!.jsonArray }
|
||||
val volume = sets.sumOf { st ->
|
||||
val so = st.jsonObject
|
||||
(so["weight"]?.jsonPrimitive?.doubleOrNull ?: 0.0) * (so["reps"]?.jsonPrimitive?.intOrNull ?: 0)
|
||||
}
|
||||
HistoryItem(
|
||||
id = o["id"]!!.jsonPrimitive.int,
|
||||
name = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Pass",
|
||||
date = o["date"]?.jsonPrimitive?.contentOrNull()
|
||||
?: o["startTime"]?.jsonPrimitive?.contentOrNull() ?: "",
|
||||
exerciseCount = o["gymSessionExercises"]!!.jsonArray.size,
|
||||
setCount = sets.size,
|
||||
volumeKg = volume,
|
||||
calories = o["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
|
||||
durationMinutes = durationMinutes(
|
||||
o["durationSecondsOverride"]?.jsonPrimitive?.intOrNull,
|
||||
o["startTime"]?.jsonPrimitive?.contentOrNull(),
|
||||
o["endTime"]?.jsonPrimitive?.contentOrNull(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Passlängd i minuter: manuell rättning vinner, annars slut − start. */
|
||||
private fun durationMinutes(overrideSeconds: Int?, start: String?, end: String?): Int? {
|
||||
overrideSeconds?.let { return it / 60 }
|
||||
if (start == null || end == null) return null
|
||||
return runCatching {
|
||||
val s = java.time.OffsetDateTime.parse(start)
|
||||
val e = java.time.OffsetDateTime.parse(end)
|
||||
java.time.Duration.between(s, e).toMinutes().toInt().takeIf { it >= 0 }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
data class HistoryItem(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val date: String,
|
||||
val exerciseCount: Int,
|
||||
val setCount: Int,
|
||||
val volumeKg: Double,
|
||||
val calories: Double?,
|
||||
val durationMinutes: Int? = null,
|
||||
)
|
||||
|
||||
data class HistorySet(
|
||||
val order: Int,
|
||||
val weight: Double?,
|
||||
val reps: Int?,
|
||||
val distanceMeters: Double?,
|
||||
val durationSeconds: Int?,
|
||||
val rpe: Double?,
|
||||
val isWarmup: Boolean,
|
||||
val id: Int = 0,
|
||||
val notes: String? = null,
|
||||
/** Rekordstatus: training / competition / excluded (se LiftStatusSheet). */
|
||||
val status: String = "training",
|
||||
val statusNote: String? = null,
|
||||
)
|
||||
|
||||
data class SessionDetail(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val date: String,
|
||||
val notes: String?,
|
||||
val calories: Double?,
|
||||
val durationMinutes: Int? = null,
|
||||
val exercises: List<SessionDetailExercise>,
|
||||
)
|
||||
|
||||
data class SessionDetailExercise(
|
||||
val name: String,
|
||||
val sets: List<HistorySet>,
|
||||
val exerciseTypeId: Int = 0,
|
||||
)
|
||||
|
||||
data class ExerciseHistoryEntry(
|
||||
val date: String,
|
||||
val sessionName: String,
|
||||
val sets: List<HistorySet>,
|
||||
)
|
||||
|
||||
data class TopLift(val date: String, val weight: Double, val reps: Int?)
|
||||
|
||||
data class ExerciseHistory(
|
||||
val entries: List<ExerciseHistoryEntry>,
|
||||
val topLifts: List<TopLift>,
|
||||
)
|
||||
|
||||
data class SessionStats(
|
||||
val durationMinutes: Int?,
|
||||
val estimatedCalories: Double?,
|
||||
val totalSets: Int,
|
||||
val totalReps: Int,
|
||||
val totalVolumeKg: Double,
|
||||
val breakdown: List<ExerciseStats>,
|
||||
)
|
||||
|
||||
data class ExerciseStats(
|
||||
val exerciseTypeId: Int?,
|
||||
val name: String,
|
||||
val allocatedSeconds: Int?,
|
||||
val estimatedCalories: Double?,
|
||||
val metValue: Double?,
|
||||
)
|
||||
|
||||
data class BodyMeasurement(
|
||||
val id: String,
|
||||
val date: String,
|
||||
val weightKg: Double,
|
||||
val musclePercent: Double?,
|
||||
val fatPercent: Double?,
|
||||
val waterPercent: Double?,
|
||||
)
|
||||
|
||||
data class TrendBucket(val label: String, val volumeKg: Double, val sessionCount: Int)
|
||||
data class PbRecord(
|
||||
val reps: Int,
|
||||
val weight: Double,
|
||||
val date: String,
|
||||
val liftId: Int = 0,
|
||||
val status: String = "training",
|
||||
val statusNote: String? = null,
|
||||
val notes: String? = null,
|
||||
val sessionName: String? = null,
|
||||
val est1Rm: Double = 0.0,
|
||||
)
|
||||
data class PbEntry(val exerciseName: String, val records: List<PbRecord>, val exerciseTypeId: Int = 0)
|
||||
|
||||
/** Ett lyft med allt PB-arket behöver (pbCandidates / liftDetail). */
|
||||
data class LiftDetail(
|
||||
val id: Int,
|
||||
val exerciseTypeId: Int,
|
||||
val exerciseName: String,
|
||||
val weight: Double,
|
||||
val reps: Int,
|
||||
val date: String,
|
||||
val notes: String?,
|
||||
val status: String,
|
||||
val statusNote: String?,
|
||||
val sessionId: Int?,
|
||||
val sessionName: String?,
|
||||
val setId: Int?,
|
||||
val est1Rm: Double,
|
||||
val isCurrentRecord: Boolean,
|
||||
)
|
||||
|
||||
data class StatsBundle(
|
||||
val sessionCount: Int,
|
||||
val prevSessionCount: Int?,
|
||||
val totalDurationMinutes: Int,
|
||||
val prevDurationMinutes: Int?,
|
||||
val totalCalories: Double,
|
||||
val prevCalories: Double?,
|
||||
val totalVolumeKg: Double,
|
||||
val prevVolumeKg: Double?,
|
||||
val totalSets: Int,
|
||||
val totalReps: Int,
|
||||
val sessionsPerWeek: Double,
|
||||
val streakWeeks: Int,
|
||||
val trend: List<TrendBucket>,
|
||||
val heaviestLift: String?,
|
||||
val newPrCount: Int,
|
||||
/** muskelgruppnamn → volym kg (för kroppskartan) */
|
||||
val muscleVolumes: Map<String, Double>,
|
||||
val personalBests: List<PbEntry>,
|
||||
)
|
||||
|
||||
private fun kotlinx.serialization.json.JsonPrimitive.contentOrNull(): String? =
|
||||
if (this is kotlinx.serialization.json.JsonNull) null else content
|
||||
102
app/src/main/java/eu/brassepc/fitnessdroid/data/RestTimer.kt
Normal file
102
app/src/main/java/eu/brassepc/fitnessdroid/data/RestTimer.kt
Normal file
@@ -0,0 +1,102 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.content.Context
|
||||
import android.media.RingtoneManager
|
||||
import android.os.VibrationEffect
|
||||
import android.os.VibratorManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class RestState(
|
||||
val totalSeconds: Int,
|
||||
val endsAtEpochMs: Long,
|
||||
val secondsLeft: Int,
|
||||
val finished: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Vilotimern. Räknar mot en absolut sluttid (överlever att appen pausas)
|
||||
* och larmar enligt inställningarna när tiden är ute.
|
||||
*/
|
||||
class RestTimerController(
|
||||
private val context: Context,
|
||||
private val settingsStore: SettingsStore,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val _state = MutableStateFlow<RestState?>(null)
|
||||
val state: StateFlow<RestState?> = _state
|
||||
|
||||
private var ticker: Job? = null
|
||||
|
||||
fun start(seconds: Int? = null) {
|
||||
scope.launch {
|
||||
val settings = settingsStore.settings.first()
|
||||
val total = seconds ?: settings.defaultRestSeconds
|
||||
_state.value = RestState(
|
||||
totalSeconds = total,
|
||||
endsAtEpochMs = System.currentTimeMillis() + total * 1000L,
|
||||
secondsLeft = total,
|
||||
)
|
||||
startTicker()
|
||||
}
|
||||
}
|
||||
|
||||
fun adjust(deltaSeconds: Int) {
|
||||
val current = _state.value ?: return
|
||||
if (current.finished) return
|
||||
_state.value = current.copy(
|
||||
endsAtEpochMs = (current.endsAtEpochMs + deltaSeconds * 1000L)
|
||||
.coerceAtLeast(System.currentTimeMillis()),
|
||||
totalSeconds = (current.totalSeconds + deltaSeconds).coerceAtLeast(5),
|
||||
)
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
ticker?.cancel()
|
||||
_state.value = null
|
||||
}
|
||||
|
||||
private fun startTicker() {
|
||||
ticker?.cancel()
|
||||
ticker = scope.launch {
|
||||
while (true) {
|
||||
val current = _state.value ?: return@launch
|
||||
val left = (((current.endsAtEpochMs - System.currentTimeMillis()) + 999) / 1000)
|
||||
.coerceAtLeast(0).toInt()
|
||||
if (left != current.secondsLeft) {
|
||||
_state.value = current.copy(secondsLeft = left)
|
||||
}
|
||||
if (left <= 0) {
|
||||
_state.value = _state.value?.copy(secondsLeft = 0, finished = true)
|
||||
alert()
|
||||
// Låt "klart"-läget synas en stund, försvinn sedan självmant.
|
||||
delay(8_000)
|
||||
if (_state.value?.finished == true) _state.value = null
|
||||
return@launch
|
||||
}
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun alert() {
|
||||
when (settingsStore.settings.first().restAlert) {
|
||||
RestAlert.SOUND -> runCatching {
|
||||
val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
RingtoneManager.getRingtone(context, uri)?.play()
|
||||
}
|
||||
RestAlert.VIBRATE -> runCatching {
|
||||
val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
vm.defaultVibrator.vibrate(
|
||||
VibrationEffect.createWaveform(longArrayOf(0, 350, 150, 350, 150, 500), -1)
|
||||
)
|
||||
}
|
||||
RestAlert.VISUAL, RestAlert.NONE -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
110
app/src/main/java/eu/brassepc/fitnessdroid/data/ScaleManager.kt
Normal file
110
app/src/main/java/eu/brassepc/fitnessdroid/data/ScaleManager.kt
Normal file
@@ -0,0 +1,110 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.content.Context
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.ScaleCommunicator
|
||||
import com.health.openscale.core.bluetooth.ScaleFactory
|
||||
import com.health.openscale.core.data.GenderType
|
||||
import com.health.openscale.core.data.User
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.health.openscale.core.service.BluetoothScannerManager
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Calendar
|
||||
|
||||
/**
|
||||
* Brygga mellan appen och de vendrade openScale-drivrutinerna.
|
||||
* Äger facade-shims, [ScaleFactory] och skannern, och exponerar ett enkelt
|
||||
* flöde för UI:t: skanna → spara våg → väg dig (events + mätresultat).
|
||||
*/
|
||||
class ScaleManager(
|
||||
context: Context,
|
||||
private val settingsStore: SettingsStore,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
private val settingsFacade = SettingsFacade(appContext)
|
||||
private val userFacade = UserFacade()
|
||||
private val measurementFacade = MeasurementFacade()
|
||||
|
||||
val factory = ScaleFactory(appContext, settingsFacade, measurementFacade, userFacade)
|
||||
val scanner by lazy { BluetoothScannerManager(appContext, scope, factory) }
|
||||
|
||||
private var communicator: ScaleCommunicator? = null
|
||||
private var eventJob: Job? = null
|
||||
|
||||
/** Senaste händelsen från vågen — UI:t visar status utifrån denna. */
|
||||
private val _lastEvent = MutableStateFlow<BluetoothEvent?>(null)
|
||||
val lastEvent: StateFlow<BluetoothEvent?> = _lastEvent.asStateFlow()
|
||||
|
||||
/**
|
||||
* Uppdatera drivrutinernas användarprofil från appens inställningar.
|
||||
* Impedansvågar (som Exingtech Y1) får kön/ålder/längd skrivna till sig
|
||||
* och räknar ut kroppssammansättningen ombord.
|
||||
*/
|
||||
private suspend fun refreshScaleUser() {
|
||||
val s = settingsStore.settings.first()
|
||||
val birthMillis = s.birthYear?.let { year ->
|
||||
Calendar.getInstance().apply { set(year, Calendar.JULY, 1, 12, 0, 0) }.timeInMillis
|
||||
}
|
||||
userFacade.setSelectedUser(
|
||||
User(
|
||||
id = 1,
|
||||
name = "FitnessDroid",
|
||||
birthDate = birthMillis,
|
||||
heightCm = s.heightCm?.toFloat() ?: -1f,
|
||||
gender = if (s.isFemale == true) GenderType.FEMALE else GenderType.MALE,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Har användaren fyllt i kroppsdatan som vågen behöver? */
|
||||
suspend fun hasBodyData(): Boolean {
|
||||
val s = settingsStore.settings.first()
|
||||
return s.heightCm != null && s.birthYear != null && s.isFemale != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Anslut till den sparade vågen och börja lyssna på händelser.
|
||||
* Resultatet (inklusive [BluetoothEvent.MeasurementReceived]) kommer i [lastEvent].
|
||||
* @return false om ingen våg är sparad eller ingen drivrutin matchar.
|
||||
*/
|
||||
suspend fun connectSavedScale(): Boolean {
|
||||
val s = settingsStore.settings.first()
|
||||
val address = s.scaleAddress ?: return false
|
||||
val name = s.scaleName ?: ""
|
||||
|
||||
refreshScaleUser()
|
||||
disconnect()
|
||||
|
||||
val info = ScannedDeviceInfo(name, address, 0, emptyList(), null)
|
||||
val comm = factory.createCommunicator(info) ?: return false
|
||||
communicator = comm
|
||||
|
||||
_lastEvent.value = null
|
||||
eventJob = scope.launch {
|
||||
comm.getEventsFlow().collect { _lastEvent.value = it }
|
||||
}
|
||||
comm.connect(address, null)
|
||||
return true
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
eventJob?.cancel()
|
||||
eventJob = null
|
||||
communicator?.let { comm ->
|
||||
runCatching { comm.disconnect() }
|
||||
runCatching { (comm as? AutoCloseable)?.close() }
|
||||
}
|
||||
communicator = null
|
||||
}
|
||||
}
|
||||
194
app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt
Normal file
194
app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt
Normal file
@@ -0,0 +1,194 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.settingsDataStore by preferencesDataStore(name = "settings")
|
||||
|
||||
enum class RestTimerStyle { FULLSCREEN, BANNER }
|
||||
enum class RestAlert { SOUND, VIBRATE, VISUAL, NONE }
|
||||
|
||||
data class AppSettings(
|
||||
val restTimerStyle: RestTimerStyle = RestTimerStyle.FULLSCREEN,
|
||||
val restAlert: RestAlert = RestAlert.VIBRATE,
|
||||
val defaultRestSeconds: Int = 90,
|
||||
/** Steg för viktstepparna i passläget (kg) */
|
||||
val weightStep: Double = 2.5,
|
||||
/** Spara inloggningen krypterat och logga in tyst igen vid behov */
|
||||
val autoRelogin: Boolean = true,
|
||||
/** Egna stångvikter i skivkalkylatorn (utöver snabbvalen 20/0 kg) */
|
||||
val customBarWeights: List<Double> = listOf(15.0),
|
||||
/** Sparad Bluetooth-våg (MAC-adress + BLE-namn + drivrutinens visningsnamn) */
|
||||
val scaleAddress: String? = null,
|
||||
val scaleName: String? = null,
|
||||
val scaleDriver: String? = null,
|
||||
/** Kroppsdata som impedansvågar behöver (skickas till vågen vid vägning) */
|
||||
val heightCm: Double? = null,
|
||||
val birthYear: Int? = null,
|
||||
/** null = inte satt, false = man, true = kvinna */
|
||||
val isFemale: Boolean? = null,
|
||||
/** Visa teknisk logg på spårskärmen (felsökning) */
|
||||
val trackLogEnabled: Boolean = false,
|
||||
/** GPS-filter: förkasta fixar med sämre noggrannhet än detta (meter) */
|
||||
val gpsAccuracyLimitM: Int = 35,
|
||||
/** GPS-filter: max tillåten positionsfart som multipel av Doppler-farten */
|
||||
val gpsSpeedFactor: Double = 2.0,
|
||||
/** GPS-filter: fartgolv (km/h) när Doppler-fart saknas/är noll */
|
||||
val gpsSpeedFloorKmh: Int = 12,
|
||||
/** Rörelsevakt: frys spår/distans när accelerometern säger stilla */
|
||||
val motionGuardEnabled: Boolean = true,
|
||||
/** Rörelsevaktens känslighet (m/s² avvikelse från vila; lägre = känsligare) */
|
||||
val motionThreshold: Double = 0.35,
|
||||
/** Nedräkning (sekunder) när man trycker play på en aktivitet */
|
||||
val countdownSeconds: Int = 3,
|
||||
)
|
||||
|
||||
class SettingsStore(private val context: Context) {
|
||||
|
||||
val settings: Flow<AppSettings> = context.settingsDataStore.data.map { prefs ->
|
||||
AppSettings(
|
||||
restTimerStyle = prefs[KEY_TIMER_STYLE]?.let {
|
||||
runCatching { RestTimerStyle.valueOf(it) }.getOrNull()
|
||||
} ?: RestTimerStyle.FULLSCREEN,
|
||||
restAlert = prefs[KEY_REST_ALERT]?.let {
|
||||
runCatching { RestAlert.valueOf(it) }.getOrNull()
|
||||
} ?: RestAlert.VIBRATE,
|
||||
defaultRestSeconds = prefs[KEY_REST_SECONDS] ?: 90,
|
||||
weightStep = prefs[KEY_WEIGHT_STEP]?.toDoubleOrNull() ?: 2.5,
|
||||
autoRelogin = prefs[KEY_AUTO_RELOGIN]?.toBooleanStrictOrNull() ?: true,
|
||||
customBarWeights = prefs[KEY_CUSTOM_BARS]?.split(",")
|
||||
?.mapNotNull { it.toDoubleOrNull() }
|
||||
// migrera från gamla enkel-värdet
|
||||
?: prefs[KEY_CUSTOM_BAR]?.toDoubleOrNull()?.let { listOf(it) }
|
||||
?: listOf(15.0),
|
||||
scaleAddress = prefs[KEY_SCALE_ADDRESS],
|
||||
scaleName = prefs[KEY_SCALE_NAME],
|
||||
scaleDriver = prefs[KEY_SCALE_DRIVER],
|
||||
heightCm = prefs[KEY_HEIGHT_CM]?.toDoubleOrNull(),
|
||||
birthYear = prefs[KEY_BIRTH_YEAR],
|
||||
isFemale = prefs[KEY_IS_FEMALE]?.toBooleanStrictOrNull(),
|
||||
trackLogEnabled = prefs[KEY_TRACK_LOG]?.toBooleanStrictOrNull() ?: false,
|
||||
gpsAccuracyLimitM = prefs[KEY_GPS_ACC] ?: 35,
|
||||
gpsSpeedFactor = prefs[KEY_GPS_FACTOR]?.toDoubleOrNull() ?: 2.0,
|
||||
gpsSpeedFloorKmh = prefs[KEY_GPS_FLOOR] ?: 12,
|
||||
motionGuardEnabled = prefs[KEY_MOTION_GUARD]?.toBooleanStrictOrNull() ?: true,
|
||||
motionThreshold = prefs[KEY_MOTION_THRESHOLD]?.toDoubleOrNull() ?: 0.35,
|
||||
countdownSeconds = prefs[KEY_COUNTDOWN] ?: 3,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setCountdownSeconds(value: Int) {
|
||||
context.settingsDataStore.edit { it[KEY_COUNTDOWN] = value.coerceIn(0, 15) }
|
||||
}
|
||||
|
||||
suspend fun setMotionGuardEnabled(value: Boolean) {
|
||||
context.settingsDataStore.edit { it[KEY_MOTION_GUARD] = value.toString() }
|
||||
}
|
||||
|
||||
suspend fun setMotionThreshold(value: Double) {
|
||||
context.settingsDataStore.edit {
|
||||
it[KEY_MOTION_THRESHOLD] = (Math.round(value.coerceIn(0.1, 1.5) * 100) / 100.0).toString()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setGpsAccuracyLimit(value: Int) {
|
||||
context.settingsDataStore.edit { it[KEY_GPS_ACC] = value.coerceIn(10, 100) }
|
||||
}
|
||||
|
||||
suspend fun setGpsSpeedFactor(value: Double) {
|
||||
context.settingsDataStore.edit { it[KEY_GPS_FACTOR] = value.coerceIn(1.2, 5.0).toString() }
|
||||
}
|
||||
|
||||
suspend fun setGpsSpeedFloor(value: Int) {
|
||||
context.settingsDataStore.edit { it[KEY_GPS_FLOOR] = value.coerceIn(4, 40) }
|
||||
}
|
||||
|
||||
suspend fun setTrackLogEnabled(value: Boolean) {
|
||||
context.settingsDataStore.edit { it[KEY_TRACK_LOG] = value.toString() }
|
||||
}
|
||||
|
||||
suspend fun setScale(address: String?, name: String?, driver: String?) {
|
||||
context.settingsDataStore.edit { prefs ->
|
||||
if (address == null) {
|
||||
prefs.remove(KEY_SCALE_ADDRESS); prefs.remove(KEY_SCALE_NAME); prefs.remove(KEY_SCALE_DRIVER)
|
||||
} else {
|
||||
prefs[KEY_SCALE_ADDRESS] = address
|
||||
prefs[KEY_SCALE_NAME] = name ?: ""
|
||||
prefs[KEY_SCALE_DRIVER] = driver ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setBodyData(heightCm: Double?, birthYear: Int?, isFemale: Boolean?) {
|
||||
context.settingsDataStore.edit { prefs ->
|
||||
heightCm?.let { prefs[KEY_HEIGHT_CM] = it.toString() } ?: prefs.remove(KEY_HEIGHT_CM)
|
||||
birthYear?.let { prefs[KEY_BIRTH_YEAR] = it } ?: prefs.remove(KEY_BIRTH_YEAR)
|
||||
isFemale?.let { prefs[KEY_IS_FEMALE] = it.toString() } ?: prefs.remove(KEY_IS_FEMALE)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addCustomBarWeight(value: Double) {
|
||||
val v = value.coerceIn(0.1, 100.0)
|
||||
context.settingsDataStore.edit { prefs ->
|
||||
val current = prefs[KEY_CUSTOM_BARS]?.split(",")
|
||||
?.mapNotNull { it.toDoubleOrNull() } ?: listOf(15.0)
|
||||
prefs[KEY_CUSTOM_BARS] = (current + v).distinct().sorted().joinToString(",")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeCustomBarWeight(value: Double) {
|
||||
context.settingsDataStore.edit { prefs ->
|
||||
val current = prefs[KEY_CUSTOM_BARS]?.split(",")
|
||||
?.mapNotNull { it.toDoubleOrNull() } ?: listOf(15.0)
|
||||
prefs[KEY_CUSTOM_BARS] = current.filter { it != value }.joinToString(",")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setWeightStep(value: Double) {
|
||||
context.settingsDataStore.edit { it[KEY_WEIGHT_STEP] = value.toString() }
|
||||
}
|
||||
|
||||
suspend fun setAutoRelogin(value: Boolean) {
|
||||
context.settingsDataStore.edit { it[KEY_AUTO_RELOGIN] = value.toString() }
|
||||
}
|
||||
|
||||
suspend fun setRestTimerStyle(value: RestTimerStyle) {
|
||||
context.settingsDataStore.edit { it[KEY_TIMER_STYLE] = value.name }
|
||||
}
|
||||
|
||||
suspend fun setRestAlert(value: RestAlert) {
|
||||
context.settingsDataStore.edit { it[KEY_REST_ALERT] = value.name }
|
||||
}
|
||||
|
||||
suspend fun setDefaultRestSeconds(value: Int) {
|
||||
context.settingsDataStore.edit { it[KEY_REST_SECONDS] = value.coerceIn(10, 600) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY_TIMER_STYLE = stringPreferencesKey("rest_timer_style")
|
||||
private val KEY_REST_ALERT = stringPreferencesKey("rest_alert")
|
||||
private val KEY_REST_SECONDS = intPreferencesKey("default_rest_seconds")
|
||||
private val KEY_WEIGHT_STEP = stringPreferencesKey("weight_step")
|
||||
private val KEY_AUTO_RELOGIN = stringPreferencesKey("auto_relogin")
|
||||
private val KEY_CUSTOM_BAR = stringPreferencesKey("custom_bar_weight")
|
||||
private val KEY_CUSTOM_BARS = stringPreferencesKey("custom_bar_weights")
|
||||
private val KEY_SCALE_ADDRESS = stringPreferencesKey("scale_address")
|
||||
private val KEY_SCALE_NAME = stringPreferencesKey("scale_name")
|
||||
private val KEY_SCALE_DRIVER = stringPreferencesKey("scale_driver")
|
||||
private val KEY_HEIGHT_CM = stringPreferencesKey("height_cm")
|
||||
private val KEY_BIRTH_YEAR = intPreferencesKey("birth_year")
|
||||
private val KEY_IS_FEMALE = stringPreferencesKey("is_female")
|
||||
private val KEY_TRACK_LOG = stringPreferencesKey("track_log_enabled")
|
||||
private val KEY_GPS_ACC = intPreferencesKey("gps_accuracy_limit_m")
|
||||
private val KEY_GPS_FACTOR = stringPreferencesKey("gps_speed_factor")
|
||||
private val KEY_GPS_FLOOR = intPreferencesKey("gps_speed_floor_kmh")
|
||||
private val KEY_MOTION_GUARD = stringPreferencesKey("motion_guard_enabled")
|
||||
private val KEY_MOTION_THRESHOLD = stringPreferencesKey("motion_threshold")
|
||||
private val KEY_COUNTDOWN = intPreferencesKey("countdown_seconds")
|
||||
}
|
||||
}
|
||||
80
app/src/main/java/eu/brassepc/fitnessdroid/data/StepsSync.kt
Normal file
80
app/src/main/java/eu/brassepc/fitnessdroid/data/StepsSync.kt
Normal file
@@ -0,0 +1,80 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.health.connect.client.permission.HealthPermission
|
||||
import androidx.health.connect.client.records.StepsRecord
|
||||
import androidx.health.connect.client.request.AggregateRequest
|
||||
import androidx.health.connect.client.time.TimeRangeFilter
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* Steg via Health Connect: läser dagens + gårdagens stegsumma (som andra
|
||||
* appar/telefonen loggat) och synkar upp till gym-API:t (upsertDailySteps).
|
||||
* Servern räknar sedan in stegen i kcal-mätarens telefondel och stegmålen.
|
||||
*/
|
||||
class StepsSync(
|
||||
private val context: Context,
|
||||
private val gymApi: GymApi,
|
||||
) {
|
||||
val stepsPermission: String = HealthPermission.getReadPermission(StepsRecord::class)
|
||||
|
||||
private var lastSyncMs = 0L
|
||||
|
||||
/** SDK_UNAVAILABLE / SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED / SDK_AVAILABLE */
|
||||
fun sdkStatus(): Int = HealthConnectClient.getSdkStatus(context)
|
||||
|
||||
fun isAvailable(): Boolean = sdkStatus() == HealthConnectClient.SDK_AVAILABLE
|
||||
|
||||
suspend fun hasPermission(): Boolean {
|
||||
if (!isAvailable()) return false
|
||||
return runCatching {
|
||||
HealthConnectClient.getOrCreate(context)
|
||||
.permissionController.getGrantedPermissions()
|
||||
.contains(stepsPermission)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Läs och synka. Returnerar dagens steg, eller null om HC saknas /
|
||||
* behörighet inte getts. Fel sväljs (nästa synk försöker igen).
|
||||
*/
|
||||
suspend fun syncNow(): Int? {
|
||||
if (!hasPermission()) return null
|
||||
val client = HealthConnectClient.getOrCreate(context)
|
||||
val zone = ZoneId.systemDefault()
|
||||
val today = LocalDate.now()
|
||||
var todaySteps: Int? = null
|
||||
|
||||
for (day in listOf(today.minusDays(1), today)) {
|
||||
val startInstant = day.atStartOfDay(zone).toInstant()
|
||||
val endInstant = if (day == today) Instant.now()
|
||||
else day.plusDays(1).atStartOfDay(zone).toInstant()
|
||||
|
||||
val steps = runCatching {
|
||||
val result = client.aggregate(
|
||||
AggregateRequest(
|
||||
metrics = setOf(StepsRecord.COUNT_TOTAL),
|
||||
timeRangeFilter = TimeRangeFilter.between(startInstant, endInstant),
|
||||
)
|
||||
)
|
||||
(result[StepsRecord.COUNT_TOTAL] ?: 0L).toInt()
|
||||
}.getOrNull() ?: continue
|
||||
|
||||
if (day == today) todaySteps = steps
|
||||
runCatching {
|
||||
gymApi.upsertDailySteps(startInstant.toString(), steps)
|
||||
}
|
||||
}
|
||||
lastSyncMs = System.currentTimeMillis()
|
||||
return todaySteps
|
||||
}
|
||||
|
||||
/** Synka högst var 10:e minut — anropas t.ex. när hemskärmen visas. */
|
||||
suspend fun syncThrottled() {
|
||||
if (System.currentTimeMillis() - lastSyncMs < 10 * 60_000) return
|
||||
syncNow()
|
||||
}
|
||||
}
|
||||
237
app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt
Normal file
237
app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt
Normal file
@@ -0,0 +1,237 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import eu.brassepc.fitnessdroid.data.local.AppDatabase
|
||||
import eu.brassepc.fitnessdroid.data.local.PendingOp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import java.io.IOException
|
||||
|
||||
object OpKind {
|
||||
const val START_SESSION = "start_session"
|
||||
const val ADD_EXERCISE = "add_exercise"
|
||||
const val ADD_SET = "add_set"
|
||||
const val UPDATE_SET = "update_set"
|
||||
/** targetLocalId bär server-id:t — den lokala raden är redan borta */
|
||||
const val REMOVE_SET = "remove_set"
|
||||
const val COMPLETE_SESSION = "complete_session"
|
||||
/** targetLocalId pekar på pending_activity-raden */
|
||||
const val ADD_ACTIVITY = "add_activity"
|
||||
}
|
||||
|
||||
enum class SyncStatus { SYNCED, PENDING, OFFLINE, ERROR }
|
||||
|
||||
/**
|
||||
* Kör op-kön mot servern i strikt ordning (FIFO). Varje op pekar på en lokal
|
||||
* rad; server-id:n som kommer tillbaka skrivs in på raden så att efterföljande
|
||||
* ops kan referera dem. Nätfel → backoff och nytt försök; kön ligger kvar i
|
||||
* Room så inget tappas om appen dödas.
|
||||
*/
|
||||
class SyncEngine(
|
||||
private val db: AppDatabase,
|
||||
private val client: GraphQlClient,
|
||||
private val auth: AuthRepository,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
private val _status = MutableStateFlow(SyncStatus.SYNCED)
|
||||
val status: StateFlow<SyncStatus> = _status
|
||||
val pendingCount = db.opDao().pendingCount()
|
||||
|
||||
/** Puffa igång kön; ofarlig att kalla ofta. */
|
||||
fun kick() {
|
||||
scope.launch { drain() }
|
||||
}
|
||||
|
||||
suspend fun drain() {
|
||||
mutex.withLock {
|
||||
while (true) {
|
||||
val op = db.opDao().next() ?: break
|
||||
try {
|
||||
execute(op)
|
||||
db.opDao().done(op.id)
|
||||
_status.value = if (db.opDao().pendingCountNow() == 0) {
|
||||
SyncStatus.SYNCED
|
||||
} else {
|
||||
SyncStatus.PENDING
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
// Inget nät — försök igen senare, rör inte kön.
|
||||
_status.value = SyncStatus.OFFLINE
|
||||
scheduleRetry(op)
|
||||
return
|
||||
} catch (e: GraphQlException) {
|
||||
// Servern sa nej. Behåll op:en för felsökning men fortsätt inte —
|
||||
// efterföljande ops kan bero på den här.
|
||||
db.opDao().update(
|
||||
op.copy(attempts = op.attempts + 1, lastError = e.message)
|
||||
)
|
||||
_status.value = SyncStatus.ERROR
|
||||
scheduleRetry(op, longDelay = true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var retryScheduled = false
|
||||
private fun scheduleRetry(op: PendingOp, longDelay: Boolean = false) {
|
||||
if (retryScheduled) return
|
||||
retryScheduled = true
|
||||
scope.launch {
|
||||
val backoff = if (longDelay) 30_000L else 5_000L
|
||||
delay(backoff * (op.attempts + 1).coerceAtMost(6))
|
||||
retryScheduled = false
|
||||
drain()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun execute(op: PendingOp) {
|
||||
val dao = db.sessionDao()
|
||||
when (op.kind) {
|
||||
OpKind.START_SESSION -> {
|
||||
val session = dao.session(op.targetLocalId) ?: return
|
||||
if (session.serverId != null) return
|
||||
val input = buildJsonObject {
|
||||
session.name?.let { put("name", it) }
|
||||
session.notes?.let { put("notes", it) }
|
||||
}
|
||||
val data = client.execute(
|
||||
"mutation(\$input: StartGymSessionInput!){startGymSession(input:\$input){id}}",
|
||||
buildJsonObject { put("input", input) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val id = data["startGymSession"]!!.jsonObject["id"]!!.jsonPrimitive.int
|
||||
dao.updateSession(session.copy(serverId = id))
|
||||
}
|
||||
|
||||
OpKind.ADD_EXERCISE -> {
|
||||
val exercise = dao.exercise(op.targetLocalId) ?: return
|
||||
if (exercise.serverId != null) return
|
||||
val session = dao.session(exercise.sessionId) ?: return
|
||||
val sessionServerId = session.serverId
|
||||
?: throw GraphQlException(listOf("Passet är inte synkat än"))
|
||||
val input = buildJsonObject {
|
||||
put("gymSessionId", sessionServerId)
|
||||
put("exerciseTypeId", exercise.exerciseTypeId)
|
||||
put("order", exercise.order)
|
||||
exercise.notes?.let { put("notes", it) }
|
||||
}
|
||||
val data = client.execute(
|
||||
"mutation(\$input: AddSessionExerciseInput!){addExerciseToSession(input:\$input){id}}",
|
||||
buildJsonObject { put("input", input) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val id = data["addExerciseToSession"]!!.jsonObject["id"]!!.jsonPrimitive.int
|
||||
dao.updateExercise(exercise.copy(serverId = id))
|
||||
}
|
||||
|
||||
OpKind.ADD_SET, OpKind.UPDATE_SET -> {
|
||||
val set = dao.set(op.targetLocalId) ?: return
|
||||
if (op.kind == OpKind.ADD_SET && set.serverId != null) return
|
||||
val exercise = dao.exercise(set.exerciseId) ?: return
|
||||
val exerciseServerId = exercise.serverId
|
||||
?: throw GraphQlException(listOf("Övningen är inte synkad än"))
|
||||
val fields = buildJsonObject {
|
||||
if (op.kind == OpKind.ADD_SET) {
|
||||
put("sessionExerciseId", exerciseServerId)
|
||||
} else {
|
||||
put("id", set.serverId ?: throw GraphQlException(listOf("Setet är inte synkat än")))
|
||||
}
|
||||
set.reps?.let { put("reps", it) }
|
||||
set.weight?.let { put("weight", it) }
|
||||
set.distanceMeters?.let { put("distanceMeters", it) }
|
||||
set.durationSeconds?.let { put("durationSeconds", it) }
|
||||
set.rpe?.let { put("rpe", it) }
|
||||
put("isWarmup", set.isWarmup)
|
||||
put("isCompleted", set.isCompleted)
|
||||
put("order", set.order)
|
||||
}
|
||||
if (op.kind == OpKind.ADD_SET) {
|
||||
val data = client.execute(
|
||||
"mutation(\$input: AddSessionSetInput!){addSessionSet(input:\$input){id}}",
|
||||
buildJsonObject { put("input", fields) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val id = data["addSessionSet"]!!.jsonObject["id"]!!.jsonPrimitive.int
|
||||
dao.updateSet(set.copy(serverId = id))
|
||||
} else {
|
||||
client.execute(
|
||||
"mutation(\$input: UpdateSessionSetInput!){updateSessionSet(input:\$input){id}}",
|
||||
buildJsonObject { put("input", fields) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OpKind.REMOVE_SET -> {
|
||||
client.execute(
|
||||
"mutation(\$id: Int!){removeSessionSet(id:\$id)}",
|
||||
buildJsonObject { put("id", op.targetLocalId.toInt()) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
|
||||
OpKind.ADD_ACTIVITY -> {
|
||||
val a = db.opDao().pendingActivity(op.targetLocalId) ?: return
|
||||
client.execute(
|
||||
"mutation(\$input: AddActivityInput!){addActivity(input:\$input){id}}",
|
||||
buildJsonObject {
|
||||
put("input", buildJsonObject {
|
||||
put("activityTypeId", a.activityTypeId)
|
||||
put("startedAt", a.startedAtIso)
|
||||
put("durationSeconds", a.durationSeconds)
|
||||
a.distanceMeters?.let { put("distanceMeters", it) }
|
||||
a.elevationGainMeters?.let { put("elevationGainMeters", it) }
|
||||
a.rpe?.let { put("rpe", it) }
|
||||
put("source", a.source)
|
||||
a.routePolyline?.let { put("routePolyline", it) }
|
||||
a.notes?.let { put("notes", it) }
|
||||
})
|
||||
},
|
||||
auth.bearerToken(),
|
||||
)
|
||||
db.opDao().activityDone(op.targetLocalId)
|
||||
}
|
||||
|
||||
OpKind.COMPLETE_SESSION -> {
|
||||
val session = dao.session(op.targetLocalId) ?: return
|
||||
val sessionServerId = session.serverId
|
||||
?: throw GraphQlException(listOf("Passet är inte synkat än"))
|
||||
// Rättad starttid: flytta passets datum innan det avslutas.
|
||||
session.editedStartEpochMs?.let { startMs ->
|
||||
val iso = java.time.Instant.ofEpochMilli(startMs).toString()
|
||||
client.execute(
|
||||
"mutation(\$input: UpdateGymSessionInput!){updateGymSession(input:\$input){id date}}",
|
||||
buildJsonObject {
|
||||
put("input", buildJsonObject {
|
||||
put("id", sessionServerId)
|
||||
put("date", iso)
|
||||
})
|
||||
},
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
val input = buildJsonObject {
|
||||
put("id", sessionServerId)
|
||||
session.name?.let { put("name", it) }
|
||||
session.durationSecondsOverride?.let { put("durationSecondsOverride", it) }
|
||||
}
|
||||
client.execute(
|
||||
"mutation(\$input: CompleteGymSessionInput!){completeGymSession(input:\$input){id status}}",
|
||||
buildJsonObject { put("input", input) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
@@ -63,6 +64,25 @@ class TokenStore(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Kroppsvikten cachas från profilen — behövs för kaloriuppskattningen offline. */
|
||||
suspend fun saveBodyWeight(kg: Double?) {
|
||||
context.authDataStore.edit {
|
||||
if (kg == null) it.remove(KEY_BODY_WEIGHT) else it[KEY_BODY_WEIGHT] = kg
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun bodyWeightKg(): Double? =
|
||||
context.authDataStore.data.first()[KEY_BODY_WEIGHT]
|
||||
|
||||
suspend fun saveDisplayName(name: String?) {
|
||||
context.authDataStore.edit {
|
||||
if (name.isNullOrBlank()) it.remove(KEY_DISPLAY_NAME) else it[KEY_DISPLAY_NAME] = name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun displayName(): String? =
|
||||
context.authDataStore.data.first()[KEY_DISPLAY_NAME]
|
||||
|
||||
/** Rensar sessionen men behåller vald API-url. */
|
||||
suspend fun clearSession() {
|
||||
context.authDataStore.edit {
|
||||
@@ -83,5 +103,7 @@ class TokenStore(private val context: Context) {
|
||||
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
|
||||
private val KEY_EXPIRATION = longPreferencesKey("expiration_epoch_ms")
|
||||
private val KEY_API_URL = stringPreferencesKey("api_url")
|
||||
private val KEY_BODY_WEIGHT = doublePreferencesKey("body_weight_kg")
|
||||
private val KEY_DISPLAY_NAME = stringPreferencesKey("display_name")
|
||||
}
|
||||
}
|
||||
|
||||
32
app/src/main/java/eu/brassepc/fitnessdroid/data/TrackLog.kt
Normal file
32
app/src/main/java/eu/brassepc/fitnessdroid/data/TrackLog.kt
Normal file
@@ -0,0 +1,32 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Teknisk logg för aktivitetsspårningen — ringbuffert som visas i UI:t
|
||||
* ("Teknisk logg" på spårskärmen) och kan delas, så fältfel går att felsöka
|
||||
* utan adb. Speglas till logcat (tag TrackLog).
|
||||
*/
|
||||
object TrackLog {
|
||||
private const val MAX_LINES = 400
|
||||
private val fmt = DateTimeFormatter.ofPattern("HH:mm:ss")
|
||||
|
||||
private val _lines = MutableStateFlow<List<String>>(emptyList())
|
||||
val lines: StateFlow<List<String>> = _lines
|
||||
|
||||
fun log(msg: String) {
|
||||
val line = "${LocalTime.now().format(fmt)} $msg"
|
||||
Log.d("TrackLog", msg)
|
||||
_lines.value = (_lines.value + line).takeLast(MAX_LINES)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_lines.value = emptyList()
|
||||
}
|
||||
|
||||
fun asText(): String = _lines.value.joinToString("\n")
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import eu.brassepc.fitnessdroid.MainActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Spårningens faser: startar i READY (pausad — GPS värmer upp), play startar
|
||||
* en nedräkning (inställbar) och sen ACTIVE. Kan pausas/återupptas.
|
||||
*/
|
||||
enum class TrackingPhase { IDLE, READY, COUNTDOWN, ACTIVE, PAUSED }
|
||||
|
||||
/** Läget för en pågående (eller nyss avslutad) aktivitetsspårning. */
|
||||
data class TrackingState(
|
||||
val isActive: Boolean = false,
|
||||
val phase: TrackingPhase = TrackingPhase.IDLE,
|
||||
/** Sekunder kvar av nedräkningen (fas COUNTDOWN) */
|
||||
val countdownLeft: Int = 0,
|
||||
/** Aktivitetstypen som spåras */
|
||||
val typeId: Int = 0,
|
||||
val typeKey: String = "",
|
||||
val typeName: String = "",
|
||||
val category: String = "OTHER",
|
||||
val met: Double = 4.0,
|
||||
val isDistanceBased: Boolean = false,
|
||||
val startedAtEpochMs: Long = 0,
|
||||
val elapsedSeconds: Int = 0,
|
||||
val distanceMeters: Double = 0.0,
|
||||
val elevationGainMeters: Double = 0.0,
|
||||
/** Steg räknade av telefonens sensor under aktiv tid (null = sensor saknas/nekad) */
|
||||
val steps: Int? = null,
|
||||
/** Committade spårpunkter (spikfiltrerade) */
|
||||
val points: List<Pair<Double, Double>> = emptyList(),
|
||||
/** Senast kända position oavsett kvalitet — för kartcentrering/markör */
|
||||
val currentPosition: Pair<Double, Double>? = null,
|
||||
val currentSpeedKmh: Double? = null,
|
||||
val kcal: Double? = null,
|
||||
val gpsFix: Boolean = false,
|
||||
/** false = accelerometern säger stilla → spår/distans fryst */
|
||||
val isMoving: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* Foreground-service som spårar en aktivitet: GPS-rutt, distans, höjdmeter,
|
||||
* steg, tid och live-kcal. Positionspipeline: fartgrind (Doppler) →
|
||||
* rörelsevakt (accelerometer) → lag-buffrat spikfilter (en punkt committas
|
||||
* först när nästa setts; "ut-och-tillbaka"-hopp slängs) → stillastående-filter.
|
||||
*/
|
||||
class TrackingService : Service(), LocationListener, SensorEventListener {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private var locationManager: LocationManager? = null
|
||||
private var sensorManager: SensorManager? = null
|
||||
|
||||
private var weightKg: Double? = null
|
||||
// GPS-filter (justerbara i inställningarna)
|
||||
private var accuracyLimitM = 35f
|
||||
private var speedFactor = 2.0
|
||||
private var speedFloorMs = 12.0 / 3.6
|
||||
private var motionGuard = true
|
||||
private var motionThreshold = 0.35
|
||||
private var countdownSeconds = 3
|
||||
|
||||
// Tidräkning: ackumulerad aktiv tid + när nuvarande aktiva stint började
|
||||
private var accumulatedActiveMs = 0L
|
||||
private var activeSinceElapsed = 0L
|
||||
|
||||
// Positionspipeline
|
||||
private var lastCommitted: Location? = null
|
||||
private var candidate: Location? = null
|
||||
private var dopplerEmaMs = 0.0
|
||||
private var rejectStreak = 0
|
||||
|
||||
// Höjd (glidande medel mot GPS-brus)
|
||||
private val altitudeWindow = ArrayDeque<Double>()
|
||||
private var smoothedAltitude: Double? = null
|
||||
|
||||
// Rörelsevakt
|
||||
private val motionWindow = ArrayDeque<Double>()
|
||||
private var isMoving = true
|
||||
|
||||
// Steg (TYPE_STEP_COUNTER är kumulativ sedan boot)
|
||||
private var stepSensorAvailable = false
|
||||
private var stepCounterLast: Long = -1
|
||||
private var stepsDuringActivity = 0L
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
stopTracking()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_PLAY -> {
|
||||
play()
|
||||
return START_STICKY
|
||||
}
|
||||
ACTION_PAUSE -> {
|
||||
pause()
|
||||
return START_STICKY
|
||||
}
|
||||
else -> {
|
||||
if (intent == null) return START_NOT_STICKY
|
||||
startTracking(intent)
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun startTracking(intent: Intent) {
|
||||
val distanceBased = intent.getBooleanExtra(EXTRA_DISTANCE_BASED, false)
|
||||
weightKg = intent.getDoubleExtra(EXTRA_WEIGHT_KG, -1.0).takeIf { it > 0 }
|
||||
accuracyLimitM = intent.getIntExtra(EXTRA_GPS_ACC_LIMIT, 35).toFloat()
|
||||
speedFactor = intent.getDoubleExtra(EXTRA_GPS_SPEED_FACTOR, 2.0)
|
||||
speedFloorMs = intent.getIntExtra(EXTRA_GPS_SPEED_FLOOR, 12) / 3.6
|
||||
motionGuard = intent.getBooleanExtra(EXTRA_MOTION_GUARD, true)
|
||||
motionThreshold = intent.getDoubleExtra(EXTRA_MOTION_THRESHOLD, 0.35)
|
||||
countdownSeconds = intent.getIntExtra(EXTRA_COUNTDOWN, 3)
|
||||
|
||||
accumulatedActiveMs = 0L
|
||||
activeSinceElapsed = 0L
|
||||
lastCommitted = null
|
||||
candidate = null
|
||||
dopplerEmaMs = 0.0
|
||||
rejectStreak = 0
|
||||
altitudeWindow.clear()
|
||||
smoothedAltitude = null
|
||||
motionWindow.clear()
|
||||
isMoving = true
|
||||
stepCounterLast = -1
|
||||
stepsDuringActivity = 0
|
||||
|
||||
_state.value = TrackingState(
|
||||
isActive = true,
|
||||
phase = TrackingPhase.READY,
|
||||
typeId = intent.getIntExtra(EXTRA_TYPE_ID, 0),
|
||||
typeKey = intent.getStringExtra(EXTRA_TYPE_KEY) ?: "",
|
||||
typeName = intent.getStringExtra(EXTRA_TYPE_NAME) ?: "Aktivitet",
|
||||
category = intent.getStringExtra(EXTRA_CATEGORY) ?: "OTHER",
|
||||
met = intent.getDoubleExtra(EXTRA_MET, 4.0),
|
||||
isDistanceBased = distanceBased,
|
||||
startedAtEpochMs = System.currentTimeMillis(),
|
||||
)
|
||||
TrackLog.clear()
|
||||
TrackLog.log("start: ${_state.value.typeName} (distans=$distanceBased, vikt=${weightKg ?: "?"} kg) — READY, tryck play")
|
||||
if (distanceBased) {
|
||||
TrackLog.log(
|
||||
"filter: acc≤${accuracyLimitM.toInt()} m, fartgrind ${speedFactor}× Doppler " +
|
||||
"(golv ${"%.0f".format(speedFloorMs * 3.6)} km/h), spikfilter på, nedräkning ${countdownSeconds}s"
|
||||
)
|
||||
}
|
||||
|
||||
startForeground(
|
||||
NOTIFICATION_ID, buildNotification(),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION,
|
||||
)
|
||||
|
||||
if (distanceBased) {
|
||||
startGps()
|
||||
startSensors()
|
||||
}
|
||||
|
||||
// Tick: nedräkning, aktiv tid, kcal, notis
|
||||
scope.launch {
|
||||
while (_state.value.isActive) {
|
||||
val s = _state.value
|
||||
when (s.phase) {
|
||||
TrackingPhase.COUNTDOWN -> {
|
||||
val left = s.countdownLeft - 1
|
||||
if (left <= 0) {
|
||||
goActive()
|
||||
} else {
|
||||
_state.value = _state.value.copy(countdownLeft = left)
|
||||
}
|
||||
}
|
||||
TrackingPhase.ACTIVE -> {
|
||||
val elapsed = currentElapsedSeconds()
|
||||
_state.value = _state.value.copy(
|
||||
elapsedSeconds = elapsed,
|
||||
steps = if (stepSensorAvailable) stepsDuringActivity.toInt() else _state.value.steps,
|
||||
kcal = ActivityKcal.estimate(
|
||||
met = s.met,
|
||||
category = s.category,
|
||||
isDistanceBased = s.isDistanceBased,
|
||||
weightKg = weightKg,
|
||||
durationSeconds = elapsed,
|
||||
distanceMeters = s.distanceMeters.takeIf { it > 0 },
|
||||
elevationGainMeters = s.elevationGainMeters.takeIf { it > 0 },
|
||||
rpe = null,
|
||||
),
|
||||
)
|
||||
if (elapsed % 10 == 0) {
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.notify(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentElapsedSeconds(): Int {
|
||||
val extra = if (_state.value.phase == TrackingPhase.ACTIVE && activeSinceElapsed > 0) {
|
||||
SystemClock.elapsedRealtime() - activeSinceElapsed
|
||||
} else 0L
|
||||
return ((accumulatedActiveMs + extra) / 1000).toInt()
|
||||
}
|
||||
|
||||
private fun play() {
|
||||
val s = _state.value
|
||||
if (!s.isActive || s.phase == TrackingPhase.ACTIVE || s.phase == TrackingPhase.COUNTDOWN) return
|
||||
if (countdownSeconds > 0) {
|
||||
TrackLog.log("play → nedräkning ${countdownSeconds}s")
|
||||
_state.value = s.copy(phase = TrackingPhase.COUNTDOWN, countdownLeft = countdownSeconds)
|
||||
} else {
|
||||
goActive()
|
||||
}
|
||||
}
|
||||
|
||||
private fun goActive() {
|
||||
activeSinceElapsed = SystemClock.elapsedRealtime()
|
||||
stepCounterLast = -1 // ny baslinje vid nästa sensoravläsning
|
||||
TrackLog.log("AKTIV — klockan går")
|
||||
_state.value = _state.value.copy(
|
||||
phase = TrackingPhase.ACTIVE,
|
||||
countdownLeft = 0,
|
||||
startedAtEpochMs = if (accumulatedActiveMs == 0L) System.currentTimeMillis()
|
||||
else _state.value.startedAtEpochMs,
|
||||
)
|
||||
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
|
||||
private fun pause() {
|
||||
val s = _state.value
|
||||
if (!s.isActive || s.phase != TrackingPhase.ACTIVE) return
|
||||
accumulatedActiveMs += SystemClock.elapsedRealtime() - activeSinceElapsed
|
||||
activeSinceElapsed = 0L
|
||||
// Positionskedjan bryts så pausvandring inte räknas vid återupptag
|
||||
lastCommitted = null
|
||||
candidate = null
|
||||
TrackLog.log("pausad vid ${currentElapsedSeconds()}s, ${"%.0f".format(s.distanceMeters)} m")
|
||||
_state.value = s.copy(phase = TrackingPhase.PAUSED, elapsedSeconds = (accumulatedActiveMs / 1000).toInt())
|
||||
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
|
||||
/* ---------- GPS ---------- */
|
||||
|
||||
private fun startGps() {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
TrackLog.log("FEL: platsbehörighet saknas — ingen GPS-lyssning")
|
||||
return
|
||||
}
|
||||
val lm = getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
locationManager = lm
|
||||
|
||||
val providers = listOf(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
LocationManager.FUSED_PROVIDER,
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
)
|
||||
for (provider in providers) {
|
||||
val exists = runCatching { lm.allProviders.contains(provider) }.getOrDefault(false)
|
||||
val enabled = exists && runCatching { lm.isProviderEnabled(provider) }.getOrDefault(false)
|
||||
TrackLog.log("provider $provider: ${if (!exists) "saknas" else if (enabled) "på" else "AVSTÄNGD"}")
|
||||
if (!enabled) continue
|
||||
runCatching {
|
||||
// 1 s / 0 m — tät sampling ger spikfiltret mer att jobba med
|
||||
lm.requestLocationUpdates(provider, 1000L, 0f, this, Looper.getMainLooper())
|
||||
TrackLog.log("lyssnar på $provider (1 s)")
|
||||
}.onFailure { TrackLog.log("FEL: kunde inte lyssna på $provider: ${it.message}") }
|
||||
}
|
||||
|
||||
providers.firstNotNullOfOrNull { p ->
|
||||
runCatching { lm.getLastKnownLocation(p) }.getOrNull()
|
||||
}?.let { last ->
|
||||
TrackLog.log("lastKnown: ${last.provider} acc=${if (last.hasAccuracy()) "%.0f".format(last.accuracy) else "?"} m")
|
||||
_state.value = _state.value.copy(currentPosition = last.latitude to last.longitude)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLocationChanged(location: Location) {
|
||||
val s = _state.value
|
||||
if (!s.isActive || !s.isDistanceBased) return
|
||||
val acc = if (location.hasAccuracy()) location.accuracy else -1f
|
||||
|
||||
// Position för kartan uppdateras alltid, oavsett kvalitet och fas
|
||||
_state.value = _state.value.copy(
|
||||
currentPosition = location.latitude to location.longitude,
|
||||
gpsFix = true,
|
||||
currentSpeedKmh = if (location.hasSpeed()) location.speed * 3.6 else _state.value.currentSpeedKmh,
|
||||
)
|
||||
|
||||
// Ackumulering sker bara i ACTIVE
|
||||
if (s.phase != TrackingPhase.ACTIVE) return
|
||||
|
||||
// Grind 1: noggrannhet
|
||||
if (location.hasAccuracy() && location.accuracy > accuracyLimitM) {
|
||||
TrackLog.log("${location.provider}: förkastad, acc=${"%.0f".format(acc)} m (>${accuracyLimitM.toInt()})")
|
||||
return
|
||||
}
|
||||
|
||||
// Doppler-fart (glidande medel)
|
||||
if (location.hasSpeed()) {
|
||||
dopplerEmaMs = if (dopplerEmaMs == 0.0) location.speed.toDouble()
|
||||
else 0.7 * dopplerEmaMs + 0.3 * location.speed
|
||||
}
|
||||
|
||||
// Grind 2: fartgrinden mot senast committade punkt
|
||||
lastCommitted?.let { prev ->
|
||||
val dtSec = (location.elapsedRealtimeNanos - prev.elapsedRealtimeNanos) / 1e9
|
||||
if (dtSec > 0.3) {
|
||||
val jump = prev.distanceTo(location).toDouble()
|
||||
val impliedMs = jump / dtSec
|
||||
val allowedMs = maxOf(speedFloorMs, dopplerEmaMs * speedFactor)
|
||||
if (impliedMs > allowedMs) {
|
||||
rejectStreak++
|
||||
TrackLog.log(
|
||||
"${location.provider}: fartgrind — ${"%.0f".format(jump)} m/${"%.1f".format(dtSec)} s " +
|
||||
"= ${"%.0f".format(impliedMs * 3.6)} km/h (max ${"%.0f".format(allowedMs * 3.6)}), förkastad ($rejectStreak)"
|
||||
)
|
||||
if (rejectStreak >= 4) {
|
||||
TrackLog.log("fartgrind: ny baslinje efter $rejectStreak förkastade")
|
||||
lastCommitted = location
|
||||
candidate = null
|
||||
rejectStreak = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
rejectStreak = 0
|
||||
|
||||
// Grind 3: rörelsevakten — stilla ⇒ frys allt utom kartposition
|
||||
if (motionGuard && !isMoving) {
|
||||
lastCommitted = location
|
||||
candidate = null
|
||||
return
|
||||
}
|
||||
|
||||
// Grind 4: lag-buffrat spikfilter — committa kandidaten först när vi
|
||||
// sett nästa punkt; "ut-och-direkt-tillbaka" slängs (triangel-kollaps).
|
||||
val prev = lastCommitted
|
||||
val cand = candidate
|
||||
if (prev == null) {
|
||||
lastCommitted = location
|
||||
commitPoint(location, addDistance = 0.0)
|
||||
return
|
||||
}
|
||||
if (cand == null) {
|
||||
candidate = location
|
||||
return
|
||||
}
|
||||
|
||||
val d1 = prev.distanceTo(cand).toDouble()
|
||||
val d2 = cand.distanceTo(location).toDouble()
|
||||
val d02 = prev.distanceTo(location).toDouble()
|
||||
val isSpike = d1 > 4.0 && (d1 + d2) > 0 && d02 < 0.6 * (d1 + d2)
|
||||
if (isSpike) {
|
||||
TrackLog.log("spikfilter: slängde punkt (ut ${"%.0f".format(d1)} m, tillbaka ${"%.0f".format(d2)} m, direkt ${"%.0f".format(d02)} m)")
|
||||
candidate = location
|
||||
return
|
||||
}
|
||||
|
||||
// Kandidaten är trovärdig — committa den
|
||||
val minMove = maxOf(2.0, (if (cand.hasAccuracy()) cand.accuracy else 5f) * 0.5)
|
||||
commitPoint(cand, addDistance = if (d1 >= minMove) d1 else 0.0)
|
||||
lastCommitted = cand
|
||||
candidate = location
|
||||
}
|
||||
|
||||
private fun commitPoint(location: Location, addDistance: Double) {
|
||||
var elevGain = _state.value.elevationGainMeters
|
||||
if (location.hasAltitude()) {
|
||||
altitudeWindow.addLast(location.altitude)
|
||||
if (altitudeWindow.size > 5) altitudeWindow.removeFirst()
|
||||
val avg = altitudeWindow.average()
|
||||
smoothedAltitude?.let { prevAlt ->
|
||||
val delta = avg - prevAlt
|
||||
if (delta > 1.0) elevGain += delta
|
||||
}
|
||||
smoothedAltitude = avg
|
||||
}
|
||||
|
||||
val cur = _state.value
|
||||
_state.value = cur.copy(
|
||||
distanceMeters = cur.distanceMeters + addDistance,
|
||||
elevationGainMeters = elevGain,
|
||||
points = if (addDistance > 0 || cur.points.isEmpty()) {
|
||||
cur.points + (location.latitude to location.longitude)
|
||||
} else cur.points,
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- Sensorer (rörelsevakt + steg) ---------- */
|
||||
|
||||
private fun startSensors() {
|
||||
val sm = getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
sensorManager = sm
|
||||
if (motionGuard) {
|
||||
val accel = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||
if (accel != null) {
|
||||
sm.registerListener(this, accel, SensorManager.SENSOR_DELAY_UI)
|
||||
TrackLog.log("rörelsevakt: på (tröskel $motionThreshold m/s²)")
|
||||
} else {
|
||||
TrackLog.log("rörelsevakt: ingen accelerometer — avstängd")
|
||||
motionGuard = false
|
||||
}
|
||||
}
|
||||
// Stegsensorn kräver ACTIVITY_RECOGNITION-behörighet (Android 10+)
|
||||
val hasActivityPermission = ContextCompat.checkSelfPermission(
|
||||
this, Manifest.permission.ACTIVITY_RECOGNITION,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
val stepSensor = sm.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
|
||||
if (stepSensor != null && hasActivityPermission) {
|
||||
stepSensorAvailable = true
|
||||
sm.registerListener(this, stepSensor, SensorManager.SENSOR_DELAY_UI)
|
||||
TrackLog.log("stegsensor: på")
|
||||
} else {
|
||||
stepSensorAvailable = false
|
||||
TrackLog.log("stegsensor: ${if (stepSensor == null) "saknas" else "behörighet saknas"}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ACCELEROMETER -> {
|
||||
val x = event.values[0].toDouble()
|
||||
val y = event.values[1].toDouble()
|
||||
val z = event.values[2].toDouble()
|
||||
val deviation = kotlin.math.abs(kotlin.math.sqrt(x * x + y * y + z * z) - 9.81)
|
||||
motionWindow.addLast(deviation)
|
||||
if (motionWindow.size > 20) motionWindow.removeFirst()
|
||||
if (motionWindow.size < 8) return
|
||||
val moving = motionWindow.average() > motionThreshold
|
||||
if (moving != isMoving) {
|
||||
isMoving = moving
|
||||
_state.value = _state.value.copy(isMoving = moving)
|
||||
TrackLog.log("rörelsevakt: ${if (moving) "i rörelse" else "stilla"} (nivå ${"%.2f".format(motionWindow.average())})")
|
||||
}
|
||||
}
|
||||
Sensor.TYPE_STEP_COUNTER -> {
|
||||
val counter = event.values[0].toLong()
|
||||
if (_state.value.phase == TrackingPhase.ACTIVE) {
|
||||
if (stepCounterLast >= 0 && counter > stepCounterLast) {
|
||||
stepsDuringActivity += counter - stepCounterLast
|
||||
}
|
||||
stepCounterLast = counter
|
||||
} else {
|
||||
// följ med räknaren utanför ACTIVE utan att ackumulera
|
||||
stepCounterLast = counter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
|
||||
/* ---------- Stopp & städning ---------- */
|
||||
|
||||
private fun stopTracking() {
|
||||
// Räkna in pågående aktiv stint
|
||||
if (_state.value.phase == TrackingPhase.ACTIVE && activeSinceElapsed > 0) {
|
||||
accumulatedActiveMs += SystemClock.elapsedRealtime() - activeSinceElapsed
|
||||
activeSinceElapsed = 0L
|
||||
}
|
||||
val s = _state.value
|
||||
TrackLog.log(
|
||||
"stopp: ${(accumulatedActiveMs / 1000)}s aktiv tid, ${"%.0f".format(s.distanceMeters)} m, " +
|
||||
"+${"%.0f".format(s.elevationGainMeters)} hm, ${s.points.size} punkter, " +
|
||||
"steg=${s.steps ?: "?"}, kcal=${s.kcal?.toInt() ?: "?"}"
|
||||
)
|
||||
sensorManager?.unregisterListener(this)
|
||||
locationManager?.removeUpdates(this)
|
||||
_state.value = _state.value.copy(
|
||||
isActive = false,
|
||||
elapsedSeconds = (accumulatedActiveMs / 1000).toInt(),
|
||||
)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
sensorManager?.unregisterListener(this)
|
||||
locationManager?.removeUpdates(this)
|
||||
if (_state.value.isActive) _state.value = _state.value.copy(isActive = false)
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
/* ---------- Notis ---------- */
|
||||
|
||||
private fun createChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID, "Aktivitetsspårning", NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { description = "Pågående aktivitet (GPS/timer)" }
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val s = _state.value
|
||||
val open = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val text = when (s.phase) {
|
||||
TrackingPhase.READY -> "Redo — tryck play i appen"
|
||||
TrackingPhase.PAUSED -> "Pausad · ${formatElapsed(s.elapsedSeconds)}"
|
||||
else -> buildString {
|
||||
append(formatElapsed(s.elapsedSeconds))
|
||||
if (s.isDistanceBased) append(" · ${"%.2f".format(s.distanceMeters / 1000)} km")
|
||||
s.kcal?.let { append(" · ${it.toInt()} kcal") }
|
||||
}
|
||||
}
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
|
||||
.setContentTitle(s.typeName)
|
||||
.setContentText(text)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(open)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun formatElapsed(sec: Int): String {
|
||||
val h = sec / 3600
|
||||
val m = (sec % 3600) / 60
|
||||
val s = sec % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "activity_tracking"
|
||||
private const val NOTIFICATION_ID = 44
|
||||
|
||||
const val ACTION_STOP = "eu.brassepc.fitnessdroid.TRACKING_STOP"
|
||||
const val ACTION_PLAY = "eu.brassepc.fitnessdroid.TRACKING_PLAY"
|
||||
const val ACTION_PAUSE = "eu.brassepc.fitnessdroid.TRACKING_PAUSE"
|
||||
const val EXTRA_TYPE_ID = "typeId"
|
||||
const val EXTRA_TYPE_KEY = "typeKey"
|
||||
const val EXTRA_TYPE_NAME = "typeName"
|
||||
const val EXTRA_CATEGORY = "category"
|
||||
const val EXTRA_MET = "met"
|
||||
const val EXTRA_DISTANCE_BASED = "distanceBased"
|
||||
const val EXTRA_WEIGHT_KG = "weightKg"
|
||||
const val EXTRA_GPS_ACC_LIMIT = "gpsAccLimit"
|
||||
const val EXTRA_GPS_SPEED_FACTOR = "gpsSpeedFactor"
|
||||
const val EXTRA_GPS_SPEED_FLOOR = "gpsSpeedFloor"
|
||||
const val EXTRA_MOTION_GUARD = "motionGuard"
|
||||
const val EXTRA_MOTION_THRESHOLD = "motionThreshold"
|
||||
const val EXTRA_COUNTDOWN = "countdownSeconds"
|
||||
|
||||
private val _state = MutableStateFlow(TrackingState())
|
||||
/** Läses av UI:t — servicen äger sanningen. */
|
||||
val state: StateFlow<TrackingState> = _state.asStateFlow()
|
||||
|
||||
fun start(
|
||||
context: Context,
|
||||
type: ActivityType,
|
||||
weightKg: Double?,
|
||||
gpsAccuracyLimitM: Int = 35,
|
||||
gpsSpeedFactor: Double = 2.0,
|
||||
gpsSpeedFloorKmh: Int = 12,
|
||||
motionGuardEnabled: Boolean = true,
|
||||
motionThreshold: Double = 0.35,
|
||||
countdownSeconds: Int = 3,
|
||||
) {
|
||||
val intent = Intent(context, TrackingService::class.java).apply {
|
||||
putExtra(EXTRA_TYPE_ID, type.id)
|
||||
putExtra(EXTRA_TYPE_KEY, type.key)
|
||||
putExtra(EXTRA_TYPE_NAME, type.nameSv)
|
||||
putExtra(EXTRA_CATEGORY, type.category)
|
||||
putExtra(EXTRA_MET, type.met)
|
||||
putExtra(EXTRA_DISTANCE_BASED, type.isDistanceBased)
|
||||
weightKg?.let { putExtra(EXTRA_WEIGHT_KG, it) }
|
||||
putExtra(EXTRA_GPS_ACC_LIMIT, gpsAccuracyLimitM)
|
||||
putExtra(EXTRA_GPS_SPEED_FACTOR, gpsSpeedFactor)
|
||||
putExtra(EXTRA_GPS_SPEED_FLOOR, gpsSpeedFloorKmh)
|
||||
putExtra(EXTRA_MOTION_GUARD, motionGuardEnabled)
|
||||
putExtra(EXTRA_MOTION_THRESHOLD, motionThreshold)
|
||||
putExtra(EXTRA_COUNTDOWN, countdownSeconds)
|
||||
}
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
fun play(context: Context) {
|
||||
context.startService(
|
||||
Intent(context, TrackingService::class.java).apply { action = ACTION_PLAY }
|
||||
)
|
||||
}
|
||||
|
||||
fun pause(context: Context) {
|
||||
context.startService(
|
||||
Intent(context, TrackingService::class.java).apply { action = ACTION_PAUSE }
|
||||
)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.startService(
|
||||
Intent(context, TrackingService::class.java).apply { action = ACTION_STOP }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
109
app/src/main/java/eu/brassepc/fitnessdroid/data/UpdateChecker.kt
Normal file
109
app/src/main/java/eu/brassepc/fitnessdroid/data/UpdateChecker.kt
Normal file
@@ -0,0 +1,109 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class UpdateInfo(
|
||||
val versionCode: Long,
|
||||
val versionName: String,
|
||||
val apkUrl: String,
|
||||
val sha256: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Uppdateringskoll mot släppsidans version.json (skrivs av CI:t).
|
||||
* Nedladdning till appens cache + verifiering, sedan Androids
|
||||
* installationsdialog — samma signeringsnyckel gör att den uppdaterar
|
||||
* rakt över befintlig installation.
|
||||
*/
|
||||
class UpdateChecker(private val context: Context) {
|
||||
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun installedVersionCode(): Long =
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode
|
||||
|
||||
/** null = uppdaterad, nätfel eller trasig manifest — stör aldrig användaren. */
|
||||
suspend fun check(): UpdateInfo? = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url(VERSION_URL).build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
val o = Json.parseToJsonElement(response.body!!.string()).jsonObject
|
||||
val info = UpdateInfo(
|
||||
versionCode = (o["versionCode"]?.jsonPrimitive?.intOrNull ?: 0).toLong(),
|
||||
versionName = o["versionName"]?.jsonPrimitive?.content ?: "?",
|
||||
apkUrl = o["apkUrl"]?.jsonPrimitive?.content ?: return@withContext null,
|
||||
sha256 = o["sha256"]?.jsonPrimitive?.content,
|
||||
)
|
||||
if (info.versionCode > installedVersionCode()) info else null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Laddar ner APK:n till cachen, verifierar sha256, returnerar filen. */
|
||||
suspend fun download(info: UpdateInfo, onProgress: (Float) -> Unit): File =
|
||||
withContext(Dispatchers.IO) {
|
||||
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||
val file = File(dir, "FitnessDroid-${info.versionCode}.apk")
|
||||
val request = Request.Builder().url(info.apkUrl).build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) error("HTTP ${response.code}")
|
||||
val body = response.body!!
|
||||
val total = body.contentLength().takeIf { it > 0 }
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
body.byteStream().use { input ->
|
||||
file.outputStream().use { output ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var read: Int
|
||||
var done = 0L
|
||||
while (input.read(buffer).also { read = it } != -1) {
|
||||
output.write(buffer, 0, read)
|
||||
digest.update(buffer, 0, read)
|
||||
done += read
|
||||
total?.let { onProgress(done.toFloat() / it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val actual = digest.digest().joinToString("") { "%02x".format(it) }
|
||||
if (info.sha256 != null && !actual.equals(info.sha256, ignoreCase = true)) {
|
||||
file.delete()
|
||||
error("sha256 stämmer inte — avbryter")
|
||||
}
|
||||
}
|
||||
file
|
||||
}
|
||||
|
||||
/** Öppnar Androids installationsdialog för den nedladdade APK:n. */
|
||||
fun install(file: File) {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.fileprovider", file,
|
||||
)
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val VERSION_URL =
|
||||
"https://gitea.brasse-pc.eu/brasse/FitnessDroid/releases/download/latest/version.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package eu.brassepc.fitnessdroid.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
LocalSession::class,
|
||||
LocalExercise::class,
|
||||
LocalSet::class,
|
||||
PendingOp::class,
|
||||
CachedExerciseType::class,
|
||||
CachedMuscleGroup::class,
|
||||
CachedMuscle::class,
|
||||
CachedStartCard::class,
|
||||
CachedPb::class,
|
||||
PendingActivity::class,
|
||||
],
|
||||
version = 5,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun sessionDao(): SessionDao
|
||||
abstract fun opDao(): OpDao
|
||||
abstract fun cacheDao(): CacheDao
|
||||
|
||||
companion object {
|
||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE local_session ADD COLUMN durationSecondsOverride INTEGER")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE local_session ADD COLUMN editedStartEpochMs INTEGER")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `cached_pb` (" +
|
||||
"`key` TEXT NOT NULL, `exerciseTypeId` INTEGER NOT NULL, " +
|
||||
"`reps` INTEGER NOT NULL, `weight` REAL NOT NULL, " +
|
||||
"PRIMARY KEY(`key`))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `pending_activity` (" +
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`activityTypeId` INTEGER NOT NULL, `typeName` TEXT NOT NULL, " +
|
||||
"`isCardio` INTEGER NOT NULL DEFAULT 0, " +
|
||||
"`startedAtIso` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, " +
|
||||
"`distanceMeters` REAL, `elevationGainMeters` REAL, `rpe` REAL, " +
|
||||
"`source` TEXT NOT NULL DEFAULT 'manual', " +
|
||||
"`routePolyline` TEXT, `notes` TEXT)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun build(context: Context): AppDatabase =
|
||||
Room.databaseBuilder(context, AppDatabase::class.java, "fitnessdroid.db")
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
161
app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt
Normal file
161
app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt
Normal file
@@ -0,0 +1,161 @@
|
||||
package eu.brassepc.fitnessdroid.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface SessionDao {
|
||||
@Insert
|
||||
suspend fun insertSession(session: LocalSession): Long
|
||||
|
||||
@Insert
|
||||
suspend fun insertExercise(exercise: LocalExercise): Long
|
||||
|
||||
@Insert
|
||||
suspend fun insertSet(set: LocalSet): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateSession(session: LocalSession)
|
||||
|
||||
@Update
|
||||
suspend fun updateExercise(exercise: LocalExercise)
|
||||
|
||||
@Update
|
||||
suspend fun updateSet(set: LocalSet)
|
||||
|
||||
@Query("SELECT * FROM local_session WHERE status = 'active' ORDER BY id DESC LIMIT 1")
|
||||
fun activeSession(): Flow<LocalSession?>
|
||||
|
||||
@Query("SELECT * FROM local_session WHERE id = :id")
|
||||
suspend fun session(id: Long): LocalSession?
|
||||
|
||||
@Query("SELECT id FROM local_session WHERE status = 'active' ORDER BY id DESC LIMIT 1")
|
||||
suspend fun activeSessionIdNow(): Long?
|
||||
|
||||
@Query("SELECT * FROM local_exercise WHERE sessionId = :sessionId ORDER BY `order`")
|
||||
fun exercises(sessionId: Long): Flow<List<LocalExercise>>
|
||||
|
||||
@Query("SELECT * FROM local_exercise WHERE id = :id")
|
||||
suspend fun exercise(id: Long): LocalExercise?
|
||||
|
||||
@Query("SELECT * FROM local_set WHERE exerciseId IN (SELECT id FROM local_exercise WHERE sessionId = :sessionId) ORDER BY `order`")
|
||||
fun setsForSession(sessionId: Long): Flow<List<LocalSet>>
|
||||
|
||||
@Query("SELECT * FROM local_set WHERE id = :id")
|
||||
suspend fun set(id: Long): LocalSet?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM local_exercise WHERE sessionId = :sessionId")
|
||||
suspend fun exerciseCount(sessionId: Long): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM local_set WHERE exerciseId = :exerciseId")
|
||||
suspend fun setCount(exerciseId: Long): Int
|
||||
|
||||
@Query("DELETE FROM local_exercise WHERE id = :id")
|
||||
suspend fun deleteExercise(id: Long)
|
||||
|
||||
@Query("DELETE FROM local_set WHERE id = :id")
|
||||
suspend fun deleteSet(id: Long)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface OpDao {
|
||||
@Insert
|
||||
suspend fun enqueue(op: PendingOp): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(op: PendingOp)
|
||||
|
||||
@Query("SELECT * FROM pending_op ORDER BY id LIMIT 1")
|
||||
suspend fun next(): PendingOp?
|
||||
|
||||
@Query("DELETE FROM pending_op WHERE id = :id")
|
||||
suspend fun done(id: Long)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM pending_op")
|
||||
fun pendingCount(): Flow<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM pending_op")
|
||||
suspend fun pendingCountNow(): Int
|
||||
|
||||
/* ---- Aktiviteter loggade offline ---- */
|
||||
|
||||
@Insert
|
||||
suspend fun enqueueActivity(activity: PendingActivity): Long
|
||||
|
||||
@Query("SELECT * FROM pending_activity WHERE id = :id")
|
||||
suspend fun pendingActivity(id: Long): PendingActivity?
|
||||
|
||||
@Query("DELETE FROM pending_activity WHERE id = :id")
|
||||
suspend fun activityDone(id: Long)
|
||||
|
||||
@Query("SELECT * FROM pending_activity ORDER BY id DESC")
|
||||
fun pendingActivities(): Flow<List<PendingActivity>>
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface CacheDao {
|
||||
@Query("SELECT * FROM cached_exercise_type ORDER BY isFavorite DESC, name")
|
||||
fun exerciseTypes(): Flow<List<CachedExerciseType>>
|
||||
|
||||
@Query("SELECT * FROM cached_exercise_type WHERE id = :id")
|
||||
suspend fun exerciseType(id: Int): CachedExerciseType?
|
||||
|
||||
@Query("SELECT * FROM cached_muscle_group ORDER BY name")
|
||||
fun muscleGroups(): Flow<List<CachedMuscleGroup>>
|
||||
|
||||
@Query("SELECT * FROM cached_muscle ORDER BY name")
|
||||
fun muscles(): Flow<List<CachedMuscle>>
|
||||
|
||||
@Query("SELECT * FROM cached_start_card ORDER BY sortOrder")
|
||||
fun startCards(): Flow<List<CachedStartCard>>
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceExerciseTypes(rows: List<CachedExerciseType>) {
|
||||
// Behåll lastWeight/lastReps-fälten vid refresh — de fylls på separat.
|
||||
for (row in rows) {
|
||||
val old = exerciseType(row.id)
|
||||
upsertExerciseType(
|
||||
if (old != null) row.copy(
|
||||
lastWeight = old.lastWeight,
|
||||
lastReps = old.lastReps,
|
||||
lastDurationSeconds = old.lastDurationSeconds,
|
||||
lastDistanceMeters = old.lastDistanceMeters,
|
||||
) else row
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertExerciseType(row: CachedExerciseType)
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertMuscleGroups(rows: List<CachedMuscleGroup>)
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertMuscles(rows: List<CachedMuscle>)
|
||||
|
||||
@Query("DELETE FROM cached_start_card")
|
||||
suspend fun clearStartCards()
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertStartCards(rows: List<CachedStartCard>)
|
||||
|
||||
@Query("UPDATE cached_exercise_type SET lastWeight = :weight, lastReps = :reps, lastDurationSeconds = :duration, lastDistanceMeters = :distance WHERE id = :id")
|
||||
suspend fun updateLastPerformance(id: Int, weight: Double?, reps: Int?, duration: Int?, distance: Double?)
|
||||
|
||||
@Query("UPDATE cached_exercise_type SET isFavorite = :favorite WHERE id = :id")
|
||||
suspend fun setExerciseFavorite(id: Int, favorite: Boolean)
|
||||
|
||||
@Query("SELECT * FROM cached_pb")
|
||||
suspend fun personalBests(): List<CachedPb>
|
||||
|
||||
@Query("DELETE FROM cached_pb")
|
||||
suspend fun clearPersonalBests()
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertPersonalBests(rows: List<CachedPb>)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package eu.brassepc.fitnessdroid.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Lokal-först-modell: passet lever i Room medan du tränar och synkas till
|
||||
* servern via op-kön ([PendingOp]) i bakgrunden. serverId är null tills
|
||||
* motsvarande mutation gått igenom — det driver synk-indikatorerna i UI:t.
|
||||
*/
|
||||
|
||||
@Entity(tableName = "local_session")
|
||||
data class LocalSession(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val serverId: Int? = null,
|
||||
val name: String? = null,
|
||||
val notes: String? = null,
|
||||
val startedAtEpochMs: Long,
|
||||
/** "active" eller "completed" (lokalt avslutad, ev. ej synkad än) */
|
||||
val status: String = "active",
|
||||
/** Manuellt rättad passtid — skickas som durationSecondsOverride vid avslut */
|
||||
val durationSecondsOverride: Int? = null,
|
||||
/** Manuellt rättad starttid — skickas som updateGymSession(date) vid avslut */
|
||||
val editedStartEpochMs: Long? = null,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "local_exercise",
|
||||
indices = [Index("sessionId")],
|
||||
)
|
||||
data class LocalExercise(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val sessionId: Long,
|
||||
val serverId: Int? = null,
|
||||
val exerciseTypeId: Int,
|
||||
val order: Int,
|
||||
val notes: String? = null,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "local_set",
|
||||
indices = [Index("exerciseId")],
|
||||
)
|
||||
data class LocalSet(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val exerciseId: Long,
|
||||
val serverId: Int? = null,
|
||||
val order: Int,
|
||||
val reps: Int? = null,
|
||||
val weight: Double? = null,
|
||||
val distanceMeters: Double? = null,
|
||||
val durationSeconds: Int? = null,
|
||||
val rpe: Double? = null,
|
||||
val isWarmup: Boolean = false,
|
||||
val isCompleted: Boolean = true,
|
||||
val loggedAtEpochMs: Long,
|
||||
)
|
||||
|
||||
/** Kön av mutationer som väntar på att nå servern. Körs strikt i id-ordning. */
|
||||
@Entity(tableName = "pending_op")
|
||||
data class PendingOp(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
/** se [eu.brassepc.fitnessdroid.data.OpKind] */
|
||||
val kind: String,
|
||||
/** Lokalt id på raden operationen gäller (session/övning/set beroende på kind) */
|
||||
val targetLocalId: Long,
|
||||
val createdAtEpochMs: Long,
|
||||
val attempts: Int = 0,
|
||||
val lastError: String? = null,
|
||||
)
|
||||
|
||||
/** Aktivitet som väntar på att synkas till servern (loggad offline). */
|
||||
@Entity(tableName = "pending_activity")
|
||||
data class PendingActivity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val activityTypeId: Int,
|
||||
/** För visning i listan innan synk */
|
||||
val typeName: String,
|
||||
val isCardio: Boolean = false,
|
||||
val startedAtIso: String,
|
||||
val durationSeconds: Int,
|
||||
val distanceMeters: Double? = null,
|
||||
val elevationGainMeters: Double? = null,
|
||||
val rpe: Double? = null,
|
||||
val source: String = "manual",
|
||||
val routePolyline: String? = null,
|
||||
val notes: String? = null,
|
||||
)
|
||||
|
||||
/* ---------- Referensdata-cache (funkar offline) ---------- */
|
||||
|
||||
@Entity(tableName = "cached_exercise_type")
|
||||
data class CachedExerciseType(
|
||||
@PrimaryKey val id: Int,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val metValue: Double = 5.0,
|
||||
val tracksWeight: Boolean = false,
|
||||
val tracksReps: Boolean = false,
|
||||
val tracksDistance: Boolean = false,
|
||||
val tracksDuration: Boolean = false,
|
||||
val isBodyweight: Boolean = false,
|
||||
/** kommaseparerade muskel-id:n */
|
||||
val muscleIds: String = "",
|
||||
val isFavorite: Boolean = false,
|
||||
/** från API-tillägget ExerciseType.defaultRestSeconds; null → appens standardvila */
|
||||
val defaultRestSeconds: Int? = null,
|
||||
/** Senast kända prestation (för förifyllnad offline) */
|
||||
val lastWeight: Double? = null,
|
||||
val lastReps: Int? = null,
|
||||
val lastDurationSeconds: Int? = null,
|
||||
val lastDistanceMeters: Double? = null,
|
||||
)
|
||||
|
||||
@Entity(tableName = "cached_muscle_group")
|
||||
data class CachedMuscleGroup(
|
||||
@PrimaryKey val id: Int,
|
||||
val name: String,
|
||||
/** från API-tillägget MuscleGroup.iconKey; null → namnheuristik */
|
||||
val iconKey: String? = null,
|
||||
)
|
||||
|
||||
@Entity(tableName = "cached_muscle")
|
||||
data class CachedMuscle(
|
||||
@PrimaryKey val id: Int,
|
||||
val name: String,
|
||||
val muscleGroupId: Int? = null,
|
||||
)
|
||||
|
||||
/** Personbästa per övning och rep-antal — för PB-markering vid avslut. */
|
||||
@Entity(tableName = "cached_pb")
|
||||
data class CachedPb(
|
||||
/** "<exerciseTypeId>-<reps>" */
|
||||
@PrimaryKey val key: String,
|
||||
val exerciseTypeId: Int,
|
||||
val reps: Int,
|
||||
val weight: Double,
|
||||
)
|
||||
|
||||
/** Favoritpass/mallar som visas på hemskärmen, cachade för offline-start. */
|
||||
@Entity(tableName = "cached_start_card")
|
||||
data class CachedStartCard(
|
||||
/** "template-<id>" eller "favsession-<id>" */
|
||||
@PrimaryKey val key: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
/** kommaseparerade exerciseTypeId:n i ordning — används för att starta passet */
|
||||
val exerciseTypeIds: String,
|
||||
val templateId: Int? = null,
|
||||
val sortOrder: Int = 0,
|
||||
)
|
||||
272
app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt
Normal file
272
app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt
Normal file
@@ -0,0 +1,272 @@
|
||||
package eu.brassepc.fitnessdroid.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.BarChart
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import eu.brassepc.fitnessdroid.ui.about.AboutScreen
|
||||
import eu.brassepc.fitnessdroid.ui.activities.ActivitiesScreen
|
||||
import eu.brassepc.fitnessdroid.ui.goals.GoalsScreen
|
||||
import eu.brassepc.fitnessdroid.ui.history.HistoryDetailScreen
|
||||
import eu.brassepc.fitnessdroid.ui.history.HistoryScreen
|
||||
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
|
||||
import eu.brassepc.fitnessdroid.ui.picker.ExercisePickerScreen
|
||||
import eu.brassepc.fitnessdroid.ui.profile.ProfileScreen
|
||||
import eu.brassepc.fitnessdroid.ui.scale.ScalePairScreen
|
||||
import eu.brassepc.fitnessdroid.ui.scale.WeighScreen
|
||||
import eu.brassepc.fitnessdroid.ui.track.TrackScreen
|
||||
import eu.brassepc.fitnessdroid.ui.session.SessionScreen
|
||||
import eu.brassepc.fitnessdroid.ui.settings.SettingsScreen
|
||||
import eu.brassepc.fitnessdroid.ui.stats.StatsScreen
|
||||
|
||||
object Routes {
|
||||
const val HOME = "home"
|
||||
const val HISTORY = "history"
|
||||
const val HISTORY_DETAIL = "history/{id}"
|
||||
const val STATS = "stats"
|
||||
const val PROFILE = "profile"
|
||||
const val SETTINGS = "settings"
|
||||
const val SESSION = "session"
|
||||
const val PICKER = "picker"
|
||||
const val SCALE = "scale"
|
||||
const val WEIGH = "weigh"
|
||||
const val ABOUT = "about"
|
||||
const val ACTIVITIES = "activities"
|
||||
const val GOALS = "goals"
|
||||
const val TRACK = "track/{typeId}"
|
||||
|
||||
fun track(typeId: Int) = "track/$typeId"
|
||||
|
||||
fun historyDetail(id: Int) = "history/$id"
|
||||
}
|
||||
|
||||
private data class TabItem(val route: String, val label: String, val icon: ImageVector)
|
||||
|
||||
private val tabs = listOf(
|
||||
TabItem(Routes.HOME, "Hem", Icons.Default.Home),
|
||||
TabItem(Routes.HISTORY, "Pass", Icons.AutoMirrored.Filled.List),
|
||||
TabItem(Routes.STATS, "Statistik", Icons.Default.BarChart),
|
||||
TabItem(Routes.PROFILE, "Profil", Icons.Default.Person),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppRoot(rootViewModel: RootViewModel = viewModel(factory = RootViewModel.Factory)) {
|
||||
val nav = rememberNavController()
|
||||
val backStack by nav.currentBackStackEntryAsState()
|
||||
val route = backStack?.destination?.route
|
||||
val hasActiveSession by rootViewModel.hasActiveSession.collectAsStateWithLifecycle(false)
|
||||
val restState by rootViewModel.restState.collectAsStateWithLifecycle()
|
||||
val pendingCount by rootViewModel.pendingCount.collectAsStateWithLifecycle(0)
|
||||
|
||||
val showChrome = route in tabs.map { it.route }
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (showChrome) {
|
||||
Column {
|
||||
if (hasActiveSession) {
|
||||
MiniPlayer(
|
||||
restSecondsLeft = restState?.secondsLeft,
|
||||
pendingCount = pendingCount,
|
||||
onOpen = { nav.navigate(Routes.SESSION) },
|
||||
)
|
||||
}
|
||||
BottomBar(nav = nav, currentRoute = route, onStart = {
|
||||
rootViewModel.startOrResume { nav.navigate(Routes.SESSION) }
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
NavHost(
|
||||
navController = nav,
|
||||
startDestination = Routes.HOME,
|
||||
modifier = Modifier.padding(padding),
|
||||
) {
|
||||
composable(Routes.HOME) {
|
||||
HomeScreen(
|
||||
onOpenSession = { nav.navigate(Routes.SESSION) },
|
||||
onOpenHistory = { nav.navigate(Routes.HISTORY) },
|
||||
onOpenActivities = { nav.navigate(Routes.ACTIVITIES) },
|
||||
onOpenTrack = { typeId -> nav.navigate(Routes.track(typeId)) },
|
||||
onOpenGoals = { nav.navigate(Routes.GOALS) },
|
||||
)
|
||||
}
|
||||
composable(Routes.GOALS) {
|
||||
GoalsScreen(onBack = { nav.popBackStack() })
|
||||
}
|
||||
composable(Routes.ACTIVITIES) {
|
||||
ActivitiesScreen(
|
||||
onBack = { nav.popBackStack() },
|
||||
onStartTracking = { typeId -> nav.navigate(Routes.track(typeId)) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Routes.TRACK,
|
||||
arguments = listOf(navArgument("typeId") { type = NavType.IntType }),
|
||||
) { entry ->
|
||||
TrackScreen(
|
||||
typeId = entry.arguments?.getInt("typeId") ?: 0,
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Routes.HISTORY) {
|
||||
HistoryScreen(onOpenSession = { id -> nav.navigate(Routes.historyDetail(id)) })
|
||||
}
|
||||
composable(
|
||||
Routes.HISTORY_DETAIL,
|
||||
arguments = listOf(navArgument("id") { type = NavType.IntType }),
|
||||
) { entry ->
|
||||
HistoryDetailScreen(
|
||||
sessionId = entry.arguments?.getInt("id") ?: 0,
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Routes.STATS) { StatsScreen() }
|
||||
composable(Routes.PROFILE) {
|
||||
ProfileScreen(
|
||||
onOpenSettings = { nav.navigate(Routes.SETTINGS) },
|
||||
onOpenWeigh = { nav.navigate(Routes.WEIGH) },
|
||||
onOpenAbout = { nav.navigate(Routes.ABOUT) },
|
||||
)
|
||||
}
|
||||
composable(Routes.SETTINGS) {
|
||||
SettingsScreen(
|
||||
onBack = { nav.popBackStack() },
|
||||
onOpenScale = { nav.navigate(Routes.SCALE) },
|
||||
)
|
||||
}
|
||||
composable(Routes.SCALE) { ScalePairScreen(onBack = { nav.popBackStack() }) }
|
||||
composable(Routes.WEIGH) { WeighScreen(onBack = { nav.popBackStack() }) }
|
||||
composable(Routes.ABOUT) { AboutScreen(onBack = { nav.popBackStack() }) }
|
||||
composable(Routes.SESSION) {
|
||||
SessionScreen(
|
||||
onBack = { nav.popBackStack() },
|
||||
onAddExercise = { nav.navigate(Routes.PICKER) },
|
||||
onSessionEnded = {
|
||||
nav.popBackStack(Routes.HOME, inclusive = false)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Routes.PICKER) {
|
||||
ExercisePickerScreen(onDone = { nav.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomBar(nav: NavHostController, currentRoute: String?, onStart: () -> Unit) {
|
||||
Box {
|
||||
NavigationBar {
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
if (index == 2) {
|
||||
// plats för mittknappen
|
||||
NavigationBarItem(
|
||||
selected = false,
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
icon = {},
|
||||
label = { Text("") },
|
||||
)
|
||||
}
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == tab.route,
|
||||
onClick = {
|
||||
nav.navigate(tab.route) {
|
||||
popUpTo(Routes.HOME) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = { Icon(tab.icon, contentDescription = tab.label) },
|
||||
label = { Text(tab.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
FloatingActionButton(
|
||||
onClick = onStart,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = "Starta pass")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniPlayer(restSecondsLeft: Int?, pendingCount: Int, onOpen: () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpen),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
|
||||
Text("Pass pågår", style = MaterialTheme.typography.labelLarge)
|
||||
val sub = buildString {
|
||||
if (restSecondsLeft != null && restSecondsLeft > 0) {
|
||||
append("Vila ${restSecondsLeft / 60}:${"%02d".format(restSecondsLeft % 60)} kvar")
|
||||
} else {
|
||||
append("Tryck för att fortsätta")
|
||||
}
|
||||
if (pendingCount > 0) append(" · $pendingCount osynkat")
|
||||
}
|
||||
Text(
|
||||
sub,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"ÖPPNA",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import eu.brassepc.fitnessdroid.data.AuthState
|
||||
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
|
||||
import eu.brassepc.fitnessdroid.ui.login.LoginScreen
|
||||
|
||||
@Composable
|
||||
@@ -35,7 +34,7 @@ fun FitnessDroidApp(appViewModel: AppViewModel = viewModel(factory = AppViewMode
|
||||
}
|
||||
}
|
||||
AuthState.LoggedOut -> LoginScreen()
|
||||
is AuthState.LoggedIn -> HomeScreen()
|
||||
is AuthState.LoggedIn -> AppRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package eu.brassepc.fitnessdroid.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.RestTimerController
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RootViewModel(
|
||||
private val repo: GymRepository,
|
||||
restTimer: RestTimerController,
|
||||
) : ViewModel() {
|
||||
|
||||
val hasActiveSession = repo.activeSession.map { it != null }
|
||||
val restState = restTimer.state
|
||||
val pendingCount = repo.pendingCount
|
||||
|
||||
private val activeSessionState = repo.activeSession
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
init {
|
||||
repo.refreshAllAsync()
|
||||
}
|
||||
|
||||
/** Mittknappen: öppna pågående pass, eller starta ett fritt pass. */
|
||||
fun startOrResume(onReady: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
if (activeSessionState.value == null) {
|
||||
repo.startFreeSession()
|
||||
}
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
RootViewModel(c.gymRepository, c.restTimer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package eu.brassepc.fitnessdroid.ui.about
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.brassepc.fitnessdroid.BuildConfig
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AboutScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
fun open(url: String) {
|
||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Om appen") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("FitnessDroid", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Version ${BuildConfig.VERSION_NAME}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"Fri programvara under GNU GPL v3. Du får använda, ändra och " +
|
||||
"sprida appen vidare under samma licens.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
LinkRow("Källkod (Gitea)", "https://gitea.brasse-pc.eu/brasse/FitnessDroid", ::open)
|
||||
LinkRow("GNU GPL v3", "https://www.gnu.org/licenses/gpl-3.0.html", ::open)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Öppen källkod som används", style = MaterialTheme.typography.titleSmall)
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("openScale", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Bluetooth-drivrutinerna för kroppsvågar kommer från openScale " +
|
||||
"av olie.xdev m.fl. (GPL-3.0). Stort tack!",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
LinkRow("github.com/oliexdev/openScale", "https://github.com/oliexdev/openScale", ::open)
|
||||
}
|
||||
}
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("Blessed-Kotlin", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"BLE-bibliotek av Martijn van Welie (MIT-licens).",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
LinkRow("github.com/weliem/blessed-kotlin", "https://github.com/weliem/blessed-kotlin", ::open)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkRow(label: String, url: String, open: (String) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { open(url) }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
package eu.brassepc.fitnessdroid.ui.activities
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Map
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.Activity
|
||||
import eu.brassepc.fitnessdroid.data.ActivityType
|
||||
import eu.brassepc.fitnessdroid.data.GymApi
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import eu.brassepc.fitnessdroid.ui.common.compact
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
val CATEGORY_LABELS = linkedMapOf(
|
||||
"WALK" to "Promenad",
|
||||
"RUN" to "Löpning",
|
||||
"HIKE" to "Vandring",
|
||||
"CYCLE" to "Cykling",
|
||||
"SWIM" to "Simning",
|
||||
"WATER" to "Vatten",
|
||||
"CARDIO" to "Kondition",
|
||||
"FITNESS" to "Rörlighet",
|
||||
"STRENGTH" to "Styrka",
|
||||
"MARTIAL_ARTS" to "Kampsport & fäktning",
|
||||
"SPORT" to "Sport",
|
||||
"WINTER" to "Vinter",
|
||||
"OTHER" to "Övrigt",
|
||||
)
|
||||
|
||||
val RPE_LABELS = listOf(
|
||||
"Ingen ansträngning", "Väldigt lätt", "Lätt", "Lätt–måttlig", "Måttlig",
|
||||
"Normal för aktiviteten", "Något ansträngande", "Ansträngande",
|
||||
"Mycket ansträngande", "Nästan max", "Kan inte prata mer än ett par ord",
|
||||
)
|
||||
|
||||
class ActivitiesViewModel(
|
||||
private val gymApi: GymApi,
|
||||
private val repo: eu.brassepc.fitnessdroid.data.GymRepository,
|
||||
) : ViewModel() {
|
||||
val types = MutableStateFlow<List<ActivityType>>(emptyList())
|
||||
val activities = MutableStateFlow<List<Activity>>(emptyList())
|
||||
val message = MutableStateFlow<String?>(null)
|
||||
|
||||
/** Offline-loggade aktiviteter som väntar på synk. */
|
||||
val pending = repo.pendingActivities
|
||||
|
||||
init { load() }
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
runCatching { types.value = gymApi.activityTypes() }
|
||||
.onFailure { message.value = "Kunde inte hämta aktivitetsbiblioteket — offline?" }
|
||||
runCatching { activities.value = gymApi.activities() }
|
||||
}
|
||||
}
|
||||
|
||||
fun save(
|
||||
editingId: String?,
|
||||
type: ActivityType,
|
||||
startedAt: LocalDateTime,
|
||||
durationSeconds: Int,
|
||||
distanceKm: Double?,
|
||||
elevation: Double?,
|
||||
rpe: Double?,
|
||||
notes: String?,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val startedIso = startedAt.atZone(ZoneId.systemDefault()).toInstant().toString()
|
||||
if (editingId == null) {
|
||||
gymApi.addActivity(
|
||||
activityTypeId = type.id,
|
||||
startedAtIso = startedIso,
|
||||
durationSeconds = durationSeconds,
|
||||
distanceMeters = distanceKm?.times(1000),
|
||||
elevationGainMeters = elevation,
|
||||
rpe = rpe,
|
||||
notes = notes,
|
||||
)
|
||||
message.value = "Aktiviteten loggad"
|
||||
} else {
|
||||
// -1/"" = rensa på servern
|
||||
gymApi.updateActivity(
|
||||
id = editingId,
|
||||
activityTypeId = type.id,
|
||||
startedAtIso = startedIso,
|
||||
durationSeconds = durationSeconds,
|
||||
distanceMeters = distanceKm?.times(1000) ?: -1.0,
|
||||
elevationGainMeters = elevation ?: -1.0,
|
||||
rpe = rpe ?: -1.0,
|
||||
notes = notes ?: "",
|
||||
)
|
||||
message.value = "Aktiviteten uppdaterad"
|
||||
}
|
||||
load()
|
||||
onDone()
|
||||
} catch (e: Exception) {
|
||||
if (editingId == null) {
|
||||
// Ny aktivitet offline → köa lokalt (synkas som passen)
|
||||
runCatching {
|
||||
repo.queueActivity(
|
||||
eu.brassepc.fitnessdroid.data.local.PendingActivity(
|
||||
activityTypeId = type.id,
|
||||
typeName = type.nameSv,
|
||||
isCardio = type.isCardio,
|
||||
startedAtIso = startedAt
|
||||
.atZone(ZoneId.systemDefault()).toInstant().toString(),
|
||||
durationSeconds = durationSeconds,
|
||||
distanceMeters = distanceKm?.times(1000),
|
||||
elevationGainMeters = elevation,
|
||||
rpe = rpe,
|
||||
source = "manual",
|
||||
notes = notes,
|
||||
)
|
||||
)
|
||||
message.value = "Ingen kontakt med servern — sparad lokalt, synkas automatiskt."
|
||||
onDone()
|
||||
}.onFailure { message.value = "Kunde inte spara — försök igen." }
|
||||
} else {
|
||||
message.value = "Kunde inte uppdatera — offline?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(id: String, onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
gymApi.deleteActivity(id)
|
||||
message.value = "Aktiviteten borttagen"
|
||||
load()
|
||||
onDone()
|
||||
} catch (e: Exception) {
|
||||
message.value = "Kunde inte ta bort — offline?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
ActivitiesViewModel(c.gymApi, c.gymRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ActivitiesScreen(
|
||||
onBack: () -> Unit,
|
||||
onStartTracking: (Int) -> Unit = {},
|
||||
viewModel: ActivitiesViewModel = viewModel(factory = ActivitiesViewModel.Factory),
|
||||
) {
|
||||
val types by viewModel.types.collectAsStateWithLifecycle()
|
||||
val activities by viewModel.activities.collectAsStateWithLifecycle()
|
||||
val pending by viewModel.pending.collectAsStateWithLifecycle(emptyList())
|
||||
val message by viewModel.message.collectAsStateWithLifecycle()
|
||||
var showLog by remember { mutableStateOf(false) }
|
||||
var showStartPicker by remember { mutableStateOf(false) }
|
||||
var editTarget by remember { mutableStateOf<Activity?>(null) }
|
||||
var mapTarget by remember { mutableStateOf<Activity?>(null) }
|
||||
|
||||
mapTarget?.let { a ->
|
||||
RouteMapDialog(activity = a, onDismiss = { mapTarget = null })
|
||||
}
|
||||
|
||||
// Uppdatera listan när man kommer tillbaka (t.ex. efter avslutad spårning)
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.load() }
|
||||
|
||||
if (showStartPicker) {
|
||||
TypePickerDialog(
|
||||
types = types,
|
||||
title = "Starta aktivitet",
|
||||
onPick = { t ->
|
||||
showStartPicker = false
|
||||
onStartTracking(t.id)
|
||||
},
|
||||
onDismiss = { showStartPicker = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showLog || editTarget != null) {
|
||||
LogActivityDialog(
|
||||
types = types,
|
||||
editing = editTarget,
|
||||
onSave = { type, start, dur, dist, elev, rpe, notes ->
|
||||
viewModel.save(editTarget?.id, type, start, dur, dist, elev, rpe, notes) {
|
||||
showLog = false; editTarget = null
|
||||
}
|
||||
},
|
||||
onDelete = editTarget?.let { a -> { viewModel.delete(a.id) { editTarget = null } } },
|
||||
onDismiss = { showLog = false; editTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Favorite,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text("Aktiviteter", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(onClick = { showLog = true }) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Text("Logga manuellt", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { showStartPicker = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
Text(
|
||||
"Starta aktivitet — GPS eller timer",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
message?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (activities.isEmpty()) {
|
||||
Text(
|
||||
"Inga aktiviteter loggade än. Tryck på Logga aktivitet — " +
|
||||
"promenader, löprundor, fäktning och annat du gör över tid.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(pending, key = { "p${'$'}{it.id}" }) { p ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(p.typeName, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"${'$'}{p.startedAtIso.take(10)} · ${'$'}{formatDuration(p.durationSeconds)}" +
|
||||
(p.distanceMeters?.let { " · ${'$'}{(it / 1000).compact()} km" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"väntar på synk",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(activities, key = { it.id }) { a ->
|
||||
ActivityRow(
|
||||
a,
|
||||
onClick = { editTarget = a },
|
||||
onShowMap = if (a.routePolyline != null) {
|
||||
{ mapTarget = a }
|
||||
} else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Kategorigrupperad aktivitetsväljare — används av både start- och loggflödet. */
|
||||
@Composable
|
||||
fun TypePickerDialog(
|
||||
types: List<ActivityType>,
|
||||
title: String,
|
||||
onPick: (ActivityType) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
if (types.isEmpty()) {
|
||||
Text(
|
||||
"Aktivitetsbiblioteket kunde inte hämtas — offline?",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
CATEGORY_LABELS.forEach { (cat, label) ->
|
||||
val inCat = types.filter { it.category == cat }
|
||||
if (inCat.isNotEmpty()) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
inCat.forEach { t ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onPick(t) }
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (t.isCardio) {
|
||||
Icon(
|
||||
Icons.Default.Favorite,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(t.nameSv)
|
||||
Text(
|
||||
if (t.isDistanceBased) "GPS + distans" else "Timer (ingen rörelsespårning)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Avbryt") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActivityRow(a: Activity, onClick: () -> Unit, onShowMap: (() -> Unit)? = null) {
|
||||
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (a.activityType.isCardio) {
|
||||
Icon(
|
||||
Icons.Default.Favorite,
|
||||
contentDescription = "Kondition",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(end = 10.dp),
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(a.activityType.nameSv, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
buildString {
|
||||
append(a.startedAt.take(10))
|
||||
append(" · ${formatDuration(a.durationSeconds)}")
|
||||
a.distanceMeters?.let {
|
||||
append(" · ${(it / 1000).compact()} km")
|
||||
val kmh = (it / 1000) / (a.durationSeconds / 3600.0)
|
||||
append(" · ${kmh.compact()} km/h")
|
||||
}
|
||||
a.rpe?.let { append(" · RPE ${it.toInt()}") }
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
a.estimatedKcal?.let {
|
||||
Text(
|
||||
"${it.toInt()} kcal",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
onShowMap?.let {
|
||||
androidx.compose.material3.IconButton(onClick = it) {
|
||||
Icon(
|
||||
Icons.Filled.Map,
|
||||
contentDescription = "Visa rutt",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fullskärmsdialog med rutten på en OSM-karta (osmdroid). */
|
||||
@Composable
|
||||
fun RouteMapDialog(activity: Activity, onDismiss: () -> Unit) {
|
||||
val points = remember(activity.id) {
|
||||
activity.routePolyline?.let { eu.brassepc.fitnessdroid.data.decodePolyline(it) } ?: emptyList()
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text("${activity.activityType.nameSv} · ${activity.startedAt.take(10)}")
|
||||
},
|
||||
text = {
|
||||
if (points.size < 2) {
|
||||
Text("Ingen rutt sparad för den här aktiviteten.")
|
||||
} else {
|
||||
val lineColor = MaterialTheme.colorScheme.primary.toArgb()
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
factory = { ctx ->
|
||||
org.osmdroid.config.Configuration.getInstance().apply {
|
||||
userAgentValue = "FitnessDroid"
|
||||
osmdroidBasePath = java.io.File(ctx.cacheDir, "osmdroid")
|
||||
osmdroidTileCache = java.io.File(ctx.cacheDir, "osmdroid/tiles")
|
||||
}
|
||||
org.osmdroid.views.MapView(ctx).apply {
|
||||
setTileSource(org.osmdroid.tileprovider.tilesource.TileSourceFactory.MAPNIK)
|
||||
setMultiTouchControls(true)
|
||||
val line = org.osmdroid.views.overlay.Polyline().apply {
|
||||
outlinePaint.color = lineColor
|
||||
outlinePaint.strokeWidth = 10f
|
||||
setPoints(points.map { (lat, lon) -> org.osmdroid.util.GeoPoint(lat, lon) })
|
||||
}
|
||||
overlays.add(line)
|
||||
post {
|
||||
zoomToBoundingBox(line.bounds.increaseByScale(1.3f), false)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(380.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Stäng") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatDuration(sec: Int): String {
|
||||
val h = sec / 3600
|
||||
val m = (sec % 3600) / 60
|
||||
return if (h > 0) "$h h $m min" else "$m min"
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LogActivityDialog(
|
||||
types: List<ActivityType>,
|
||||
editing: Activity?,
|
||||
onSave: (ActivityType, LocalDateTime, Int, Double?, Double?, Double?, String?) -> Unit,
|
||||
onDelete: (() -> Unit)?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var selectedType by remember {
|
||||
mutableStateOf(
|
||||
editing?.activityType
|
||||
?: types.find { it.key == "walking_city" }
|
||||
?: types.firstOrNull()
|
||||
)
|
||||
}
|
||||
var showTypePicker by remember { mutableStateOf(false) }
|
||||
var date by remember {
|
||||
mutableStateOf(
|
||||
editing?.let { runCatching { java.time.OffsetDateTime.parse(it.startedAt).atZoneSameInstant(ZoneId.systemDefault()).toLocalDate() }.getOrNull() }
|
||||
?: java.time.LocalDate.now()
|
||||
)
|
||||
}
|
||||
var time by remember {
|
||||
mutableStateOf(
|
||||
editing?.let { runCatching { java.time.OffsetDateTime.parse(it.startedAt).atZoneSameInstant(ZoneId.systemDefault()).toLocalTime().withSecond(0).withNano(0) }.getOrNull() }
|
||||
?: java.time.LocalTime.now().minusHours(1).withSecond(0).withNano(0)
|
||||
)
|
||||
}
|
||||
var hours by remember { mutableStateOf(editing?.let { (it.durationSeconds / 3600).takeIf { h -> h > 0 }?.toString() } ?: "") }
|
||||
var minutes by remember { mutableStateOf(editing?.let { ((it.durationSeconds % 3600) / 60).toString() } ?: "") }
|
||||
var distance by remember { mutableStateOf(editing?.distanceMeters?.let { (it / 1000).compact() } ?: "") }
|
||||
var elevation by remember { mutableStateOf(editing?.elevationGainMeters?.let { it.toInt().toString() } ?: "") }
|
||||
var rpe by remember { mutableStateOf(editing?.rpe?.toFloat() ?: 5f) }
|
||||
var notes by remember { mutableStateOf(editing?.notes ?: "") }
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
var showTimePicker by remember { mutableStateOf(false) }
|
||||
|
||||
val durationSeconds = ((hours.toIntOrNull() ?: 0) * 3600) + ((minutes.toIntOrNull() ?: 0) * 60)
|
||||
val canSave = selectedType != null && durationSeconds > 0
|
||||
|
||||
if (showTypePicker) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showTypePicker = false },
|
||||
title = { Text("Välj aktivitet") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
CATEGORY_LABELS.forEach { (cat, label) ->
|
||||
val inCat = types.filter { it.category == cat }
|
||||
if (inCat.isNotEmpty()) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
inCat.forEach { t ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
selectedType = t
|
||||
showTypePicker = false
|
||||
}
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (t.isCardio) {
|
||||
Icon(
|
||||
Icons.Default.Favorite,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
}
|
||||
Text(t.nameSv)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showTypePicker = false }) { Text("Stäng") }
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (showDatePicker) {
|
||||
val dateState = androidx.compose.material3.rememberDatePickerState(
|
||||
initialSelectedDateMillis = date.atStartOfDay(java.time.ZoneOffset.UTC).toInstant().toEpochMilli(),
|
||||
)
|
||||
androidx.compose.material3.DatePickerDialog(
|
||||
onDismissRequest = { showDatePicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
dateState.selectedDateMillis?.let {
|
||||
date = java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneOffset.UTC).toLocalDate()
|
||||
}
|
||||
showDatePicker = false
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { showDatePicker = false }) { Text("Avbryt") } },
|
||||
) { androidx.compose.material3.DatePicker(state = dateState) }
|
||||
}
|
||||
|
||||
if (showTimePicker) {
|
||||
val timeState = androidx.compose.material3.rememberTimePickerState(
|
||||
initialHour = time.hour, initialMinute = time.minute, is24Hour = true,
|
||||
)
|
||||
AlertDialog(
|
||||
onDismissRequest = { showTimePicker = false },
|
||||
title = { Text("Starttid") },
|
||||
text = { androidx.compose.material3.TimePicker(state = timeState) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
time = java.time.LocalTime.of(timeState.hour, timeState.minute)
|
||||
showTimePicker = false
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { showTimePicker = false }) { Text("Avbryt") } },
|
||||
)
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (editing == null) "Logga aktivitet" else "Rätta aktivitet") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
androidx.compose.material3.FilledTonalButton(
|
||||
onClick = { showTypePicker = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (selectedType?.isCardio == true) {
|
||||
Icon(
|
||||
Icons.Default.Favorite,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
)
|
||||
}
|
||||
Text(selectedType?.nameSv ?: "Välj aktivitet…")
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
androidx.compose.material3.OutlinedButton(
|
||||
onClick = { showDatePicker = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("$date") }
|
||||
androidx.compose.material3.OutlinedButton(
|
||||
onClick = { showTimePicker = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("%02d:%02d".format(time.hour, time.minute)) }
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = hours,
|
||||
onValueChange = { hours = it },
|
||||
label = { Text("Timmar") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = minutes,
|
||||
onValueChange = { minutes = it },
|
||||
label = { Text("Minuter") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedType?.isDistanceBased == true) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = distance,
|
||||
onValueChange = { distance = it },
|
||||
label = { Text("Distans (km)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = elevation,
|
||||
onValueChange = { elevation = it },
|
||||
label = { Text("Höjdmeter") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"Ansträngning: ${RPE_LABELS[rpe.toInt().coerceIn(0, 10)]}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Slider(
|
||||
value = rpe,
|
||||
onValueChange = { rpe = it },
|
||||
valueRange = 0f..10f,
|
||||
steps = 9,
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
label = { Text("Anteckning (valfritt)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
onDelete?.let {
|
||||
TextButton(onClick = it) {
|
||||
Text("Ta bort aktiviteten", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val type = selectedType ?: return@Button
|
||||
onSave(
|
||||
type,
|
||||
LocalDateTime.of(date, time),
|
||||
durationSeconds,
|
||||
if (type.isDistanceBased) distance.trim().replace(',', '.').toDoubleOrNull() else null,
|
||||
if (type.isDistanceBased) elevation.trim().toDoubleOrNull() else null,
|
||||
if (!type.isDistanceBased) rpe.toInt().toDouble() else null,
|
||||
notes.trim().ifBlank { null },
|
||||
)
|
||||
},
|
||||
enabled = canSave,
|
||||
) { Text("Spara") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Avbryt") }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package eu.brassepc.fitnessdroid.ui.common
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
|
||||
/**
|
||||
* Öppna systemets inställningssida för appen — snabbvägen att slå på
|
||||
* behörigheter (plats, Bluetooth, notiser) som nekats eller aldrig frågats.
|
||||
*/
|
||||
fun Context.openAppSettings() {
|
||||
runCatching {
|
||||
startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", packageName, null),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package eu.brassepc.fitnessdroid.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** En rad i kaloriuträkningen: en övning med MET, tilldelad tid och kcal. */
|
||||
data class CalorieRow(
|
||||
val name: String,
|
||||
val metValue: Double?,
|
||||
val seconds: Int,
|
||||
val kcal: Double?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Klient-side-uppskattning som speglar serverns ComputeCalories:
|
||||
* passets tid fördelas jämnt över övningarna (tidssatta set utgör golv
|
||||
* för sin övning) och per övning gäller kcal = MET × kroppsvikt × timmar.
|
||||
*/
|
||||
fun estimateCalories(
|
||||
exercises: List<Triple<String, Double, Int>>, // (namn, MET, golv-sekunder från tidssatta set)
|
||||
bodyWeightKg: Double?,
|
||||
totalSeconds: Int,
|
||||
): Pair<Double?, List<CalorieRow>> {
|
||||
if (exercises.isEmpty()) return null to emptyList()
|
||||
val floorsSum = exercises.sumOf { it.third }
|
||||
val leftover = (totalSeconds - floorsSum).coerceAtLeast(0)
|
||||
val perAuto = leftover / exercises.size.toDouble()
|
||||
val rows = exercises.map { (name, met, floor) ->
|
||||
val sec = (floor + perAuto).toInt()
|
||||
CalorieRow(
|
||||
name = name,
|
||||
metValue = met,
|
||||
seconds = sec,
|
||||
kcal = bodyWeightKg?.let { met * it * (sec / 3600.0) },
|
||||
)
|
||||
}
|
||||
val total = if (bodyWeightKg == null) null else rows.sumOf { it.kcal ?: 0.0 }
|
||||
return total to rows
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CalorieInfoDialog(
|
||||
rows: List<CalorieRow>,
|
||||
bodyWeightKg: Double?,
|
||||
totalKcal: Double?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Så räknas kalorierna") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
"Per övning: kcal = MET × kroppsvikt (kg) × tid (timmar). " +
|
||||
"Passets tid fördelas jämnt över övningarna, men set med " +
|
||||
"uppmätt tid räknas alltid in i sin övning.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (bodyWeightKg == null) {
|
||||
Text(
|
||||
"Ingen kroppsvikt satt i profilen — därför kan inga " +
|
||||
"kalorier beräknas. Sätt den på webben under Profil.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Din kroppsvikt: ${bodyWeightKg.compact()} kg",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
rows.forEach { row ->
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(row.name, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
buildString {
|
||||
append("MET ${row.metValue?.compact() ?: "?"}")
|
||||
append(" · ${row.seconds / 60} min")
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
row.kcal?.let { "${it.toInt()} kcal" } ?: "—",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (totalKcal != null) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) {
|
||||
Text(
|
||||
"Totalt",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text("${totalKcal.toInt()} kcal", style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Stäng") }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package eu.brassepc.fitnessdroid.ui.common
|
||||
|
||||
import eu.brassepc.fitnessdroid.data.HistorySet
|
||||
|
||||
fun Double.compact(): String =
|
||||
if (this % 1.0 == 0.0) toInt().toString() else "%.1f".format(this)
|
||||
|
||||
/** "80 kg × 8 · RPE 8" — samma format överallt där set visas. */
|
||||
fun HistorySet.describe(): String = buildString {
|
||||
weight?.let { append("${it.compact()} kg") }
|
||||
reps?.let {
|
||||
if (isNotEmpty()) append(" × $it") else append("$it reps")
|
||||
}
|
||||
durationSeconds?.let { if (isNotEmpty()) append(" · ${it}s") else append("${it}s") }
|
||||
distanceMeters?.let { if (isNotEmpty()) append(" · ${it.compact()} m") else append("${it.compact()} m") }
|
||||
rpe?.let { append(" · RPE ${it.compact()}") }
|
||||
if (isWarmup) append(" · uppvärmning")
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package eu.brassepc.fitnessdroid.ui.common
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.LiftDetail
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Rekordstatus på ett lyft: träning (standard), tävling eller räknas ej, med
|
||||
* kommentar. Öppnas från PB-listan (Statistik), passhistoriken och det aktiva
|
||||
* passet. Visar detaljer om lyftet och en topplista ("näst i tur") för samma
|
||||
* övning × reps där man kan välja ett annat lyft att ändra.
|
||||
*/
|
||||
sealed interface LiftStatusTarget {
|
||||
val exerciseTypeId: Int
|
||||
val exerciseName: String
|
||||
val reps: Int?
|
||||
|
||||
data class Lift(
|
||||
override val exerciseTypeId: Int,
|
||||
override val exerciseName: String,
|
||||
override val reps: Int,
|
||||
val liftId: Int,
|
||||
) : LiftStatusTarget
|
||||
|
||||
data class Set(
|
||||
val setId: Int,
|
||||
override val exerciseTypeId: Int,
|
||||
override val exerciseName: String,
|
||||
override val reps: Int?,
|
||||
val summary: String,
|
||||
val status: String,
|
||||
val statusNote: String?,
|
||||
) : LiftStatusTarget
|
||||
}
|
||||
|
||||
val LIFT_STATUS_OPTIONS = listOf(
|
||||
"training" to "Träning",
|
||||
"competition" to "Tävling",
|
||||
"excluded" to "Räknas ej",
|
||||
)
|
||||
|
||||
fun liftStatusLabel(status: String): String =
|
||||
LIFT_STATUS_OPTIONS.firstOrNull { it.first == status }?.second ?: status
|
||||
|
||||
fun liftStatusIcon(status: String): String = when (status) {
|
||||
"competition" -> "🎖️"
|
||||
"excluded" -> "⛔"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun statusHelp(status: String) = when (status) {
|
||||
"competition" -> "Tävlingslyft. Får egen rekordlista och räknas även bland alla giltiga."
|
||||
"excluded" -> "Fusk, felregistrerat eller ej godkänt. Tas ur rekord, 1RM och PB-höjdpunkter – nästa lyft i topplistan blir rekord."
|
||||
else -> "Vanligt träningslyft. Räknas i alla rekordlistor utom Tävling."
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LiftStatusSheet(
|
||||
target: LiftStatusTarget,
|
||||
repo: GymRepository,
|
||||
mode: String = "all",
|
||||
onDismiss: () -> Unit,
|
||||
onChanged: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var candidates by remember { mutableStateOf<List<LiftDetail>>(emptyList()) }
|
||||
var focus by remember { mutableStateOf<LiftDetail?>(null) }
|
||||
var status by remember { mutableStateOf((target as? LiftStatusTarget.Set)?.status ?: "training") }
|
||||
var note by remember { mutableStateOf((target as? LiftStatusTarget.Set)?.statusNote ?: "") }
|
||||
|
||||
suspend fun load(keepId: Int? = null) {
|
||||
loading = true
|
||||
error = null
|
||||
try {
|
||||
val reps = target.reps
|
||||
candidates = if (reps != null) repo.pbCandidates(target.exerciseTypeId, reps, mode) else emptyList()
|
||||
focus = when (target) {
|
||||
is LiftStatusTarget.Lift ->
|
||||
candidates.firstOrNull { it.id == (keepId ?: target.liftId) } ?: repo.liftDetail(target.liftId)
|
||||
is LiftStatusTarget.Set ->
|
||||
candidates.firstOrNull { if (keepId != null) it.id == keepId else it.setId == target.setId }
|
||||
}
|
||||
val cur = focus
|
||||
status = cur?.status ?: (target as? LiftStatusTarget.Set)?.status ?: "training"
|
||||
note = cur?.statusNote ?: (target as? LiftStatusTarget.Set)?.statusNote ?: ""
|
||||
} catch (e: Exception) {
|
||||
error = "Kunde inte hämta lyftet — offline?"
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(target) { load() }
|
||||
|
||||
val current = focus
|
||||
val dirty = status != (current?.status ?: (target as? LiftStatusTarget.Set)?.status ?: "training") ||
|
||||
note.trim() != (current?.statusNote ?: (target as? LiftStatusTarget.Set)?.statusNote ?: "")
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
val repsLabel = (current?.reps ?: target.reps)?.let { " · $it rep${if (it > 1) "s" else ""}" } ?: ""
|
||||
Text("🏆 ${target.exerciseName}$repsLabel", style = MaterialTheme.typography.titleLarge)
|
||||
|
||||
if (loading) {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
|
||||
// ── Fokuserat lyft / set ──
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (current != null) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
"${current.weight.compact()} kg × ${current.reps}",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (current.est1Rm > 0) {
|
||||
Text("est. 1RM ${current.est1Rm.compact()} kg", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
buildString {
|
||||
append(current.date)
|
||||
current.sessionName?.let { append(" · pass \"$it\"") } ?: append(" · löst lyft")
|
||||
if (current.isCurrentRecord) append(" · 🏆 gällande rekord")
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
current.notes?.takeIf { it.isNotBlank() }?.let {
|
||||
Text("📝 $it", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
} else if (target is LiftStatusTarget.Set) {
|
||||
Text(target.summary, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
|
||||
Text(
|
||||
"Statusen sätts på setet och följer med lyftet när passet avslutas.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status ──
|
||||
Text("Status", style = MaterialTheme.typography.labelLarge)
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
LIFT_STATUS_OPTIONS.forEachIndexed { i, (key, label) ->
|
||||
SegmentedButton(
|
||||
selected = status == key,
|
||||
onClick = { status = key },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = i, count = LIFT_STATUS_OPTIONS.size),
|
||||
) { Text(label) }
|
||||
}
|
||||
}
|
||||
Text(statusHelp(status), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
OutlinedTextField(
|
||||
value = note,
|
||||
onValueChange = { if (it.length <= 300) note = it },
|
||||
label = { Text("Kommentar (valfri)") },
|
||||
placeholder = { Text(if (status == "excluded") "T.ex. ej godkänt djup, felregistrerat…" else "T.ex. SM 2026, PB-försök…") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
saving = true
|
||||
try {
|
||||
val n = note.trim().ifBlank { null }
|
||||
val f = focus
|
||||
if (f != null) repo.setLiftStatus(f.id, status, n)
|
||||
else if (target is LiftStatusTarget.Set) repo.setSessionSetStatus(target.setId, status, n)
|
||||
onChanged()
|
||||
load(keepId = f?.id)
|
||||
} catch (e: Exception) {
|
||||
error = "Kunde inte spara — offline?"
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = dirty && !saving,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(if (saving) "Sparar…" else "Spara") }
|
||||
|
||||
// ── Topplista ──
|
||||
if (candidates.isNotEmpty()) {
|
||||
Text(
|
||||
"Topplista · ${target.reps} reps — tryck för att välja ett annat lyft",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
candidates.forEach { c ->
|
||||
val selected = c.id == current?.id
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
focus = c
|
||||
status = c.status
|
||||
note = c.statusNote ?: ""
|
||||
},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
"${c.weight.compact()} kg",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (c.status == "excluded") MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(c.date, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
buildString {
|
||||
if (c.isCurrentRecord) append("🏆 ")
|
||||
append(liftStatusIcon(c.status)).append(" ").append(liftStatusLabel(c.status))
|
||||
c.statusNote?.let { append(" · $it") }
|
||||
}.trim(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package eu.brassepc.fitnessdroid.ui.common
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.DirectionsRun
|
||||
import androidx.compose.material.icons.filled.Accessibility
|
||||
import androidx.compose.material.icons.filled.AirlineSeatLegroomExtra
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.FitnessCenter
|
||||
import androidx.compose.material.icons.filled.FrontHand
|
||||
import androidx.compose.material.icons.filled.Grid4x4
|
||||
import androidx.compose.material.icons.filled.SportsGymnastics
|
||||
import androidx.compose.material.icons.filled.SportsMartialArts
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/**
|
||||
* Ikon per muskelgrupp. I första hand via serverns iconKey
|
||||
* (API-tillägget MuscleGroup.iconKey), i andra hand namnheuristik.
|
||||
*/
|
||||
object MuscleIcons {
|
||||
|
||||
fun forKey(iconKey: String?): ImageVector? = when (iconKey?.lowercase()) {
|
||||
"chest" -> Icons.Default.FitnessCenter
|
||||
"back" -> Icons.Default.SportsGymnastics
|
||||
"legs" -> Icons.Default.AirlineSeatLegroomExtra
|
||||
"shoulders" -> Icons.Default.SportsMartialArts
|
||||
"arms" -> Icons.Default.FrontHand
|
||||
"core" -> Icons.Default.Grid4x4
|
||||
"cardio" -> Icons.AutoMirrored.Filled.DirectionsRun
|
||||
"fullbody" -> Icons.Default.Accessibility
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun forGroupName(name: String?): ImageVector {
|
||||
val n = name?.lowercase().orEmpty()
|
||||
return when {
|
||||
"bröst" in n || "chest" in n -> Icons.Default.FitnessCenter
|
||||
"rygg" in n || "back" in n || "lat" in n -> Icons.Default.SportsGymnastics
|
||||
"ben" in n || "leg" in n || "quad" in n || "vad" in n -> Icons.Default.AirlineSeatLegroomExtra
|
||||
"axl" in n || "shoulder" in n || "delt" in n -> Icons.Default.SportsMartialArts
|
||||
"arm" in n || "bicep" in n || "tricep" in n -> Icons.Default.FrontHand
|
||||
"mage" in n || "core" in n || "abs" in n || "bål" in n -> Icons.Default.Grid4x4
|
||||
"kondition" in n || "cardio" in n || "löp" in n -> Icons.AutoMirrored.Filled.DirectionsRun
|
||||
"hel" in n || "full" in n -> Icons.Default.Accessibility
|
||||
else -> Icons.Default.FavoriteBorder
|
||||
}
|
||||
}
|
||||
|
||||
fun resolve(iconKey: String?, groupName: String?): ImageVector =
|
||||
forKey(iconKey) ?: forGroupName(groupName)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package eu.brassepc.fitnessdroid.ui.goals
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Flag
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.GoalProgress
|
||||
import eu.brassepc.fitnessdroid.data.GymApi
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import eu.brassepc.fitnessdroid.ui.common.compact
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
val GOAL_METRIC_LABELS = linkedMapOf(
|
||||
"KCAL" to "Aktiva kalorier",
|
||||
"GYM_SESSIONS" to "Gympass",
|
||||
"ACTIVITIES" to "Aktiviteter",
|
||||
"DISTANCE_KM" to "Distans (km)",
|
||||
"STEPS" to "Steg",
|
||||
)
|
||||
|
||||
val GOAL_PERIOD_LABELS = linkedMapOf(
|
||||
"DAY" to "Per dag",
|
||||
"WEEK" to "Per vecka",
|
||||
"MONTH" to "Per månad",
|
||||
)
|
||||
|
||||
fun goalValueText(value: Double, metric: String): String =
|
||||
if (metric == "DISTANCE_KM") "${value.compact()} km" else "${value.toInt()}"
|
||||
|
||||
class GoalsViewModel(private val gymApi: GymApi) : ViewModel() {
|
||||
val goals = MutableStateFlow<List<GoalProgress>>(emptyList())
|
||||
val message = MutableStateFlow<String?>(null)
|
||||
|
||||
init { load() }
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val zone = ZoneId.systemDefault()
|
||||
val today = LocalDate.now()
|
||||
goals.value = gymApi.goalsWithProgress(
|
||||
today.atStartOfDay(zone).toInstant().toString(),
|
||||
today.with(DayOfWeek.MONDAY).atStartOfDay(zone).toInstant().toString(),
|
||||
today.withDayOfMonth(1).atStartOfDay(zone).toInstant().toString(),
|
||||
)
|
||||
}.onFailure { message.value = "Kunde inte hämta målen — offline?" }
|
||||
}
|
||||
}
|
||||
|
||||
fun setGoal(metric: String, period: String, target: Double) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
gymApi.setGoal(metric, period, target)
|
||||
message.value = if (target <= 0) "Målet borttaget" else "Målet sparat"
|
||||
load()
|
||||
} catch (e: Exception) {
|
||||
message.value = "Kunde inte spara — offline?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer { GoalsViewModel(appContainer().gymApi) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GoalsScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: GoalsViewModel = viewModel(factory = GoalsViewModel.Factory),
|
||||
) {
|
||||
val goals by viewModel.goals.collectAsStateWithLifecycle()
|
||||
val message by viewModel.message.collectAsStateWithLifecycle()
|
||||
|
||||
var metric by remember { mutableStateOf("KCAL") }
|
||||
var period by remember { mutableStateOf("DAY") }
|
||||
var target by remember { mutableStateOf("") }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Flag,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text("Mål", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
message?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (goals.isNotEmpty()) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
goals.forEach { g ->
|
||||
GoalRow(g, onRemove = { viewModel.setGoal(g.metric, g.period, 0.0) })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"Inga mål satta än. Sätt t.ex. 10 000 steg per dag eller 3 gympass per vecka.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Sätt mål", style = MaterialTheme.typography.titleSmall)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
GOAL_METRIC_LABELS.forEach { (key, label) ->
|
||||
if (key in listOf("KCAL", "GYM_SESSIONS")) {
|
||||
FilterChip(
|
||||
selected = metric == key,
|
||||
onClick = { metric = key },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
GOAL_METRIC_LABELS.forEach { (key, label) ->
|
||||
if (key in listOf("ACTIVITIES", "DISTANCE_KM", "STEPS")) {
|
||||
FilterChip(
|
||||
selected = metric == key,
|
||||
onClick = { metric = key },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (metric == "STEPS") {
|
||||
Text(
|
||||
"Stegdata kommer i en senare version (Health Connect) — målet " +
|
||||
"kan sättas redan nu men står på 0 tills dess.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
GOAL_PERIOD_LABELS.forEach { (key, label) ->
|
||||
FilterChip(
|
||||
selected = period == key,
|
||||
onClick = { period = key },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = target,
|
||||
onValueChange = { target = it },
|
||||
label = { Text("Målvärde") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
target.trim().replace(',', '.').toDoubleOrNull()?.let {
|
||||
viewModel.setGoal(metric, period, it)
|
||||
target = ""
|
||||
}
|
||||
},
|
||||
enabled = target.trim().replace(',', '.').toDoubleOrNull()?.let { it > 0 } == true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Spara målet") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GoalRow(g: GoalProgress, onRemove: (() -> Unit)? = null) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${GOAL_METRIC_LABELS[g.metric] ?: g.metric} · ${
|
||||
(GOAL_PERIOD_LABELS[g.period] ?: g.period).lowercase()
|
||||
}",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"${goalValueText(g.currentValue, g.metric)} / ${goalValueText(g.targetValue, g.metric)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (g.currentValue >= g.targetValue) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
onRemove?.let {
|
||||
IconButton(onClick = it) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Ta bort mål",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { (g.currentValue / g.targetValue).toFloat().coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package eu.brassepc.fitnessdroid.ui.history
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.SessionDetail
|
||||
import eu.brassepc.fitnessdroid.data.SessionStats
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import eu.brassepc.fitnessdroid.ui.common.CalorieInfoDialog
|
||||
import eu.brassepc.fitnessdroid.ui.common.CalorieRow
|
||||
import eu.brassepc.fitnessdroid.ui.common.LiftStatusSheet
|
||||
import eu.brassepc.fitnessdroid.ui.common.LiftStatusTarget
|
||||
import eu.brassepc.fitnessdroid.ui.common.describe
|
||||
import eu.brassepc.fitnessdroid.ui.common.liftStatusIcon
|
||||
import eu.brassepc.fitnessdroid.ui.common.liftStatusLabel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface DetailUiState {
|
||||
data object Loading : DetailUiState
|
||||
data class Error(val message: String) : DetailUiState
|
||||
data class Ready(
|
||||
val detail: SessionDetail,
|
||||
val stats: SessionStats?,
|
||||
val bodyWeightKg: Double?,
|
||||
) : DetailUiState
|
||||
}
|
||||
|
||||
class HistoryDetailViewModel(val repo: GymRepository) : ViewModel() {
|
||||
val uiState = MutableStateFlow<DetailUiState>(DetailUiState.Loading)
|
||||
private var loadedId: Int? = null
|
||||
|
||||
/** Hämta om passet (efter t.ex. ändrad rekordstatus). */
|
||||
fun reload() {
|
||||
val id = loadedId ?: return
|
||||
loadedId = null
|
||||
load(id)
|
||||
}
|
||||
|
||||
fun load(id: Int) {
|
||||
if (loadedId == id && uiState.value is DetailUiState.Ready) return
|
||||
loadedId = id
|
||||
uiState.value = DetailUiState.Loading
|
||||
viewModelScope.launch {
|
||||
uiState.value = try {
|
||||
DetailUiState.Ready(
|
||||
detail = repo.fetchSessionDetail(id),
|
||||
stats = runCatching { repo.fetchSessionStats(id) }.getOrNull(),
|
||||
bodyWeightKg = repo.bodyWeightKg(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
DetailUiState.Error("Kunde inte hämta passet — offline?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer { HistoryDetailViewModel(appContainer().gymRepository) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HistoryDetailScreen(
|
||||
sessionId: Int,
|
||||
onBack: () -> Unit,
|
||||
viewModel: HistoryDetailViewModel = viewModel(factory = HistoryDetailViewModel.Factory),
|
||||
) {
|
||||
LaunchedEffect(sessionId) { viewModel.load(sessionId) }
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text((uiState as? DetailUiState.Ready)?.detail?.name ?: "Pass")
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
when (val state = uiState) {
|
||||
is DetailUiState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
}
|
||||
is DetailUiState.Error -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(state.message, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
is DetailUiState.Ready -> {
|
||||
val d = state.detail
|
||||
val stats = state.stats
|
||||
var showCalorieInfo by remember { mutableStateOf(false) }
|
||||
var statusSheet by remember { mutableStateOf<LiftStatusTarget?>(null) }
|
||||
|
||||
statusSheet?.let { target ->
|
||||
LiftStatusSheet(
|
||||
target = target,
|
||||
repo = viewModel.repo,
|
||||
onDismiss = { statusSheet = null },
|
||||
onChanged = { viewModel.reload() },
|
||||
)
|
||||
}
|
||||
|
||||
if (showCalorieInfo && stats != null) {
|
||||
CalorieInfoDialog(
|
||||
rows = stats.breakdown.map {
|
||||
CalorieRow(
|
||||
name = it.name,
|
||||
metValue = it.metValue,
|
||||
seconds = it.allocatedSeconds ?: 0,
|
||||
kcal = it.estimatedCalories,
|
||||
)
|
||||
},
|
||||
bodyWeightKg = state.bodyWeightKg,
|
||||
totalKcal = stats.estimatedCalories,
|
||||
onDismiss = { showCalorieInfo = false },
|
||||
)
|
||||
}
|
||||
|
||||
val kcalByName = remember(stats) {
|
||||
stats?.breakdown?.groupBy { it.name }?.mapValues { (_, v) ->
|
||||
ArrayDeque(v)
|
||||
} ?: emptyMap()
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Text(
|
||||
buildString {
|
||||
append(d.date.take(10))
|
||||
(d.durationMinutes ?: stats?.durationMinutes)?.let {
|
||||
append(" · ")
|
||||
append(if (it >= 60) "${it / 60}h ${it % 60}m" else "$it min")
|
||||
}
|
||||
if (stats != null) {
|
||||
append(" · ${stats.totalSets} set · ${stats.totalVolumeKg.toInt()} kg volym")
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
(stats?.estimatedCalories ?: d.calories)
|
||||
?.let { "${it.toInt()} kcal" }
|
||||
?: "Inga kalorier (kroppsvikt saknas)",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (stats != null) {
|
||||
TextButton(onClick = { showCalorieInfo = true }) {
|
||||
Text("Hur räknas detta?")
|
||||
}
|
||||
}
|
||||
}
|
||||
d.notes?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items(d.exercises) { exercise ->
|
||||
val exerciseKcal = kcalByName[exercise.name]?.removeFirstOrNull()
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row {
|
||||
Text(
|
||||
exercise.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
exerciseKcal?.estimatedCalories?.let {
|
||||
Text(
|
||||
"${it.toInt()} kcal",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
exercise.sets.forEach { set ->
|
||||
// Tryck på ett set för rekordstatus (träning/tävling/räknas ej).
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = set.id > 0) {
|
||||
statusSheet = LiftStatusTarget.Set(
|
||||
setId = set.id,
|
||||
exerciseTypeId = exercise.exerciseTypeId,
|
||||
exerciseName = exercise.name,
|
||||
reps = set.reps,
|
||||
summary = set.describe(),
|
||||
status = set.status,
|
||||
statusNote = set.statusNote,
|
||||
)
|
||||
}
|
||||
.padding(vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
"Set ${set.order}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 10.dp),
|
||||
)
|
||||
Text(
|
||||
set.describe(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (set.status != "training") {
|
||||
Text(
|
||||
"${liftStatusIcon(set.status)} ${liftStatusLabel(set.status)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (set.status == "excluded") MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (exercise.sets.isEmpty()) {
|
||||
Text(
|
||||
"Inga set loggade",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package eu.brassepc.fitnessdroid.ui.history
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.HistoryItem
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface HistoryUiState {
|
||||
data object Loading : HistoryUiState
|
||||
data class Error(val message: String) : HistoryUiState
|
||||
data class Ready(val items: List<HistoryItem>) : HistoryUiState
|
||||
}
|
||||
|
||||
class HistoryViewModel(private val repo: GymRepository) : ViewModel() {
|
||||
val uiState = MutableStateFlow<HistoryUiState>(HistoryUiState.Loading)
|
||||
|
||||
init { load() }
|
||||
|
||||
fun load() {
|
||||
uiState.value = HistoryUiState.Loading
|
||||
viewModelScope.launch {
|
||||
uiState.value = try {
|
||||
HistoryUiState.Ready(repo.fetchHistory())
|
||||
} catch (e: Exception) {
|
||||
HistoryUiState.Error("Kunde inte hämta historiken — offline?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer { HistoryViewModel(appContainer().gymRepository) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HistoryScreen(
|
||||
onOpenSession: (Int) -> Unit,
|
||||
viewModel: HistoryViewModel = viewModel(factory = HistoryViewModel.Factory),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
when (val state = uiState) {
|
||||
is HistoryUiState.Loading -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is HistoryUiState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(state.message, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Button(onClick = viewModel::load, modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text("Försök igen")
|
||||
}
|
||||
}
|
||||
}
|
||||
is HistoryUiState.Ready -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
Text("Passhistorik", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
items(state.items, key = { it.id }) { item ->
|
||||
Card(
|
||||
onClick = { onOpenSession(item.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row {
|
||||
Text(
|
||||
item.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
item.date.take(10),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
buildString {
|
||||
item.durationMinutes?.let {
|
||||
append(if (it >= 60) "${it / 60}h ${it % 60}m" else "$it min")
|
||||
append(" · ")
|
||||
}
|
||||
append("${item.exerciseCount} övningar · ${item.setCount} set")
|
||||
if (item.volumeKg > 0) append(" · ${item.volumeKg.toInt()} kg volym")
|
||||
item.calories?.let { append(" · ${it.toInt()} kcal") }
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.items.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Inga avslutade pass än — dags att ändra på det!",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,35 @@
|
||||
package eu.brassepc.fitnessdroid.ui.home
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material.icons.filled.CloudDone
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FitnessCenter
|
||||
import androidx.compose.material.icons.filled.Flag
|
||||
import androidx.compose.material.icons.filled.LocalFireDepartment
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.SystemUpdate
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -26,95 +38,392 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import eu.brassepc.fitnessdroid.data.SyncStatus
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory)) {
|
||||
fun HomeScreen(
|
||||
onOpenSession: () -> Unit,
|
||||
onOpenHistory: () -> Unit,
|
||||
onOpenActivities: () -> Unit = {},
|
||||
onOpenTrack: (Int) -> Unit = {},
|
||||
onOpenGoals: () -> Unit = {},
|
||||
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val updateState by viewModel.updateState.collectAsStateWithLifecycle()
|
||||
val displayName by viewModel.displayName.collectAsStateWithLifecycle()
|
||||
val kcalToday by viewModel.kcalToday.collectAsStateWithLifecycle()
|
||||
val goals by viewModel.goals.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("FitnessDroid") },
|
||||
actions = {
|
||||
IconButton(onClick = viewModel::logout) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = "Logga ut",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
when (val state = uiState) {
|
||||
is HomeUiState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
// ViewModellen överlever i navigationsstacken — hämta om dagens kcal
|
||||
// varje gång skärmen kommer tillbaka (t.ex. efter loggad aktivitet).
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.refresh() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Tjena ${displayName ?: uiState.username}",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Text(
|
||||
"Redo för ett pass?",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
is HomeUiState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(state.message, color = MaterialTheme.colorScheme.error)
|
||||
Button(onClick = viewModel::load, modifier = Modifier.padding(top = 16.dp)) {
|
||||
Text("Försök igen")
|
||||
}
|
||||
}
|
||||
}
|
||||
is HomeUiState.Ready -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
"Hej ${state.profile.displayName ?: state.profile.username}!",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
SyncChip(status = uiState.syncStatus, pending = uiState.pendingCount)
|
||||
}
|
||||
|
||||
KcalTodayCard(kcalToday)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpenGoals),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Flag,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Profil", style = MaterialTheme.typography.titleMedium)
|
||||
ProfileRow("Användarnamn", state.profile.username)
|
||||
state.profile.email?.let { ProfileRow("E-post", it) }
|
||||
state.profile.bodyWeightKg?.let { ProfileRow("Kroppsvikt", "$it kg") }
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Pass, loggning, statistik och PB kommer i nästa steg.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
"Mål",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f).padding(start = 8.dp),
|
||||
)
|
||||
Text(
|
||||
if (goals.isEmpty()) "Sätt mål →" else "Ändra →",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
goals.forEach { g -> eu.brassepc.fitnessdroid.ui.goals.GoalRow(g) }
|
||||
if (goals.isEmpty()) {
|
||||
Text(
|
||||
"Kalorier, pass, aktiviteter eller km — per dag, vecka eller månad.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val trackingState by eu.brassepc.fitnessdroid.data.TrackingService.state.collectAsStateWithLifecycle()
|
||||
if (trackingState.isActive) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenTrack(trackingState.typeId) },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Favorite,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
|
||||
Text(
|
||||
"${trackingState.typeName} pågår",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
buildString {
|
||||
val s = trackingState.elapsedSeconds
|
||||
append("%d:%02d".format(s / 60, s % 60))
|
||||
if (trackingState.isDistanceBased) {
|
||||
append(" · ${"%.2f".format(trackingState.distanceMeters / 1000)} km")
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateState?.let { update ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.SystemUpdate,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
|
||||
when (update) {
|
||||
is UpdateState.Available -> {
|
||||
Text(
|
||||
"Ny version: ${update.info.versionName}",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
"Laddas ner från släppsidan och installeras ovanpå",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
is UpdateState.Downloading -> {
|
||||
Text("Laddar ner …", style = MaterialTheme.typography.titleSmall)
|
||||
androidx.compose.material3.LinearProgressIndicator(
|
||||
progress = { update.progress },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
is UpdateState.Failed -> {
|
||||
Text(
|
||||
update.message,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (update !is UpdateState.Downloading) {
|
||||
Button(onClick = viewModel::startUpdate) {
|
||||
Text(if (update is UpdateState.Failed) "Igen" else "Uppdatera")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.hasActiveSession) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpenSession),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
"Pass pågår — tryck för att fortsätta",
|
||||
modifier = Modifier.padding(start = 10.dp),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.startFree(onOpenSession) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
Text("Starta fritt pass", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.startCards.isNotEmpty()) {
|
||||
Text("Favoritpass & mallar", style = MaterialTheme.typography.titleMedium)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(uiState.startCards, key = { it.key }) { card ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.width(170.dp)
|
||||
.clickable(enabled = !uiState.hasActiveSession) {
|
||||
viewModel.startFromCard(card, onOpenSession)
|
||||
},
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
card.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
card.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Text(
|
||||
if (uiState.hasActiveSession) "Pass pågår" else "▶ Starta",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpenActivities),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Favorite,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
|
||||
Text("Aktiviteter", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"Promenad, löpning, fäktning — logga och se historik",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AssistChip(
|
||||
onClick = onOpenHistory,
|
||||
label = { Text("Passhistorik") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dagens kaloriförbränning — aktivt loggat / telefon / passivt (BMR). */
|
||||
@Composable
|
||||
private fun KcalTodayCard(kcal: KcalToday) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.LocalFireDepartment,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
"Dagens kalorier",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f).padding(start = 8.dp),
|
||||
)
|
||||
Text(
|
||||
"${kcal.totalKcal.toInt()} kcal",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
KcalRow(
|
||||
icon = Icons.Default.FitnessCenter,
|
||||
label = "Aktivt loggat",
|
||||
value = "${kcal.activeKcal.toInt()} kcal",
|
||||
)
|
||||
KcalRow(
|
||||
icon = Icons.Default.PhoneAndroid,
|
||||
label = "Telefonen (steg)",
|
||||
value = when {
|
||||
kcal.phoneKcal != null ->
|
||||
"${kcal.phoneKcal.toInt()} kcal" +
|
||||
(kcal.steps?.let { " · $it steg" } ?: "")
|
||||
kcal.steps != null -> "${kcal.steps} steg"
|
||||
else -> "aktivera i inställningarna"
|
||||
},
|
||||
dimValue = kcal.phoneKcal == null && kcal.steps == null,
|
||||
)
|
||||
KcalRow(
|
||||
icon = Icons.Default.Bedtime,
|
||||
label = "Passivt (BMR)",
|
||||
value = kcal.passiveKcalSoFar?.let {
|
||||
"${it.toInt()} kcal · ${kcal.bmrPerDay?.toInt()} vid midnatt"
|
||||
} ?: "—",
|
||||
)
|
||||
|
||||
if (!kcal.hasBodyData) {
|
||||
Text(
|
||||
"Fyll i vikt + kroppsdata (längd/födelseår/kön) i profilen " +
|
||||
"så kan den passiva förbränningen räknas ut.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
private fun KcalRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
value: String,
|
||||
dimValue: Boolean = false,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (dimValue) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncChip(status: SyncStatus, pending: Int) {
|
||||
val (icon, text) = when {
|
||||
status == SyncStatus.OFFLINE -> Icons.Default.CloudOff to "Offline · $pending väntar"
|
||||
status == SyncStatus.ERROR -> Icons.Default.CloudOff to "Synkfel"
|
||||
pending > 0 -> Icons.Default.CloudUpload to "$pending osynkat"
|
||||
else -> Icons.Default.CloudDone to "Synkat"
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = text,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,54 +6,214 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.AuthRepository
|
||||
import eu.brassepc.fitnessdroid.data.GraphQlException
|
||||
import eu.brassepc.fitnessdroid.data.GymApi
|
||||
import eu.brassepc.fitnessdroid.data.Profile
|
||||
import eu.brassepc.fitnessdroid.data.AuthState
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.SyncStatus
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedStartCard
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface HomeUiState {
|
||||
data object Loading : HomeUiState
|
||||
data class Error(val message: String) : HomeUiState
|
||||
data class Ready(val profile: Profile) : HomeUiState
|
||||
data class HomeUiState(
|
||||
val username: String = "",
|
||||
val hasActiveSession: Boolean = false,
|
||||
val startCards: List<CachedStartCard> = emptyList(),
|
||||
val syncStatus: SyncStatus = SyncStatus.SYNCED,
|
||||
val pendingCount: Int = 0,
|
||||
)
|
||||
|
||||
sealed interface UpdateState {
|
||||
data class Available(val info: eu.brassepc.fitnessdroid.data.UpdateInfo) : UpdateState
|
||||
data class Downloading(val progress: Float) : UpdateState
|
||||
data class Failed(val message: String) : UpdateState
|
||||
}
|
||||
|
||||
/**
|
||||
* Dagens kaloriförbränning, tredelad. Telefondelen (steg/auto-aktiviteter via
|
||||
* Health Connect) kommer i en senare etapp och är null tills dess.
|
||||
*/
|
||||
data class KcalToday(
|
||||
/** Aktivt loggat: gympass (och framöver aktiviteter) */
|
||||
val activeKcal: Double = 0.0,
|
||||
/** Passivt: BMR-andel av dygnet som gått */
|
||||
val passiveKcalSoFar: Double? = null,
|
||||
/** BMR för hela dygnet (för "i mål vid midnatt"-visning) */
|
||||
val bmrPerDay: Double? = null,
|
||||
/** Telefonens auto-loggade (steg) — via Health Connect */
|
||||
val phoneKcal: Double? = null,
|
||||
val steps: Int? = null,
|
||||
/** false = längd/födelseår/kön saknas, mätaren kan inte räkna passivt */
|
||||
val hasBodyData: Boolean = false,
|
||||
) {
|
||||
val totalKcal: Double get() = activeKcal + (passiveKcalSoFar ?: 0.0) + (phoneKcal ?: 0.0)
|
||||
}
|
||||
|
||||
class HomeViewModel(
|
||||
private val gymApi: GymApi,
|
||||
private val auth: AuthRepository,
|
||||
private val repo: GymRepository,
|
||||
auth: AuthRepository,
|
||||
private val updateChecker: eu.brassepc.fitnessdroid.data.UpdateChecker,
|
||||
private val gymApi: eu.brassepc.fitnessdroid.data.GymApi,
|
||||
private val stepsSync: eu.brassepc.fitnessdroid.data.StepsSync,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
|
||||
val uiState: StateFlow<HomeUiState> = _uiState
|
||||
val updateState = kotlinx.coroutines.flow.MutableStateFlow<UpdateState?>(null)
|
||||
|
||||
/** Helnamn från profilen — trevligare hälsning än användarnamnet. */
|
||||
val displayName = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
|
||||
|
||||
val kcalToday = kotlinx.coroutines.flow.MutableStateFlow(KcalToday())
|
||||
val goals = kotlinx.coroutines.flow.MutableStateFlow<List<eu.brassepc.fitnessdroid.data.GoalProgress>>(emptyList())
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_uiState.value = HomeUiState.Loading
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = HomeUiState.Ready(gymApi.myProfile())
|
||||
} catch (e: GraphQlException) {
|
||||
_uiState.value = HomeUiState.Error(e.message ?: "Något gick fel")
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = HomeUiState.Error("Kunde inte nå servern")
|
||||
displayName.value = repo.displayName()
|
||||
updateChecker.check()?.let { updateState.value = UpdateState.Available(it) }
|
||||
}
|
||||
viewModelScope.launch { refreshGoals() }
|
||||
viewModelScope.launch {
|
||||
refreshKcal()
|
||||
// Passivdelen tickar med tiden — räkna om lokalt varje minut
|
||||
// (nätverket rörs bara vid laddning).
|
||||
while (true) {
|
||||
kotlinx.coroutines.delay(60_000)
|
||||
tickPassive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch { auth.logout() }
|
||||
/** Uppdatera mätaren — anropas när hemskärmen visas (igen). */
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
// Steg synkas först så att dailySummary/målen får färska värden
|
||||
runCatching { stepsSync.syncThrottled() }
|
||||
refreshKcal()
|
||||
refreshGoals()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshGoals() {
|
||||
runCatching {
|
||||
val zone = java.time.ZoneId.systemDefault()
|
||||
val today = java.time.LocalDate.now()
|
||||
val day = today.atStartOfDay(zone).toInstant().toString()
|
||||
val week = today.with(java.time.DayOfWeek.MONDAY).atStartOfDay(zone).toInstant().toString()
|
||||
val month = today.withDayOfMonth(1).atStartOfDay(zone).toInstant().toString()
|
||||
goals.value = gymApi.goalsWithProgress(day, week, month)
|
||||
}
|
||||
}
|
||||
|
||||
private fun secondsIntoToday(): Long = java.time.Duration.between(
|
||||
java.time.LocalDate.now().atStartOfDay(), java.time.LocalDateTime.now()
|
||||
).seconds.coerceAtLeast(0)
|
||||
|
||||
private fun tickPassive() {
|
||||
val current = kcalToday.value
|
||||
kcalToday.value = current.copy(
|
||||
passiveKcalSoFar = current.bmrPerDay?.let { it * secondsIntoToday() / 86_400.0 },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun refreshKcal() {
|
||||
// Serverns dailySummary är sanningen (gym + aktiviteter + BMR).
|
||||
// Fallback: räkna själva mot äldre server utan aktiviteter.
|
||||
val summary = runCatching {
|
||||
val dayStart = java.time.LocalDate.now().atStartOfDay(java.time.ZoneId.systemDefault())
|
||||
gymApi.dailySummary(dayStart.toInstant().toString())
|
||||
}.getOrNull()
|
||||
|
||||
if (summary != null) {
|
||||
kcalToday.value = KcalToday(
|
||||
activeKcal = summary.activeKcal,
|
||||
passiveKcalSoFar = summary.bmrKcalPerDay?.let { it * secondsIntoToday() / 86_400.0 },
|
||||
bmrPerDay = summary.bmrKcalPerDay,
|
||||
phoneKcal = summary.phoneKcal,
|
||||
steps = summary.steps,
|
||||
hasBodyData = summary.bmrKcalPerDay != null,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val today = java.time.LocalDate.now()
|
||||
val active = runCatching {
|
||||
repo.fetchHistory(limit = 15)
|
||||
.filter { it.date.take(10) == today.toString() }
|
||||
.sumOf { it.calories ?: 0.0 }
|
||||
}.getOrElse { kcalToday.value.activeKcal }
|
||||
|
||||
val (bmr, hasBodyData) = runCatching {
|
||||
val p = gymApi.myProfile()
|
||||
val bmr = eu.brassepc.fitnessdroid.data.bmrKcalPerDay(
|
||||
weightKg = p.bodyWeightKg,
|
||||
heightCm = p.heightCm,
|
||||
birthYear = p.birthYear,
|
||||
isFemale = p.sex?.let { it == "female" },
|
||||
)
|
||||
bmr to (bmr != null)
|
||||
}.getOrElse { kcalToday.value.bmrPerDay to kcalToday.value.hasBodyData }
|
||||
|
||||
kcalToday.value = KcalToday(
|
||||
activeKcal = active,
|
||||
passiveKcalSoFar = bmr?.let { it * secondsIntoToday() / 86_400.0 },
|
||||
bmrPerDay = bmr,
|
||||
phoneKcal = null,
|
||||
hasBodyData = hasBodyData,
|
||||
)
|
||||
}
|
||||
|
||||
fun startUpdate() {
|
||||
val info = (updateState.value as? UpdateState.Available)?.info ?: return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
updateState.value = UpdateState.Downloading(0f)
|
||||
val file = updateChecker.download(info) { p ->
|
||||
updateState.value = UpdateState.Downloading(p)
|
||||
}
|
||||
updateChecker.install(file)
|
||||
// Tillbaka till "tillgänglig" om installationen avbryts.
|
||||
updateState.value = UpdateState.Available(info)
|
||||
} catch (e: Exception) {
|
||||
updateState.value = UpdateState.Failed("Nedladdningen misslyckades — försök igen")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val uiState = combine(
|
||||
repo.activeSession,
|
||||
repo.startCards,
|
||||
repo.syncStatus,
|
||||
repo.pendingCount,
|
||||
auth.state,
|
||||
) { session, cards, sync, pending, authState ->
|
||||
HomeUiState(
|
||||
username = (authState as? AuthState.LoggedIn)?.username ?: "",
|
||||
hasActiveSession = session != null,
|
||||
startCards = cards,
|
||||
syncStatus = sync,
|
||||
pendingCount = pending,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState())
|
||||
|
||||
fun startFree(onReady: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
repo.startFreeSession()
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
|
||||
fun startFromCard(card: CachedStartCard, onReady: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
repo.startFromCard(card)
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val container = appContainer()
|
||||
HomeViewModel(container.gymApi, container.authRepository)
|
||||
val c = appContainer()
|
||||
HomeViewModel(c.gymRepository, c.authRepository, c.updateChecker, c.gymApi, c.stepsSync)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package eu.brassepc.fitnessdroid.ui.picker
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import eu.brassepc.fitnessdroid.ui.common.MuscleIcons
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExercisePickerScreen(
|
||||
onDone: () -> Unit,
|
||||
viewModel: PickerViewModel = viewModel(factory = PickerViewModel.Factory),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Lägg till övning") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onDone) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = uiState.query,
|
||||
onValueChange = viewModel::onQueryChange,
|
||||
placeholder = { Text("Sök övning …") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item {
|
||||
FilterChip(
|
||||
selected = uiState.selectedGroupId == null,
|
||||
onClick = { viewModel.selectGroup(null) },
|
||||
label = { Text("Alla") },
|
||||
)
|
||||
}
|
||||
items(uiState.groups, key = { it.id }) { group ->
|
||||
FilterChip(
|
||||
selected = uiState.selectedGroupId == group.id,
|
||||
onClick = { viewModel.selectGroup(group.id) },
|
||||
label = { Text(group.name) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
MuscleIcons.resolve(group.iconKey, group.name),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.musclesInGroup.isNotEmpty()) {
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item {
|
||||
FilterChip(
|
||||
selected = uiState.selectedMuscleId == null,
|
||||
onClick = { viewModel.selectMuscle(null) },
|
||||
label = { Text("Alla i gruppen") },
|
||||
)
|
||||
}
|
||||
items(uiState.musclesInGroup, key = { it.id }) { muscle ->
|
||||
FilterChip(
|
||||
selected = uiState.selectedMuscleId == muscle.id,
|
||||
onClick = { viewModel.selectMuscle(muscle.id) },
|
||||
label = { Text(muscle.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(uiState.exercises, key = { it.id }) { row ->
|
||||
Surface(
|
||||
onClick = { viewModel.pick(row.id, onDone) },
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.size(38.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
row.icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 10.dp),
|
||||
) {
|
||||
Text(row.name, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
row.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
row.badges.forEach { badge ->
|
||||
Text(
|
||||
badge,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { viewModel.toggleFavorite(row.id, !row.favorite) }) {
|
||||
Icon(
|
||||
if (row.favorite) Icons.Default.Star else Icons.Default.StarBorder,
|
||||
contentDescription = "Favorit",
|
||||
tint = if (row.favorite) {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uiState.exercises.isEmpty()) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
if (uiState.query.isBlank() && uiState.selectedGroupId == null) {
|
||||
"Övningslistan är tom — hämtar från servern …"
|
||||
} else {
|
||||
"Inga övningar matchar — testa ett annat filter."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
androidx.compose.material3.FilledTonalButton(
|
||||
onClick = viewModel::refresh,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text("Uppdatera från servern") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package eu.brassepc.fitnessdroid.ui.picker
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedMuscle
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedMuscleGroup
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import eu.brassepc.fitnessdroid.ui.common.MuscleIcons
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ExerciseRow(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val subtitle: String,
|
||||
val badges: List<String>,
|
||||
val favorite: Boolean,
|
||||
val icon: ImageVector,
|
||||
)
|
||||
|
||||
data class PickerUiState(
|
||||
val query: String = "",
|
||||
val groups: List<CachedMuscleGroup> = emptyList(),
|
||||
val musclesInGroup: List<CachedMuscle> = emptyList(),
|
||||
val selectedGroupId: Int? = null,
|
||||
val selectedMuscleId: Int? = null,
|
||||
val exercises: List<ExerciseRow> = emptyList(),
|
||||
)
|
||||
|
||||
class PickerViewModel(private val repo: GymRepository) : ViewModel() {
|
||||
|
||||
private val query = MutableStateFlow("")
|
||||
private val selectedGroup = MutableStateFlow<Int?>(null)
|
||||
private val selectedMuscle = MutableStateFlow<Int?>(null)
|
||||
|
||||
init {
|
||||
// Tom cache (t.ex. första start eller misslyckad förra hämtning)?
|
||||
// Försök fylla den direkt när väljaren öppnas.
|
||||
viewModelScope.launch {
|
||||
if (repo.exerciseTypes.first().isEmpty()) repo.refreshAllAsync()
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() = repo.refreshAllAsync()
|
||||
|
||||
private data class Filters(val query: String, val groupId: Int?, val muscleId: Int?)
|
||||
|
||||
private val filters = combine(query, selectedGroup, selectedMuscle) { q, g, m -> Filters(q, g, m) }
|
||||
|
||||
val uiState = combine(
|
||||
repo.exerciseTypes,
|
||||
repo.muscleGroups,
|
||||
repo.muscles,
|
||||
filters,
|
||||
) { types, groups, muscles, f ->
|
||||
val musclesByGroup = muscles.groupBy { it.muscleGroupId }
|
||||
val groupByMuscleId = muscles.associate { it.id to it.muscleGroupId }
|
||||
val groupNameById = groups.associate { it.id to it.name }
|
||||
val groupIconKeyById = groups.associate { it.id to it.iconKey }
|
||||
|
||||
val filtered = types.filter { t ->
|
||||
val muscleIds = t.muscleIds.split(",").mapNotNull { it.trim().toIntOrNull() }
|
||||
val inGroup = f.groupId == null ||
|
||||
muscleIds.any { groupByMuscleId[it] == f.groupId }
|
||||
val inMuscle = f.muscleId == null || f.muscleId in muscleIds
|
||||
val matches = f.query.isBlank() || t.name.contains(f.query.trim(), ignoreCase = true)
|
||||
inGroup && inMuscle && matches
|
||||
}
|
||||
|
||||
PickerUiState(
|
||||
query = f.query,
|
||||
groups = groups,
|
||||
musclesInGroup = f.groupId?.let { musclesByGroup[it] }.orEmpty(),
|
||||
selectedGroupId = f.groupId,
|
||||
selectedMuscleId = f.muscleId,
|
||||
exercises = filtered.map { t ->
|
||||
val muscleIds = t.muscleIds.split(",").mapNotNull { it.trim().toIntOrNull() }
|
||||
val primaryGroupId = muscleIds.firstNotNullOfOrNull { groupByMuscleId[it] }
|
||||
val groupNames = muscleIds.mapNotNull { groupByMuscleId[it] }
|
||||
.distinct().mapNotNull { groupNameById[it] }
|
||||
ExerciseRow(
|
||||
id = t.id,
|
||||
name = t.name,
|
||||
subtitle = buildString {
|
||||
append(groupNames.joinToString(" · ").ifEmpty { "Övrigt" })
|
||||
t.lastWeight?.let { append(" · senast ${it.compact()} kg") }
|
||||
},
|
||||
badges = buildList {
|
||||
if (t.tracksWeight) add("KG")
|
||||
if (t.tracksReps) add("REPS")
|
||||
if (t.tracksDuration) add("TID")
|
||||
if (t.tracksDistance) add("M")
|
||||
},
|
||||
favorite = t.isFavorite,
|
||||
icon = MuscleIcons.resolve(
|
||||
primaryGroupId?.let { groupIconKeyById[it] },
|
||||
primaryGroupId?.let { groupNameById[it] },
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PickerUiState())
|
||||
|
||||
fun onQueryChange(value: String) { query.value = value }
|
||||
|
||||
fun selectGroup(id: Int?) {
|
||||
selectedGroup.value = id
|
||||
selectedMuscle.value = null
|
||||
}
|
||||
|
||||
fun selectMuscle(id: Int?) { selectedMuscle.value = id }
|
||||
|
||||
fun toggleFavorite(exerciseTypeId: Int, favorite: Boolean) =
|
||||
repo.toggleFavoriteExercise(exerciseTypeId, favorite)
|
||||
|
||||
fun pick(exerciseTypeId: Int, onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val sessionId = repo.activeSession.first()?.id ?: return@launch
|
||||
repo.addExercise(sessionId, exerciseTypeId)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer { PickerViewModel(appContainer().gymRepository) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Double.compact(): String =
|
||||
if (this % 1.0 == 0.0) toInt().toString() else "%.1f".format(this)
|
||||
@@ -0,0 +1,624 @@
|
||||
package eu.brassepc.fitnessdroid.ui.profile
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.MonitorWeight
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.AuthRepository
|
||||
import eu.brassepc.fitnessdroid.data.BodyMeasurement
|
||||
import eu.brassepc.fitnessdroid.data.GymApi
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.Profile
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import eu.brassepc.fitnessdroid.ui.common.compact
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ProfileViewModel(
|
||||
private val gymApi: GymApi,
|
||||
private val auth: AuthRepository,
|
||||
private val repo: GymRepository,
|
||||
private val settingsStore: eu.brassepc.fitnessdroid.data.SettingsStore,
|
||||
) : ViewModel() {
|
||||
val profile = MutableStateFlow<Profile?>(null)
|
||||
val measurements = MutableStateFlow<List<BodyMeasurement>>(emptyList())
|
||||
val message = MutableStateFlow<String?>(null)
|
||||
val settings = settingsStore.settings
|
||||
|
||||
/** Kroppsdata sparas lokalt (för vågen/offline) och synkas till servern. */
|
||||
fun saveBodyData(heightCm: Double?, birthYear: Int?, isFemale: Boolean?) {
|
||||
viewModelScope.launch {
|
||||
settingsStore.setBodyData(heightCm, birthYear, isFemale)
|
||||
try {
|
||||
// Tomt fält = rensa på servern (-1/"" är serverns konvention)
|
||||
gymApi.updateProfile(
|
||||
heightCm = heightCm ?: -1.0,
|
||||
birthYear = birthYear ?: -1,
|
||||
sex = when (isFemale) {
|
||||
true -> "female"
|
||||
false -> "male"
|
||||
null -> ""
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
message.value = "Kroppsdata sparad lokalt — kunde inte nå servern"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Håll lokala kroppsdatan i synk med servern: serverns värden vinner,
|
||||
* men finns de bara lokalt (satta innan servern fick fälten) pushas de upp.
|
||||
*/
|
||||
private suspend fun syncBodyData(p: Profile) {
|
||||
val local = settingsStore.settings.first()
|
||||
val serverHas = p.heightCm != null || p.birthYear != null || p.sex != null
|
||||
val localHas = local.heightCm != null || local.birthYear != null || local.isFemale != null
|
||||
if (serverHas) {
|
||||
settingsStore.setBodyData(p.heightCm, p.birthYear, p.sex?.let { it == "female" })
|
||||
} else if (localHas) {
|
||||
runCatching {
|
||||
gymApi.updateProfile(
|
||||
heightCm = local.heightCm,
|
||||
birthYear = local.birthYear,
|
||||
sex = local.isFemale?.let { if (it) "female" else "male" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init { load() }
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val p = gymApi.myProfile()
|
||||
profile.value = p
|
||||
syncBodyData(p)
|
||||
}
|
||||
runCatching { measurements.value = repo.fetchBodyMeasurements().reversed() }
|
||||
}
|
||||
}
|
||||
|
||||
fun addWeight(weightKg: Double, dateIso: String?, muscle: Double?, fat: Double?, water: Double?) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.addBodyMeasurement(weightKg, dateIso, muscle, fat, water)
|
||||
message.value = "Vikten sparad"
|
||||
load()
|
||||
} catch (e: Exception) {
|
||||
message.value = "Kunde inte spara — offline?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMeasurement(
|
||||
id: String, weightKg: Double, dateIso: String?,
|
||||
muscle: Double?, fat: Double?, water: Double?,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// -1 rensar fältet på servern om användaren tömt det
|
||||
repo.updateBodyMeasurement(
|
||||
id, weightKg, dateIso,
|
||||
muscle ?: -1.0, fat ?: -1.0, water ?: -1.0,
|
||||
)
|
||||
load()
|
||||
} catch (e: Exception) {
|
||||
message.value = "Kunde inte uppdatera — offline?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMeasurement(id: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.deleteBodyMeasurement(id)
|
||||
load()
|
||||
} catch (e: Exception) {
|
||||
message.value = "Kunde inte ta bort — offline?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch { auth.logout() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
ProfileViewModel(c.gymApi, c.authRepository, c.gymRepository, c.settingsStore)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenWeigh: () -> Unit = {},
|
||||
onOpenAbout: () -> Unit = {},
|
||||
viewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.Factory),
|
||||
) {
|
||||
val profile by viewModel.profile.collectAsStateWithLifecycle()
|
||||
val measurements by viewModel.measurements.collectAsStateWithLifecycle()
|
||||
val message by viewModel.message.collectAsStateWithLifecycle()
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle(
|
||||
eu.brassepc.fitnessdroid.data.AppSettings()
|
||||
)
|
||||
var showAddWeight by remember { mutableStateOf(false) }
|
||||
var showBodyData by remember { mutableStateOf(false) }
|
||||
var editTarget by remember { mutableStateOf<BodyMeasurement?>(null) }
|
||||
|
||||
if (showAddWeight) {
|
||||
WeightDialog(
|
||||
title = "Uppdatera kroppsvikt",
|
||||
initial = profile?.bodyWeightKg,
|
||||
initialDate = java.time.LocalDate.now(),
|
||||
initialMuscle = null,
|
||||
initialFat = null,
|
||||
initialWater = null,
|
||||
onSave = { weight, dateIso, muscle, fat, water ->
|
||||
viewModel.addWeight(weight, dateIso, muscle, fat, water)
|
||||
showAddWeight = false
|
||||
},
|
||||
onDismiss = { showAddWeight = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showBodyData) {
|
||||
BodyDataDialog(
|
||||
initialHeight = settings.heightCm,
|
||||
initialBirthYear = settings.birthYear,
|
||||
initialIsFemale = settings.isFemale,
|
||||
onSave = { h, y, f ->
|
||||
viewModel.saveBodyData(h, y, f)
|
||||
showBodyData = false
|
||||
},
|
||||
onDismiss = { showBodyData = false },
|
||||
)
|
||||
}
|
||||
|
||||
editTarget?.let { m ->
|
||||
WeightDialog(
|
||||
title = "Rätta mätning",
|
||||
initial = m.weightKg,
|
||||
initialDate = runCatching { java.time.LocalDate.parse(m.date.take(10)) }
|
||||
.getOrElse { java.time.LocalDate.now() },
|
||||
initialMuscle = m.musclePercent,
|
||||
initialFat = m.fatPercent,
|
||||
initialWater = m.waterPercent,
|
||||
onSave = { weight, dateIso, muscle, fat, water ->
|
||||
viewModel.updateMeasurement(m.id, weight, dateIso, muscle, fat, water)
|
||||
editTarget = null
|
||||
},
|
||||
onDelete = {
|
||||
viewModel.deleteMeasurement(m.id)
|
||||
editTarget = null
|
||||
},
|
||||
onDismiss = { editTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text("Profil", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ProfileRow("Användarnamn", profile?.username ?: "…")
|
||||
profile?.displayName?.let { ProfileRow("Namn", it) }
|
||||
profile?.email?.let { ProfileRow("E-post", it) }
|
||||
}
|
||||
}
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.MonitorWeight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
|
||||
Text("Kroppsvikt", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
profile?.bodyWeightKg?.let { "${it.compact()} kg" } ?: "Inte satt",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
}
|
||||
Button(onClick = { showAddWeight = true }) { Text("Uppdatera") }
|
||||
}
|
||||
if (settings.scaleAddress != null) {
|
||||
androidx.compose.material3.FilledTonalButton(
|
||||
onClick = onOpenWeigh,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Default.MonitorWeight, contentDescription = null)
|
||||
Text("Väg dig med vågen", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
message?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (measurements.isNotEmpty()) {
|
||||
Text(
|
||||
"Historik (tryck för att rätta)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
measurements.take(8).forEach { m ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { editTarget = m }
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
m.date.take(10),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
val composition = listOfNotNull(
|
||||
m.musclePercent?.let { "M ${it.compact()}%" },
|
||||
m.fatPercent?.let { "F ${it.compact()}%" },
|
||||
m.waterPercent?.let { "V ${it.compact()}%" },
|
||||
).joinToString(" · ")
|
||||
if (composition.isNotEmpty()) {
|
||||
Text(
|
||||
composition,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"${m.weightKg.compact()} kg",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { showBodyData = true },
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Kroppsdata", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"Behövs för att vågen ska kunna räkna ut muskler/fett/vatten. " +
|
||||
"Tryck för att ändra.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
ProfileRow("Längd", settings.heightCm?.let { "${it.compact()} cm" } ?: "Inte satt")
|
||||
ProfileRow("Födelseår", settings.birthYear?.toString() ?: "Inte satt")
|
||||
ProfileRow(
|
||||
"Kön",
|
||||
when (settings.isFemale) {
|
||||
true -> "Kvinna"
|
||||
false -> "Man"
|
||||
null -> "Inte satt"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpenSettings),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
|
||||
Text("Inställningar", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"Vilotimer, viktsteg, stänger, våg, inloggning",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpenAbout),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Default.Info, contentDescription = null)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
|
||||
Text("Om appen", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"Version, licens (GPL-3.0) och öppen källkod",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = viewModel::logout) {
|
||||
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
|
||||
Text("Logga ut", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun WeightDialog(
|
||||
title: String,
|
||||
initial: Double?,
|
||||
initialDate: java.time.LocalDate,
|
||||
initialMuscle: Double?,
|
||||
initialFat: Double?,
|
||||
initialWater: Double?,
|
||||
onSave: (Double, String?, Double?, Double?, Double?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
) {
|
||||
fun fmt(v: Double?) = v?.let { if (it % 1.0 == 0.0) it.toInt().toString() else it.toString() } ?: ""
|
||||
var text by remember { mutableStateOf(fmt(initial)) }
|
||||
var muscle by remember { mutableStateOf(fmt(initialMuscle)) }
|
||||
var fat by remember { mutableStateOf(fmt(initialFat)) }
|
||||
var water by remember { mutableStateOf(fmt(initialWater)) }
|
||||
var date by remember { mutableStateOf(initialDate) }
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
val parsed = text.trim().replace(',', '.').toDoubleOrNull()
|
||||
fun pct(s: String) = s.trim().replace(',', '.').toDoubleOrNull()
|
||||
|
||||
if (showDatePicker) {
|
||||
val dateState = androidx.compose.material3.rememberDatePickerState(
|
||||
initialSelectedDateMillis = date.atStartOfDay(java.time.ZoneOffset.UTC)
|
||||
.toInstant().toEpochMilli(),
|
||||
)
|
||||
androidx.compose.material3.DatePickerDialog(
|
||||
onDismissRequest = { showDatePicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
dateState.selectedDateMillis?.let { picked ->
|
||||
date = java.time.Instant.ofEpochMilli(picked)
|
||||
.atZone(java.time.ZoneOffset.UTC).toLocalDate()
|
||||
}
|
||||
showDatePicker = false
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDatePicker = false }) { Text("Avbryt") }
|
||||
},
|
||||
) {
|
||||
androidx.compose.material3.DatePicker(state = dateState)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text("Vikt (kg)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
androidx.compose.material3.FilledTonalButton(
|
||||
onClick = { showDatePicker = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Datum: $date")
|
||||
}
|
||||
Text(
|
||||
"Kroppssammansättning (valfritt)",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = muscle,
|
||||
onValueChange = { muscle = it },
|
||||
label = { Text("Muskel %") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = fat,
|
||||
onValueChange = { fat = it },
|
||||
label = { Text("Fett %") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = water,
|
||||
onValueChange = { water = it },
|
||||
label = { Text("Vatten %") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
onDelete?.let {
|
||||
TextButton(onClick = it) {
|
||||
Text("Ta bort mätningen", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
parsed?.let { weight ->
|
||||
// Mitt på dagen UTC så datumet inte glider en dag i någon tidszon
|
||||
val iso = date.atTime(12, 0).atOffset(java.time.ZoneOffset.UTC)
|
||||
.toInstant().toString()
|
||||
onSave(weight, iso, pct(muscle), pct(fat), pct(water))
|
||||
}
|
||||
},
|
||||
enabled = parsed != null && parsed > 0,
|
||||
) { Text("Spara") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Avbryt") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Längd/födelseår/kön — indata till BT-vågens kroppssammansättning. */
|
||||
@Composable
|
||||
private fun BodyDataDialog(
|
||||
initialHeight: Double?,
|
||||
initialBirthYear: Int?,
|
||||
initialIsFemale: Boolean?,
|
||||
onSave: (Double?, Int?, Boolean?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
fun fmt(v: Double?) = v?.let { if (it % 1.0 == 0.0) it.toInt().toString() else it.toString() } ?: ""
|
||||
var height by remember { mutableStateOf(fmt(initialHeight)) }
|
||||
var birthYear by remember { mutableStateOf(initialBirthYear?.toString() ?: "") }
|
||||
var isFemale by remember { mutableStateOf(initialIsFemale) }
|
||||
|
||||
val parsedHeight = height.trim().replace(',', '.').toDoubleOrNull()
|
||||
val parsedYear = birthYear.trim().toIntOrNull()
|
||||
val currentYear = java.time.LocalDate.now().year
|
||||
val yearOk = parsedYear == null || parsedYear in 1900..currentYear
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Kroppsdata") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"Skickas till vågen så att den kan räkna ut muskler, fett och vatten.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = height,
|
||||
onValueChange = { height = it },
|
||||
label = { Text("Längd (cm)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = birthYear,
|
||||
onValueChange = { birthYear = it },
|
||||
label = { Text("Födelseår") },
|
||||
singleLine = true,
|
||||
isError = !yearOk,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
androidx.compose.material3.FilterChip(
|
||||
selected = isFemale == false,
|
||||
onClick = { isFemale = false },
|
||||
label = { Text("Man") },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
androidx.compose.material3.FilterChip(
|
||||
selected = isFemale == true,
|
||||
onClick = { isFemale = true },
|
||||
label = { Text("Kvinna") },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onSave(parsedHeight, parsedYear, isFemale) },
|
||||
enabled = yearOk,
|
||||
) { Text("Spara") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Avbryt") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
package eu.brassepc.fitnessdroid.ui.scale
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.BluetoothSearching
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.MonitorWeight
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import eu.brassepc.fitnessdroid.data.AppSettings
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.ScaleManager
|
||||
import eu.brassepc.fitnessdroid.data.SettingsStore
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import eu.brassepc.fitnessdroid.ui.common.compact
|
||||
import eu.brassepc.fitnessdroid.ui.common.openAppSettings
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Behörigheterna som BLE-skanning/anslutning kräver på Android 12+. */
|
||||
val BLE_PERMISSIONS = arrayOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
)
|
||||
|
||||
/* ==================== Anslut våg (parning) ==================== */
|
||||
|
||||
class ScalePairViewModel(
|
||||
private val scaleManager: ScaleManager,
|
||||
private val settingsStore: SettingsStore,
|
||||
) : ViewModel() {
|
||||
val settings = settingsStore.settings
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AppSettings())
|
||||
|
||||
val devices = scaleManager.scanner.scannedDevices
|
||||
val isScanning = scaleManager.scanner.isScanning
|
||||
val scanError = scaleManager.scanner.scanError
|
||||
|
||||
fun startScan() {
|
||||
scaleManager.scanner.startScan(30_000)
|
||||
}
|
||||
|
||||
fun stopScan() {
|
||||
scaleManager.scanner.stopScan()
|
||||
}
|
||||
|
||||
fun saveScale(device: ScannedDeviceInfo) {
|
||||
viewModelScope.launch {
|
||||
stopScan()
|
||||
settingsStore.setScale(device.address, device.name, device.determinedHandlerDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
fun forgetScale() {
|
||||
viewModelScope.launch { settingsStore.setScale(null, null, null) }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
stopScan()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
ScalePairViewModel(c.scaleManager, c.settingsStore)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ScalePairScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: ScalePairViewModel = viewModel(factory = ScalePairViewModel.Factory),
|
||||
) {
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
val devices by viewModel.devices.collectAsStateWithLifecycle()
|
||||
val isScanning by viewModel.isScanning.collectAsStateWithLifecycle()
|
||||
val scanError by viewModel.scanError.collectAsStateWithLifecycle()
|
||||
|
||||
val context = LocalContext.current
|
||||
var permissionDenied by remember { mutableStateOf(false) }
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { granted ->
|
||||
if (granted.values.all { it }) viewModel.startScan() else permissionDenied = true
|
||||
}
|
||||
|
||||
fun scanWithPermission() {
|
||||
val missing = BLE_PERMISSIONS.any {
|
||||
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing) permissionLauncher.launch(BLE_PERMISSIONS) else viewModel.startScan()
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { viewModel.stopScan() }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Bluetooth-våg") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
settings.scaleAddress?.let { address ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.MonitorWeight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
|
||||
Text(
|
||||
settings.scaleDriver ?: settings.scaleName ?: "Våg",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
"${settings.scaleName ?: "?"} · $address",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = viewModel::forgetScale) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Ta bort våg",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.scaleAddress == null) {
|
||||
Text(
|
||||
"Sök efter din våg och välj den i listan. Kliv gärna på vågen under " +
|
||||
"sökningen — många vågar syns bara när de är vakna.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = { if (isScanning) viewModel.stopScan() else scanWithPermission() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isScanning) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Text("Söker… (tryck för att stoppa)", modifier = Modifier.padding(start = 10.dp))
|
||||
} else {
|
||||
Icon(Icons.Default.BluetoothSearching, contentDescription = null)
|
||||
Text("Sök efter våg", modifier = Modifier.padding(start = 10.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (permissionDenied) {
|
||||
Text(
|
||||
"Bluetooth-behörighet nekades. Ge appen behörigheten \"Enheter i närheten\" " +
|
||||
"i systeminställningarna för att kunna söka efter vågen.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
FilledTonalButton(
|
||||
onClick = { context.openAppSettings() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Öppna appinställningarna") }
|
||||
}
|
||||
scanError?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
val supported = devices.filter { it.isSupported }
|
||||
val others = devices.filterNot { it.isSupported }
|
||||
|
||||
if (supported.isNotEmpty()) {
|
||||
Text("Vågar med stöd", style = MaterialTheme.typography.titleSmall)
|
||||
supported.forEach { d -> DeviceRow(d, onClick = { viewModel.saveScale(d) }) }
|
||||
}
|
||||
if (others.isNotEmpty()) {
|
||||
Text(
|
||||
"Övriga enheter (ingen drivrutin matchar)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
others.filter { it.name.isNotBlank() }.take(15).forEach { d ->
|
||||
DeviceRow(d, onClick = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceRow(device: ScannedDeviceInfo, onClick: (() -> Unit)?) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.let { if (onClick != null) it.clickable(onClick = onClick) else it },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
if (device.isSupported) Icons.Default.CheckCircle else Icons.Default.Bluetooth,
|
||||
contentDescription = null,
|
||||
tint = if (device.isSupported) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
|
||||
Text(
|
||||
device.determinedHandlerDisplayName ?: device.name.ifBlank { device.address },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = if (device.isSupported) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
Text(
|
||||
"${device.name.ifBlank { "namnlös" }} · ${device.address} · ${device.rssi} dBm",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (device.isSupported) {
|
||||
Text(
|
||||
"VÄLJ",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Väg dig ==================== */
|
||||
|
||||
sealed class WeighState {
|
||||
data object Idle : WeighState()
|
||||
data class Working(val status: String) : WeighState()
|
||||
|
||||
/**
|
||||
* Vikten är mottagen. [complete] = false medan vi fortfarande lyssnar efter
|
||||
* en senare ram med kroppssammansättning (vågen skickar ofta vikten först
|
||||
* och muskler/fett/vatten någon sekund senare).
|
||||
*/
|
||||
data class Done(val measurement: ScaleMeasurement, val complete: Boolean) : WeighState()
|
||||
data class Failed(val message: String) : WeighState()
|
||||
}
|
||||
|
||||
private fun ScaleMeasurement.hasComposition(): Boolean =
|
||||
fat > 0f || water > 0f || muscle > 0f
|
||||
|
||||
class WeighViewModel(
|
||||
private val scaleManager: ScaleManager,
|
||||
private val repo: GymRepository,
|
||||
) : ViewModel() {
|
||||
val state = MutableStateFlow<WeighState>(WeighState.Idle)
|
||||
val saved = MutableStateFlow(false)
|
||||
val saveError = MutableStateFlow<String?>(null)
|
||||
val missingBodyData = MutableStateFlow(false)
|
||||
|
||||
private var compositionTimeout: kotlinx.coroutines.Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
scaleManager.lastEvent.collect { event ->
|
||||
val current = state.value
|
||||
when (event) {
|
||||
null -> {}
|
||||
is BluetoothEvent.MeasurementReceived -> onMeasurement(event.measurement)
|
||||
is BluetoothEvent.Listening ->
|
||||
if (current !is WeighState.Done)
|
||||
state.value = WeighState.Working("Lyssnar efter vågen — kliv på den…")
|
||||
is BluetoothEvent.Connected ->
|
||||
if (current !is WeighState.Done)
|
||||
state.value = WeighState.Working("Ansluten — ställ dig barfota på vågen")
|
||||
is BluetoothEvent.DeviceMessage ->
|
||||
if (current !is WeighState.Done)
|
||||
state.value = WeighState.Working(event.message)
|
||||
is BluetoothEvent.ConnectionFailed ->
|
||||
if (current !is WeighState.Done)
|
||||
state.value = WeighState.Failed("Kunde inte ansluta: ${event.error}")
|
||||
is BluetoothEvent.Error ->
|
||||
if (current !is WeighState.Done)
|
||||
state.value = WeighState.Failed(event.error)
|
||||
is BluetoothEvent.Disconnected -> when (current) {
|
||||
is WeighState.Working ->
|
||||
state.value = WeighState.Failed("Vågen kopplade från innan mätningen blev klar")
|
||||
// Tappade vi länken medan vi väntade på sammansättningen
|
||||
// behåller vi vikten och avslutar mätningen.
|
||||
is WeighState.Done -> finishMeasurement()
|
||||
else -> {}
|
||||
}
|
||||
is BluetoothEvent.BroadcastComplete, is BluetoothEvent.UserInteractionRequired -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vågen kan publicera flera ramar: först bara vikt, sen en med
|
||||
* muskler/fett/vatten. Vi mergar och blir klara först när sammansättningen
|
||||
* kommit — eller efter en väntetid om den uteblir.
|
||||
*/
|
||||
private fun onMeasurement(measurement: ScaleMeasurement) {
|
||||
val previous = (state.value as? WeighState.Done)?.measurement
|
||||
val merged = previous?.let { measurement.mergeWith(it) } ?: measurement
|
||||
|
||||
if (merged.hasComposition()) {
|
||||
compositionTimeout?.cancel()
|
||||
compositionTimeout = null
|
||||
state.value = WeighState.Done(merged, complete = true)
|
||||
scaleManager.disconnect()
|
||||
} else {
|
||||
state.value = WeighState.Done(merged, complete = false)
|
||||
if (compositionTimeout == null) {
|
||||
compositionTimeout = viewModelScope.launch {
|
||||
kotlinx.coroutines.delay(20_000)
|
||||
finishMeasurement()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Avsluta med det vi har (vikt utan sammansättning). */
|
||||
private fun finishMeasurement() {
|
||||
compositionTimeout?.cancel()
|
||||
compositionTimeout = null
|
||||
(state.value as? WeighState.Done)?.let { d ->
|
||||
if (!d.complete) state.value = d.copy(complete = true)
|
||||
}
|
||||
scaleManager.disconnect()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
compositionTimeout?.cancel()
|
||||
compositionTimeout = null
|
||||
state.value = WeighState.Working("Söker efter vågen…")
|
||||
saved.value = false
|
||||
saveError.value = null
|
||||
viewModelScope.launch {
|
||||
missingBodyData.value = !scaleManager.hasBodyData()
|
||||
val ok = scaleManager.connectSavedScale()
|
||||
if (!ok) state.value = WeighState.Failed("Ingen våg är vald, eller så saknas drivrutin. Gå till Inställningar → Bluetooth-våg.")
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
scaleManager.disconnect()
|
||||
}
|
||||
|
||||
fun save(m: ScaleMeasurement) {
|
||||
viewModelScope.launch {
|
||||
fun pct(v: Float): Double? = if (v > 0f) String.format(java.util.Locale.US, "%.1f", v).toDouble() else null
|
||||
try {
|
||||
saveError.value = null
|
||||
repo.addBodyMeasurement(
|
||||
weightKg = String.format(java.util.Locale.US, "%.1f", m.weight).toDouble(),
|
||||
dateIso = null,
|
||||
musclePercent = pct(m.muscle),
|
||||
fatPercent = pct(m.fat),
|
||||
waterPercent = pct(m.water),
|
||||
)
|
||||
saved.value = true
|
||||
} catch (e: Exception) {
|
||||
saveError.value = "Kunde inte spara mätningen — offline? Prova igen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
stop()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
WeighViewModel(c.scaleManager, c.gymRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WeighScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: WeighViewModel = viewModel(factory = WeighViewModel.Factory),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val saved by viewModel.saved.collectAsStateWithLifecycle()
|
||||
val saveError by viewModel.saveError.collectAsStateWithLifecycle()
|
||||
val missingBodyData by viewModel.missingBodyData.collectAsStateWithLifecycle()
|
||||
|
||||
val context = LocalContext.current
|
||||
var permissionDenied by remember { mutableStateOf(false) }
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { granted ->
|
||||
if (granted.values.all { it }) viewModel.start() else permissionDenied = true
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val missing = BLE_PERMISSIONS.any {
|
||||
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing) permissionLauncher.launch(BLE_PERMISSIONS) else viewModel.start()
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { viewModel.stop() }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Väg dig") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
if (missingBodyData) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
"Kroppsdata saknas!",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
"Utan längd, födelseår och kön kan vågen bara mäta vikt — " +
|
||||
"muskler/fett/vatten uteblir. Fyll i under Profil → Kroppsdata " +
|
||||
"och väg dig sedan igen.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (permissionDenied) {
|
||||
Text(
|
||||
"Bluetooth-behörighet nekades — kan inte nå vågen.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
FilledTonalButton(
|
||||
onClick = { context.openAppSettings() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Öppna appinställningarna") }
|
||||
}
|
||||
|
||||
when (val s = state) {
|
||||
is WeighState.Idle -> {}
|
||||
is WeighState.Working -> {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
s.status,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is WeighState.Failed -> {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(s.message, color = MaterialTheme.colorScheme.error)
|
||||
Button(onClick = viewModel::start, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Försök igen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is WeighState.Done -> {
|
||||
val m = s.measurement
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.MonitorWeight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
"${m.weight.toDouble().compact()} kg",
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
ResultRow("Muskler", m.muscle, "%")
|
||||
ResultRow("Fett", m.fat, "%")
|
||||
ResultRow("Vatten", m.water, "%")
|
||||
ResultRow("Benmassa", m.bone, " kg")
|
||||
ResultRow("Bukfett (index)", m.visceralFat, "")
|
||||
|
||||
if (!s.complete) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Text(
|
||||
"Väntar på muskler/fett/vatten — stå kvar på vågen…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 10.dp),
|
||||
)
|
||||
}
|
||||
} else if (!m.hasComposition()) {
|
||||
Text(
|
||||
"Ingen kroppssammansättning togs emot. Kontrollera att " +
|
||||
"längd/födelseår/kön är ifyllda i profilen och väg dig " +
|
||||
"barfota med torra fötter.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
saveError?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (saved) {
|
||||
Text(
|
||||
"Sparad som kroppsmätning ✓",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Klar")
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.save(m) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Spara mätningen") }
|
||||
TextButton(
|
||||
onClick = viewModel::start,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Väg om") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"Vågen räknar ut kroppssammansättningen med hjälp av längd, " +
|
||||
"födelseår och kön från profilen.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultRow(label: String, value: Float, suffix: String) {
|
||||
if (value <= 0f) return
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text("${value.toDouble().compact()}$suffix")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package eu.brassepc.fitnessdroid.ui.session
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Standardfärger på viktskivor (IPF-ish). Vikt i gram för exakt matematik. */
|
||||
private data class Plate(val grams: Int, val color: Color, val heightDp: Dp, val widthDp: Dp)
|
||||
|
||||
private val PLATES = listOf(
|
||||
Plate(25_000, Color(0xFFD32F2F), 88.dp, 13.dp), // 25 röd
|
||||
Plate(20_000, Color(0xFF1E64C8), 88.dp, 12.dp), // 20 blå
|
||||
Plate(15_000, Color(0xFFF9C513), 76.dp, 11.dp), // 15 gul
|
||||
Plate(10_000, Color(0xFF3E9B4F), 62.dp, 10.dp), // 10 grön
|
||||
Plate(5_000, Color(0xFFECECE4), 46.dp, 9.dp), // 5 vit
|
||||
Plate(2_500, Color(0xFF212121), 34.dp, 8.dp), // 2,5 svart
|
||||
Plate(1_250, Color(0xFF9E9E9E), 26.dp, 7.dp), // 1,25 grå
|
||||
)
|
||||
|
||||
private fun breakdown(perSideGrams: Int): Pair<List<Plate>, Int> {
|
||||
var rest = perSideGrams
|
||||
val result = mutableListOf<Plate>()
|
||||
for (plate in PLATES) {
|
||||
while (rest >= plate.grams) {
|
||||
result += plate
|
||||
rest -= plate.grams
|
||||
}
|
||||
}
|
||||
return result to rest
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlateCalculatorSheet(
|
||||
weightKg: Double,
|
||||
customBarWeights: List<Double>,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var barKg by remember { mutableStateOf(20.0) }
|
||||
val barOptions = (listOf(20.0, 0.0) + customBarWeights).distinct()
|
||||
|
||||
val perSideGrams = (((weightKg - barKg) / 2.0) * 1000).toInt().coerceAtLeast(0)
|
||||
val (plates, restGrams) = breakdown(perSideGrams)
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
"${weightKg.compact()} kg på stången",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
|
||||
androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(barOptions.size) { i ->
|
||||
val option = barOptions[i]
|
||||
FilterChip(
|
||||
selected = barKg == option,
|
||||
onClick = { barKg = option },
|
||||
label = {
|
||||
Text(
|
||||
when (option) {
|
||||
0.0 -> "Utan stång"
|
||||
20.0 -> "Stång 20 kg"
|
||||
else -> "Stång ${option.compact()} kg"
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (weightKg < barKg) {
|
||||
Text(
|
||||
"Vikten är lägre än stången — bara stången räcker (${barKg.compact()} kg).",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
// Skivorna för ena sidan, störst innerst
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(96.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// stångskaft in mot mitten
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(8.dp)
|
||||
.background(Color(0xFF757575), RoundedCornerShape(3.dp)),
|
||||
)
|
||||
// låskrage
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(width = 8.dp, height = 24.dp)
|
||||
.background(Color(0xFF8D8D8D), RoundedCornerShape(2.dp)),
|
||||
)
|
||||
plates.forEach { plate ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 1.5.dp)
|
||||
.size(width = plate.widthDp, height = plate.heightDp)
|
||||
.background(plate.color, RoundedCornerShape(3.dp)),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(0.3f)
|
||||
.height(8.dp)
|
||||
.background(Color(0xFF757575), RoundedCornerShape(3.dp)),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
buildString {
|
||||
append("Per sida: ")
|
||||
if (plates.isEmpty()) {
|
||||
append("inga skivor")
|
||||
} else {
|
||||
append(
|
||||
plates.groupBy { it.grams }.entries
|
||||
.sortedByDescending { it.key }
|
||||
.joinToString(" + ") { (grams, list) ->
|
||||
val kg = grams / 1000.0
|
||||
if (list.size > 1) "${list.size}×${kg.compact()}" else kg.compact()
|
||||
}
|
||||
)
|
||||
append(" kg")
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
if (restGrams > 0) {
|
||||
val reachable = weightKg - (restGrams * 2 / 1000.0)
|
||||
Text(
|
||||
"Går inte att lägga exakt med standardskivor — närmast är " +
|
||||
"${reachable.compact()} kg (${(restGrams / 1000.0).compact()} kg/sida saknas).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
||||
package eu.brassepc.fitnessdroid.ui.session
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.AppSettings
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.RestState
|
||||
import eu.brassepc.fitnessdroid.data.RestTimerController
|
||||
import eu.brassepc.fitnessdroid.data.SettingsStore
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedExerciseType
|
||||
import eu.brassepc.fitnessdroid.data.local.LocalSet
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Utkast till nästa set för en övning (styrs av steppers i UI:t). */
|
||||
data class SetDraft(
|
||||
val weight: Double? = null,
|
||||
val reps: Int? = null,
|
||||
val durationSeconds: Int? = null,
|
||||
val distanceMeters: Double? = null,
|
||||
val rpe: Double? = null,
|
||||
)
|
||||
|
||||
data class ExercisePage(
|
||||
val localId: Long,
|
||||
val type: CachedExerciseType?,
|
||||
val name: String,
|
||||
val doneSets: List<LocalSet>,
|
||||
val draft: SetDraft,
|
||||
val lastPerformanceText: String?,
|
||||
)
|
||||
|
||||
data class SessionUiState(
|
||||
val sessionId: Long? = null,
|
||||
val sessionName: String,
|
||||
val startedAtEpochMs: Long = 0,
|
||||
val pages: List<ExercisePage> = emptyList(),
|
||||
val pendingCount: Int = 0,
|
||||
val rest: RestState? = null,
|
||||
val settings: AppSettings = AppSettings(),
|
||||
val completedLocally: Boolean = false,
|
||||
) {
|
||||
companion object { val EMPTY = SessionUiState(sessionName = "Pass") }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SessionViewModel(
|
||||
private val repo: GymRepository,
|
||||
private val restTimer: RestTimerController,
|
||||
private val settingsStore: SettingsStore,
|
||||
) : ViewModel() {
|
||||
|
||||
/** exerciseLocalId → utkast som användaren pillat på */
|
||||
private val drafts = MutableStateFlow<Map<Long, SetDraft>>(emptyMap())
|
||||
|
||||
private val sessionFlow = repo.activeSession
|
||||
|
||||
private val bundleFlow = sessionFlow.flatMapLatest { session ->
|
||||
if (session == null) {
|
||||
flowOf(SessionUiState.EMPTY)
|
||||
} else {
|
||||
combine(
|
||||
repo.exercises(session.id),
|
||||
repo.setsForSession(session.id),
|
||||
repo.exerciseTypes,
|
||||
drafts,
|
||||
repo.pendingCount,
|
||||
) { exercises, sets, types, draftMap, pending ->
|
||||
val typeById = types.associateBy { it.id }
|
||||
val setsByExercise = sets.groupBy { it.exerciseId }
|
||||
SessionUiState(
|
||||
sessionId = session.id,
|
||||
sessionName = session.name ?: "Fritt pass",
|
||||
startedAtEpochMs = session.startedAtEpochMs,
|
||||
pendingCount = pending,
|
||||
pages = exercises.map { ex ->
|
||||
val type = typeById[ex.exerciseTypeId]
|
||||
val done = setsByExercise[ex.id].orEmpty()
|
||||
ExercisePage(
|
||||
localId = ex.id,
|
||||
type = type,
|
||||
name = type?.name ?: "Övning ${ex.order}",
|
||||
doneSets = done,
|
||||
draft = draftMap[ex.id] ?: initialDraft(type, done),
|
||||
lastPerformanceText = type?.lastWeight?.let { w ->
|
||||
"Senast: ${w.compact()} kg" +
|
||||
(type.lastReps?.let { " × $it" } ?: "")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val uiState = combine(
|
||||
bundleFlow,
|
||||
restTimer.state,
|
||||
settingsStore.settings,
|
||||
) { bundle, rest, settings ->
|
||||
bundle.copy(rest = rest, settings = settings)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SessionUiState.EMPTY)
|
||||
|
||||
/** Förifyll utkastet: senaste setet i passet, annars cachens senaste prestation. */
|
||||
private fun initialDraft(type: CachedExerciseType?, done: List<LocalSet>): SetDraft {
|
||||
val last = done.lastOrNull()
|
||||
return SetDraft(
|
||||
weight = last?.weight ?: type?.lastWeight
|
||||
?: if (type?.tracksWeight == true) 20.0 else null,
|
||||
reps = last?.reps ?: type?.lastReps
|
||||
?: if (type?.tracksReps == true) 8 else null,
|
||||
durationSeconds = last?.durationSeconds ?: type?.lastDurationSeconds
|
||||
?: if (type?.tracksDuration == true) 30 else null,
|
||||
distanceMeters = last?.distanceMeters ?: type?.lastDistanceMeters
|
||||
?: if (type?.tracksDistance == true) 1000.0 else null,
|
||||
rpe = last?.rpe,
|
||||
)
|
||||
}
|
||||
|
||||
fun updateDraft(exerciseId: Long, transform: (SetDraft) -> SetDraft) {
|
||||
val page = uiState.value.pages.firstOrNull { it.localId == exerciseId } ?: return
|
||||
drafts.value = drafts.value + (exerciseId to transform(page.draft))
|
||||
}
|
||||
|
||||
fun logSet(exerciseId: Long) {
|
||||
val page = uiState.value.pages.firstOrNull { it.localId == exerciseId } ?: return
|
||||
val d = page.draft
|
||||
viewModelScope.launch {
|
||||
repo.logSet(
|
||||
exerciseId = exerciseId,
|
||||
reps = if (page.type?.tracksReps != false) d.reps else null,
|
||||
weight = if (page.type?.tracksWeight != false) d.weight else null,
|
||||
distanceMeters = if (page.type?.tracksDistance == true) d.distanceMeters else null,
|
||||
durationSeconds = if (page.type?.tracksDuration == true) d.durationSeconds else null,
|
||||
rpe = d.rpe,
|
||||
isWarmup = false,
|
||||
)
|
||||
// API-tillägget defaultRestSeconds per övning; annars appens standard.
|
||||
restTimer.start(page.type?.defaultRestSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
fun adjustRest(delta: Int) = restTimer.adjust(delta)
|
||||
fun skipRest() = restTimer.dismiss()
|
||||
|
||||
/** Rätta ett redan loggat set (synkas som update, eller bärs av väntande add). */
|
||||
fun saveEditedSet(setId: Long, draft: SetDraft) {
|
||||
val set = uiState.value.pages.flatMap { it.doneSets }.firstOrNull { it.id == setId } ?: return
|
||||
viewModelScope.launch {
|
||||
repo.updateSet(
|
||||
set.copy(
|
||||
weight = draft.weight,
|
||||
reps = draft.reps,
|
||||
durationSeconds = draft.durationSeconds,
|
||||
distanceMeters = draft.distanceMeters,
|
||||
rpe = draft.rpe,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSet(setId: Long) {
|
||||
viewModelScope.launch { repo.deleteSet(setId) }
|
||||
}
|
||||
|
||||
/* ---------- Övningshistorik-arket ---------- */
|
||||
|
||||
sealed interface HistorySheetState {
|
||||
data class Loading(val exerciseName: String) : HistorySheetState
|
||||
data class Error(val exerciseName: String) : HistorySheetState
|
||||
data class Ready(
|
||||
val exerciseName: String,
|
||||
val history: eu.brassepc.fitnessdroid.data.ExerciseHistory,
|
||||
) : HistorySheetState
|
||||
}
|
||||
|
||||
private val _historySheet = MutableStateFlow<HistorySheetState?>(null)
|
||||
val historySheet = _historySheet.asStateFlow()
|
||||
|
||||
fun openExerciseHistory(page: ExercisePage) {
|
||||
val typeId = page.type?.id ?: return
|
||||
_historySheet.value = HistorySheetState.Loading(page.name)
|
||||
viewModelScope.launch {
|
||||
_historySheet.value = try {
|
||||
HistorySheetState.Ready(page.name, repo.fetchExerciseHistory(typeId))
|
||||
} catch (e: Exception) {
|
||||
HistorySheetState.Error(page.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeExerciseHistory() {
|
||||
_historySheet.value = null
|
||||
}
|
||||
|
||||
/** Kroppsvikt för kaloriuppskattningen (cachad från profilen). */
|
||||
val bodyWeightKg = MutableStateFlow<Double?>(null)
|
||||
|
||||
/** Personbästa (typeId, reps) → vikt, för 🏆-markeringen vid avslut. */
|
||||
val personalBests = MutableStateFlow<Map<Pair<Int, Int>, Double>>(emptyMap())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
bodyWeightKg.value = repo.bodyWeightKg()
|
||||
personalBests.value = repo.personalBests()
|
||||
}
|
||||
}
|
||||
|
||||
fun completeSession(
|
||||
durationSecondsOverride: Int?,
|
||||
editedStartEpochMs: Long?,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val id = uiState.value.sessionId ?: sessionFlow.first()?.id ?: return@launch
|
||||
restTimer.dismiss()
|
||||
repo.completeSession(id, durationSecondsOverride, editedStartEpochMs)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
SessionViewModel(c.gymRepository, c.restTimer, c.settingsStore)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Double.compact(): String =
|
||||
if (this % 1.0 == 0.0) toInt().toString() else "%.1f".format(this)
|
||||
@@ -0,0 +1,595 @@
|
||||
package eu.brassepc.fitnessdroid.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.AppSettings
|
||||
import eu.brassepc.fitnessdroid.data.RestAlert
|
||||
import eu.brassepc.fitnessdroid.data.RestTimerStyle
|
||||
import eu.brassepc.fitnessdroid.data.SettingsStore
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class StepsSyncState(
|
||||
val available: Boolean = false,
|
||||
val granted: Boolean = false,
|
||||
val todaySteps: Int? = null,
|
||||
val syncing: Boolean = false,
|
||||
)
|
||||
|
||||
class SettingsViewModel(
|
||||
private val store: SettingsStore,
|
||||
private val tokenStore: eu.brassepc.fitnessdroid.data.TokenStore,
|
||||
private val credentialStore: eu.brassepc.fitnessdroid.data.CredentialStore,
|
||||
val stepsSync: eu.brassepc.fitnessdroid.data.StepsSync,
|
||||
) : ViewModel() {
|
||||
val stepsState = kotlinx.coroutines.flow.MutableStateFlow(StepsSyncState())
|
||||
|
||||
fun refreshStepsState() = viewModelScope.launch {
|
||||
stepsState.value = stepsState.value.copy(
|
||||
available = stepsSync.isAvailable(),
|
||||
granted = stepsSync.hasPermission(),
|
||||
)
|
||||
}
|
||||
|
||||
fun syncStepsNow() = viewModelScope.launch {
|
||||
stepsState.value = stepsState.value.copy(syncing = true)
|
||||
val steps = runCatching { stepsSync.syncNow() }.getOrNull()
|
||||
stepsState.value = stepsState.value.copy(syncing = false, todaySteps = steps)
|
||||
}
|
||||
val settings = store.settings
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AppSettings())
|
||||
|
||||
val apiUrl = kotlinx.coroutines.flow.MutableStateFlow("")
|
||||
|
||||
init {
|
||||
viewModelScope.launch { apiUrl.value = tokenStore.apiUrl() }
|
||||
refreshStepsState()
|
||||
}
|
||||
|
||||
fun setStyle(value: RestTimerStyle) = viewModelScope.launch { store.setRestTimerStyle(value) }
|
||||
fun setAlert(value: RestAlert) = viewModelScope.launch { store.setRestAlert(value) }
|
||||
fun adjustRestSeconds(delta: Int) = viewModelScope.launch {
|
||||
store.setDefaultRestSeconds(settings.value.defaultRestSeconds + delta)
|
||||
}
|
||||
|
||||
fun setWeightStep(value: Double) = viewModelScope.launch { store.setWeightStep(value) }
|
||||
|
||||
fun addCustomBar(value: Double) = viewModelScope.launch { store.addCustomBarWeight(value) }
|
||||
fun removeCustomBar(value: Double) = viewModelScope.launch { store.removeCustomBarWeight(value) }
|
||||
|
||||
fun setTrackLog(value: Boolean) = viewModelScope.launch { store.setTrackLogEnabled(value) }
|
||||
|
||||
fun adjustGpsAccuracy(delta: Int) = viewModelScope.launch {
|
||||
store.setGpsAccuracyLimit(settings.value.gpsAccuracyLimitM + delta)
|
||||
}
|
||||
fun adjustGpsSpeedFactor(delta: Double) = viewModelScope.launch {
|
||||
store.setGpsSpeedFactor(settings.value.gpsSpeedFactor + delta)
|
||||
}
|
||||
fun adjustGpsSpeedFloor(delta: Int) = viewModelScope.launch {
|
||||
store.setGpsSpeedFloor(settings.value.gpsSpeedFloorKmh + delta)
|
||||
}
|
||||
fun setMotionGuard(value: Boolean) = viewModelScope.launch { store.setMotionGuardEnabled(value) }
|
||||
fun adjustCountdown(delta: Int) = viewModelScope.launch {
|
||||
store.setCountdownSeconds(settings.value.countdownSeconds + delta)
|
||||
}
|
||||
fun adjustMotionThreshold(delta: Double) = viewModelScope.launch {
|
||||
store.setMotionThreshold(settings.value.motionThreshold + delta)
|
||||
}
|
||||
|
||||
fun setAutoRelogin(value: Boolean) = viewModelScope.launch {
|
||||
store.setAutoRelogin(value)
|
||||
// Stängs funktionen av slängs de sparade uppgifterna direkt.
|
||||
if (!value) runCatching { credentialStore.clear() }
|
||||
}
|
||||
|
||||
fun onApiUrlChange(value: String) { apiUrl.value = value }
|
||||
|
||||
fun saveApiUrl() = viewModelScope.launch {
|
||||
tokenStore.saveApiUrl(apiUrl.value.trim())
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
SettingsViewModel(c.settingsStore, c.tokenStore, c.credentialStore, c.stepsSync)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenScale: () -> Unit = {},
|
||||
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory),
|
||||
) {
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Inställningar") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
SettingsCard(title = "Bluetooth-våg") {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpenScale)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
settings.scaleDriver ?: "Ingen våg vald",
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
settings.scaleAddress?.let { "Tryck för att byta eller ta bort" }
|
||||
?: "Tryck för att söka och koppla en våg",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard(title = "Stegsynk (Health Connect)") {
|
||||
val stepsState by viewModel.stepsState.collectAsStateWithLifecycle()
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val hcLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.health.connect.client.PermissionController
|
||||
.createRequestPermissionResultContract()
|
||||
) { _ -> viewModel.refreshStepsState() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Läser dagens steg från telefonen (Health Connect) och räknar in " +
|
||||
"dem i kalorimätaren och stegmålen.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
when {
|
||||
!stepsState.available -> {
|
||||
Text(
|
||||
"Health Connect saknas på telefonen.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse("market://details?id=com.google.android.apps.healthdata"),
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Installera Health Connect") }
|
||||
}
|
||||
!stepsState.granted -> {
|
||||
FilledTonalButton(
|
||||
onClick = { hcLauncher.launch(setOf(viewModel.stepsSync.stepsPermission)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Aktivera stegläsning") }
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
"Aktiv ✓" + (stepsState.todaySteps?.let { " · idag: $it steg" } ?: ""),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
FilledTonalButton(
|
||||
onClick = viewModel::syncStepsNow,
|
||||
enabled = !stepsState.syncing,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(if (stepsState.syncing) "Synkar…" else "Synka nu") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard(title = "GPS-spårning") {
|
||||
Text(
|
||||
"Filter mot GPS-brus i aktivitetsspårningen. Fartgrinden förkastar " +
|
||||
"positionshopp som skulle kräva högre fart än faktorn × GPS:ens " +
|
||||
"egen fartmätning (Doppler).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
StepperRow(
|
||||
label = "Noggrannhetsgräns",
|
||||
value = "${settings.gpsAccuracyLimitM} m",
|
||||
onMinus = { viewModel.adjustGpsAccuracy(-5) },
|
||||
onPlus = { viewModel.adjustGpsAccuracy(5) },
|
||||
)
|
||||
StepperRow(
|
||||
label = "Fartgrind (× uppmätt fart)",
|
||||
value = "${settings.gpsSpeedFactor}×",
|
||||
onMinus = { viewModel.adjustGpsSpeedFactor(-0.5) },
|
||||
onPlus = { viewModel.adjustGpsSpeedFactor(0.5) },
|
||||
)
|
||||
StepperRow(
|
||||
label = "Fartgolv",
|
||||
value = "${settings.gpsSpeedFloorKmh} km/h",
|
||||
onMinus = { viewModel.adjustGpsSpeedFloor(-2) },
|
||||
onPlus = { viewModel.adjustGpsSpeedFloor(2) },
|
||||
)
|
||||
StepperRow(
|
||||
label = "Nedräkning vid start",
|
||||
value = "${settings.countdownSeconds} s",
|
||||
onMinus = { viewModel.adjustCountdown(-1) },
|
||||
onPlus = { viewModel.adjustCountdown(1) },
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Rörelsevakt (accelerometer)", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Fryser spår och distans när telefonen känner att du står stilla.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
androidx.compose.material3.Switch(
|
||||
checked = settings.motionGuardEnabled,
|
||||
onCheckedChange = viewModel::setMotionGuard,
|
||||
)
|
||||
}
|
||||
if (settings.motionGuardEnabled) {
|
||||
StepperRow(
|
||||
label = "Rörelsekänslighet",
|
||||
value = "${settings.motionThreshold} m/s²",
|
||||
onMinus = { viewModel.adjustMotionThreshold(-0.05) },
|
||||
onPlus = { viewModel.adjustMotionThreshold(0.05) },
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Gäller från nästa startade aktivitet. Standard: 35 m / 2,0× / 12 km/h / 0,35 m/s².",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCard(title = "Felsökning") {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Teknisk spårningslogg", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Visar GPS-loggen på aktivitetsskärmen (för felsökning).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
androidx.compose.material3.Switch(
|
||||
checked = settings.trackLogEnabled,
|
||||
onCheckedChange = viewModel::setTrackLog,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard(title = "Vilotimerns utseende") {
|
||||
RadioRow(
|
||||
label = "Helskärm",
|
||||
description = "Timern tar över skärmen mellan seten",
|
||||
selected = settings.restTimerStyle == RestTimerStyle.FULLSCREEN,
|
||||
onClick = { viewModel.setStyle(RestTimerStyle.FULLSCREEN) },
|
||||
)
|
||||
RadioRow(
|
||||
label = "Liten banner",
|
||||
description = "Diskret rad längst ner på pass-sidan",
|
||||
selected = settings.restTimerStyle == RestTimerStyle.BANNER,
|
||||
onClick = { viewModel.setStyle(RestTimerStyle.BANNER) },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCard(title = "När vilan är klar") {
|
||||
RadioRow(
|
||||
label = "Larmljud",
|
||||
description = "Spelar notissignalen",
|
||||
selected = settings.restAlert == RestAlert.SOUND,
|
||||
onClick = { viewModel.setAlert(RestAlert.SOUND) },
|
||||
)
|
||||
RadioRow(
|
||||
label = "Vibration",
|
||||
description = "Telefonen vibrerar",
|
||||
selected = settings.restAlert == RestAlert.VIBRATE,
|
||||
onClick = { viewModel.setAlert(RestAlert.VIBRATE) },
|
||||
)
|
||||
RadioRow(
|
||||
label = "Bara visuellt",
|
||||
description = "Timern byter utseende, inget ljud eller surr",
|
||||
selected = settings.restAlert == RestAlert.VISUAL,
|
||||
onClick = { viewModel.setAlert(RestAlert.VISUAL) },
|
||||
)
|
||||
RadioRow(
|
||||
label = "Ingenting",
|
||||
description = "Timern försvinner tyst",
|
||||
selected = settings.restAlert == RestAlert.NONE,
|
||||
onClick = { viewModel.setAlert(RestAlert.NONE) },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCard(title = "Viktsteg för + / − i passläget") {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(1.25, 2.5, 5.0).forEach { step ->
|
||||
androidx.compose.material3.FilterChip(
|
||||
selected = settings.weightStep == step,
|
||||
onClick = { viewModel.setWeightStep(step) },
|
||||
label = {
|
||||
Text(if (step % 1.0 == 0.0) "${step.toInt()} kg" else "$step kg")
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard(title = "Skivkalkylatorn — egna stänger") {
|
||||
androidx.compose.foundation.lazy.LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
items(settings.customBarWeights.size) { i ->
|
||||
val bar = settings.customBarWeights[i]
|
||||
androidx.compose.material3.InputChip(
|
||||
selected = false,
|
||||
onClick = { viewModel.removeCustomBar(bar) },
|
||||
label = {
|
||||
Text(
|
||||
if (bar % 1.0 == 0.0) "${bar.toInt()} kg" else "$bar kg".replace('.', ',')
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
androidx.compose.material3.Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Ta bort",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
var newBar by androidx.compose.runtime.remember {
|
||||
androidx.compose.runtime.mutableStateOf("")
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = newBar,
|
||||
onValueChange = { newBar = it },
|
||||
label = { Text("Ny stångvikt (t.ex. 13,7)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
keyboardType = androidx.compose.ui.text.input.KeyboardType.Decimal,
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
FilledTonalButton(onClick = {
|
||||
newBar.trim().replace(',', '.').toDoubleOrNull()?.let {
|
||||
viewModel.addCustomBar(it)
|
||||
newBar = ""
|
||||
}
|
||||
}) { Text("Lägg till") }
|
||||
}
|
||||
Text(
|
||||
"Snabbvalen 20 kg och utan stång finns alltid i kalkylatorn.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCard(title = "Inloggning") {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Automatisk återinloggning", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Sparar inloggningen krypterat (Android Keystore) och loggar " +
|
||||
"in igen tyst om servern kastat ut dig. " +
|
||||
"Aktiveras vid nästa inloggning.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
androidx.compose.material3.Switch(
|
||||
checked = settings.autoRelogin,
|
||||
onCheckedChange = viewModel::setAutoRelogin,
|
||||
)
|
||||
}
|
||||
val apiUrl by viewModel.apiUrl.collectAsStateWithLifecycle()
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = apiUrl,
|
||||
onValueChange = viewModel::onApiUrlChange,
|
||||
label = { Text("API-url") },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
trailingIcon = {
|
||||
androidx.compose.material3.TextButton(onClick = viewModel::saveApiUrl) {
|
||||
Text("Spara")
|
||||
}
|
||||
},
|
||||
)
|
||||
Text(
|
||||
"Gäller från nästa anrop. Standard: https://gymapi.brasse-pc.eu/graphql",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCard(title = "Standardvila mellan set") {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
FilledTonalButton(onClick = { viewModel.adjustRestSeconds(-15) }) { Text("−15 s") }
|
||||
Text(
|
||||
"${settings.defaultRestSeconds} s",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
FilledTonalButton(onClick = { viewModel.adjustRestSeconds(15) }) { Text("+15 s") }
|
||||
}
|
||||
Text(
|
||||
"Övningar kan få egen vilotid via API:t — då gäller den istället.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsCard(title: String, content: @Composable () -> Unit) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepperRow(
|
||||
label: String,
|
||||
value: String,
|
||||
onMinus: () -> Unit,
|
||||
onPlus: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
|
||||
FilledTonalButton(onClick = onMinus) { Text("−") }
|
||||
Text(
|
||||
value,
|
||||
modifier = Modifier.padding(horizontal = 10.dp),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
FilledTonalButton(onClick = onPlus) { Text("+") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadioRow(label: String, description: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(selected = selected, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
Column(modifier = Modifier.padding(start = 8.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package eu.brassepc.fitnessdroid.ui.stats
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Stiliserad kroppskarta (fram + bak) där muskelgruppernas volym färgas som
|
||||
* värme. Samma formspråk som designförslaget: byggd av enkla former, tydlig
|
||||
* i litet format, temafärgad.
|
||||
*/
|
||||
|
||||
/** Zon-nycklar på figuren. En muskelgrupp kan värma flera zoner. */
|
||||
private enum class Zone { SHOULDERS, CHEST, ARMS, FOREARMS, CORE, QUADS, CALVES, TRAPS, BACK, LOWERBACK, GLUTES, HAMSTRINGS }
|
||||
|
||||
/** Muskelgruppnamn (svenska/engelska heuristik) → zoner. */
|
||||
private fun zonesFor(groupName: String): List<Zone> {
|
||||
val n = groupName.lowercase()
|
||||
return when {
|
||||
"bröst" in n || "chest" in n -> listOf(Zone.CHEST)
|
||||
"axl" in n || "shoulder" in n || "delt" in n -> listOf(Zone.SHOULDERS, Zone.TRAPS)
|
||||
"arm" in n || "bicep" in n || "tricep" in n -> listOf(Zone.ARMS, Zone.FOREARMS)
|
||||
"rygg" in n || "back" in n || "lat" in n -> listOf(Zone.BACK, Zone.LOWERBACK)
|
||||
"mage" in n || "core" in n || "abs" in n || "bål" in n -> listOf(Zone.CORE)
|
||||
"vad" in n || "calv" in n -> listOf(Zone.CALVES)
|
||||
"ben" in n || "leg" in n || "quad" in n || "glut" in n || "hamstring" in n ->
|
||||
listOf(Zone.QUADS, Zone.GLUTES, Zone.HAMSTRINGS)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BodyHeatMap(
|
||||
muscleVolumes: Map<String, Double>,
|
||||
baseColor: Color,
|
||||
hotColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val zoneHeat = HashMap<Zone, Double>()
|
||||
muscleVolumes.forEach { (group, volume) ->
|
||||
zonesFor(group).forEach { zone ->
|
||||
zoneHeat[zone] = (zoneHeat[zone] ?: 0.0) + volume
|
||||
}
|
||||
}
|
||||
val max = zoneHeat.values.maxOrNull()?.takeIf { it > 0 } ?: 1.0
|
||||
fun heat(zone: Zone): Color =
|
||||
lerp(baseColor, hotColor, ((zoneHeat[zone] ?: 0.0) / max).toFloat().coerceIn(0f, 1f))
|
||||
|
||||
Row(modifier = modifier) {
|
||||
Canvas(modifier = Modifier.size(120.dp, 234.dp)) {
|
||||
drawFigureFront(::heat)
|
||||
}
|
||||
Canvas(modifier = Modifier.size(120.dp, 234.dp)) {
|
||||
drawFigureBack(::heat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Koordinatsystem 100×195 (som designförslaget), skalas till canvasen. */
|
||||
private fun DrawScope.px(x: Double, y: Double, w: Double, h: Double, r: Double, color: Color) {
|
||||
val sx = size.width / 100f
|
||||
val sy = size.height / 195f
|
||||
drawRoundRect(
|
||||
color = color,
|
||||
topLeft = Offset((x * sx).toFloat(), (y * sy).toFloat()),
|
||||
size = Size((w * sx).toFloat(), (h * sy).toFloat()),
|
||||
cornerRadius = CornerRadius((r * sx).toFloat()),
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.head(neutral: Color) {
|
||||
val sx = size.width / 100f
|
||||
val sy = size.height / 195f
|
||||
drawCircle(neutral, radius = 9f * sx, center = Offset(50f * sx, 12f * sy))
|
||||
}
|
||||
|
||||
private fun DrawScope.drawFigureFront(heat: (Zone) -> Color) {
|
||||
val neutral = heat(Zone.CORE).copy(alpha = 0.25f)
|
||||
head(neutral)
|
||||
px(43.0, 22.0, 14.0, 7.0, 3.0, neutral) // hals
|
||||
// axlar
|
||||
px(26.0, 30.0, 15.0, 12.0, 6.0, heat(Zone.SHOULDERS))
|
||||
px(59.0, 30.0, 15.0, 12.0, 6.0, heat(Zone.SHOULDERS))
|
||||
// bröst
|
||||
px(33.0, 34.0, 15.0, 14.0, 6.0, heat(Zone.CHEST))
|
||||
px(52.0, 34.0, 15.0, 14.0, 6.0, heat(Zone.CHEST))
|
||||
// mage/core
|
||||
px(36.0, 50.0, 28.0, 26.0, 8.0, heat(Zone.CORE))
|
||||
// överarmar + underarmar
|
||||
px(22.0, 40.0, 8.0, 24.0, 4.0, heat(Zone.ARMS))
|
||||
px(70.0, 40.0, 8.0, 24.0, 4.0, heat(Zone.ARMS))
|
||||
px(21.0, 66.0, 7.0, 20.0, 3.5, heat(Zone.FOREARMS))
|
||||
px(72.0, 66.0, 7.0, 20.0, 3.5, heat(Zone.FOREARMS))
|
||||
// lår
|
||||
px(34.0, 80.0, 13.0, 42.0, 6.0, heat(Zone.QUADS))
|
||||
px(53.0, 80.0, 13.0, 42.0, 6.0, heat(Zone.QUADS))
|
||||
// smalben (neutralt fram)
|
||||
px(36.0, 126.0, 10.0, 30.0, 5.0, neutral)
|
||||
px(54.0, 126.0, 10.0, 30.0, 5.0, neutral)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawFigureBack(heat: (Zone) -> Color) {
|
||||
val neutral = heat(Zone.CORE).copy(alpha = 0.25f)
|
||||
head(neutral)
|
||||
// trapezius/nacke
|
||||
px(40.0, 22.0, 20.0, 10.0, 4.0, heat(Zone.TRAPS))
|
||||
// rygg (lats)
|
||||
px(34.0, 33.0, 32.0, 22.0, 8.0, heat(Zone.BACK))
|
||||
// ländrygg
|
||||
px(38.0, 57.0, 24.0, 18.0, 7.0, heat(Zone.LOWERBACK))
|
||||
// armar bak
|
||||
px(22.0, 40.0, 8.0, 24.0, 4.0, heat(Zone.ARMS))
|
||||
px(70.0, 40.0, 8.0, 24.0, 4.0, heat(Zone.ARMS))
|
||||
px(21.0, 66.0, 7.0, 20.0, 3.5, heat(Zone.FOREARMS))
|
||||
px(72.0, 66.0, 7.0, 20.0, 3.5, heat(Zone.FOREARMS))
|
||||
// säte
|
||||
px(35.0, 77.0, 30.0, 14.0, 6.0, heat(Zone.GLUTES))
|
||||
// baksida lår
|
||||
px(34.0, 93.0, 13.0, 32.0, 6.0, heat(Zone.HAMSTRINGS))
|
||||
px(53.0, 93.0, 13.0, 32.0, 6.0, heat(Zone.HAMSTRINGS))
|
||||
// vader
|
||||
px(36.0, 128.0, 10.0, 28.0, 5.0, heat(Zone.CALVES))
|
||||
px(54.0, 128.0, 10.0, 28.0, 5.0, heat(Zone.CALVES))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package eu.brassepc.fitnessdroid.ui.stats
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.brassepc.fitnessdroid.data.BodyMeasurement
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class CompositionSeries(val label: String, val color: Color, val values: List<Double?>)
|
||||
|
||||
/**
|
||||
* Flerlinjes-graf för kroppssammansättning över tid. Varje serie (muskel/
|
||||
* fett/vatten) ritas som egen linje; null-värden hoppas över (spanGaps).
|
||||
* Delar tidsordningen med [WeightGraph].
|
||||
*/
|
||||
@Composable
|
||||
fun CompositionGraph(
|
||||
measurements: List<BodyMeasurement>,
|
||||
series: List<CompositionSeries>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val order = measurements.mapIndexedNotNull { i, m ->
|
||||
runCatching { OffsetDateTime.parse(m.date).toInstant().toEpochMilli() }.getOrNull()?.let { i to it }
|
||||
}.sortedBy { it.second }.map { it.first }
|
||||
if (order.size < 2) return
|
||||
|
||||
val allValues = series.flatMap { it.values }.filterNotNull()
|
||||
if (allValues.isEmpty()) return
|
||||
val lo = allValues.min() * 0.9
|
||||
val hi = allValues.max() * 1.1
|
||||
val span = (hi - lo).takeIf { it > 0 } ?: 1.0
|
||||
|
||||
Canvas(modifier = modifier.fillMaxWidth().height(160.dp)) {
|
||||
fun x(idx: Int) = idx / (order.size - 1).toFloat() * size.width
|
||||
fun y(v: Double) = (size.height - ((v - lo) / span).toFloat() * size.height)
|
||||
|
||||
series.forEach { s ->
|
||||
val path = Path()
|
||||
var started = false
|
||||
order.forEachIndexed { plotIdx, measurementIdx ->
|
||||
val v = s.values.getOrNull(measurementIdx) ?: return@forEachIndexed
|
||||
val px = x(plotIdx)
|
||||
val py = y(v)
|
||||
if (!started) { path.moveTo(px, py); started = true } else path.lineTo(px, py)
|
||||
drawCircle(s.color, radius = 5f, center = Offset(px, py))
|
||||
}
|
||||
if (started) drawPath(path, color = s.color, style = Stroke(width = 4f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
package eu.brassepc.fitnessdroid.ui.stats
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.StatsBundle
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import eu.brassepc.fitnessdroid.ui.common.LiftStatusSheet
|
||||
import eu.brassepc.fitnessdroid.ui.common.LiftStatusTarget
|
||||
import eu.brassepc.fitnessdroid.ui.common.compact
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PeriodOption(val id: String, val label: String)
|
||||
|
||||
val PB_MODES = listOf("all" to "Alla giltiga", "competition" to "Tävling", "training" to "Träning")
|
||||
|
||||
val PERIODS = listOf(
|
||||
PeriodOption("week", "Vecka"),
|
||||
PeriodOption("month", "Månad"),
|
||||
PeriodOption("halfyear", "Halvår"),
|
||||
PeriodOption("year", "År"),
|
||||
PeriodOption("all", "Totalt"),
|
||||
)
|
||||
|
||||
sealed interface StatsUiState {
|
||||
data object Loading : StatsUiState
|
||||
data class Error(val message: String) : StatsUiState
|
||||
data class Ready(val stats: StatsBundle) : StatsUiState
|
||||
}
|
||||
|
||||
class StatsViewModel(
|
||||
val repo: GymRepository,
|
||||
private val gymApi: eu.brassepc.fitnessdroid.data.GymApi,
|
||||
) : ViewModel() {
|
||||
val period = MutableStateFlow("month")
|
||||
val uiState = MutableStateFlow<StatsUiState>(StatsUiState.Loading)
|
||||
val bodyMeasurements = MutableStateFlow<List<eu.brassepc.fitnessdroid.data.BodyMeasurement>>(emptyList())
|
||||
val activities = MutableStateFlow<List<eu.brassepc.fitnessdroid.data.Activity>>(emptyList())
|
||||
|
||||
private fun periodStart(): java.time.Instant? {
|
||||
val now = java.time.Instant.now()
|
||||
return when (period.value) {
|
||||
"week" -> now.minus(java.time.Duration.ofDays(7))
|
||||
"month" -> now.minus(java.time.Duration.ofDays(30))
|
||||
"halfyear" -> now.minus(java.time.Duration.ofDays(182))
|
||||
"year" -> now.minus(java.time.Duration.ofDays(365))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshActivities() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val all = gymApi.activities(limit = 500)
|
||||
val start = periodStart()
|
||||
activities.value = if (start == null) all else all.filter {
|
||||
runCatching { java.time.OffsetDateTime.parse(it.startedAt).toInstant() >= start }
|
||||
.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
load()
|
||||
refreshMeasurements()
|
||||
refreshActivities()
|
||||
}
|
||||
|
||||
fun refreshMeasurements() {
|
||||
viewModelScope.launch {
|
||||
runCatching { bodyMeasurements.value = repo.fetchBodyMeasurements() }
|
||||
}
|
||||
}
|
||||
|
||||
fun setPeriod(id: String) {
|
||||
if (period.value == id) return
|
||||
period.value = id
|
||||
load()
|
||||
refreshActivities()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
uiState.value = StatsUiState.Loading
|
||||
viewModelScope.launch {
|
||||
uiState.value = try {
|
||||
StatsUiState.Ready(repo.fetchStats(period.value))
|
||||
} catch (e: Exception) {
|
||||
StatsUiState.Error("Kunde inte hämta statistiken — offline?")
|
||||
}
|
||||
}
|
||||
refreshPbs()
|
||||
}
|
||||
|
||||
// ── Personbästa: läge (alla giltiga / tävling / träning) + lista ──
|
||||
val pbMode = MutableStateFlow("all")
|
||||
val pbs = MutableStateFlow<List<eu.brassepc.fitnessdroid.data.PbEntry>>(emptyList())
|
||||
|
||||
fun setPbMode(mode: String) {
|
||||
if (pbMode.value == mode) return
|
||||
pbMode.value = mode
|
||||
refreshPbs()
|
||||
}
|
||||
|
||||
fun refreshPbs() {
|
||||
viewModelScope.launch {
|
||||
runCatching { pbs.value = repo.personalBestsDetailed(pbMode.value) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
StatsViewModel(c.gymRepository, c.gymApi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val period by viewModel.period.collectAsStateWithLifecycle()
|
||||
val pbMode by viewModel.pbMode.collectAsStateWithLifecycle()
|
||||
val pbs by viewModel.pbs.collectAsStateWithLifecycle()
|
||||
var pbSheet by remember { mutableStateOf<LiftStatusTarget?>(null) }
|
||||
|
||||
pbSheet?.let { target ->
|
||||
LiftStatusSheet(
|
||||
target = target,
|
||||
repo = viewModel.repo,
|
||||
mode = pbMode,
|
||||
onDismiss = { pbSheet = null },
|
||||
onChanged = { viewModel.refreshPbs() },
|
||||
)
|
||||
}
|
||||
|
||||
// Hämta om mätningarna varje gång fliken öppnas — ny vikt kan ha
|
||||
// lagts in via profilen sedan sist.
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
viewModel.refreshMeasurements()
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Text("Statistik", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
item {
|
||||
androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(PERIODS, key = { it.id }) { p ->
|
||||
FilterChip(
|
||||
selected = period == p.id,
|
||||
onClick = { viewModel.setPeriod(p.id) },
|
||||
label = { Text(p.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val state = uiState) {
|
||||
is StatsUiState.Loading -> item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(48.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
}
|
||||
is StatsUiState.Error -> item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(state.message, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Button(onClick = viewModel::load, modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text("Försök igen")
|
||||
}
|
||||
}
|
||||
}
|
||||
is StatsUiState.Ready -> {
|
||||
val stats = state.stats
|
||||
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
KpiCard("Pass", "${stats.sessionCount}", delta(stats.sessionCount.toDouble(), stats.prevSessionCount?.toDouble()), Modifier.weight(1f))
|
||||
KpiCard("Tid", "${stats.totalDurationMinutes / 60}h ${stats.totalDurationMinutes % 60}m", delta(stats.totalDurationMinutes.toDouble(), stats.prevDurationMinutes?.toDouble()), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
KpiCard("Volym", "${stats.totalVolumeKg.toInt()} kg", delta(stats.totalVolumeKg, stats.prevVolumeKg), Modifier.weight(1f))
|
||||
KpiCard("Kalorier", "${stats.totalCalories.toInt()} kcal", delta(stats.totalCalories, stats.prevCalories), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
KpiCard("Streak", "${stats.streakWeeks} v", null, Modifier.weight(1f))
|
||||
KpiCard("Pass/vecka", "%.1f".format(stats.sessionsPerWeek), null, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
val periodActivities by viewModel.activities.collectAsStateWithLifecycle()
|
||||
CardioCard(periodActivities)
|
||||
}
|
||||
|
||||
if (stats.trend.size > 1) {
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Text(
|
||||
"Volym per " + when (period) {
|
||||
"week", "month" -> "dag"
|
||||
"halfyear" -> "vecka"
|
||||
else -> "månad"
|
||||
},
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
TrendBars(stats)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Text("Var hamnade volymen?", style = MaterialTheme.typography.titleSmall)
|
||||
if (stats.muscleVolumes.isEmpty()) {
|
||||
Text(
|
||||
"Ingen loggad volym i perioden.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
BodyHeatMap(
|
||||
muscleVolumes = stats.muscleVolumes,
|
||||
baseColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
hotColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text("Framsida", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("Mörkare = mer volym", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("Baksida", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
stats.muscleVolumes.entries.sortedByDescending { it.value }.take(3)
|
||||
.let { top ->
|
||||
Text(
|
||||
"Mest: " + top.joinToString(" · ") { "${it.key} ${it.value.toInt()} kg" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
val measurements by viewModel.bodyMeasurements.collectAsStateWithLifecycle()
|
||||
if (measurements.size >= 2) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row {
|
||||
Text(
|
||||
"Kroppsvikt",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"${measurements.last().weightKg.compact()} kg nu",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
WeightGraph(
|
||||
measurements = measurements,
|
||||
lineColor = MaterialTheme.colorScheme.primary,
|
||||
trendColor = MaterialTheme.colorScheme.tertiary,
|
||||
pointColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
measurements.first().date.take(10),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"— mjuk linje · - - trend",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
measurements.last().date.take(10),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
val measurements by viewModel.bodyMeasurements.collectAsStateWithLifecycle()
|
||||
val hasComposition = measurements.any {
|
||||
it.musclePercent != null || it.fatPercent != null || it.waterPercent != null
|
||||
}
|
||||
if (measurements.size >= 2 && hasComposition) {
|
||||
CompositionCard(measurements)
|
||||
}
|
||||
}
|
||||
|
||||
if (stats.heaviestLift != null || stats.newPrCount > 0) {
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Höjdpunkter", style = MaterialTheme.typography.titleSmall)
|
||||
stats.heaviestLift?.let { Text("🏋 Tyngsta lyft: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
if (stats.newPrCount > 0) {
|
||||
Text("🏆 ${stats.newPrCount} nya personbästa i perioden", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Personbästa", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
||||
}
|
||||
androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp)) {
|
||||
items(PB_MODES, key = { it.first }) { (key, label) ->
|
||||
FilterChip(
|
||||
selected = pbMode == key,
|
||||
onClick = { viewModel.setPbMode(key) },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pbs.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
if (pbMode == "competition") "Inga tävlingslyft märkta än. Tryck på ett rekord eller ett set i historiken och välj Tävling."
|
||||
else "Inga rekord än.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
items(pbs, key = { "${it.exerciseTypeId}-${it.exerciseName}" }) { pb ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Text(pb.exerciseName, style = MaterialTheme.typography.titleSmall)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 6.dp)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
pb.records.forEach { r ->
|
||||
androidx.compose.material3.AssistChip(
|
||||
onClick = {
|
||||
pbSheet = LiftStatusTarget.Lift(pb.exerciseTypeId, pb.exerciseName, r.reps, r.liftId)
|
||||
},
|
||||
label = {
|
||||
Text("${r.reps}RM ${r.weight.compact()} kg${if (r.status == "competition") " 🎖️" else ""}")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Tryck på ett rekord för detaljer, tävling eller räknas ej.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class CompPart(val label: String, val value: (eu.brassepc.fitnessdroid.data.BodyMeasurement) -> Double?)
|
||||
|
||||
@Composable
|
||||
private fun CompositionCard(measurements: List<eu.brassepc.fitnessdroid.data.BodyMeasurement>) {
|
||||
var showKg by remember { mutableStateOf(false) }
|
||||
val latest = measurements.last()
|
||||
|
||||
val muscle = MaterialTheme.colorScheme.primary
|
||||
val fat = MaterialTheme.colorScheme.tertiary
|
||||
val water = MaterialTheme.colorScheme.secondary
|
||||
|
||||
val parts = listOf(
|
||||
Triple("Muskler", muscle, CompPart("Muskler") { it.musclePercent }),
|
||||
Triple("Fett", fat, CompPart("Fett") { it.fatPercent }),
|
||||
Triple("Vatten", water, CompPart("Vatten") { it.waterPercent }),
|
||||
).filter { (_, _, p) -> measurements.any { p.value(it) != null } }
|
||||
|
||||
fun kgOf(m: eu.brassepc.fitnessdroid.data.BodyMeasurement, pct: Double?): Double? =
|
||||
if (pct == null) null else m.weightKg * pct / 100.0
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Sammansättning",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// %/kg-växel
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
FilterChip(
|
||||
selected = !showKg,
|
||||
onClick = { showKg = false },
|
||||
label = { Text("%") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = showKg,
|
||||
onClick = { showKg = true },
|
||||
label = { Text("kg") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Senaste värden: % + omräknat till kg
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
parts.forEach { (label, color, part) ->
|
||||
val pct = part.value(latest)
|
||||
val kg = kgOf(latest, pct)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = color)
|
||||
Text(
|
||||
pct?.let { "${it.compact()}%" } ?: "–",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
kg?.let { "≈ ${it.compact()} kg" } ?: "",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionGraph(
|
||||
measurements = measurements,
|
||||
series = parts.map { (label, color, part) ->
|
||||
CompositionSeries(
|
||||
label = label,
|
||||
color = color,
|
||||
values = measurements.map { m ->
|
||||
val pct = part.value(m)
|
||||
if (showKg) kgOf(m, pct) else pct
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Text(
|
||||
if (showKg) "Faktisk vikt per del (kg)" else "Andel av kroppsvikten (%)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun delta(current: Double, previous: Double?): String? {
|
||||
previous ?: return null
|
||||
if (previous == 0.0) return null
|
||||
val pct = ((current - previous) / previous * 100).toInt()
|
||||
return if (pct >= 0) "+$pct%" else "$pct%"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KpiCard(label: String, value: String, deltaText: String?, modifier: Modifier = Modifier) {
|
||||
Card(modifier = modifier) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
deltaText?.let {
|
||||
Text(
|
||||
"$it mot förra perioden",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (it.startsWith("+")) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrendBars(stats: StatsBundle) {
|
||||
val max = stats.trend.maxOf { it.volumeKg }.takeIf { it > 0 } ?: 1.0
|
||||
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
|
||||
// Börja längst till höger — senaste perioden är den intressanta.
|
||||
androidx.compose.runtime.LaunchedEffect(stats.trend.size) {
|
||||
if (stats.trend.isNotEmpty()) listState.scrollToItem(stats.trend.size - 1)
|
||||
}
|
||||
|
||||
androidx.compose.foundation.lazy.LazyRow(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(112.dp)
|
||||
.padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
items(stats.trend.size) { i ->
|
||||
val bucket = stats.trend[i]
|
||||
Column(
|
||||
modifier = Modifier.width(38.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (bucket.volumeKg > 0) {
|
||||
Text(
|
||||
if (bucket.volumeKg >= 1000) {
|
||||
"${(bucket.volumeKg / 1000).toInt()}t"
|
||||
} else {
|
||||
"${bucket.volumeKg.toInt()}"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(((bucket.volumeKg / max) * 64).dp.coerceAtLeast(2.dp))
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary.copy(
|
||||
alpha = if (bucket.volumeKg > 0) 0.85f else 0.25f,
|
||||
),
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
bucket.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Konditionssektionen ❤️ — aktiviteter i vald period: KPI, vecko-km och per typ. */
|
||||
@Composable
|
||||
private fun CardioCard(activities: List<eu.brassepc.fitnessdroid.data.Activity>) {
|
||||
if (activities.isEmpty()) return
|
||||
val totalKm = activities.sumOf { it.distanceMeters ?: 0.0 } / 1000.0
|
||||
val totalMin = activities.sumOf { it.durationSeconds } / 60
|
||||
val totalKcal = activities.sumOf { it.estimatedKcal ?: 0.0 }
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.material3.Icon(
|
||||
Icons.Filled.Favorite,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
"Kondition & aktiviteter",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
CardioKpi("Aktiviteter", "${activities.size}", Modifier.weight(1f))
|
||||
CardioKpi("Distans", "%.1f km".format(totalKm), Modifier.weight(1f))
|
||||
CardioKpi("Tid", "${totalMin / 60}h ${totalMin % 60}m", Modifier.weight(1f))
|
||||
CardioKpi("Kcal", "${totalKcal.toInt()}", Modifier.weight(1f))
|
||||
}
|
||||
|
||||
// Km per vecka (senaste 8 med data)
|
||||
val weekly = activities
|
||||
.mapNotNull { a ->
|
||||
runCatching {
|
||||
val d = java.time.OffsetDateTime.parse(a.startedAt)
|
||||
.atZoneSameInstant(java.time.ZoneId.systemDefault()).toLocalDate()
|
||||
val week = d.with(java.time.DayOfWeek.MONDAY)
|
||||
week to (a.distanceMeters ?: 0.0)
|
||||
}.getOrNull()
|
||||
}
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, v) -> v.sum() / 1000.0 }
|
||||
.toSortedMap()
|
||||
.entries.toList().takeLast(8)
|
||||
val maxKm = weekly.maxOfOrNull { it.value } ?: 0.0
|
||||
if (weekly.size > 1 && maxKm > 0) {
|
||||
Text(
|
||||
"Km per vecka",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
weekly.forEach { (week, km) ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"v${week.get(java.time.temporal.WeekFields.ISO.weekOfWeekBasedYear())}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.width(32.dp),
|
||||
)
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(14.dp),
|
||||
) {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(fraction = (km / maxKm).toFloat().coerceIn(0.02f, 1f))
|
||||
.height(14.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
MaterialTheme.shapes.small,
|
||||
),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"%.1f".format(km),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per typ
|
||||
val byType = activities.groupBy { it.activityType.nameSv }
|
||||
.entries.sortedByDescending { it.value.size }
|
||||
Text(
|
||||
"Per aktivitet",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
byType.take(8).forEach { (name, list) ->
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
val km = list.sumOf { it.distanceMeters ?: 0.0 } / 1000.0
|
||||
Text(
|
||||
buildString {
|
||||
append("${list.size} st")
|
||||
if (km > 0.05) append(" · %.1f km".format(km))
|
||||
append(" · ${list.sumOf { it.durationSeconds } / 60} min")
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CardioKpi(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(value, style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package eu.brassepc.fitnessdroid.ui.stats
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.brassepc.fitnessdroid.data.BodyMeasurement
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
/**
|
||||
* Kroppsvikt över tid: råpunkter, glidande medelvärde (mjuk linje) och
|
||||
* linjär trendlinje (streckad).
|
||||
*/
|
||||
@Composable
|
||||
fun WeightGraph(
|
||||
measurements: List<BodyMeasurement>,
|
||||
lineColor: Color,
|
||||
trendColor: Color,
|
||||
pointColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var points = measurements.mapNotNull { m ->
|
||||
runCatching {
|
||||
OffsetDateTime.parse(m.date).toInstant().toEpochMilli().toFloat() to m.weightKg.toFloat()
|
||||
}.getOrNull()
|
||||
}.sortedBy { it.first }
|
||||
if (points.size < 2) return
|
||||
|
||||
// Alla mätningar inom samma dygn? Sprid dem jämnt på index istället,
|
||||
// annars kollapsar hela grafen till en punkt.
|
||||
if (points.last().first - points.first().first < 86_400_000f) {
|
||||
points = points.mapIndexed { i, (_, w) -> i.toFloat() to w }
|
||||
}
|
||||
|
||||
val minX = points.first().first
|
||||
val maxX = points.last().first
|
||||
val minW = points.minOf { it.second }
|
||||
val maxW = points.maxOf { it.second }
|
||||
val padW = ((maxW - minW).takeIf { it > 0f } ?: 1f) * 0.15f
|
||||
val loW = minW - padW
|
||||
val hiW = maxW + padW
|
||||
|
||||
// Glidande medelvärde (fönster 5)
|
||||
val smooth = points.mapIndexed { i, _ ->
|
||||
val from = (i - 2).coerceAtLeast(0)
|
||||
val to = (i + 2).coerceAtMost(points.lastIndex)
|
||||
val slice = points.subList(from, to + 1)
|
||||
points[i].first to slice.map { it.second }.average().toFloat()
|
||||
}
|
||||
|
||||
// Linjär regression för trendlinjen
|
||||
val n = points.size.toFloat()
|
||||
val meanX = points.map { it.first }.average().toFloat()
|
||||
val meanY = points.map { it.second }.average().toFloat()
|
||||
val denominator = points.sumOf { ((it.first - meanX) * (it.first - meanX)).toDouble() }
|
||||
val slope = if (denominator == 0.0) 0f else {
|
||||
(points.sumOf { ((it.first - meanX) * (it.second - meanY)).toDouble() } / denominator).toFloat()
|
||||
}
|
||||
val intercept = meanY - slope * meanX
|
||||
|
||||
Canvas(modifier = modifier.fillMaxWidth().height(160.dp)) {
|
||||
fun x(v: Float) = (v - minX) / (maxX - minX).coerceAtLeast(1f) * size.width
|
||||
fun y(w: Float) = size.height - (w - loW) / (hiW - loW) * size.height
|
||||
|
||||
// Trendlinje (streckad)
|
||||
drawLine(
|
||||
color = trendColor,
|
||||
start = Offset(0f, y(slope * minX + intercept)),
|
||||
end = Offset(size.width, y(slope * maxX + intercept)),
|
||||
strokeWidth = 3f,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(14f, 10f)),
|
||||
)
|
||||
|
||||
// Mjuk linje (glidande medelvärde)
|
||||
val path = Path()
|
||||
smooth.forEachIndexed { i, (px, pw) ->
|
||||
if (i == 0) path.moveTo(x(px), y(pw)) else path.lineTo(x(px), y(pw))
|
||||
}
|
||||
drawPath(path, color = lineColor, style = Stroke(width = 5f))
|
||||
|
||||
// Råpunkter
|
||||
points.forEach { (px, pw) ->
|
||||
drawCircle(color = pointColor, radius = 6f, center = Offset(x(px), y(pw)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
package eu.brassepc.fitnessdroid.ui.track
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.ActivityKcal
|
||||
import eu.brassepc.fitnessdroid.data.ActivityType
|
||||
import eu.brassepc.fitnessdroid.data.GymApi
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.TrackLog
|
||||
import eu.brassepc.fitnessdroid.data.TrackingPhase
|
||||
import eu.brassepc.fitnessdroid.data.TrackingService
|
||||
import eu.brassepc.fitnessdroid.data.TrackingState
|
||||
import eu.brassepc.fitnessdroid.data.encodePolyline
|
||||
import eu.brassepc.fitnessdroid.ui.activities.RPE_LABELS
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import eu.brassepc.fitnessdroid.ui.common.compact
|
||||
import eu.brassepc.fitnessdroid.ui.common.openAppSettings
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Polyline
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
|
||||
class TrackViewModel(
|
||||
private val gymApi: GymApi,
|
||||
private val repo: GymRepository,
|
||||
private val settingsStore: eu.brassepc.fitnessdroid.data.SettingsStore,
|
||||
) : ViewModel() {
|
||||
val tracking = TrackingService.state
|
||||
/** Teknisk logg visas bara om den slagits på i inställningarna. */
|
||||
val logEnabled = settingsStore.settings.map { it.trackLogEnabled }
|
||||
val message = MutableStateFlow<String?>(null)
|
||||
val saved = MutableStateFlow(false)
|
||||
/** Sammanfattningen som visas efter stopp (null = spårning pågår/ej startad) */
|
||||
val summary = MutableStateFlow<TrackingState?>(null)
|
||||
|
||||
val pendingType = MutableStateFlow<ActivityType?>(null)
|
||||
val typeLoadError = MutableStateFlow(false)
|
||||
|
||||
fun loadType(typeId: Int) {
|
||||
viewModelScope.launch {
|
||||
typeLoadError.value = false
|
||||
if (TrackingService.state.value.isActive) return@launch
|
||||
runCatching { gymApi.activityTypes().find { it.id == typeId } }
|
||||
.fold(
|
||||
onSuccess = { t ->
|
||||
if (t != null) pendingType.value = t else typeLoadError.value = true
|
||||
},
|
||||
onFailure = { typeLoadError.value = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun start(context: android.content.Context) {
|
||||
val type = pendingType.value ?: return
|
||||
viewModelScope.launch {
|
||||
val cfg = settingsStore.settings.first()
|
||||
TrackingService.start(
|
||||
context, type, repo.bodyWeightKg(),
|
||||
gpsAccuracyLimitM = cfg.gpsAccuracyLimitM,
|
||||
gpsSpeedFactor = cfg.gpsSpeedFactor,
|
||||
gpsSpeedFloorKmh = cfg.gpsSpeedFloorKmh,
|
||||
motionGuardEnabled = cfg.motionGuardEnabled,
|
||||
motionThreshold = cfg.motionThreshold,
|
||||
countdownSeconds = cfg.countdownSeconds,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: android.content.Context) {
|
||||
summary.value = tracking.value
|
||||
TrackingService.stop(context)
|
||||
}
|
||||
|
||||
fun save(rpe: Double?, overrideTypeId: Int?) {
|
||||
val s = summary.value ?: return
|
||||
viewModelScope.launch {
|
||||
val startedIso = Instant.ofEpochMilli(s.startedAtEpochMs).toString()
|
||||
val distance = s.distanceMeters.takeIf { s.isDistanceBased && it > 10 }
|
||||
val elevation = s.elevationGainMeters.takeIf { s.isDistanceBased && it > 1 }
|
||||
// Banta rutten före sparning — Douglas-Peucker 4 m + tak 800 punkter
|
||||
val polyline = s.points.takeIf { it.size >= 2 }
|
||||
?.let { eu.brassepc.fitnessdroid.data.simplifyRoute(it) }
|
||||
?.let { if (it.size > 800) it.filterIndexed { i, _ -> i % (it.size / 800 + 1) == 0 } else it }
|
||||
?.let { encodePolyline(it) }
|
||||
try {
|
||||
gymApi.addActivity(
|
||||
activityTypeId = overrideTypeId ?: s.typeId,
|
||||
startedAtIso = startedIso,
|
||||
durationSeconds = s.elapsedSeconds.coerceAtLeast(1),
|
||||
distanceMeters = distance,
|
||||
elevationGainMeters = elevation,
|
||||
rpe = rpe,
|
||||
source = "tracked",
|
||||
routePolyline = polyline,
|
||||
steps = s.steps?.takeIf { it > 0 },
|
||||
)
|
||||
saved.value = true
|
||||
message.value = null
|
||||
TrackLog.log("sparad på servern")
|
||||
} catch (e: Exception) {
|
||||
// Offline → köa lokalt, synk-kön skickar när nätet är tillbaka
|
||||
runCatching {
|
||||
repo.queueActivity(
|
||||
eu.brassepc.fitnessdroid.data.local.PendingActivity(
|
||||
activityTypeId = overrideTypeId ?: s.typeId,
|
||||
typeName = s.typeName,
|
||||
isCardio = true,
|
||||
startedAtIso = startedIso,
|
||||
durationSeconds = s.elapsedSeconds.coerceAtLeast(1),
|
||||
distanceMeters = distance,
|
||||
elevationGainMeters = elevation,
|
||||
rpe = rpe,
|
||||
source = "tracked",
|
||||
routePolyline = polyline,
|
||||
)
|
||||
)
|
||||
saved.value = true
|
||||
message.value = "Ingen kontakt med servern — sparad lokalt, synkas automatiskt."
|
||||
TrackLog.log("offline: köad lokalt för synk")
|
||||
}.onFailure {
|
||||
message.value = "Kunde inte spara — försök igen."
|
||||
TrackLog.log("FEL vid sparning: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
TrackViewModel(c.gymApi, c.gymRepository, c.settingsStore)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TrackScreen(
|
||||
typeId: Int,
|
||||
onBack: () -> Unit,
|
||||
viewModel: TrackViewModel = viewModel(factory = TrackViewModel.Factory),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val tracking by viewModel.tracking.collectAsStateWithLifecycle()
|
||||
val summary by viewModel.summary.collectAsStateWithLifecycle()
|
||||
val saved by viewModel.saved.collectAsStateWithLifecycle()
|
||||
val message by viewModel.message.collectAsStateWithLifecycle()
|
||||
val logEnabled by viewModel.logEnabled.collectAsStateWithLifecycle(false)
|
||||
|
||||
val pendingType by viewModel.pendingType.collectAsStateWithLifecycle()
|
||||
val typeLoadError by viewModel.typeLoadError.collectAsStateWithLifecycle()
|
||||
var permissionDenied by remember { mutableStateOf(false) }
|
||||
var started by remember { mutableStateOf(false) }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { granted ->
|
||||
// Notis-permission är trevlig men inte nödvändig; GPS krävs för distans
|
||||
val fineOk = granted[Manifest.permission.ACCESS_FINE_LOCATION] ?: false
|
||||
val needsGps = viewModel.pendingType.value?.isDistanceBased == true
|
||||
if (!needsGps || fineOk) {
|
||||
viewModel.start(context)
|
||||
started = true
|
||||
} else {
|
||||
permissionDenied = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Begär saknade behörigheter (eller starta direkt) — körs även från "Försök igen". */
|
||||
fun tryStart() {
|
||||
val type = pendingType ?: return
|
||||
permissionDenied = false
|
||||
val needed = buildList {
|
||||
if (type.isDistanceBased) {
|
||||
add(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
add(Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
// Stegsensorn (valfri — nekas den blir stegen bara tomma)
|
||||
add(Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
}
|
||||
// POST_NOTIFICATIONS finns först i Android 13 (API 33)
|
||||
if (android.os.Build.VERSION.SDK_INT >= 33) {
|
||||
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}.filter {
|
||||
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (needed.isEmpty()) {
|
||||
viewModel.start(context)
|
||||
started = true
|
||||
} else {
|
||||
permissionLauncher.launch(needed.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
// Ladda typen (om inte en spårning redan pågår) …
|
||||
LaunchedEffect(typeId) {
|
||||
if (tracking.isActive) { started = true; return@LaunchedEffect }
|
||||
viewModel.loadType(typeId)
|
||||
}
|
||||
// … och starta när den kommit
|
||||
LaunchedEffect(pendingType) {
|
||||
if (pendingType != null && !started && !tracking.isActive) tryStart()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Favorite,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
tracking.typeName.ifBlank { pendingType?.nameSv ?: "Aktivitet" },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
when {
|
||||
summary != null -> SummaryContent(
|
||||
summary = summary!!,
|
||||
saved = saved,
|
||||
message = message,
|
||||
onSave = { rpe, overrideId -> viewModel.save(rpe, overrideId) },
|
||||
onDone = onBack,
|
||||
showLog = logEnabled,
|
||||
)
|
||||
permissionDenied -> Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
"Platsbehörighet saknas — GPS-spårning kräver den.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
"Öppna appens inställningar och ge \"Plats\"-behörigheten " +
|
||||
"(Tillåt endast när appen används räcker), kom sen tillbaka " +
|
||||
"och tryck Försök igen.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
onClick = { context.openAppSettings() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Öppna appinställningarna") }
|
||||
androidx.compose.material3.FilledTonalButton(
|
||||
onClick = { tryStart() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Försök igen") }
|
||||
}
|
||||
typeLoadError -> Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
"Kunde inte hämta aktivitetstypen — offline?",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Button(
|
||||
onClick = { viewModel.loadType(typeId) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Försök igen") }
|
||||
}
|
||||
!tracking.isActive && !started -> Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
else -> LiveContent(
|
||||
tracking = tracking,
|
||||
onStop = { viewModel.stop(context) },
|
||||
onPlay = { TrackingService.play(context) },
|
||||
onPause = { TrackingService.pause(context) },
|
||||
showLog = logEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LiveContent(
|
||||
tracking: TrackingState,
|
||||
onStop: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
onPause: () -> Unit,
|
||||
showLog: Boolean,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (tracking.isDistanceBased) {
|
||||
TrackMap(
|
||||
points = tracking.points,
|
||||
currentPosition = tracking.currentPosition,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
formatElapsed(tracking.elapsedSeconds),
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
)
|
||||
Text(
|
||||
"Rörelsespårning avstängd — bara tiden loggas",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
when (tracking.phase) {
|
||||
TrackingPhase.READY -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Redo att starta", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
if (tracking.isDistanceBased && !tracking.gpsFix)
|
||||
"GPS söker — vänta gärna på fix, eller kör igång direkt."
|
||||
else "Tryck play när du är redo.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
TrackingPhase.COUNTDOWN -> Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
"${tracking.countdownLeft}",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
TrackingPhase.PAUSED -> Text(
|
||||
"Pausad — tryck play för att fortsätta",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
StatBox("Tid", formatElapsed(tracking.elapsedSeconds), Modifier.weight(1f))
|
||||
if (tracking.isDistanceBased) {
|
||||
StatBox("Distans", "${(tracking.distanceMeters / 1000).compact()} km", Modifier.weight(1f))
|
||||
StatBox("Steg", tracking.steps?.toString() ?: "—", Modifier.weight(1f))
|
||||
}
|
||||
StatBox("Kcal", tracking.kcal?.toInt()?.toString() ?: "—", Modifier.weight(1f))
|
||||
}
|
||||
if (tracking.isDistanceBased) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
StatBox("Höjdmeter", "+${tracking.elevationGainMeters.toInt()} m", Modifier.weight(1f))
|
||||
StatBox(
|
||||
"Snittfart",
|
||||
if (tracking.elapsedSeconds > 30 && tracking.distanceMeters > 10) {
|
||||
val kmh = (tracking.distanceMeters / 1000) / (tracking.elapsedSeconds / 3600.0)
|
||||
"${kmh.compact()} km/h"
|
||||
} else "—",
|
||||
Modifier.weight(1f),
|
||||
)
|
||||
StatBox(
|
||||
"Fart",
|
||||
tracking.currentSpeedKmh?.let { "${it.compact()} km/h" } ?: "—",
|
||||
Modifier.weight(1f),
|
||||
)
|
||||
StatBox(
|
||||
"GPS",
|
||||
when {
|
||||
!tracking.gpsFix -> "söker…"
|
||||
!tracking.isMoving -> "stilla"
|
||||
else -> "OK"
|
||||
},
|
||||
Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
when (tracking.phase) {
|
||||
TrackingPhase.READY, TrackingPhase.PAUSED -> Button(
|
||||
onClick = onPlay,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
Text(
|
||||
if (tracking.phase == TrackingPhase.PAUSED) "Fortsätt" else "Starta",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
TrackingPhase.ACTIVE -> Button(
|
||||
onClick = onPause,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondary,
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Default.Pause, contentDescription = null)
|
||||
Text("Pausa", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
Button(
|
||||
onClick = onStop,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Default.Stop, contentDescription = null)
|
||||
Text("Avsluta", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
if (showLog) TechLogSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Expanderbar teknisk logg (GPS-fixar m.m.) med delningsknapp — för felsökning. */
|
||||
@Composable
|
||||
fun TechLogSection() {
|
||||
val context = LocalContext.current
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val lines by TrackLog.lines.collectAsStateWithLifecycle()
|
||||
|
||||
Column {
|
||||
TextButton(onClick = { expanded = !expanded }) {
|
||||
Text(if (expanded) "Dölj teknisk logg" else "Teknisk logg (${lines.size})")
|
||||
}
|
||||
if (expanded) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
lines.takeLast(100).forEach {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
fontSize = androidx.compose.ui.unit.TextUnit(10f, androidx.compose.ui.unit.TextUnitType.Sp),
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(android.content.Intent.EXTRA_TEXT, TrackLog.asText())
|
||||
}
|
||||
context.startActivity(
|
||||
android.content.Intent.createChooser(intent, "Dela teknisk logg")
|
||||
)
|
||||
}) { Text("Dela loggen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatBox(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(value, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackMap(
|
||||
points: List<Pair<Double, Double>>,
|
||||
currentPosition: Pair<Double, Double>?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lineColor = MaterialTheme.colorScheme.primary.toArgb()
|
||||
|
||||
val mapView = remember {
|
||||
Configuration.getInstance().apply {
|
||||
userAgentValue = "FitnessDroid"
|
||||
osmdroidBasePath = File(context.cacheDir, "osmdroid")
|
||||
osmdroidTileCache = File(context.cacheDir, "osmdroid/tiles")
|
||||
}
|
||||
MapView(context).apply {
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
setMultiTouchControls(true)
|
||||
controller.setZoom(17.0)
|
||||
}
|
||||
}
|
||||
val polyline = remember {
|
||||
Polyline().apply {
|
||||
outlinePaint.color = lineColor
|
||||
outlinePaint.strokeWidth = 10f
|
||||
}.also { mapView.overlays.add(it) }
|
||||
}
|
||||
val positionMarker = remember {
|
||||
org.osmdroid.views.overlay.Marker(mapView).apply {
|
||||
setAnchor(org.osmdroid.views.overlay.Marker.ANCHOR_CENTER, org.osmdroid.views.overlay.Marker.ANCHOR_CENTER)
|
||||
setInfoWindow(null)
|
||||
}.also { mapView.overlays.add(it) }
|
||||
}
|
||||
// Centrera bara automatiskt tills användaren själv panorerat
|
||||
var hasCentered by remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
mapView.onResume()
|
||||
onDispose {
|
||||
mapView.onPause()
|
||||
mapView.onDetach()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { mapView },
|
||||
modifier = modifier,
|
||||
update = { map ->
|
||||
val pos = points.lastOrNull() ?: currentPosition
|
||||
if (pos != null) {
|
||||
positionMarker.position = GeoPoint(pos.first, pos.second)
|
||||
positionMarker.isEnabled = true
|
||||
if (!hasCentered) {
|
||||
map.controller.setZoom(17.0)
|
||||
map.controller.setCenter(GeoPoint(pos.first, pos.second))
|
||||
hasCentered = true
|
||||
} else {
|
||||
map.controller.animateTo(GeoPoint(pos.first, pos.second))
|
||||
}
|
||||
} else {
|
||||
positionMarker.isEnabled = false
|
||||
}
|
||||
if (points.isNotEmpty()) {
|
||||
polyline.setPoints(points.map { (lat, lon) -> GeoPoint(lat, lon) })
|
||||
}
|
||||
map.invalidate()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryContent(
|
||||
summary: TrackingState,
|
||||
saved: Boolean,
|
||||
message: String?,
|
||||
onSave: (Double?, Int?) -> Unit,
|
||||
onDone: () -> Unit,
|
||||
showLog: Boolean,
|
||||
) {
|
||||
var rpe by remember { mutableStateOf(5f) }
|
||||
|
||||
// Aktivitetsgissning: föreslå bättre matchande typ utifrån tempo + höjd
|
||||
val guessKey = remember(summary) {
|
||||
if (summary.isDistanceBased && summary.distanceMeters > 200 && summary.elapsedSeconds > 60) {
|
||||
val kmh = (summary.distanceMeters / 1000) / (summary.elapsedSeconds / 3600.0)
|
||||
val elevPerKm = summary.elevationGainMeters / (summary.distanceMeters / 1000)
|
||||
ActivityKcal.guessTypeKey(kmh, elevPerKm).takeIf { it != summary.typeKey }
|
||||
} else null
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text("Sammanfattning", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SummaryRow("Aktivitet", summary.typeName)
|
||||
SummaryRow("Tid", formatElapsed(summary.elapsedSeconds))
|
||||
if (summary.isDistanceBased) {
|
||||
SummaryRow("Distans", "${(summary.distanceMeters / 1000).compact()} km")
|
||||
SummaryRow("Höjdmeter", "+${summary.elevationGainMeters.toInt()} m")
|
||||
if (summary.elapsedSeconds > 0 && summary.distanceMeters > 10) {
|
||||
val kmh = (summary.distanceMeters / 1000) / (summary.elapsedSeconds / 3600.0)
|
||||
SummaryRow("Snittfart", "${kmh.compact()} km/h")
|
||||
}
|
||||
}
|
||||
summary.steps?.takeIf { it > 0 }?.let { SummaryRow("Steg", "$it") }
|
||||
summary.kcal?.let { SummaryRow("Kcal (preliminärt)", "${it.toInt()}") }
|
||||
|
||||
guessKey?.let {
|
||||
Text(
|
||||
"Tempot ser ut som \"$it\" — aktiviteten sparas som " +
|
||||
"${summary.typeName}, rätta i efterhand om det blev fel.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!summary.isDistanceBased) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("Hur ansträngande var det?", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
RPE_LABELS[rpe.toInt().coerceIn(0, 10)],
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Slider(value = rpe, onValueChange = { rpe = it }, valueRange = 0f..10f, steps = 9)
|
||||
Text(
|
||||
"Används för kaloriberäkningen — från \"ingen ansträngning\" " +
|
||||
"till \"kan inte prata mer än ett par ord\".",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
message?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
|
||||
if (showLog) TechLogSection()
|
||||
|
||||
if (saved) {
|
||||
Text(
|
||||
"Sparad ✓",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) { Text("Klar") }
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
onSave(if (!summary.isDistanceBased) rpe.toInt().toDouble() else null, null)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = summary.elapsedSeconds > 0,
|
||||
) { Text("Spara aktiviteten") }
|
||||
TextButton(onClick = onDone, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Släng utan att spara", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatElapsed(sec: Int): String {
|
||||
val h = sec / 3600
|
||||
val m = (sec % 3600) / 60
|
||||
val s = sec % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s)
|
||||
}
|
||||
@@ -1,4 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">FitnessDroid</string>
|
||||
|
||||
<!-- Strängar som de vendrade openScale-drivrutinerna refererar (svenska motsvarigheter
|
||||
till upstreams engelska; formatargumenten måste matcha upstream). -->
|
||||
<string name="bt_error_delivery_user_feedback">Kunde inte leverera användarsvar: %1$s</string>
|
||||
<string name="bt_error_generic">Fel</string>
|
||||
<string name="bt_error_handler_connect_failed">Drivrutinen misslyckades vid anslutning: %1$s</string>
|
||||
<string name="bt_error_handler_parse_error">Drivrutinen kunde inte tolka %1$s: %2$s</string>
|
||||
<string name="bt_error_no_bluetooth_adapter">Ingen Bluetooth-adapter hittades på enheten.</string>
|
||||
<string name="bt_error_no_device_found">Vågen hittades inte. Kontrollera att den är på och inom räckhåll.</string>
|
||||
<string name="bt_error_no_user_selected">Ingen användare vald</string>
|
||||
<string name="bt_info_reconnecting_try">Återansluter… (försök %1$d/%2$d)</string>
|
||||
<string name="bt_info_step_on_scale">Ställ dig barfota på vågen</string>
|
||||
<string name="bt_info_waiting_for_measurement">Väntar på mätning…</string>
|
||||
<string name="bt_warn_notify_failed">Kunde inte aktivera notifieringar för %1$s.</string>
|
||||
<string name="bt_warn_write_failed_status">Skrivning till %1$s misslyckades: %2$s</string>
|
||||
<string name="cap_battery">Batteri</string>
|
||||
<string name="cap_body_composition">Kroppssammansättning</string>
|
||||
<string name="cap_history_read">Historikläsning</string>
|
||||
<string name="cap_live_weight">Livevikt</string>
|
||||
<string name="cap_time_sync">Tidssynk</string>
|
||||
<string name="cap_unit_config">Enhetsval</string>
|
||||
<string name="cap_user_sync">Användarsynk</string>
|
||||
<string name="no_special_configuration_available">Ingen extra konfiguration finns för den här vågen.</string>
|
||||
<string name="tuning_aggressive">Aggressiv</string>
|
||||
<string name="tuning_balanced">Balanserad</string>
|
||||
<string name="tuning_conservative">Försiktig</string>
|
||||
</resources>
|
||||
|
||||
4
app/src/main/res/xml/file_paths.xml
Normal file
4
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="updates" path="updates/" />
|
||||
</paths>
|
||||
@@ -3,4 +3,5 @@ plugins {
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
}
|
||||
|
||||
109
doc/activities-plan.md
Normal file
109
doc/activities-plan.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Aktiviteter, kcal-mätare & mål — plan
|
||||
|
||||
*Godkänd 2026-08-23. Samma plan finns i brasse-pc.eu-v2:
|
||||
`docs/gym-activities-plan.md` — uppdatera båda vid ändringar.*
|
||||
|
||||
Cardio/fristående aktiviteter (promenad, jogg, vandring, fäktning/HEMA m.m.)
|
||||
som eget koncept vid sidan av gympass, en daglig kcal-mätare på hemskärmen,
|
||||
mål (dag/vecka/månad) och GPS-livespårning. Kondition markeras med ❤️.
|
||||
|
||||
## Grundkoncept
|
||||
|
||||
- **Pass** (finns): gympass med övningar/set.
|
||||
- **Aktivitet** (nytt): något man gör över tid. Har tid, ev. distans/höjd/
|
||||
rutt, kcal, ansträngning (RPE) och källa (manuellt/spårat/telefon/import).
|
||||
|
||||
### Aktivitetsbibliotek
|
||||
|
||||
~60 kurerade aktiviteter från **Compendium of Physical Activities**
|
||||
(publika MET-standarden), seedade i gym-API:t:
|
||||
`key, namnSv, namnEn, met, kategori, isDistanceBased, iconKey, googleFitType`.
|
||||
Google Fit-typmappningen gör framtida import rak. Stadspromenad, jogg,
|
||||
skogsvandring och HEMA/fäktning får tempo-/RPE-justerade MET-kurvor.
|
||||
|
||||
### Kcal-modellen
|
||||
|
||||
Kräver längd/födelseår/kön i API:t (gamla "milstolpe 5" ur
|
||||
`openscale-integration.md` bakas in i etapp 1).
|
||||
|
||||
- **Passivt:** BMR (Mifflin–St Jeor: `10·vikt + 6.25·längd − 5·ålder + k`,
|
||||
k = +5 man / −161 kvinna), fylls på linjärt över dygnet.
|
||||
- **Aktivt loggat:** gympass (befintlig MET-modell) + aktiviteter
|
||||
(MET × vikt × timmar; distansaktiviteter justeras efter verkligt tempo och
|
||||
höjdmeter, icke-distans efter RPE-slidern). Netto-kcal (MET−1) används så
|
||||
BMR inte dubbelräknas.
|
||||
- **Telefon-auto:** steg + externa pass via **Health Connect**, med
|
||||
dubbelräkningsskydd (steg under egen loggad aktivitet räknas inte).
|
||||
- **RPE-slider:** "ingen ansträngning" → "kan inte prata mer än ett par ord"
|
||||
(talk test) → intensitetsfaktor på aktivitetens MET.
|
||||
- **Aktivitetsgissning:** tempo + höjdprofil ⇒ promenad (<~6,5 km/h)/jogg/
|
||||
vandring (lågt tempo + höjdmeter); stillastående + timer ⇒ fäktning/HEMA.
|
||||
|
||||
### Mätaren på hemskärmen
|
||||
|
||||
Dagens kcal överst på Hem, tredelad: **aktivt loggat / telefon / passivt**.
|
||||
Mål visas som progress-bars/mätare under.
|
||||
|
||||
## Datamodell (gym-API, EF-migrationer körs vid deploy)
|
||||
|
||||
- `User` + `HeightCm` (double?), `BirthYear` (int?), `Sex` (string? "male"/"female")
|
||||
- `ActivityType` — seedad tabell (se ovan), utökningsbar
|
||||
- `Activity` — Guid id, userId, activityTypeId, startedAt, durationSeconds,
|
||||
distanceMeters?, elevationGainMeters?, estimatedKcal, rpe? (0–10),
|
||||
source (`manual`/`tracked`/`phone`/`imported`), routePolyline?
|
||||
(Google encoded), externalId? (dedup vid import), notes?
|
||||
- `DailySteps` — userId, date, steps (upsert från appen)
|
||||
- `Goal` — metric (`KCAL`/`GYM_SESSIONS`/`STEPS`/`ACTIVITIES`/`DISTANCE_KM`)
|
||||
× period (`DAY`/`WEEK`/`MONTH`), targetValue; max ett mål per metrik+period
|
||||
- GraphQL: `activityTypes`, `activities(from,to)`, `addActivity`/`update`/
|
||||
`delete`, `upsertDailySteps`, `goals` + `setGoal`/`deleteGoal`,
|
||||
`myProfile`/`updateProfile` (+längd/år/kön), `dailySummary(date)`
|
||||
(kcal-tredelningen serverberäknad så app+webb delar logik)
|
||||
|
||||
## Tagna designbeslut (2026-08-23)
|
||||
|
||||
| Fråga | Beslut |
|
||||
|---|---|
|
||||
| Karta i appen | **osmdroid + OpenStreetMap** (inga nycklar, GPL-vänligt) |
|
||||
| Steg/telefondata | **Health Connect** (även vägen för Google Fit-import) |
|
||||
| Ruttlagring | **Encoded polyline + sammanfattning** på servern |
|
||||
| Aktivitetsbibliotek | **~60 kurerade** ur MET-kompendiet |
|
||||
|
||||
## Etapper
|
||||
|
||||
Leveransordning per etapp: **backend → CI grönt → webb → CI grönt → app →
|
||||
användartest**. Endast ETT Pi5-bygge åt gången — polla CI och vänta hellre
|
||||
för länge. (Path-filter: gym-api triggas av `backend-gym-traker/**`,
|
||||
frontend av `src/**` m.fl., FitnessDroid av varje push till main.)
|
||||
|
||||
1. **Profil & kalorigrund** — API: HeightCm/BirthYear/Sex + `updateProfile`.
|
||||
Webb: profilfält. App: kroppsdata synkas mot servern (lokal cache kvar),
|
||||
BMR, kcal-mätare v1 på hemskärmen (passivt + gympass).
|
||||
2. **Aktiviteter: bibliotek + manuell logg** — API: ActivityType (seed) +
|
||||
Activity CRUD + `dailySummary`. Webb: aktivitetsflik ❤️ (logga/lista/
|
||||
rätta). App: aktivitetsflik med bibliotek, manuell loggning
|
||||
(tid/distans/RPE), kcal in i mätaren.
|
||||
3. **Live-spårning (GPS)** — App: starta aktivitet, foreground-service,
|
||||
osmdroid-livekarta, live kcal/distans/tempo/höjdmeter; icke-distansläge
|
||||
(timer + RPE efteråt); aktivitetsgissning; polyline till servern.
|
||||
Webb: Leaflet-karta i aktivitetsdetalj.
|
||||
4. **Mål & mätare** — API: Goal CRUD. App: målkort med progress på Hem
|
||||
(dag/vecka/månad × kcal/pass/steg/aktiviteter/km). Webb: målinställningar
|
||||
+ progress.
|
||||
5. **Steg & telefondata** — API: DailySteps. App: Health Connect-permissions,
|
||||
daglig stegsynk, auto-aktiviteter från HC ("telefon"-källa), mätarens
|
||||
tredelning komplett. Webb: stegvisning.
|
||||
6. **Statistik & polish** — ❤️-sektion i statistiken (app+webb):
|
||||
distans/tempo-trender per typ, vecko-km; dokumentation; Google
|
||||
Fit-importens grund (source/externalId/typmappning/HC) verifierad klar.
|
||||
|
||||
## Status
|
||||
|
||||
- [x] Etapp 1 — Profil & kalorigrund *(kod klar 2026-08-23: backend a1cd4d3, webb 560894c, app — väntar på användartest + omdeploy av gym-api/frontend-containrarna)*
|
||||
- [x] Etapp 2 — Aktiviteter: bibliotek + manuell logg *(kod klar 2026-08-23: backend 9a81cf6, webb — aktivitetsflik, app 0.6.0; väntar på användartest)*
|
||||
- [x] Etapp 3 — Live-spårning (GPS) *(klar & fälttestad 2026-08-26: GPS-väntfas, providerlyssning gps/fused/network, fartgrind (Doppler), rörelsevakt (accelerometer), offline-kö, teknisk logg — GPS-filter justerbara i inställningarna)*
|
||||
- [x] Etapp 4 — Mål & mätare *(kod klar 2026-08-26: Goal-modell + goalsWithProgress i API:t, målsektion i webbens aktivitetsflik, målkort med progress-bars på appens hemskärm + målskärm; väntar på användartest)*
|
||||
- [x] Etapp 5 — Steg & telefondata *(kod klar 2026-08-26: DailySteps + upsert i API:t med telefon-kcal (0.00038 kcal/steg/kg, dubbelräkningsskydd mot gång/löp/vandringsaktiviteter), Health Connect-läsning i appen (connect-client 1.1.0-alpha07 — nyare kräver AGP 8.9/SDK 36), aktivering under Inställningar → Stegsynk; webben behövde ingen ändring. Väntar på användartest)*
|
||||
- [x] Etapp 6 — Statistik & polish *(kod klar 2026-08-26: konditionssektion ❤️ i både appens och webbens statistik (KPI, km/vecka, per typ), README uppdaterad. Google Fit-importens grund verifierad: source=imported, externalId-dedup, googleFitType-mappning i biblioteket och Health Connect-läsning på plats)*
|
||||
|
||||
Google Fit-importen byggs som separat arbete — all grund finns: source=imported, externalId-dedup i addActivity, googleFitType-mappning per aktivitetstyp och Health Connect-integrationen i appen.
|
||||
645
doc/design-forslag-m2.html
Normal file
645
doc/design-forslag-m2.html
Normal file
@@ -0,0 +1,645 @@
|
||||
<title>FitnessDroid — designförslag</title>
|
||||
<meta name="description" content="Passpilot-konceptet: mockups för passläge, hemskärm, övningsväljare, Android-ytor, kroppskarta och API-förslag.">
|
||||
<style>
|
||||
:root{
|
||||
--ground:#0c0f0b; --panel:#131812; --panel-hi:#1a201a; --line:#263026;
|
||||
--ink:#e4e7de; --dim:#96a093; --accent:#8fd694; --accent-deep:#2e4a33;
|
||||
--m3-bg:#101410; --m3-surf:#1a211a; --m3-surf2:#232b23; --m3-surf3:#2b342b;
|
||||
--m3-pri:#8fd694; --m3-onpri:#08300f; --m3-on:#e2e5dc; --m3-dim:#a9b2a5;
|
||||
--m3-outline:#3e483d; --m3-seccont:#33503a; --m3-onsec:#cfeacf;
|
||||
--heat0:#233026; --heat1:#3d5c42; --heat2:#5f8f60; --heat3:#8fd694; --heat4:#d3f0c9;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0}
|
||||
body{background:var(--ground);color:var(--ink);
|
||||
font:16px/1.55 Roboto,system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||
-webkit-font-smoothing:antialiased;padding:0 20px 96px}
|
||||
.wrap{max-width:1180px;margin:0 auto}
|
||||
a{color:var(--accent)}
|
||||
|
||||
header.top{padding:64px 0 24px;border-bottom:1px solid var(--line);margin-bottom:8px}
|
||||
.eyebrow{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);font-weight:600}
|
||||
h1{font-size:clamp(28px,4.5vw,44px);font-weight:800;letter-spacing:-.02em;text-wrap:balance;margin:10px 0 12px}
|
||||
.lede{color:var(--dim);max-width:66ch}
|
||||
section{padding:52px 0 8px}
|
||||
.secnum{font:600 12px/1 ui-monospace,monospace;color:var(--dim);letter-spacing:.1em}
|
||||
h2{font-size:26px;font-weight:750;letter-spacing:-.01em;margin:8px 0 6px;text-wrap:balance}
|
||||
.secintro{color:var(--dim);max-width:64ch;margin-bottom:28px}
|
||||
h3{font-size:17px;font-weight:700;margin:0 0 4px}
|
||||
|
||||
/* koncept-kort */
|
||||
.modes{display:flex;gap:14px;flex-wrap:wrap;margin-top:22px}
|
||||
.mode{flex:1 1 240px;background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:18px}
|
||||
.mode b{display:block;font-size:15px;margin-bottom:5px}
|
||||
.mode span{color:var(--dim);font-size:14px}
|
||||
.mode .k{font:700 11px/1 ui-monospace,monospace;color:var(--accent);letter-spacing:.12em;display:block;margin-bottom:10px}
|
||||
|
||||
/* telefonram */
|
||||
.phone{width:320px;background:#000;border:1px solid #2c342c;border-radius:30px;
|
||||
padding:9px;flex:0 0 auto;box-shadow:0 24px 60px -30px rgba(0,0,0,.8)}
|
||||
.screen{background:var(--m3-bg);border-radius:22px;overflow:hidden;display:flex;
|
||||
flex-direction:column;height:640px;font-size:13.5px;color:var(--m3-on)}
|
||||
.statusbar{display:flex;justify-content:space-between;align-items:center;
|
||||
padding:10px 18px 4px;font-size:11px;color:var(--m3-dim)}
|
||||
.sb-ic{display:flex;gap:5px;align-items:center}
|
||||
.content{flex:1;overflow:hidden;padding:6px 14px 0;display:flex;flex-direction:column;gap:11px}
|
||||
.gesture{height:18px;display:flex;align-items:center;justify-content:center}
|
||||
.gesture i{display:block;width:96px;height:4px;border-radius:2px;background:#3a423a}
|
||||
|
||||
/* m3-bitar */
|
||||
.m3-greet{padding:6px 4px 0}
|
||||
.m3-greet b{font-size:21px;font-weight:700;display:block}
|
||||
.m3-greet span{color:var(--m3-dim);font-size:12.5px}
|
||||
.card{background:var(--m3-surf);border-radius:16px;padding:13px 14px}
|
||||
.card.tonal{background:var(--m3-surf2)}
|
||||
.rowline{display:flex;align-items:center;gap:10px}
|
||||
.rowline .grow{flex:1;min-width:0}
|
||||
.btn-filled{background:var(--m3-pri);color:var(--m3-onpri);border-radius:999px;
|
||||
padding:13px 18px;font-weight:700;font-size:14.5px;text-align:center;
|
||||
display:flex;align-items:center;justify-content:center;gap:8px}
|
||||
.seclabel{display:flex;justify-content:space-between;align-items:baseline;padding:2px 4px 0}
|
||||
.seclabel b{font-size:13.5px;font-weight:700}
|
||||
.seclabel a{font-size:11.5px;color:var(--m3-pri);text-decoration:none;font-weight:600}
|
||||
.hscroll{display:flex;gap:10px;overflow:hidden}
|
||||
.favcard{background:var(--m3-surf2);border-radius:16px;padding:12px;min-width:150px;flex:0 0 auto}
|
||||
.favcard .nm{font-weight:700;font-size:13.5px;display:flex;gap:6px;align-items:center}
|
||||
.favcard .muscles{color:var(--m3-dim);font-size:10.5px;margin:3px 0 8px}
|
||||
.favcard .go{margin-top:6px;background:var(--m3-pri);color:var(--m3-onpri);border-radius:999px;
|
||||
font-size:11.5px;font-weight:700;padding:6px 0;text-align:center}
|
||||
.star{color:#ffd977;font-size:12px}
|
||||
.listrow{display:flex;gap:11px;align-items:center;padding:9px 4px;border-bottom:1px solid #202820}
|
||||
.listrow:last-child{border-bottom:0}
|
||||
.listrow .t{font-weight:600;font-size:13px}
|
||||
.listrow .s{color:var(--m3-dim);font-size:11px}
|
||||
.mchip{width:34px;height:34px;border-radius:50%;background:var(--m3-surf3);
|
||||
display:flex;align-items:center;justify-content:center;flex:0 0 auto}
|
||||
.mchip svg{width:19px;height:19px;stroke:var(--m3-pri);fill:none;stroke-width:1.7;
|
||||
stroke-linecap:round;stroke-linejoin:round}
|
||||
.badge{font-size:9.5px;font-weight:700;background:var(--m3-surf3);color:var(--m3-dim);
|
||||
border-radius:5px;padding:2px 5px;letter-spacing:.03em}
|
||||
.searchbar{background:var(--m3-surf2);border-radius:999px;padding:10px 15px;color:var(--m3-dim);
|
||||
display:flex;gap:9px;align-items:center;font-size:13px}
|
||||
.chiprow{display:flex;gap:7px;overflow:hidden;padding:1px}
|
||||
.chip{border:1px solid var(--m3-outline);border-radius:9px;padding:6px 11px;font-size:11.5px;
|
||||
font-weight:600;color:var(--m3-dim);display:inline-flex;gap:6px;align-items:center;flex:0 0 auto}
|
||||
.chip svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.8;
|
||||
stroke-linecap:round;stroke-linejoin:round}
|
||||
.chip.sel{background:var(--m3-seccont);color:var(--m3-onsec);border-color:transparent}
|
||||
.chip.small{padding:4px 10px;font-size:10.5px;border-radius:999px}
|
||||
.topbar-menu{display:flex;justify-content:space-between;align-items:center;padding:2px 4px}
|
||||
.topbar-menu b{font-size:16px}
|
||||
|
||||
/* passläget */
|
||||
.sess-top{display:flex;justify-content:space-between;align-items:center;padding:2px 4px}
|
||||
.sess-top b{font-size:15.5px}
|
||||
.timerchip{background:var(--m3-seccont);color:var(--m3-onsec);border-radius:999px;
|
||||
padding:4px 11px;font:700 12px/1.4 ui-monospace,monospace}
|
||||
.pagerdots{display:flex;gap:5px;align-items:center;padding:0 4px}
|
||||
.pagerdots i{height:4px;border-radius:2px;background:var(--m3-surf3);flex:1}
|
||||
.pagerdots i.done{background:var(--m3-pri);opacity:.45}
|
||||
.pagerdots i.cur{background:var(--m3-pri)}
|
||||
.exhead{padding:2px 4px}
|
||||
.exhead b{font-size:19px;font-weight:750;display:block}
|
||||
.exhead span{color:var(--m3-dim);font-size:11.5px}
|
||||
.setdone{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:11px;
|
||||
background:var(--m3-surf);color:var(--m3-dim);font-size:12.5px}
|
||||
.setdone .ck{color:var(--m3-pri);font-weight:800}
|
||||
.setactive{background:var(--m3-surf2);border-radius:16px;padding:13px;display:flex;
|
||||
flex-direction:column;gap:11px}
|
||||
.steppers{display:flex;gap:9px}
|
||||
.stepper{flex:1;background:var(--m3-bg);border-radius:13px;padding:8px 9px;
|
||||
display:flex;align-items:center;justify-content:space-between}
|
||||
.stepper .pm{width:30px;height:30px;border-radius:50%;background:var(--m3-surf3);
|
||||
color:var(--m3-on);font:700 16px/30px sans-serif;text-align:center}
|
||||
.stepper .val{text-align:center}
|
||||
.stepper .val b{font-size:17px;font-variant-numeric:tabular-nums}
|
||||
.stepper .val span{display:block;font-size:9.5px;color:var(--m3-dim);letter-spacing:.08em;text-transform:uppercase}
|
||||
.rpe{display:flex;gap:6px;align-items:center;font-size:10.5px;color:var(--m3-dim)}
|
||||
.rpe i{font-style:normal;border:1px solid var(--m3-outline);border-radius:7px;
|
||||
padding:3px 9px;font-weight:700;font-size:11px}
|
||||
.rpe i.sel{background:var(--m3-seccont);color:var(--m3-onsec);border-color:transparent}
|
||||
.nextup{display:flex;align-items:center;gap:9px;color:var(--m3-dim);font-size:11.5px;padding:0 4px}
|
||||
|
||||
/* vilo-overlay */
|
||||
.rest{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px}
|
||||
.ring{width:190px;height:190px;border-radius:50%;position:relative;
|
||||
background:conic-gradient(var(--m3-pri) 74%,var(--m3-surf3) 0)}
|
||||
.ring::after{content:"";position:absolute;inset:12px;border-radius:50%;background:var(--m3-bg)}
|
||||
.ring .mid{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;
|
||||
justify-content:center;z-index:1}
|
||||
.ring .mid b{font:800 40px/1 ui-monospace,monospace;font-variant-numeric:tabular-nums}
|
||||
.ring .mid span{color:var(--m3-dim);font-size:11px;letter-spacing:.1em;text-transform:uppercase}
|
||||
.restbtns{display:flex;gap:10px}
|
||||
.restbtns .btn-tonal{background:var(--m3-surf3);color:var(--m3-on);border-radius:999px;
|
||||
padding:9px 16px;font-weight:700;font-size:12.5px}
|
||||
.restnext{color:var(--m3-dim);font-size:12px;text-align:center}
|
||||
.restnext b{color:var(--m3-on)}
|
||||
|
||||
/* strips: minispelare + notis */
|
||||
.strips{display:flex;flex-direction:column;gap:18px;min-width:280px;flex:1 1 340px}
|
||||
.striplabel{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);font-weight:700;margin-bottom:7px}
|
||||
.stripbar{background:var(--m3-surf2);border:1px solid var(--line);border-radius:14px;
|
||||
padding:10px 13px;display:flex;align-items:center;gap:11px;max-width:340px}
|
||||
.stripbar .grow{flex:1;min-width:0}
|
||||
.stripbar .t{font-weight:700;font-size:12.5px;color:var(--m3-on)}
|
||||
.stripbar .s{font-size:10.5px;color:var(--m3-dim)}
|
||||
.stripbar .act{color:var(--m3-pri);font-weight:800;font-size:11px}
|
||||
.noti{background:#181d18;border:1px solid var(--line);border-radius:18px;padding:13px 15px;max-width:340px}
|
||||
.noti .approw{display:flex;gap:7px;align-items:center;font-size:10px;color:var(--m3-dim);margin-bottom:7px}
|
||||
.noti .approw i{width:13px;height:13px;border-radius:4px;background:var(--m3-pri);display:inline-block}
|
||||
.noti b{font-size:13px;color:var(--m3-on)}
|
||||
.noti p{font-size:12px;color:var(--m3-dim);margin:2px 0 9px}
|
||||
.noti .actions{display:flex;gap:16px;font-weight:700;font-size:12px;color:var(--m3-pri)}
|
||||
|
||||
/* skivkalkylator */
|
||||
.sheet{background:var(--m3-surf);border-radius:20px 20px 14px 14px;padding:16px;max-width:340px;border:1px solid var(--line)}
|
||||
.sheet .grab{width:36px;height:4px;border-radius:2px;background:var(--m3-outline);margin:0 auto 12px}
|
||||
.sheet h4{font-size:14px;margin-bottom:2px}
|
||||
.sheet .sub{color:var(--m3-dim);font-size:11.5px;margin-bottom:13px}
|
||||
.bar{display:flex;align-items:center;height:64px;margin:4px 0 12px}
|
||||
.bar .shaft{height:7px;background:#5a635a;flex:1;border-radius:3px}
|
||||
.bar .collar{width:9px;height:22px;background:#788078;border-radius:2px}
|
||||
.plate{border-radius:4px;margin:0 1.5px;box-shadow:inset 0 0 0 1.5px rgba(0,0,0,.35)}
|
||||
.p20{width:13px;height:62px;background:#3f6fb5}
|
||||
.p10{width:11px;height:46px;background:#4d9b57}
|
||||
.p5{width:10px;height:34px;background:#d7d7cf}
|
||||
.p25{width:9px;height:26px;background:#c05252}
|
||||
.sheet .plates-txt{font-size:12.5px;color:var(--m3-dim)}
|
||||
.sheet .plates-txt b{color:var(--m3-on)}
|
||||
|
||||
/* widget */
|
||||
.widget{background:linear-gradient(145deg,#1c231c,#141a14);border:1px solid var(--line);
|
||||
border-radius:22px;padding:14px 16px;max-width:340px}
|
||||
.widget .wt{display:flex;justify-content:space-between;align-items:center;margin-bottom:11px}
|
||||
.widget .wt b{font-size:12.5px;color:var(--m3-on)}
|
||||
.widget .wt span{font-size:10px;color:var(--m3-dim)}
|
||||
.widget .wbtns{display:flex;gap:8px}
|
||||
.widget .wb{flex:1;border-radius:12px;padding:10px 8px;font-size:11.5px;font-weight:700;text-align:center}
|
||||
.widget .wb.pri{background:var(--m3-pri);color:var(--m3-onpri)}
|
||||
.widget .wb.ton{background:var(--m3-surf3);color:var(--m3-on)}
|
||||
|
||||
/* kroppskarta */
|
||||
.bodymaps{display:flex;gap:34px;flex-wrap:wrap;align-items:flex-start}
|
||||
.bodycard{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:22px 26px;display:flex;gap:26px}
|
||||
.bodycard figure{text-align:center}
|
||||
.bodycard figcaption{font-size:11px;color:var(--dim);margin-top:8px;letter-spacing:.08em;text-transform:uppercase;font-weight:700}
|
||||
.heatlegend{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--dim);margin-top:16px}
|
||||
.heatlegend i{width:22px;height:10px;border-radius:3px;display:inline-block}
|
||||
|
||||
/* layout runt telefoner */
|
||||
.phones{display:flex;gap:26px;flex-wrap:wrap;align-items:flex-start}
|
||||
.duo{display:flex;gap:40px;flex-wrap:wrap;align-items:flex-start}
|
||||
.notes{flex:1 1 340px;min-width:280px;display:flex;flex-direction:column;gap:16px}
|
||||
.note{display:flex;gap:13px;align-items:flex-start}
|
||||
.note .dot{width:24px;height:24px;border-radius:50%;background:var(--accent);
|
||||
color:#0a2e10;font:700 12.5px/24px ui-monospace,monospace;text-align:center;flex:0 0 auto}
|
||||
.note p{color:var(--dim);font-size:14.5px}
|
||||
.note b{color:var(--ink)}
|
||||
.phcaption{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);
|
||||
font-weight:700;text-align:center;margin-top:12px}
|
||||
|
||||
table{border-collapse:collapse;width:100%;font-size:13.5px;margin-top:22px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
th{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.1em}
|
||||
td:first-child{font-weight:600;white-space:nowrap;color:var(--ink)}
|
||||
td{color:var(--dim)}
|
||||
.tablewrap{overflow-x:auto}
|
||||
.pill-sm{display:inline-block;font-size:10px;font-weight:800;border-radius:5px;padding:2px 7px;
|
||||
letter-spacing:.06em}
|
||||
.pill-sm.s{background:var(--accent-deep);color:var(--accent)}
|
||||
.pill-sm.m{background:#4a3f22;color:#e8c96e}
|
||||
|
||||
.decide{background:var(--panel);border:1px solid var(--line);border-radius:16px;
|
||||
padding:22px 24px;margin-top:44px}
|
||||
.decide h2{font-size:19px;margin:0 0 10px}
|
||||
.decide ol{margin:0;padding-left:20px;color:var(--dim);display:flex;flex-direction:column;gap:8px;font-size:14.5px}
|
||||
.decide b{color:var(--ink)}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<header class="top">
|
||||
<div class="eyebrow">FitnessDroid · Milstolpe 2 · Designförslag v2</div>
|
||||
<h1>En passpilot i fickan — inte webben i mindre format</h1>
|
||||
<p class="lede">Nytt tänk: appen byggs runt <b>själva gympasset</b>, inte runt flikar.
|
||||
Mellan seten har du svettiga händer och 90 sekunder — allt i passläget är därför
|
||||
stora tryckytor, förifyllda värden och en vilotimer som sköter sig själv, även på
|
||||
låsskärmen. Runt det: en hemskärm som startar pass på ett tryck, och Android-ytor
|
||||
som webben aldrig kan nå.</p>
|
||||
<div class="modes">
|
||||
<div class="mode"><span class="k">LÄGE 1 · STARTA</span><b>Hemskärmen</b>
|
||||
<span>Favoritpass, fritt pass, widget och appgenvägar — max två tryck till första setet.</span></div>
|
||||
<div class="mode"><span class="k">LÄGE 2 · KÖR</span><b>Passläget</b>
|
||||
<span>En övning i taget, set loggas med ett tryck, vilotimern tar över — appen coachar, du lyfter.</span></div>
|
||||
<div class="mode"><span class="k">LÄGE 3 · FÖLJ UPP</span><b>Statistik & PB</b>
|
||||
<span>Kroppskarta som visar var veckans volym hamnat, trender och PB-historik.</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ============ 1. PASSLÄGET ============ -->
|
||||
<section>
|
||||
<div class="secnum">1 / 6</div>
|
||||
<h2>Passläget — en övning i taget, ett tryck per set</h2>
|
||||
<p class="secintro">Hela skärmen ägnas åt övningen du står vid. Svep i sidled mellan
|
||||
övningarna i passet. Värdena är alltid förifyllda från förra passet plus
|
||||
progressionsregeln — stämmer allt trycker du bara på gröna knappen.</p>
|
||||
|
||||
<div class="duo">
|
||||
<div>
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>19:24</span><span class="sb-ic">▲ ▮</span></div>
|
||||
<div class="content">
|
||||
<div class="sess-top">
|
||||
<span style="display:flex;align-items:center;gap:9px">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" style="stroke:var(--m3-on);fill:none;stroke-width:1.8;stroke-linecap:round"><path d="M15 5l-7 7 7 7"/></svg>
|
||||
<b>Push A</b></span>
|
||||
<span class="timerchip">24:31</span>
|
||||
</div>
|
||||
<div class="pagerdots"><i class="done"></i><i class="done"></i><i class="cur"></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
<div class="exhead"><b>Bänkpress</b>
|
||||
<span>Övning 3 av 8 · Bröst, triceps · Förra passet: 4×8 @ 77,5 kg</span></div>
|
||||
|
||||
<div class="setdone"><span class="ck">✓</span>Set 1 · 80,0 kg × 8 · RPE 7</div>
|
||||
<div class="setdone"><span class="ck">✓</span>Set 2 · 80,0 kg × 8 · RPE 8</div>
|
||||
|
||||
<div class="setactive">
|
||||
<div style="font-size:11px;color:var(--m3-dim);font-weight:700;letter-spacing:.08em">SET 3 AV 4</div>
|
||||
<div class="steppers">
|
||||
<div class="stepper"><span class="pm">−</span>
|
||||
<span class="val"><b>80,0</b><span>kg · ±2,5</span></span><span class="pm">+</span></div>
|
||||
<div class="stepper"><span class="pm">−</span>
|
||||
<span class="val"><b>8</b><span>reps</span></span><span class="pm">+</span></div>
|
||||
</div>
|
||||
<div class="rpe">RPE <i>7</i><i class="sel">8</i><i>9</i><i>10</i></div>
|
||||
<div class="btn-filled">✓ Logga set — vilan startar</div>
|
||||
</div>
|
||||
|
||||
<div class="nextup">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" style="stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round"><path d="M5 12h14m-6-6 6 6-6 6"/></svg>
|
||||
Nästa: Lutande hantelpress · 3×10 @ 28 kg</div>
|
||||
</div>
|
||||
<div class="gesture"><i></i></div>
|
||||
</div></div>
|
||||
<div class="phcaption">Passläget · aktivt set</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>19:26</span><span class="sb-ic">▲ ▮</span></div>
|
||||
<div class="content">
|
||||
<div class="sess-top"><b style="font-size:14px">Vila</b><span class="timerchip">Push A · 26:04</span></div>
|
||||
<div class="rest">
|
||||
<div class="ring"><div class="mid"><b>1:07</b><span>av 1:30</span></div></div>
|
||||
<div class="restbtns">
|
||||
<span class="btn-tonal">−15 s</span>
|
||||
<span class="btn-tonal">+15 s</span>
|
||||
<span class="btn-tonal">Hoppa över</span>
|
||||
</div>
|
||||
<div class="restnext">Härnäst: <b>Set 4 av 4 · 80,0 kg × 8</b><br>
|
||||
Sista setet på bänken — sen lutande hantelpress</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gesture"><i></i></div>
|
||||
</div></div>
|
||||
<div class="phcaption">Vilan tar över skärmen</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><p><b>Ett tryck per set.</b> Vikt och
|
||||
reps förifylls från förra passet + progressionsregeln (API:t har redan
|
||||
<i>lastExercisePerformance</i>). Stämmer det: tryck ✓. Annars ±-knappar —
|
||||
aldrig tangentbord för standardfallet.</p></div>
|
||||
<div class="note"><div class="dot">2</div><p><b>Vilan sköter sig själv.</b> Loggat
|
||||
set → timern startar, skärmen växlar till ringen, telefonen vibrerar när det
|
||||
är dags. Längden hämtas per övning (litet API-tillägg, se sist).</p></div>
|
||||
<div class="note"><div class="dot">3</div><p><b>Svep = nästa övning.</b> Ingen
|
||||
lista att scrolla i mitt i passet. Prickraden överst visar var i passet du är;
|
||||
långtryck öppnar överblicken där du kan ändra ordning eller lägga till övningar.</p></div>
|
||||
<div class="note"><div class="dot">4</div><p><b>PB firas direkt</b> — slår du
|
||||
ett personbästa på ett set får du konfetti och en 🏆-notis i samma sekund,
|
||||
inte när passet är slut.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:38px" class="duo">
|
||||
<div class="strips">
|
||||
<div><div class="striplabel">Minispelare — passet följer med i hela appen (som Spotify)</div>
|
||||
<div class="stripbar">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" style="fill:var(--m3-pri)"><path d="M8 5v14l11-7z"/></svg>
|
||||
<div class="grow"><div class="t">Push A · Bänkpress</div><div class="s">Vila 0:47 kvar · set 4 av 4</div></div>
|
||||
<span class="act">ÖPPNA</span>
|
||||
</div></div>
|
||||
<div><div class="striplabel">Notis på låsskärmen — timern utan att ta upp telefonen ur fickan</div>
|
||||
<div class="noti">
|
||||
<div class="approw"><i></i> FitnessDroid · pågår</div>
|
||||
<b>Vila 0:47 — sen set 4 av 4</b>
|
||||
<p>Bänkpress 80,0 kg × 8 · Push A 26:04</p>
|
||||
<div class="actions"><span>+15 s</span><span>LOGGA SET</span><span>PAUSA PASS</span></div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="notes" style="justify-content:center">
|
||||
<div class="note"><div class="dot">5</div><p><b>Lämna passläget utan att tappa
|
||||
passet.</b> Kollar du statistik mitt i passet ligger minispelaren kvar ovanför
|
||||
navigeringen. Notisen med timer-åtgärder gör att telefonen kan ligga kvar i
|
||||
fickan mellan seten — <b>det här är appens största övertag över webben.</b></p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 2. HEMSKÄRM ============ -->
|
||||
<section>
|
||||
<div class="secnum">2 / 6</div>
|
||||
<h2>Hemskärmen — startmotorn</h2>
|
||||
<p class="secintro">Öppnar du appen med ett pass igång åker du rakt in i passläget.
|
||||
Annars: starta på ett eller två tryck.</p>
|
||||
|
||||
<div class="duo">
|
||||
<div>
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>19:24</span><span class="sb-ic">▲ ▮</span></div>
|
||||
<div class="content">
|
||||
<div class="m3-greet"><b>Tjena Björn</b><span>Vecka 30 · 2 pass · 3 veckors streak 🔥</span></div>
|
||||
<div class="btn-filled" style="padding:15px 18px"><svg width="18" height="18" viewBox="0 0 24 24" style="fill:currentColor"><path d="M8 5v14l11-7z"/></svg> Starta fritt pass</div>
|
||||
<div class="seclabel"><b>Favoritpass</b><a>Alla mallar</a></div>
|
||||
<div class="hscroll">
|
||||
<div class="favcard">
|
||||
<div class="nm"><span class="star">★</span>Push A</div>
|
||||
<div class="muscles">Bröst · Axlar · Triceps</div>
|
||||
<div class="go">▶ Starta</div>
|
||||
</div>
|
||||
<div class="favcard">
|
||||
<div class="nm"><span class="star">★</span>Ben & core</div>
|
||||
<div class="muscles">Ben · Mage</div>
|
||||
<div class="go">▶ Starta</div>
|
||||
</div>
|
||||
<div class="favcard">
|
||||
<div class="nm"><span class="star">★</span>Rygg</div>
|
||||
<div class="muscles">Rygg · Biceps</div>
|
||||
<div class="go">▶ Starta</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card tonal rowline">
|
||||
<div class="mchip"><svg viewBox="0 0 24 24"><path d="M4 19V9m6 10V5m6 14v-7"/></svg></div>
|
||||
<div class="grow"><div style="font-weight:700;font-size:12.5px">Veckans muskler</div>
|
||||
<div style="font-size:10.5px;color:var(--m3-dim)">Mest bröst & triceps — benen släpar efter</div></div>
|
||||
<svg width="30" height="44" viewBox="0 0 30 46" aria-hidden="true">
|
||||
<circle cx="15" cy="5" r="4" fill="#3d5c42"/>
|
||||
<rect x="7" y="11" width="16" height="15" rx="5" fill="#8fd694"/>
|
||||
<rect x="2" y="12" width="4.5" height="12" rx="2" fill="#5f8f60"/>
|
||||
<rect x="23.5" y="12" width="4.5" height="12" rx="2" fill="#5f8f60"/>
|
||||
<rect x="8" y="27" width="6" height="16" rx="2.6" fill="#233026"/>
|
||||
<rect x="16" y="27" width="6" height="16" rx="2.6" fill="#233026"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="seclabel"><b>Senaste pass</b><a>Historik</a></div>
|
||||
<div>
|
||||
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M4 12h16M7 8v8m10-8v8M4 10v4m16-4v4"/></svg></div>
|
||||
<div class="grow"><div class="t">Push A</div><div class="s">mån 21/7 · 52 min · 4 890 kg</div></div>
|
||||
<span class="badge">2 PB</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gesture"><i></i></div>
|
||||
</div></div>
|
||||
<div class="phcaption">Hemskärmen · inget pass igång</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><p><b>Fritt pass = ett tryck.</b>
|
||||
Passet skapas direkt och övningsväljaren öppnas. Namn kan sättas efteråt —
|
||||
inga formulär mellan dig och första setet.</p></div>
|
||||
<div class="note"><div class="dot">2</div><p><b>Favoritpass = två tryck.</b>
|
||||
API:ts favoritpass + mallar, med auto-progressionen förifylld.</p></div>
|
||||
<div class="note"><div class="dot">3</div><p><b>Veckans muskler</b> — en liten
|
||||
kroppskarta som teaser för statistiken, och en knuff om vad som är underjobbat.
|
||||
Trycker du på kortet öppnas hela kartan (sektion 5).</p></div>
|
||||
<div class="note"><div class="dot">4</div><p><b>Pågår ett pass öppnas appen
|
||||
direkt i passläget</b> — hemskärmen visas bara när inget är igång. Därför
|
||||
behövs inget "fortsätt"-kort: appen minns åt dig.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 3. ÖVNINGSVÄLJAREN ============ -->
|
||||
<section>
|
||||
<div class="secnum">3 / 6</div>
|
||||
<h2>Övningsväljaren — sök, muskelfilter, favoriter först</h2>
|
||||
<p class="secintro">Muskelgrupper som chips med ikoner; vald grupp fäller ut sina
|
||||
muskler som en andra rad. Brickorna (KG/REPS/TID/M) kommer från övningens
|
||||
tracks-flaggor och styr sedan set-loggningens fält.</p>
|
||||
|
||||
<div class="duo">
|
||||
<div>
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>19:24</span><span class="sb-ic">▲ ▮</span></div>
|
||||
<div class="content">
|
||||
<div class="topbar-menu"><span style="display:flex;align-items:center;gap:10px">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" style="stroke:var(--m3-on);fill:none;stroke-width:1.8;stroke-linecap:round"><path d="M15 5l-7 7 7 7"/></svg>
|
||||
<b>Lägg till övning</b></span></div>
|
||||
<div class="searchbar"><svg width="15" height="15" viewBox="0 0 24 24" style="stroke:currentColor;fill:none;stroke-width:1.8"><circle cx="11" cy="11" r="6"/><path d="m20 20-4-4"/></svg> Sök övning …</div>
|
||||
<div class="chiprow">
|
||||
<span class="chip">Alla</span>
|
||||
<span class="chip"><svg viewBox="0 0 24 24"><path d="M6 4h12l-1.5 8a4.5 4.5 0 0 1-9 0z"/><path d="M12 12v8"/></svg>Bröst</span>
|
||||
<span class="chip sel"><svg viewBox="0 0 24 24"><path d="M7 4v7l5 9 5-9V4"/><path d="M7 8h10"/></svg>Rygg</span>
|
||||
<span class="chip"><svg viewBox="0 0 24 24"><path d="M9 4v6l-2.5 9h4L12 13l1.5 6h4L15 10V4"/></svg>Ben</span>
|
||||
<span class="chip"><svg viewBox="0 0 24 24"><path d="M4 12a8 8 0 0 1 16 0"/><circle cx="4" cy="14" r="2"/><circle cx="20" cy="14" r="2"/></svg>Axlar</span>
|
||||
<span class="chip"><svg viewBox="0 0 24 24"><path d="M5 18l5-5m0 0 5-8 4 3-5 7-4-2z"/><circle cx="5" cy="19" r="2"/></svg>Armar</span>
|
||||
</div>
|
||||
<div class="chiprow">
|
||||
<span class="chip small sel">Alla i Rygg</span>
|
||||
<span class="chip small">Latsen</span>
|
||||
<span class="chip small">Trapezius</span>
|
||||
<span class="chip small">Ländrygg</span>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M4 12h16M7 8v8m10-8v8M4 10v4m16-4v4"/></svg></div>
|
||||
<div class="grow"><div class="t">Marklyft <span class="star">★</span></div><div class="s">Rygg · Ben · senast 77,5 kg</div></div>
|
||||
<span class="badge">KG</span><span class="badge">REPS</span></div>
|
||||
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M7 4v7l5 9 5-9V4"/><path d="M7 8h10"/></svg></div>
|
||||
<div class="grow"><div class="t">Latsdrag <span class="star">★</span></div><div class="s">Latsen · senast 65 kg</div></div>
|
||||
<span class="badge">KG</span><span class="badge">REPS</span></div>
|
||||
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M7 4v7l5 9 5-9V4"/><path d="M7 8h10"/></svg></div>
|
||||
<div class="grow"><div class="t">Skivstångsrodd</div><div class="s">Rygg · Biceps</div></div>
|
||||
<span class="badge">KG</span><span class="badge">REPS</span></div>
|
||||
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M4 6h16M8 6v4a4 4 0 0 0 8 0V6"/><path d="M12 14v6"/></svg></div>
|
||||
<div class="grow"><div class="t">Pullups</div><div class="s">Latsen · kroppsvikt</div></div>
|
||||
<span class="badge">REPS</span></div>
|
||||
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M7 4v7l5 9 5-9V4"/><path d="M7 8h10"/></svg></div>
|
||||
<div class="grow"><div class="t">Ryggresning</div><div class="s">Ländrygg</div></div>
|
||||
<span class="badge">REPS</span><span class="badge">TID</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gesture"><i></i></div>
|
||||
</div></div>
|
||||
<div class="phcaption">Övningsväljaren · Rygg valt</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">★</div><p><b>Favoriter alltid överst</b>
|
||||
(API:ts favoritövningar), med senaste vikten som förhandsinfo så du slipper
|
||||
minnas var du låg.</p></div>
|
||||
<div class="note"><div class="dot">◐</div><p><b>Ikoner istället för foton:</b>
|
||||
API:t saknar övningsbilder, så appen buntar vektorikoner per muskelgrupp —
|
||||
offline, blixtsnabba, temafärgade. Vill du hellre ha en <b>kroppskarta där
|
||||
vald muskel tänds</b> som filter är det samma dataunderlag — säg till vilken
|
||||
du föredrar (kartan finns i sektion 5).</p></div>
|
||||
<div class="note"><div class="dot">+</div><p><b>Ny övning på plats:</b> hittas
|
||||
inte övningen visas "Skapa övningen ‹sökordet›" längst ned — grunddatan
|
||||
behöver aldrig besökas mitt i ett pass.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 4. ANDROID-YTOR ============ -->
|
||||
<section>
|
||||
<div class="secnum">4 / 6</div>
|
||||
<h2>Utanför appen — noll tryck innan gymmet</h2>
|
||||
<p class="secintro">Det webben aldrig kan: starta och styra pass utan att ens öppna appen.</p>
|
||||
<div class="duo">
|
||||
<div class="strips">
|
||||
<div><div class="striplabel">Hemskärms-widget (4×2)</div>
|
||||
<div class="widget">
|
||||
<div class="wt"><b>FitnessDroid</b><span>3 v streak · 2 pass denna vecka</span></div>
|
||||
<div class="wbtns">
|
||||
<span class="wb pri">▶ Push A</span>
|
||||
<span class="wb ton">▶ Ben & core</span>
|
||||
<span class="wb ton">Fritt pass</span>
|
||||
</div>
|
||||
</div></div>
|
||||
<div><div class="striplabel">Långtryck på appikonen — genvägar</div>
|
||||
<div class="noti" style="padding:9px 0;max-width:250px">
|
||||
<div style="font-size:12.5px;font-weight:700;color:var(--m3-on);padding:6px 16px">▶ Starta Push A</div>
|
||||
<div style="font-size:12.5px;font-weight:700;color:var(--m3-on);padding:6px 16px;border-top:1px solid var(--line)">▶ Starta fritt pass</div>
|
||||
<div style="font-size:12.5px;font-weight:700;color:var(--m3-on);padding:6px 16px;border-top:1px solid var(--line)">📊 Veckans statistik</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="strips">
|
||||
<div><div class="striplabel">Skivkalkylatorn — tryck på vikten i passläget</div>
|
||||
<div class="sheet">
|
||||
<div class="grab"></div>
|
||||
<h4>80,0 kg på stången</h4>
|
||||
<div class="sub">Stång 20 kg · per sida:</div>
|
||||
<div class="bar">
|
||||
<span class="plate p5"></span><span class="plate p25"></span><span class="plate p10"></span><span class="plate p20"></span>
|
||||
<span class="collar"></span><span class="shaft"></span><span class="collar"></span>
|
||||
<span class="plate p20"></span><span class="plate p10"></span><span class="plate p25"></span><span class="plate p5"></span>
|
||||
</div>
|
||||
<div class="plates-txt">Per sida: <b>20 + 10 + 2,5 + 5</b> — dina skivor
|
||||
ställs in en gång under Profil. Räknar även ut närmsta möjliga vikt.</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 5. KROPPSKARTAN ============ -->
|
||||
<section>
|
||||
<div class="secnum">5 / 6</div>
|
||||
<h2>Kroppskartan — var hamnade veckans volym?</h2>
|
||||
<p class="secintro">Statistikens signaturvy: en stiliserad figur där varje muskelgrupp
|
||||
färgas efter träningsvolymen i perioden (datat finns redan i API:ts workoutStats).
|
||||
Samma karta kan återanvändas som muskelväljare i övningsfiltret om du hellre vill
|
||||
det än chips.</p>
|
||||
|
||||
<div class="bodymaps">
|
||||
<div class="bodycard">
|
||||
<figure>
|
||||
<svg width="150" height="290" viewBox="0 0 100 195" role="img" aria-label="Kroppskarta framsida">
|
||||
<circle cx="50" cy="12" r="9" fill="#233026"/>
|
||||
<rect x="43" y="22" width="14" height="7" rx="3" fill="#233026"/>
|
||||
<ellipse cx="35" cy="36" rx="9" ry="7" fill="#5f8f60"/>
|
||||
<ellipse cx="65" cy="36" rx="9" ry="7" fill="#5f8f60"/>
|
||||
<rect x="33" y="34" width="15" height="14" rx="6" fill="#8fd694"/>
|
||||
<rect x="52" y="34" width="15" height="14" rx="6" fill="#8fd694"/>
|
||||
<rect x="36" y="50" width="28" height="26" rx="8" fill="#3d5c42"/>
|
||||
<rect x="22" y="40" width="8" height="24" rx="4" fill="#d3f0c9"/>
|
||||
<rect x="70" y="40" width="8" height="24" rx="4" fill="#d3f0c9"/>
|
||||
<rect x="21" y="66" width="7" height="20" rx="3.5" fill="#3d5c42"/>
|
||||
<rect x="72" y="66" width="7" height="20" rx="3.5" fill="#3d5c42"/>
|
||||
<rect x="34" y="80" width="13" height="42" rx="6" fill="#233026"/>
|
||||
<rect x="53" y="80" width="13" height="42" rx="6" fill="#233026"/>
|
||||
<rect x="36" y="126" width="10" height="30" rx="5" fill="#233026"/>
|
||||
<rect x="54" y="126" width="10" height="30" rx="5" fill="#233026"/>
|
||||
</svg>
|
||||
<figcaption>Framsida</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<svg width="150" height="290" viewBox="0 0 100 195" role="img" aria-label="Kroppskarta baksida">
|
||||
<circle cx="50" cy="12" r="9" fill="#233026"/>
|
||||
<rect x="40" y="22" width="20" height="10" rx="4" fill="#5f8f60"/>
|
||||
<rect x="34" y="33" width="32" height="22" rx="8" fill="#3d5c42"/>
|
||||
<rect x="38" y="57" width="24" height="18" rx="7" fill="#233026"/>
|
||||
<rect x="22" y="40" width="8" height="24" rx="4" fill="#3d5c42"/>
|
||||
<rect x="70" y="40" width="8" height="24" rx="4" fill="#3d5c42"/>
|
||||
<rect x="21" y="66" width="7" height="20" rx="3.5" fill="#233026"/>
|
||||
<rect x="72" y="66" width="7" height="20" rx="3.5" fill="#233026"/>
|
||||
<rect x="35" y="77" width="30" height="14" rx="6" fill="#233026"/>
|
||||
<rect x="34" y="93" width="13" height="32" rx="6" fill="#233026"/>
|
||||
<rect x="53" y="93" width="13" height="32" rx="6" fill="#233026"/>
|
||||
<rect x="36" y="128" width="10" height="28" rx="5" fill="#3d5c42"/>
|
||||
<rect x="54" y="128" width="10" height="28" rx="5" fill="#3d5c42"/>
|
||||
</svg>
|
||||
<figcaption>Baksida</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="notes" style="justify-content:center">
|
||||
<div class="note"><div class="dot">→</div><p><b>Färgskalan är volym i perioden</b>
|
||||
— här syns direkt att bröst/underarmar fått mest och att ben & mage släpar.
|
||||
Tryck på en muskel → övningarna och seten bakom siffran.</p></div>
|
||||
<div class="heatlegend">Mindre
|
||||
<i style="background:var(--heat0)"></i><i style="background:var(--heat1)"></i>
|
||||
<i style="background:var(--heat2)"></i><i style="background:var(--heat3)"></i>
|
||||
<i style="background:var(--heat4)"></i> Mer volym</div>
|
||||
<div class="note" style="margin-top:10px"><div class="dot">✎</div><p>Figuren är
|
||||
medvetet stiliserad (byggd av former, inte anatomisk) — den blir tydlig i litet
|
||||
format, temafärgas och kräver inga bildlicenser. Vill du ha en mer anatomisk
|
||||
siluett går det, men liten och skarp slår detaljerad och grötig på en telefon.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 6. NAVIGERING + API ============ -->
|
||||
<section>
|
||||
<div class="secnum">6 / 6</div>
|
||||
<h2>Navigeringen — chrome runt tre lägen</h2>
|
||||
<p class="secintro">Med passläge + minispelare behövs färre flikar än webben har:
|
||||
<b>Hem · Historik · Statistik · Profil</b> med startknappen i mitten (förslag B
|
||||
från förra rundan). PB och kroppskartan bor i Statistik, grunddata och
|
||||
skivinställningar under Profil. Fritt pass, favoriter, widget och appgenvägar
|
||||
gör att flikarna nästan aldrig behövs på väg <i>in</i> i ett pass — de är för
|
||||
uppföljningen.</p>
|
||||
|
||||
<h2 style="margin-top:36px">Små API-tillägg som lyfter appen</h2>
|
||||
<p class="secintro">Inget av detta blockerar bygget — appen kan falla tillbaka på
|
||||
vettiga defaultvärden tills de finns.</p>
|
||||
<div class="tablewrap"><table>
|
||||
<tr><th>Tillägg</th><th>Vad det ger</th><th>Storlek</th></tr>
|
||||
<tr><td>MuscleGroup.iconKey</td>
|
||||
<td>Servern pekar ut vilken ikon en muskelgrupp har istället för att appen
|
||||
gissar på namnsträngar ("Bröst" vs "Chest"). En kolumn + fält i GraphQL.</td>
|
||||
<td><span class="pill-sm s">LITEN</span></td></tr>
|
||||
<tr><td>ExerciseType.defaultRestSeconds</td>
|
||||
<td>Auto-vilotimern vet hur länge just den övningen vilar (90 s bänk, 180 s
|
||||
marklyft). Fallback i appen: 90 s. Kolumn + fält + med i mallexport.</td>
|
||||
<td><span class="pill-sm s">LITEN</span></td></tr>
|
||||
<tr><td>Lift/Set: clientId (UUID)</td>
|
||||
<td>Offline-läge på gymmet: appen kan köa set lokalt och synka i efterhand utan
|
||||
dubbletter när samma mutation skickas om. Unik kolumn + upsert-logik.</td>
|
||||
<td><span class="pill-sm m">MEDEL · roadmap</span></td></tr>
|
||||
<tr><td>ExerciseType.imageUrl</td>
|
||||
<td>Frivillig påbyggnad om du senare vill ha riktiga foton/anatomibilder per
|
||||
övning — appen visar dem när de finns, ikoner annars.</td>
|
||||
<td><span class="pill-sm s">LITEN · valfri</span></td></tr>
|
||||
</table></div>
|
||||
|
||||
<div class="decide">
|
||||
<h2>Det jag behöver från dig</h2>
|
||||
<ol>
|
||||
<li><b>Passläget:</b> köper du "en övning i taget + svep + vilo-takeover", eller
|
||||
vill du hellre se hela passet som scrollbar lista med allt synligt?</li>
|
||||
<li><b>Muskelfiltret:</b> chips med ikoner (sektion 3) eller kroppskartan
|
||||
(sektion 5) som väljare — eller båda (chips snabbt, karta bakom en knapp)?</li>
|
||||
<li><b>Android-ytorna:</b> vilka vill du ha i första versionen — notistimer,
|
||||
widget, appgenvägar, skivkalkylator? (Notistimern föreslår jag ingår direkt,
|
||||
resten kan komma stegvis.)</li>
|
||||
<li><b>API-tilläggen:</b> ok att jag gör iconKey + defaultRestSeconds i
|
||||
gym-API:t (byggs och rullas ut via befintliga CI:t)?</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
133
doc/openscale-integration.md
Normal file
133
doc/openscale-integration.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# openScale BT-vågar — integrationsplan
|
||||
|
||||
Bygga in [openScale](https://github.com/oliexdev/openScale):s Bluetooth-
|
||||
drivrutiner i FitnessDroid så att vägningar (vikt + %muskler/fett/vatten)
|
||||
kan hämtas direkt från en BT-våg in i appens kroppsmätningar.
|
||||
|
||||
## Beslut (2026-07-26)
|
||||
|
||||
- **Licens: FitnessDroid relicensas till GPL-3.0.** openScale är GPL-3.0;
|
||||
att bädda in deras kod kräver att hela appen blir GPL-3.0. LICENSE byts,
|
||||
och källfilerna som kommer från openScale behåller sina copyright-headers.
|
||||
- **Alla drivrutiner portas** (hela `core/bluetooth`-paketet, ~55 handlers +
|
||||
adaptrar), inte bara en modell. `ScaleFactory` auto-detekterar vågen vid
|
||||
skanning precis som i openScale.
|
||||
- **Attribution:** en **About**-vy under Profil som anger att appen använder
|
||||
openScales drivrutiner, med länk till deras GitHub och GPL-3.0-licensen,
|
||||
plus en lista på öppen källkod som används (openScale, Blessed-Kotlin).
|
||||
|
||||
## Teknik (bekräftat från repot)
|
||||
|
||||
- openScale använder **Blessed-Kotlin** (coroutine-BLE-wrapper) — läggs till
|
||||
som Gradle-beroende; vi portar drivrutinerna som ligger ovanpå, inte
|
||||
BLE-plumbingen.
|
||||
- `ScaleDeviceHandler.supportFor(device)` → `DeviceSupport` (displayName,
|
||||
linkMode, tuningProfile). `ScaleFactory` returnerar första matchande handler.
|
||||
- Tre kopplingslägen: `CONNECT_GATT`, `BROADCAST_ONLY`, `CLASSIC_SPP` med var
|
||||
sin adapter (Gatt/Broadcast/Spp).
|
||||
- `ScaleMeasurement` bär weight, fat, water, muscle, visceralFat, bone, lbm,
|
||||
bmr, protein, impedance m.m. → vi mappar weight + muscle/fat/water till vår
|
||||
`BodyMeasurement` (resten kan tas in senare).
|
||||
- minSdk 31 matchar vår app (BLE-permissions `BLUETOOTH_SCAN`/`_CONNECT`).
|
||||
|
||||
## Fallgrop: impedansvågar behöver användarprofil
|
||||
|
||||
Vissa vågar skickar bara **rå impedans**; openScale räknar då själv ut
|
||||
fett/muskler/vatten med en formel som kräver **längd, ålder, kön** (ScaleUser).
|
||||
Vi måste därför:
|
||||
- Lägga till längd/födelseår/kön i profilen (se API-ändring nedan).
|
||||
- Porta openScales BIA-beräkning för de vågar som kräver den.
|
||||
Vågar som räknar ombord och skickar färdiga procent behöver inte detta.
|
||||
|
||||
## API-ändring: spara användarens längd (+ ålder/kön)
|
||||
|
||||
Längden ska sparas **på servern** (gym-API:t) så att den är gemensam för app
|
||||
och webb, överlever ominstallation, och kan användas för mer korrekta
|
||||
beräkningar:
|
||||
|
||||
- **BIA/kroppssammansättning:** openScales formler för impedansvågar kräver
|
||||
längd (+ ålder + kön) för att ge korrekta %fett/muskler/vatten.
|
||||
- **Energiförbrukning:** med längd (+ ålder + kön) kan kaloriberäkningen gå
|
||||
från ren MET × vikt × tid till en BMR-baserad uppskattning
|
||||
(Mifflin–St Jeor: `10·vikt + 6.25·längd − 5·ålder + könskonstant`), vilket
|
||||
ger rimligare siffror per pass.
|
||||
|
||||
**Backend (brasse-pc.eu-v2, gym-api):**
|
||||
- Utöka `User` med `HeightCm` (double?), och för full BIA även `BirthDate`
|
||||
(eller `BirthYear`) och `Sex` (enum/sträng). Nullable + bakåtkompatibla.
|
||||
- Migration (körs automatiskt vid uppstart, samma mönster som
|
||||
`BodyMeasurement`).
|
||||
- Exponera i `myProfile`-query och en `updateProfile`-mutation (eller utöka
|
||||
`updateBodyWeight` → `updateProfile` med längd/ålder/kön).
|
||||
- Deploya om gym-api-prod + -test (serialiserat bygge, se
|
||||
[[pi5-ci-concurrency]]).
|
||||
|
||||
**App:** profil-fält för längd (och ålder/kön) som läser/skriver mot API:t;
|
||||
cachas lokalt (som kroppsvikten) för offline och för BIA-beräkningen.
|
||||
|
||||
**Webb (brasse-pc.eu-v2):** samma fält på profil-fliken via `updateProfile`.
|
||||
|
||||
## Användarens våg (identifierad 2026-08-23)
|
||||
|
||||
**Biltema 84-1002 "Bluetooth Analyser Scale", modell PT-727** (Ningbo Putian).
|
||||
Annonserar som BLE-namnet **"VScale"** (verifierat med `bluetoothctl` på
|
||||
brasse-linux01: `Device C4:BE:84:79:D0:6C VScale`) → openScales
|
||||
**`ExingtechY1Handler`** matchar (exakt namnmatch `vscale`).
|
||||
|
||||
Protokoll (GATT): appen skriver `[0x10, userId, kön, ålder, längd_cm]` till
|
||||
vågen; vågen räknar ut kroppssammansättningen **ombord** och notifierar en
|
||||
20-bytes-ram (vikt, fett-%, vatten-%, muskel-%, benmassa kg, bukfettsindex).
|
||||
Ingen BIA-formel behövs i appen — men längd/födelseår/kön måste finnas.
|
||||
|
||||
**Lärdom från första testet (2026-08-23):** utan ifylld kroppsdata
|
||||
(längd/födelseår/kön) skickar vågen bara vikt — sammansättningen uteblir
|
||||
tyst. Väg-skärmen varnar numera tydligt när kroppsdata saknas, och håller
|
||||
dessutom anslutningen öppen och mergar ramar (vågen kan skicka vikten först
|
||||
och sammansättningen i en senare ram; max 20 s väntan).
|
||||
|
||||
Hårdvarudetaljer från GATT-dump (openScales debug-läge): tillverkarsträng
|
||||
**"VTrump"**, modellnummer **V300B1000001** — dvs. en VTrump-OEM. Notify-
|
||||
tecknet `1a2ea400…` ligger i service `78667579-7b48…` (inte i `f433bd80…` som
|
||||
klassiska Y1), och `f433bd80…` har ett extra notify-tecken `23b4fec0…`.
|
||||
Fungerar ändå med `ExingtechY1Handler` — bra att veta vid framtida felsökning.
|
||||
|
||||
## Milstolpar (byggbara steg)
|
||||
|
||||
1. **Grund + licens + About** *(klar 2026-08-23)* — LICENSE → GPL-3.0;
|
||||
`blessed-kotlin` 3.0.12 via JitPack (krävde Kotlin 2.0.21→2.2.0, KSP2,
|
||||
Room 2.7.2); BLE-permissions (`BLUETOOTH_SCAN` neverForLocation +
|
||||
`BLUETOOTH_CONNECT`); About-vy under Profil med GPL-attribution.
|
||||
2. **Porta bluetooth-paketet (grund)** *(klar 2026-08-23)* — vendrat
|
||||
`com.health.openscale.core.bluetooth` med copyright-headers kvar:
|
||||
ScaleDeviceHandler, Gatt/Broadcast/Spp-adaptrar, ScaleFactory (utan Hilt),
|
||||
ScaleCommunicator, BleScanner, ConverterUtils, ScaleMeasurement/ScaleUser,
|
||||
samt **ExingtechY1Handler**. Shims i samma paketnamn för openScales
|
||||
interna beroenden (facades → DataStore, slimmade enums, LogManager →
|
||||
logcat), så fler drivrutiner kan portas nästan rakt av.
|
||||
*Kvar: resterande ~59 handlers + libs/ portas allteftersom.*
|
||||
3. **Skanna + koppla** *(klar 2026-08-23)* — Inställningar → Bluetooth-våg:
|
||||
BLE-skanning, vågar med drivrutinsstöd överst (drivrutinsnamn visas),
|
||||
spara/ta bort vald våg. Runtime-permission-flöde.
|
||||
4. **Väg dig-flöde** *(klar och verifierad mot Biltema-vågen 2026-08-23)* — knapp i
|
||||
profilen → väg-skärm med livestatus, resultat och spara via
|
||||
`addBodyMeasurement`. Kroppsdata (längd/födelseår/kön) i profilen skickas
|
||||
till vågen. Vikt + muskel/fett/vatten-% bekräftade end-to-end.
|
||||
5. **API: längd (+ ålder/kön)** — utöka gym-API:ts `User` med `HeightCm`
|
||||
(+ `BirthDate`/`Sex`), migration, `myProfile` + `updateProfile`, deploy.
|
||||
App + webb får profilfält; appens lokala kroppsdata börjar synka mot
|
||||
API:t. Se "API-ändring" ovan.
|
||||
6. **Fler drivrutiner + impedansvågar + bättre kalorier** — porta resterande
|
||||
handlers + `libs/` (BIA-beräkningar för vågar som skickar rå impedans),
|
||||
och BMR-baserad energiförbrukning.
|
||||
7. **Polering + docs** — felhantering, timeouts, ominställning av våg;
|
||||
uppdatera README/wiki + infra-Doc (GPL-relicens noterad).
|
||||
|
||||
## Öppna frågor / risker
|
||||
|
||||
- ~~Vilken våg du har~~ → Biltema PT-727 = Exingtech Y1 ("VScale"), räknar
|
||||
ombord — milstolpe 6:s BIA behövs inte för den.
|
||||
- ~~Blessed-Kotlins koordinater~~ → `com.github.weliem:blessed-kotlin:3.0.12`
|
||||
(JitPack). Byggd med Kotlin 2.2 → tvingade upp projektets Kotlin/KSP/Room.
|
||||
- APK växer (drivrutiner + BLE-lib) — troligen någon MB, oproblematiskt.
|
||||
- GPL-relicens är permanent för koden; medvetet val.
|
||||
- ~~Väg-flödet är obekräftat~~ → verifierat mot Biltema-vågen 2026-08-23 (vikt + sammansättning).
|
||||
43
doc/plan.md
43
doc/plan.md
@@ -28,13 +28,46 @@ mallar, grunddata och profil.
|
||||
|
||||
## Milstolpar
|
||||
|
||||
1. **Grund + login** *(klar i och med första commit)*
|
||||
1. **Grund + login** *(klar 2026-07-23)*
|
||||
Projektskelett som kompilerar, inloggning mot API:t med sessions-
|
||||
återställning vid appstart, hemskärm som visar profilen.
|
||||
2. **Aktivt pass** — starta/återuppta pass, lägga till övningar, logga set
|
||||
(reps/vikt/distans/tid/RPE), vilotimer, passtimer, avsluta pass.
|
||||
Fälten styrs av övningens tracks-flaggor precis som i webben.
|
||||
3. **Passhistorik** — lista, visa, redigera och ta bort tidigare pass.
|
||||
2. **Aktivt pass** *(klar 2026-07-24)* — passläge med en övning i taget
|
||||
(svep), ±steppers och ett-trycks setloggning med förifyllda värden,
|
||||
vilotimer (helskärm/banner + larm/vibration/visuell/inget — inställbart),
|
||||
offline-först via Room + synk-kö med statusindikatorer, övningsväljare
|
||||
med muskelfilter, bottenrad-navigation med start-FAB och minispelare.
|
||||
API-tillägg: `MuscleGroup.iconKey`, `ExerciseType.defaultRestSeconds`.
|
||||
*Polering 2026-07-24/25:* rätta/ta bort loggade set, passdetaljvy i
|
||||
historiken, övningshistorik i passläget (tidigare pass + tyngsta 3 mån),
|
||||
avsluta-överblick med redigerbar start/varaktighet (date/time-pickers,
|
||||
hopfällt bakom "Ändra tid"), kcal-summering med "Hur räknas detta?"
|
||||
(MET × kroppsvikt × timmar, även i historiken) och 🏆-markering av
|
||||
nya personbästa per rep-antal (PB-cache i Room).
|
||||
3. **Statistik + robust inloggning** *(klar 2026-07-25, version 0.3.0)* —
|
||||
statistikfliken med periodväljare (vecka/månad/halvår/år/totalt),
|
||||
KPI-kort med delta mot förra perioden, scrollbar volymtrend,
|
||||
kroppskarta (fram/bak, volymvärme per muskelgrupp), höjdpunkter och
|
||||
PB-lista per övning. Tyst återinloggning: uppgifterna sparas
|
||||
AES-krypterat via Android Keystore och appen loggar in igen automatiskt
|
||||
när servern avvisat sessionen (aktiveras vid inloggning; toggle i
|
||||
inställningarna). Utökade inställningar: viktsteg (1,25/2,5/5 kg),
|
||||
API-url, auto-återinloggning, egna stångvikter.
|
||||
*Dessutom:* **in-app-uppdatering** (CI publicerar version.json;
|
||||
hemskärmskort laddar ner, sha256-verifierar och installerar),
|
||||
**skivkalkylator** (tryck på vikten i aktiva setet; stång 20/0/egna,
|
||||
skivor i standardfärger), **passlängd** i historiken, och
|
||||
**kroppsviktspårning**: API-modellen BodyMeasurement (uuid, datum,
|
||||
vikt + muskel/fett/vatten-% för framtiden), uppdatera/rätta/ta bort
|
||||
mätningar från profilen med datumväljare, viktgraf i statistiken med
|
||||
glidande medelvärde och linjär trendlinje. Profilens bodyWeightKg
|
||||
speglar alltid senaste mätningen.
|
||||
4. **BT-våg via openScale-drivrutiner** *(påbörjad 2026-08-23 — milstolpe 1–4
|
||||
klara, väntar på fysiskt vågtest)* — hämta vägningar (vikt +
|
||||
%muskler/fett/vatten) direkt från en Bluetooth-våg genom att bädda in
|
||||
openScales drivrutiner. Relicensierad till GPL-3.0. Först ut:
|
||||
Biltema 84-1002 (PT-727) = Exingtech Y1 ("VScale"). Full plan:
|
||||
[`doc/openscale-integration.md`](openscale-integration.md).
|
||||
5. **Passhistorik** — lista, visa, redigera och ta bort tidigare pass.
|
||||
4. **Logga lyft (snabbloggning) + PB** — enkel lyftlogg och PB-matrisen.
|
||||
5. **Statistik** — perioder (vecka/månad/halvår/år), KPI-kort, trenddiagram,
|
||||
muskelfördelning, aktivitets-heatmap.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
agp = "8.7.3"
|
||||
kotlin = "2.0.21"
|
||||
kotlin = "2.2.0"
|
||||
coreKtx = "1.15.0"
|
||||
lifecycle = "2.8.7"
|
||||
activityCompose = "1.9.3"
|
||||
@@ -9,6 +9,11 @@ navigationCompose = "2.8.5"
|
||||
okhttp = "4.12.0"
|
||||
kotlinxSerialization = "1.7.3"
|
||||
datastore = "1.1.1"
|
||||
room = "2.7.2"
|
||||
ksp = "2.2.0-2.0.2"
|
||||
blessedKotlin = "3.0.12"
|
||||
osmdroid = "6.1.20"
|
||||
healthConnect = "1.1.0-alpha07"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -26,9 +31,16 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
blessed-kotlin = { group = "com.github.weliem", name = "blessed-kotlin", version.ref = "blessedKotlin" }
|
||||
osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
|
||||
health-connect = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
|
||||
@@ -16,6 +16,10 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// Blessed-Kotlin (BLE-biblioteket som openScale-drivrutinerna använder) finns bara på JitPack
|
||||
maven("https://jitpack.io") {
|
||||
content { includeGroup("com.github.weliem") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user