mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-07-06 20:29:57 +00:00
Compare commits
72 Commits
nightly-cb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6ebbed674 | ||
|
|
e34474f765 | ||
|
|
4f910136dc | ||
|
|
721819e792 | ||
|
|
bf3920718c | ||
|
|
cb0c46dc33 | ||
|
|
cd40975a1f | ||
|
|
790e396345 | ||
|
|
633d036dd7 | ||
|
|
7341e41837 | ||
|
|
bffb5c8b3e | ||
|
|
aca4373ec4 | ||
|
|
8804197760 | ||
|
|
57d692c4ae | ||
|
|
0477674810 | ||
|
|
c1093fbe24 | ||
|
|
0f50eab788 | ||
|
|
1381022b2e | ||
|
|
af4261485a | ||
|
|
571db0ebde | ||
|
|
3c3c0edffd | ||
|
|
f86d7b9aca | ||
|
|
29a914c2ff | ||
|
|
3f2a7df749 | ||
|
|
f9367f4445 | ||
|
|
0101428108 | ||
|
|
37c1837d0b | ||
|
|
154ed17c05 | ||
|
|
362e34202a | ||
|
|
5bc5079e13 | ||
|
|
64d233d427 | ||
|
|
ea2c2b811b | ||
|
|
6f28df734e | ||
|
|
c15e15a6b7 | ||
|
|
75a52cdfd7 | ||
|
|
d1b32d5a00 | ||
|
|
044aff731f | ||
|
|
216c97f9ca | ||
|
|
fd3774b513 | ||
|
|
b7336940e6 | ||
|
|
b2ba830a80 | ||
|
|
f08769e62f | ||
|
|
d1933c3b67 | ||
|
|
fb44f01ac0 | ||
|
|
93a93cbe68 | ||
|
|
a4898293b8 | ||
|
|
845f26192c | ||
|
|
3321bb1c43 | ||
|
|
c7a5cb2d8c | ||
|
|
7b81411417 | ||
|
|
d80f2275a1 | ||
|
|
795bebc6ae | ||
|
|
4ef3e4d4da | ||
|
|
b88b14de15 | ||
|
|
7cd4dfa3e0 | ||
|
|
30d16e9977 | ||
|
|
ddcf15eefe | ||
|
|
3e89d7f41b | ||
|
|
9eb6010a25 | ||
|
|
60e865fc1f | ||
|
|
629b7b917e | ||
|
|
d4ee741224 | ||
|
|
4c8b0d720d | ||
|
|
e20b0f7fd7 | ||
|
|
b64ff1d09e | ||
|
|
37056c6de7 | ||
|
|
3a636e37a4 | ||
|
|
136e3e8995 | ||
|
|
e39c1cfeba | ||
|
|
b06d780eee | ||
|
|
70f420dedb | ||
|
|
23193ceb39 |
110
.github/ISSUE_TEMPLATE/01-bug-report-android.yml
vendored
Normal file
110
.github/ISSUE_TEMPLATE/01-bug-report-android.yml
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
name: Bug report (Android)
|
||||
description: Report a bug in the Android app
|
||||
labels: ["bug", "android"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug. Please fill in as much as you can.
|
||||
- type: input
|
||||
id: app-version
|
||||
attributes:
|
||||
label: App version
|
||||
description: "Find this in `Settings → About → Version` in the app, or in your phone's app info."
|
||||
placeholder: "v0.2.5 (build 46)"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: app-source
|
||||
attributes:
|
||||
label: App source
|
||||
options:
|
||||
- GitHub
|
||||
- Play
|
||||
- Built from source
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device
|
||||
description: Manufacturer and model.
|
||||
placeholder: "Google Pixel 8 Pro"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: android-version
|
||||
attributes:
|
||||
label: Android / OS version
|
||||
description: Include the OEM skin if relevant.
|
||||
placeholder: "Android 16, OxygenOS 16, ColorOS 16, ..."
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: root-method
|
||||
attributes:
|
||||
label: Root / hook method
|
||||
options:
|
||||
- No root (native L2CAP support)
|
||||
- Magisk + Xposed
|
||||
- KernelSU + Xposed
|
||||
- Other (describe in additional context)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: airpods-model
|
||||
attributes:
|
||||
label: AirPods model
|
||||
options:
|
||||
- AirPods (1st gen)
|
||||
- AirPods (2nd gen)
|
||||
- AirPods (3rd gen)
|
||||
- AirPods (4th gen)
|
||||
- AirPods (4th gen) with ANC
|
||||
- AirPods Pro (1st gen)
|
||||
- AirPods Pro 2 (Lightning)
|
||||
- AirPods Pro 2 (USB-C)
|
||||
- AirPods Pro 3
|
||||
- Other / not sure
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: firmware
|
||||
attributes:
|
||||
label: AirPods firmware
|
||||
description: Find this under `About` in the app once connected.
|
||||
placeholder: "8454768"
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened
|
||||
description: Describe what you observed and what you expected. Include steps to reproduce if applicable.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
If you are rooted, give the app root access, open the app, go to `Settings → Troubleshooting → Collect Logs`, and attach the resulting file here.
|
||||
|
||||
Without logs most bugs are very hard to diagnose. If you are not, follow these instructions:
|
||||
(Needs access to a computer, and USB/Wireless Debugging under developer options enabled)
|
||||
|
||||
Commands:
|
||||
- Get the uid: Linux/Mac: `adb shell dumpsys package me.kavishdevar.librepods | grep uid`
|
||||
- Start logs: `adb logcat --uid=<uid>,1002 >> librepods-logs.txt` (1002 is for bluetooth)
|
||||
|
||||
Steps for proper logs
|
||||
- force close the app
|
||||
- turn off bluetooth
|
||||
- start logs
|
||||
- open the app
|
||||
- turn on bluetooth and connect
|
||||
|
||||
placeholder: Paste log content or attach the file
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Anything else that might help (screenshots, video, related issues, what you've already tried).
|
||||
83
.github/ISSUE_TEMPLATE/02-bug-report-linux.yml
vendored
Normal file
83
.github/ISSUE_TEMPLATE/02-bug-report-linux.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
name: Bug report (Linux)
|
||||
description: Report a bug in the Linux program
|
||||
labels: ["bug", "linux"]
|
||||
title: "[Linux] "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for the report. Please fill in as much as you can.
|
||||
- type: input
|
||||
id: app-version
|
||||
attributes:
|
||||
label: App version
|
||||
placeholder: "linux-v0.1.0, or linux-rust commit abc1234"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: variant
|
||||
attributes:
|
||||
label: Variant
|
||||
options:
|
||||
- Rust rewrite (`linux-rust` branch)
|
||||
- QT version (NOT MAINTAINED! issues will be closed)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: distro
|
||||
attributes:
|
||||
label: Distro and version
|
||||
placeholder: "Arch Linux, Fedora 41, Ubuntu 24.04, NixOS 25.05"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: desktop
|
||||
attributes:
|
||||
label: Desktop environment / compositor
|
||||
placeholder: "GNOME 47 (Wayland), KDE 6 (X11), Hyprland, ..."
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: install-method
|
||||
attributes:
|
||||
label: Install method (only official sources)
|
||||
options:
|
||||
- Built from source (`nix` or otherwise)
|
||||
- Pre-built binary
|
||||
- AppImage
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: airpods-model
|
||||
attributes:
|
||||
label: AirPods model
|
||||
options:
|
||||
- AirPods (1st gen)
|
||||
- AirPods (2nd gen)
|
||||
- AirPods (3rd gen)
|
||||
- AirPods (4th gen)
|
||||
- AirPods (4th gen) with ANC
|
||||
- AirPods Pro (1st gen)
|
||||
- AirPods Pro 2 (Lightning)
|
||||
- AirPods Pro 2 (USB-C)
|
||||
- AirPods Pro 3
|
||||
- Other / not sure
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened
|
||||
description: Describe what you observed and what you expected. Include steps to reproduce if applicable.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs and stderr
|
||||
description: Run the app from a terminal with `--debug` and paste the output.
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional context
|
||||
31
.github/ISSUE_TEMPLATE/03-feature-request.yml
vendored
Normal file
31
.github/ISSUE_TEMPLATE/03-feature-request.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Feature request
|
||||
description: Suggest a new feature or improvement
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: scope
|
||||
attributes:
|
||||
label: Scope
|
||||
options:
|
||||
- Android
|
||||
- Linux
|
||||
- Both
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem or use case
|
||||
description: What are you trying to do? What is missing or hard today?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: How might it work? UI sketches, behavior, edge cases.
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
75
.github/workflows/ci-android.yml
vendored
75
.github/workflows/ci-android.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
- '*'
|
||||
paths:
|
||||
- 'android/**'
|
||||
- 'root-module-manual/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'android/**'
|
||||
@@ -22,6 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
short_sha: ${{ steps.vars.outputs.short_sha }}
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -33,6 +35,7 @@ jobs:
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- name: Decode keystore
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.RELEASE_KEYSTORE_FILE }}" | base64 --decode > android/release.keystore
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
@@ -41,6 +44,7 @@ jobs:
|
||||
- name: Install NDK
|
||||
run: sdkmanager "ndk;30.0.14904198"
|
||||
- name: Create local.properties
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
cat <<EOF > android/local.properties
|
||||
RELEASE_STORE_FILE=../release.keystore
|
||||
@@ -48,32 +52,51 @@ jobs:
|
||||
RELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }}
|
||||
RELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||
EOF
|
||||
- name: Build
|
||||
- name: Build debug APK for PRs
|
||||
if: github.event_name == 'pull_request'
|
||||
run: ./gradlew assembleFossDebug
|
||||
working-directory: android
|
||||
- name: Build release artifacts
|
||||
if: github.event_name != 'pull_request'
|
||||
run: ./gradlew packageReleaseArtifacts
|
||||
working-directory: android
|
||||
- name: Get app version
|
||||
id: version
|
||||
run: echo "app_version=$(grep 'appVersionName =' android/app/build.gradle.kts | sed 's/.*= "\(.*\)"/\1/')" >> $GITHUB_OUTPUT
|
||||
- id: vars
|
||||
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: apk-release
|
||||
path: release/*release.apk
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
name: apk-debug
|
||||
path: android/app/build/outputs/apk/foss/debug/app-foss-debug.apk
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: apk-debug
|
||||
path: release/*debug.apk
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: root-module-release
|
||||
path: release/*release.zip
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: root-module-debug
|
||||
path: release/*debug.zip
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: release-bundle
|
||||
path: release/*.aab
|
||||
@@ -122,9 +145,9 @@ jobs:
|
||||
- id: changelog
|
||||
run: |
|
||||
if [ -z "${{ steps.prev.outputs.tag }}" ]; then
|
||||
NOTES=$(git log --pretty=format:"- %s (%h)")
|
||||
NOTES=$(git log --pretty=format:"- %s ([%h](https://github.com/kavishdevar/librepods/commit/%H))")
|
||||
else
|
||||
NOTES=$(git log ${{ steps.prev.outputs.tag }}..HEAD --pretty=format:"- %s (%h)")
|
||||
NOTES=$(git log ${{ steps.prev.outputs.tag }}..HEAD --pretty=format:"- %s ([%h](https://github.com/kavishdevar/librepods/commit/%H))")
|
||||
fi
|
||||
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||
@@ -141,3 +164,49 @@ jobs:
|
||||
-t "Nightly ${{ needs.build.outputs.short_sha }}" \
|
||||
--notes "${{ steps.changelog.outputs.notes }}" \
|
||||
--prerelease
|
||||
- name: Get timestamp
|
||||
id: timestamp
|
||||
run: echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
|
||||
- name: Post to Discord
|
||||
run: |
|
||||
curl -X POST "${{ secrets.DISCORD_ANDROID_CI_URL }}?with_components=true" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"embeds": [
|
||||
{
|
||||
"title": "LibrePods Nightly Build",
|
||||
"description": "Download the latest debug and release APKs.",
|
||||
"color": 253060,
|
||||
"fields": [
|
||||
{
|
||||
"name": "Changelog",
|
||||
"value": "${{ steps.changelog.outputs.notes }}",
|
||||
"inline": false
|
||||
}
|
||||
],
|
||||
"timestamp": "${{ steps.timestamp.outputs.timestamp }}",
|
||||
"footer": {
|
||||
"text": "GitHub Actions"
|
||||
}
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"type": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": 2,
|
||||
"label": "Download Release APK",
|
||||
"style": 5,
|
||||
"url": "https://github.com/kavishdevar/librepods/releases/download/nightly-${{ needs.build.outputs.short_sha }}/LibrePods-FOSS-v${{ needs.build.outputs.app_version }}-release.apk"
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"label": "Download Debug APK",
|
||||
"style": 5,
|
||||
"url": "https://github.com/kavishdevar/librepods/releases/download/nightly-${{ needs.build.outputs.short_sha }}/LibrePods-FOSS-v${{ needs.build.outputs.app_version }}-debug.apk"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}'
|
||||
|
||||
2
.github/workflows/ci-linux-rust.yml
vendored
2
.github/workflows/ci-linux-rust.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Linux Build & Release
|
||||
name: Linux CI (Rust)
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
# Bluetooth Low Energy (BLE) - Apple Proximity Pairing Message
|
||||
|
||||
This document describes how the AirPods BLE "Proximity Pairing Message" is parsed and interpreted in the application. This message is broadcast by Apple devices (such as AirPods) and contains key information about the device's state, battery, and other properties.
|
||||
|
||||
## Overview
|
||||
|
||||
When scanning for BLE devices, the application looks for manufacturer data with Apple's ID (`0x004C`). If the data starts with `0x07`, it is identified as a Proximity Pairing Message. The message contains various fields, each representing a specific property of the AirPods.
|
||||
|
||||
## Proximity Pairing Message Structure
|
||||
|
||||
| Byte Index | Field Name | Description | Example Value(s) |
|
||||
|------------|-------------------------|---------------------------------------------------------|--------------------------|
|
||||
| 0 | Prefix | Message type (should be `0x07` for proximity pairing) | `0x07` |
|
||||
| 1 | Length | Length of the message | `0x12` |
|
||||
| 2 | Pairing Mode | `0x01` = Paired, `0x00` = Pairing mode | `0x01`, `0x00` |
|
||||
| 3-4 | Device Model | Big-endian: [3]=high, [4]=low | `0x0E20` (AirPods Pro) |
|
||||
| 5 | Status | Bitfield, see below | `0x62` |
|
||||
| 6 | Pods Battery Byte | Nibbles for left/right pod battery | `0xA7` |
|
||||
| 7 | Flags & Case Battery | Upper nibble: case battery, lower: flags | `0xB3` |
|
||||
| 8 | Lid Indicator | Bits for lid state and open counter | `0x09` |
|
||||
| 9 | Device Color | Color code | `0x02` |
|
||||
| 10 | Connection State | Enum, see below | `0x04` |
|
||||
| 11-26 | Encrypted Payload | 16 bytes, not parsed | |
|
||||
|
||||
## Field Details
|
||||
|
||||
### Device Model
|
||||
|
||||
| Value (hex) | Model Name |
|
||||
|-------------|--------------------------|
|
||||
| 0x0220 | AirPods 1st Gen |
|
||||
| 0x0F20 | AirPods 2nd Gen |
|
||||
| 0x1320 | AirPods 3rd Gen |
|
||||
| 0x1920 | AirPods 4th Gen |
|
||||
| 0x1B20 | AirPods 4th Gen (ANC) |
|
||||
| 0x0A20 | AirPods Max |
|
||||
| 0x1F20 | AirPods Max (USB-C) |
|
||||
| 0x0E20 | AirPods Pro |
|
||||
| 0x1420 | AirPods Pro 2nd Gen |
|
||||
| 0x2420 | AirPods Pro 2nd Gen (USB-C) |
|
||||
|
||||
### Status Byte (Bitfield)
|
||||
|
||||
| Bit | Meaning | Value if Set |
|
||||
|-----|--------------------------------|-------------|
|
||||
| 0 | Right Pod In Ear (XOR logic) | true |
|
||||
| 1 | Right Pod In Ear (XOR logic) | true |
|
||||
| 2 | Both Pods In Case | true |
|
||||
| 3 | Left Pod In Ear (XOR logic) | true |
|
||||
| 4 | One Pod In Case | true |
|
||||
| 5 | Primary Pod (1=Left, 0=Right) | true/false |
|
||||
| 6 | This Pod In Case | true |
|
||||
|
||||
### Ear Detection Logic
|
||||
|
||||
The in-ear detection uses XOR logic based on:
|
||||
- Whether the right pod is primary (`areValuesFlipped`)
|
||||
- Whether this pod is in the case (`isThisPodInTheCase`)
|
||||
|
||||
```cpp
|
||||
bool xorFactor = areValuesFlipped ^ deviceInfo.isThisPodInTheCase;
|
||||
deviceInfo.isLeftPodInEar = xorFactor ? (status & 0x08) != 0 : (status & 0x02) != 0; // Bit 3 or 1
|
||||
deviceInfo.isRightPodInEar = xorFactor ? (status & 0x02) != 0 : (status & 0x08) != 0; // Bit 1 or 3
|
||||
```
|
||||
|
||||
### Primary Pod
|
||||
|
||||
Determined by bit 5 of the status byte:
|
||||
- `1` = Left pod is primary
|
||||
- `0` = Right pod is primary
|
||||
|
||||
This affects:
|
||||
1. Battery level interpretation (which nibble corresponds to which pod)
|
||||
2. Microphone assignment
|
||||
3. Ear detection logic
|
||||
|
||||
### Microphone Status
|
||||
|
||||
The active microphone is determined by:
|
||||
```cpp
|
||||
deviceInfo.isLeftPodMicrophone = primaryLeft ^ deviceInfo.isThisPodInTheCase;
|
||||
deviceInfo.isRightPodMicrophone = !primaryLeft ^ deviceInfo.isThisPodInTheCase;
|
||||
```
|
||||
|
||||
### Pods Battery Byte
|
||||
|
||||
- Upper nibble: one pod battery (depends on primary)
|
||||
- Lower nibble: other pod battery
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|----------------|
|
||||
| 0x0-0x9 | 0-90% (x10) |
|
||||
| 0xA-0xE | 100% |
|
||||
| 0xF | Not available|
|
||||
|
||||
### Flags & Case Battery Byte
|
||||
|
||||
- Upper nibble: case battery (same encoding as pods)
|
||||
- Lower nibble: flags
|
||||
|
||||
#### Flags (Lower Nibble)
|
||||
|
||||
| Bit | Meaning |
|
||||
|-----|--------------------------|
|
||||
| 0 | Right Pod Charging (XOR) |
|
||||
| 1 | Left Pod Charging (XOR) |
|
||||
| 2 | Case Charging |
|
||||
|
||||
### Lid Indicator
|
||||
|
||||
| Bits | Meaning |
|
||||
|------|------------------------|
|
||||
| 0-2 | Lid Open Counter |
|
||||
| 3 | Lid State (0=Open, 1=Closed) |
|
||||
|
||||
### Device Color
|
||||
|
||||
| Value | Color |
|
||||
|-------|-------------|
|
||||
| 0x00 | White |
|
||||
| 0x01 | Black |
|
||||
| 0x02 | Red |
|
||||
| 0x03 | Blue |
|
||||
| 0x04 | Pink |
|
||||
| 0x05 | Gray |
|
||||
| 0x06 | Silver |
|
||||
| 0x07 | Gold |
|
||||
| 0x08 | Rose Gold |
|
||||
| 0x09 | Space Gray |
|
||||
| 0x0A | Dark Blue |
|
||||
| 0x0B | Light Blue |
|
||||
| 0x0C | Yellow |
|
||||
| 0x0D+ | Unknown |
|
||||
|
||||
### Connection State
|
||||
|
||||
| Value | State |
|
||||
|-------|--------------|
|
||||
| 0x00 | Disconnected |
|
||||
| 0x04 | Idle |
|
||||
| 0x05 | Music |
|
||||
| 0x06 | Call |
|
||||
| 0x07 | Ringing |
|
||||
| 0x09 | Hanging Up |
|
||||
| 0xFF | Unknown |
|
||||
|
||||
## Example Message
|
||||
|
||||
| Byte Index | Example Value | Description |
|
||||
|------------|--------------|----------------------------|
|
||||
| 0 | 0x07 | Proximity Pairing Message |
|
||||
| 1 | 0x12 | Length |
|
||||
| 2 | 0x01 | Paired |
|
||||
| 3-4 | 0x0E 0x20 | AirPods Pro |
|
||||
| 5 | 0x62 | Status |
|
||||
| 6 | 0xA7 | Pods Battery |
|
||||
| 7 | 0xB3 | Flags & Case Battery |
|
||||
| 8 | 0x09 | Lid Indicator |
|
||||
| 9 | 0x02 | Device Color |
|
||||
| 10 | 0x04 | Connection State (Idle) |
|
||||
|
||||
---
|
||||
|
||||
For further details, see [`BleManager`](linux/ble/blemanager.cpp) and [`BleScanner`](linux/ble/blescanner.cpp).
|
||||
303
README.md
303
README.md
@@ -1,114 +1,99 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/stars/kavishdevar/librepods?style=for-the-badge&logoColor=white" />
|
||||
<img src="https://img.shields.io/github/license/kavishdevar/librepods?style=for-the-badge" />
|
||||
<img src="https://img.shields.io/github/v/release/kavishdevar/librepods?style=for-the-badge&logoColor=white&label=Release" />
|
||||
<img src="https://img.shields.io/github/downloads/kavishdevar/librepods/total?style=for-the-badge&label=Downloads" />
|
||||
<img src="https://img.shields.io/github/issues/kavishdevar/librepods?style=for-the-badge" />
|
||||
|
||||
<a href="https://discord.gg/HhG4ycVum4">
|
||||
<img src="https://img.shields.io/discord/1441416992027574375?style=for-the-badge&logoColor=white&color=5865F2&label=Discord" />
|
||||
</a>
|
||||
</p>
|
||||
> [!WARNING]
|
||||
> librepods.org is not an official website of the LibrePods project. It inaccurately claims to be the official website of the project by claiming copyrights and using the LibrePods logo in the footer. And at the same time, they say that the project is not affiliated with the LibrePods project or its developers.
|
||||
>
|
||||
> Please report any other such websites to [me@kavish.xyz](mailto:me@kavish.xyz)
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./imgs/banner-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./imgs/banner.png" />
|
||||
<img alt="LibrePods" src="./imgs/banner.png" />
|
||||
</picture>
|
||||
|
||||
>[!IMPORTANT]
|
||||
Development paused due to lack of time until 17th May 2026 (JEE Advanced). PRs and issues might not be responded to until then.
|
||||
|
||||

|
||||
<div align="center" style="margin: 20px 0px;">
|
||||
<a href="https://github.com/kavishdevar/librepods/releases/latest">
|
||||
<img src="https://img.shields.io/github/downloads/kavishdevar/librepods/total?label=GitHub%20Downloads" />
|
||||
</a>
|
||||
<a href="https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml">
|
||||
<img src="https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml/badge.svg" />
|
||||
</a>
|
||||
<a href="https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml">
|
||||
<img src="https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml/badge.svg" />
|
||||
</a>
|
||||
<a href="https://github.com/kavishdevar/librepods/issues">
|
||||
<img src="https://img.shields.io/github/issues/kavishdevar/librepods" />
|
||||
</a>
|
||||
<a href="https://discord.gg/HhG4ycVum4">
|
||||
<img src="https://img.shields.io/discord/1441416992027574375?logoColor=white&color=5865F2&label=Discord" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
# What is LibrePods?
|
||||
|
||||
LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, hearing aid, customized transparency mode, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem.
|
||||
LibrePods allows you to use AirPods features that are exclusive to Apple devices. It implements the proprietary protocol used to exchange data between AirPods and Apple devices, enabling features like changing noise control modes, fast ear detection, accurate battery status, head gestures, conversational awareness, and more on non-Apple platforms.
|
||||
|
||||
# Device Compatibility
|
||||
# Feature availability
|
||||
|
||||
| Status | Device | Features |
|
||||
| ------ | --------------------- | ---------------------------------------------------------- |
|
||||
| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested |
|
||||
| ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) |
|
||||
| ✅ | AirPods Max | Fully supported (client shows unsupported features) |
|
||||
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work |
|
||||
| Feature | Linux | Android |
|
||||
| ----------------------------------------------------------- | ----- | ------- |
|
||||
| Changing Listening Mode | ✅ | ✅ |
|
||||
| Ear detection | ✅ | ✅ |
|
||||
| Battery status | ✅ | ✅ |
|
||||
| Renaming AirPods <details><summary>Note for Android</summary>On Android, you need to re-pair your AirPods after renaming them because Android might not use the latest name.</details> | ✅ | ✅ |
|
||||
| Loud Sound Reduction | 🔴 | ⚪ |
|
||||
| Head Gestures | ⛔ | ✅ |
|
||||
| Conversational Awareness | ✅ | ✅ |
|
||||
| Automatically connect to AirPods | ✅ | ✅ |
|
||||
| Hearing Aid | 🔴 | ⚪ |
|
||||
| Transparency Mode customization | 🔴 | ⚪ |
|
||||
| Multi-device connectivity (Bluetooth Multipoint; 2 devices only) | ⚪ | ⚪ |
|
||||
| <details><summary>Other accessibility configs (click to expand)</summary><ul><li>Press speed</li><li>Press and Hold duration</li><li>Noise Cancellation with single AirPod</li><li>Volume control on swipe</li><li>Volume swipe speed</li></ul></details> | 🔴 | ✅ |
|
||||
| <details><summary>Other general configs</summary><ul><li>Press and Hold to cycle between listening modes/invoke digital assistant (invoking digital assistant needs a recent firmware)</li><li>Configure call controls</li><li>Personalized volume</li><li>Loud Sound Reduction (needs <a href="#vendorid-spoofing">VendorID spoofing</a>)</li><li>Microphone side</li><li>Pause media when falling asleep (needs a recent firmware)</li><li>Enable <code>Off listening mode</code> to switch to <code>Off</code></li></ul></details> | 🔴 | ✅ |
|
||||
| [Head-tracked Spatial Audio](#spatial-audio) | ❓ | ❓ |
|
||||
| [Heart Rate Monitoring](#heart-rate-monitoring) | ⛔ | 🔴 |
|
||||
| [Find My](#find-my) | ❓ | ❓ |
|
||||
| [High quality two-way audio](#high-quality-two-way-audio) | 🔴 | 🔴 |
|
||||
|
||||
Most features should work with any AirPods. Currently, I've only got AirPods Pro 2 to test with. But, I believe the protocol remains the same for all other AirPods (based on analysis of the bluetooth stack on macOS).
|
||||
| Symbol | Meaning |
|
||||
| ------ | ------------------------------------------------------------------- |
|
||||
| ✅ | Implemented and works well |
|
||||
| ⚪ | Needs [VendorID spoofing](#vendorid-spoofing); use at your own risk |
|
||||
| 🔴 | Not implemented yet; planned |
|
||||
| ⛔ | Will not be implemented |
|
||||
| ❓ | Unknown |
|
||||
|
||||
# Key Features
|
||||
## Find My
|
||||
|
||||
- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press
|
||||
- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out
|
||||
- **Battery Status**: Accurate battery levels
|
||||
- **Head Gestures**: Answer calls just by nodding your head
|
||||
- **Conversational Awareness**: Volume automatically lowers when you speak
|
||||
- **Hearing Aid\***
|
||||
- **Customize Transparency Mode\***
|
||||
- **Multi-device connectivity\*** (upto 2 devices)
|
||||
- **Other customizations**:
|
||||
- Rename your AirPods
|
||||
- Customize long-press actions
|
||||
- All accessibility settings
|
||||
- And more!
|
||||
The following features related to Find My are planned, but require further RE and might need root on Android:
|
||||
|
||||
* Features marked with an asterisk require the VendorID to be change to that of Apple.
|
||||
- Add your AirPods to the Find My network
|
||||
- Play sound through charging case to find it
|
||||
- Notify when leaving behind
|
||||
- Toggle case charging sounds
|
||||
|
||||
# Platform Support
|
||||
## Spatial Audio
|
||||
|
||||
## Linux
|
||||
for the old version see the [Linux README](./linux/README.md). (doesn't have many features, maintainer didn't have time to work on it)
|
||||
The app does not currently provide head tracking information to Android for the OS to perform HRTF. This has not been explored completely, and it might need root.
|
||||
|
||||
new version in development ([#241](https://github.com/kavishdevar/librepods/pull/241))
|
||||
Spatializing stereo sound is beyond this project's scope and will never be available. Many OEMs have an implementation of their own for this.
|
||||
|
||||

|
||||
## Heart Rate Monitoring (AirPods Pro 3 and later)
|
||||
This is being worked upon, check the #reverse-engineering channel on the LibrePods Discord server for more information. If it is ever implemented, it will most likely need root on Android.
|
||||
|
||||
## Android
|
||||
## High quality two-way audio
|
||||
On iOS/iPadOS, you can continue using A2DP while AirPods send the audio stream from its microphone over AACP.
|
||||
|
||||
### Screenshots
|
||||
Since this needs deeper integration with audio on Android, it will most likely need root.
|
||||
|
||||
| | | |
|
||||
| --------------------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
# Installation
|
||||
|
||||
- [**Android**](/android/README.md)
|
||||
- [**Linux**](/linux/README.md)
|
||||
|
||||
here's a very unprofessional demo video
|
||||
|
||||
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
|
||||
|
||||
### Root Requirement
|
||||
|
||||
The app needs root because of a bug in the Android Bluetooth stack Fluoride/non-compliance of Apple with Bluetooth standards. You must have Xposed installed for the app to workaround this bug and connect to AirPods.
|
||||
|
||||
[https://issuetracker.google.com/issues/371713238](https://issuetracker.google.com/issues/371713238)
|
||||
|
||||
Please do not comment in the thread. The issue has already been resolved and should be available in Android 17 for all devices.
|
||||
|
||||
However, if you are using ColorOS/OxygenOS 16, Android 16 QPR3 on Pixel (ensure you're on the latest Play system update), you don't need root for most features.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This workaround with Xposed is not guaranteed to work on all devices.
|
||||
|
||||
Features requiring the VendorID hook will still require root. These features include customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint.
|
||||
|
||||
### Troubleshooting steps for common errors
|
||||
- Ensure the correct scope is set in LSPosed/Vector.
|
||||
- Ensure there is no root-hiding module preventing the hook from loading on the Bluetooth app.
|
||||
- Restart your phone after confirming the scope.
|
||||
|
||||
### A few notes
|
||||
|
||||
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, loud sounds are not reduced.
|
||||
|
||||
- If you have take both AirPods out, the app will automatically switch to the phone speaker. But, Android might keep on trying to connect to the AirPods because the phone is still connected to them, just the A2DP profile is not connected. The app tries to disconnect the A2DP profile as soon as it detects that Android has connected again if they're not in the ear.
|
||||
|
||||
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
|
||||
|
||||
- If you want the AirPods icon and battery status to show in Android Settings app, install the app as a system app by using the root module.
|
||||
|
||||
# Changing VendorID in the DID profile to that of Apple
|
||||
# VendorID Spoofing
|
||||
|
||||
Turns out, if you change the VendorID in DID Profile to that of Apple, you get access to several special features!
|
||||
|
||||
You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings.
|
||||
You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings (shown only when Xposed is available and LibrePods module is enabled).
|
||||
|
||||
## Multi-device Connectivity
|
||||
|
||||
@@ -120,49 +105,123 @@ Accessibility settings like customizing transparency mode (amplification, balanc
|
||||
|
||||
All hearing aid customizations can be done from Android (linux soon), including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
|
||||
|
||||
# Protocol and Reverse Engineering
|
||||
|
||||
Please refer to the Wireshark dissector plugin by Nojus ([@pabloaul](https://github.com/pabloaul)) for more information on the protocols used: [pabloaul/apple-wireshark](https://github.com/pabloaul/apple-wireshark)
|
||||
|
||||
The dissector had not been used in LibrePods for most of the implementation; I had reverse engineered the protocol myself before this dissector was made. But many (future) features including two-way high quality audio and spatial audio would not have been possible without their RE efforts!
|
||||
|
||||
# Use of AI
|
||||
|
||||
## Android app
|
||||
|
||||
These parts of the app were completely AI-generated:
|
||||
- Head Gestures - all of it, including logic and the UI
|
||||
- The offset setup with r2+the xposed module (both versions)
|
||||
- Troubleshooter and LogCollector
|
||||
|
||||
Rest everything- the background service, the Bluetooth manager classes (AACP and ATT), the entire UI, even the smallest components were written manually.
|
||||
|
||||
Some parts of the UI components were borrowed from [Kyant0's demo app](https://github.com/Kyant0/AndroidLiquidGlass/tree/master/catalog), which is licensed under [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0).
|
||||
|
||||
## Linux (rewrite)
|
||||
|
||||
The `aacp.rs` and the `att.rs` files were translated from Kotlin to Rust with AI. Some parts of the `media_controller.rs` file, mainly the pulse integration, was also AI-generated.
|
||||
|
||||
# Supporters
|
||||
|
||||
A huge thank you to everyone supporting the project!
|
||||
- @davdroman
|
||||
- @tedsalmon
|
||||
- @wiless
|
||||
- @SmartMsg
|
||||
- @lunaroyster
|
||||
- @ressiwage
|
||||
|
||||
# Special thanks
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/davdroman">
|
||||
<img src="https://github.com/davdroman.png?size=48" width="48" height="48"alt="davdroman"/><br />
|
||||
@davdroman
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/tedsalmon">
|
||||
<img src="https://github.com/tedsalmon.png?size=48" width="48" height="48"alt="tedsalmon"/><br />
|
||||
@tedsalmon
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/wiless">
|
||||
<img src="https://github.com/wiless.png?size=48" width="48" height="48"alt="wiless"/><br />
|
||||
@wiless
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/SmartMsg">
|
||||
<img src="https://github.com/SmartMsg.png?size=48" width="48" height="48"alt="SmartMsg"/><br />
|
||||
@SmartMsg
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/lunaroyster">
|
||||
<img src="https://github.com/lunaroyster.png?size=48" width="48" height="48"alt="lunaroyster"/><br />
|
||||
@lunaroyster
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/ressiwage">
|
||||
<img src="https://github.com/ressiwage.png?size=48" width="48" height="48"alt="ressiwage"/><br />
|
||||
@ressiwage
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/kkjdroid">
|
||||
<img src="https://github.com/kkjdroid.png?size=48" width="48" height="48"alt="kkjdroid"/><br />
|
||||
@kkjdroid
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/CitrusJoules">
|
||||
<img src="https://github.com/CitrusJoules.png?size=48" width="48" height="48"alt="CitrusJoules"/><br />
|
||||
@CitrusJoules
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/DanielReyesDev">
|
||||
<img src="https://github.com/DanielReyesDev.png?size=48" width="48" height="48"alt="DanielReyesDev"/><br />
|
||||
@DanielReyesDev
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/sumitduster">
|
||||
<img src="https://github.com/sumitduster.png?size=48" width="48" height="48"alt="sumitduster"/><br />
|
||||
@sumitduster
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/GrifTheDev">
|
||||
<img src="https://github.com/GrifTheDev.png?size=48" width="48" height="48"alt="GrifTheDev"/><br />
|
||||
@GrifTheDev
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
# Special Thanks
|
||||
- @tyalie for making the first documentation on the protocol! ([tyalie/AAP-Protocol-Definition](https://github.com/tyalie/AAP-Protocol-Defintion))
|
||||
- @rithvikvibhu and folks over at lagrangepoint for helping with the hearing aid feature ([gist](https://gist.github.com/rithvikvibhu/45e24bbe5ade30125f152383daf07016))
|
||||
- @devnoname120 for helping with the first root patch
|
||||
- @timgromeyer for making the first version of the linux app
|
||||
- @hackclub for hosting [High Seas](https://highseas.hackclub.com) and [Low Skies](https://low-skies.hackclub.com)!
|
||||
- Of course, everyone who has contributed to the project in any way, including by testing, sharing feedback, or just showing interest!
|
||||
|
||||
# Alternates for other platforms:
|
||||
- CAPod - A companion app for AirPods on Android. ([play store](https://play.google.com/store/apps/details?id=eu.darken.capod) | [source code](https://github.com/d4rken-org/capod)). Use this if you're using Android version 16 QPR3 or below and are not rooted.
|
||||
- MagicPods for Steam Deck ([website](https://magicpods.app/steamdeck/))
|
||||
- MagicPods - if you're looking for "LibrePods for Windows" ([ms store](https://apps.microsoft.com/store/detail/9P6SKKFKSHKM) [installer](https://magicpods.app/installer/MagicPods.appinstaller) | [website](https://magicpods.app/))
|
||||
|
||||
# Nightly/Development Builds
|
||||
|
||||
Want to try the latest features before they're officially released? You can grab nightly builds from GitHub Actions:
|
||||
|
||||
### Android
|
||||
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml)
|
||||
2. Click on the most recent successful workflow run
|
||||
3. Scroll down to **Artifacts** and download the **Debug APK** zip file
|
||||
4. Extract the zip and install the `.apk` on your device
|
||||
|
||||
> [!NOTE]
|
||||
> You need to be signed in to GitHub to download artifacts. Nightly builds are debug-signed and may not auto-update. You may need to uninstall the stable version first.
|
||||
|
||||
### Linux (Rust)
|
||||
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml)
|
||||
2. Click on the most recent successful workflow run
|
||||
3. Download the **librepods-x86_64.AppImage** or **librepods** binary from **Artifacts**
|
||||
|
||||
> [!WARNING]
|
||||
> Nightly builds are unstable and may contain bugs. Use at your own risk.
|
||||
|
||||
# Star History
|
||||
|
||||
<a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left">
|
||||
@@ -191,4 +250,12 @@ 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/>.
|
||||
|
||||
All trademarks, logos, and brand names are the property of their respective owners. Use of them does not imply any affiliation with or endorsement by them. All AirPods images, symbols, and the SF Pro font are the property of Apple Inc.
|
||||
# Trademark Notice
|
||||
|
||||
The GPL does not grant any rights to use the LibrePods name, logo, or branding. The LibrePods name and logo may not be used for software, websites, domains, products, services, or other projects in a manner that suggests affiliation with, endorsement by, or association with the official LibrePods project without prior permission.
|
||||
|
||||
If you see any misuse of the LibrePods name or logo, please report it to [me@kavish.xyz](mailto:me@kavish.xyz).
|
||||
|
||||
The SF Pro font used in the Android app is the property of Apple Inc.. This will be removed in future versions of the app and replaced with an open alternative soon.
|
||||
|
||||
AirPods, AirPods Pro, AirPods Max, and the AirPods logo are trademarks of Apple Inc. The LibrePods project is not affiliated with or endorsed by Apple Inc. in any way.
|
||||
67
android/README.md
Normal file
67
android/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
## Root Requirement
|
||||
|
||||
LibrePods *may* require root depending on your device/OS and what features you want access to:
|
||||
|
||||
- Features requiring the VendorID hook ([the features marked with an asterisk here](https://github.com/kavishdevar/librepods#key-features)) will always require root regardless of your device/OS.
|
||||
- On **ColorOS/OxygenOS 16 and realme UI 7.0** and **Pixel devices on Android 16 QPR3** (with the latest Google Play system update), LibrePods does not need root for most features.
|
||||
- On other devices, LibrePods needs root because of a bug in the Android Bluetooth stack Fluoride/non-compliance of Apple with Bluetooth standards. You must have Xposed installed for the app to workaround this bug and connect to AirPods. [This issue is being tracked here](https://issuetracker.google.com/issues/371713238). **Please do not comment on the issue thread.** The issue has already been resolved and should be available in **Android 17** for all devices.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This workaround with Xposed is not guaranteed to work on all devices.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
### Google Play Store
|
||||
|
||||
If you are using a supported device/OS combination, you can install LibrePods from the Google Play Store. You can use the VendorID hook features with root even from the Play Store version.
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=me.kavishdevar.librepods"><img width="170" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/2948308f-af92-443f-94d9-ee381c3a6ccc"/></a>
|
||||
|
||||
### GitHub Releases
|
||||
|
||||
If you need xposed because of the [root requirement](#root-requirement), you will have to use the apk/zip from the [GitHub releases](https://github.com/kavishdevar/librepods/releases/latest).
|
||||
|
||||
### As a system app (root module)
|
||||
|
||||
If you want LibrePods to have privileged Bluetooth permissions to
|
||||
- show battery status in the system settings and widgets
|
||||
- show AirPods icon in the system settings (xposed is also currently required for this)
|
||||
- switch audio to phone speakers when you are not wearing your AirPods
|
||||
|
||||
you can install the root module. This is optional and only provides extra features, but it is not required for the app to work.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> When using the root module, do not install the Play Store version. There might be issues because of the signature mismatch between the Play Store version and the root module.
|
||||
|
||||
## Nightly/Development Builds
|
||||
|
||||
Want to try the latest features before they're officially released? You can grab nightly builds from the [latest nightly release](https://github.com/kavishdevar/librepods/releases?q=nightly).
|
||||
|
||||
> [!WARNING]
|
||||
> These builds are automatically generated from the latest code and may contain new features and bug fixes that haven't been included in a stable release yet. However, please note that they may also be less stable than official releases, so use them at your own risk.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| | | |
|
||||
| ------------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|
||||
|
||||
here's a very unprofessional demo video
|
||||
|
||||
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
|
||||
|
||||
### Troubleshooting steps for common errors
|
||||
- Ensure the correct scope is set in LSPosed/Vector.
|
||||
- Ensure there is no root-hiding module preventing the hook from loading on the Bluetooth app.
|
||||
- Restart your phone after confirming the scope.
|
||||
|
||||
### A few notes
|
||||
|
||||
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, loud sounds are not reduced.
|
||||
|
||||
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
|
||||
@@ -1,6 +1,6 @@
|
||||
import java.util.Properties
|
||||
|
||||
val appVersionName = "0.2.4"
|
||||
val appVersionName = "1.0.0-rc2"
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
@@ -10,17 +10,37 @@ plugins {
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
val localPropsFile = rootProject.file("local.properties")
|
||||
val props = Properties().apply {
|
||||
load(rootProject.file("local.properties").inputStream())
|
||||
if (localPropsFile.exists()) {
|
||||
load(localPropsFile.inputStream())
|
||||
}
|
||||
}
|
||||
|
||||
val releaseSigningAvailable = listOf(
|
||||
"RELEASE_STORE_FILE",
|
||||
"RELEASE_STORE_PASSWORD",
|
||||
"RELEASE_KEY_ALIAS",
|
||||
"RELEASE_KEY_PASSWORD"
|
||||
).all { props[it]?.toString()?.isNotBlank() == true }
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
optIn.add(
|
||||
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(props["RELEASE_STORE_FILE"] as String)
|
||||
storePassword = props["RELEASE_STORE_PASSWORD"] as String
|
||||
keyAlias = props["RELEASE_KEY_ALIAS"] as String
|
||||
keyPassword = props["RELEASE_KEY_PASSWORD"] as String
|
||||
if (releaseSigningAvailable) {
|
||||
create("release") {
|
||||
storeFile = file(props["RELEASE_STORE_FILE"] as String)
|
||||
storePassword = props["RELEASE_STORE_PASSWORD"] as String
|
||||
keyAlias = props["RELEASE_KEY_ALIAS"] as String
|
||||
keyPassword = props["RELEASE_KEY_PASSWORD"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
namespace = "me.kavishdevar.librepods"
|
||||
@@ -28,9 +48,8 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "me.kavishdevar.librepods"
|
||||
minSdk = 33
|
||||
targetSdk = 37
|
||||
versionCode = 40
|
||||
versionCode = 63
|
||||
versionName = appVersionName
|
||||
}
|
||||
buildTypes {
|
||||
@@ -45,23 +64,33 @@ android {
|
||||
arguments += "-DCMAKE_BUILD_TYPE=Release"
|
||||
}
|
||||
}
|
||||
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
if (releaseSigningAvailable) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
defaultConfig {
|
||||
minSdk = 33
|
||||
}
|
||||
}
|
||||
debug {
|
||||
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
if (releaseSigningAvailable) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
versionNameSuffix = "-debug"
|
||||
defaultConfig {
|
||||
minSdk = 33
|
||||
}
|
||||
}
|
||||
create("playRelease") {
|
||||
initWith(getByName("release"))
|
||||
}
|
||||
productFlavors {
|
||||
create("foss") {
|
||||
dimension = "env"
|
||||
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
||||
}
|
||||
create("play") {
|
||||
dimension = "env"
|
||||
buildConfigField("Boolean", "PLAY_BUILD", "true")
|
||||
versionNameSuffix = "-play"
|
||||
}
|
||||
create("playDebug") {
|
||||
initWith(getByName("debug"))
|
||||
buildConfigField("Boolean", "PLAY_BUILD", "true")
|
||||
versionNameSuffix = "-youshouldnothavethis"
|
||||
minSdk = 36
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
@@ -91,30 +120,12 @@ android {
|
||||
ndkVersion = "30.0.14904198"
|
||||
|
||||
flavorDimensions += "env"
|
||||
|
||||
productFlavors {
|
||||
create("normal") {
|
||||
dimension = "env"
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += "-DIS_XPOSED=OFF"
|
||||
}
|
||||
}
|
||||
}
|
||||
create("xposed") {
|
||||
dimension = "env"
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += "-DIS_XPOSED=ON"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.androidx.compose.ui.text.google.fonts)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
@@ -139,9 +150,14 @@ dependencies {
|
||||
implementation(libs.backdrop)
|
||||
// implementation(libs.hilt)
|
||||
// implementation(libs.hilt.compiler)
|
||||
add("xposedCompileOnly", libs.libxposed.api)
|
||||
add("xposedImplementation", libs.libxposed.service)
|
||||
add("playReleaseImplementation", libs.billing)
|
||||
compileOnly(libs.libxposed.api)
|
||||
implementation(libs.libxposed.service)
|
||||
implementation(libs.play.review)
|
||||
implementation(libs.play.review.ktx)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||
implementation(libs.androidx.navigationevent)
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
@@ -177,19 +193,21 @@ fun registerRootModuleZipTask(
|
||||
rename { "LibrePods.apk" }
|
||||
}
|
||||
|
||||
delete(layout.buildDirectory.dir("outputs/rootModuleZips"))
|
||||
|
||||
archiveFileName.set("LibrePods-FOSS-v$appVersionName-$buildType.zip")
|
||||
destinationDirectory.set(layout.buildDirectory.dir("outputs/rootModuleZips"))
|
||||
}
|
||||
|
||||
val zipRelease = registerRootModuleZipTask(
|
||||
"zipXposedReleaseModule",
|
||||
"xposed",
|
||||
"zipReleaseModule",
|
||||
"foss",
|
||||
"release"
|
||||
)
|
||||
|
||||
val zipDebug = registerRootModuleZipTask(
|
||||
"zipXposedDebugModule",
|
||||
"xposed",
|
||||
"zipDebugModule",
|
||||
"foss",
|
||||
"debug"
|
||||
)
|
||||
|
||||
@@ -198,22 +216,22 @@ val collect = tasks.register<Copy>("collectReleaseArtifacts") {
|
||||
dependsOn(
|
||||
zipRelease,
|
||||
zipDebug,
|
||||
"bundleXposedPlayRelease"
|
||||
"bundlePlayRelease"
|
||||
)
|
||||
|
||||
into(releaseDir)
|
||||
|
||||
from(layout.buildDirectory.dir("outputs/apk/xposed/release")) {
|
||||
from(layout.buildDirectory.dir("outputs/apk/foss/release")) {
|
||||
include("*.apk")
|
||||
rename(".*", "LibrePods-FOSS-v$appVersionName-release.apk")
|
||||
}
|
||||
|
||||
from(layout.buildDirectory.dir("outputs/apk/xposed/debug")) {
|
||||
from(layout.buildDirectory.dir("outputs/apk/foss/debug")) {
|
||||
include("*.apk")
|
||||
rename(".*", "LibrePods-FOSS-v$appVersionName-debug.apk")
|
||||
}
|
||||
|
||||
from(layout.buildDirectory.dir("outputs/bundle/xposedPlayRelease")) {
|
||||
from(layout.buildDirectory.dir("outputs/bundle/playRelease")) {
|
||||
include("*.aab")
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ cmake_minimum_required(VERSION 3.22.1)
|
||||
project("l2c_fcr_hook")
|
||||
set(CMAKE_CXX_STANDARD 23)
|
||||
|
||||
option(IS_XPOSED "Build Xposed components" OFF)
|
||||
|
||||
add_library(bluetooth_socket SHARED
|
||||
bluetooth_socket.cpp
|
||||
)
|
||||
@@ -24,40 +22,36 @@ target_link_libraries(bluetooth_socket
|
||||
log
|
||||
)
|
||||
|
||||
if(IS_XPOSED)
|
||||
|
||||
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
|
||||
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
|
||||
|
||||
add_library(l2c_fcr_hook SHARED
|
||||
${XPOSED_SRC_DIR}/l2c_fcr_hook.cpp
|
||||
add_library(l2c_fcr_hook SHARED
|
||||
l2c_fcr_hook.cpp
|
||||
|
||||
${XPOSED_SRC_DIR}/xz/xz_crc32.c
|
||||
${XPOSED_SRC_DIR}/xz/xz_crc64.c
|
||||
${XPOSED_SRC_DIR}/xz/xz_sha256.c
|
||||
${XPOSED_SRC_DIR}/xz/xz_dec_stream.c
|
||||
${XPOSED_SRC_DIR}/xz/xz_dec_lzma2.c
|
||||
${XPOSED_SRC_DIR}/xz/xz_dec_bcj.c
|
||||
)
|
||||
xz/xz_crc32.c
|
||||
xz/xz_crc64.c
|
||||
xz/xz_sha256.c
|
||||
xz/xz_dec_stream.c
|
||||
xz/xz_dec_lzma2.c
|
||||
xz/xz_dec_bcj.c
|
||||
)
|
||||
|
||||
target_include_directories(l2c_fcr_hook PRIVATE
|
||||
${XPOSED_SRC_DIR}
|
||||
${XPOSED_SRC_DIR}/xz
|
||||
)
|
||||
target_include_directories(l2c_fcr_hook PRIVATE
|
||||
xz
|
||||
)
|
||||
|
||||
target_compile_definitions(l2c_fcr_hook PRIVATE
|
||||
XZ_DEC_X86
|
||||
XZ_DEC_ARM
|
||||
XZ_DEC_ARMTHUMB
|
||||
XZ_DEC_ARM64
|
||||
XZ_DEC_ANY_CHECK
|
||||
XZ_USE_CRC64
|
||||
XZ_USE_SHA256
|
||||
XZ_DEC_CONCATENATED
|
||||
)
|
||||
target_compile_definitions(l2c_fcr_hook PRIVATE
|
||||
XZ_DEC_X86
|
||||
XZ_DEC_ARM
|
||||
XZ_DEC_ARMTHUMB
|
||||
XZ_DEC_ARM64
|
||||
XZ_DEC_ANY_CHECK
|
||||
XZ_USE_CRC64
|
||||
XZ_USE_SHA256
|
||||
XZ_DEC_CONCATENATED
|
||||
)
|
||||
|
||||
target_link_libraries(l2c_fcr_hook
|
||||
android
|
||||
log
|
||||
)
|
||||
|
||||
endif()
|
||||
target_link_libraries(l2c_fcr_hook
|
||||
android
|
||||
log
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import me.kavishdevar.librepods.utils.XposedServiceHolder
|
||||
import me.kavishdevar.librepods.utils.XposedState
|
||||
|
||||
class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver {
|
||||
|
||||
override fun onCreate() {
|
||||
XposedServiceHelper.registerListener(this)
|
||||
BillingManager.provider = BillingProviderFactory.create(this)
|
||||
@@ -23,7 +24,7 @@ class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
BillingManager.provider.queryPurchases()
|
||||
XposedState.isAvailable = true
|
||||
XposedState.isAvailable = XposedServiceHolder.service != null
|
||||
XposedState.bluetoothScopeEnabled = XposedServiceHolder.service?.scope?.contains("com.google.android.bluetooth") == true || XposedServiceHolder.service?.scope?.contains("com.android.bluetooth") == true
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,8 @@
|
||||
package me.kavishdevar.librepods.bluetooth
|
||||
|
||||
import android.util.Log
|
||||
import me.kavishdevar.librepods.data.Capability
|
||||
import me.kavishdevar.librepods.data.CustomEq
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
@@ -31,9 +33,8 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
* constructing and parsing packets for communication with AirPods.
|
||||
*/
|
||||
class AACPManager {
|
||||
private val TAG = "AACPManager[${System.identityHashCode(this)}]"
|
||||
companion object {
|
||||
private const val TAG = "AACPManager"
|
||||
|
||||
@Suppress("unused")
|
||||
object Opcodes {
|
||||
const val SET_FEATURE_FLAGS: Byte = 0x4D
|
||||
@@ -48,7 +49,7 @@ class AACPManager {
|
||||
const val PROXIMITY_KEYS_REQ: Byte = 0x30
|
||||
const val PROXIMITY_KEYS_RSP: Byte = 0x31
|
||||
const val STEM_PRESS: Byte = 0x19
|
||||
const val EQ_DATA: Byte = 0x53
|
||||
const val HEADPHONE_ACCOMMODATION: Byte = 0x53
|
||||
const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1
|
||||
const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2
|
||||
const val SMART_ROUTING: Byte = 0x10
|
||||
@@ -56,6 +57,7 @@ class AACPManager {
|
||||
const val SMART_ROUTING_RESP: Byte = 0x11
|
||||
const val SEND_CONNECTED_MAC: Byte = 0x14
|
||||
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
|
||||
const val CUSTOM_EQ: Byte = 0x63
|
||||
}
|
||||
|
||||
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
||||
@@ -109,7 +111,8 @@ class AACPManager {
|
||||
EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG(
|
||||
0x37
|
||||
),
|
||||
PPE_CAP_LEVEL_CONFIG(0x38);
|
||||
PPE_CAP_LEVEL_CONFIG(0x38),
|
||||
DYNAMIC_END_OF_CHARGE(0x3B);
|
||||
|
||||
companion object {
|
||||
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
|
||||
@@ -199,6 +202,11 @@ class AACPManager {
|
||||
var eqOnMedia: Boolean = false
|
||||
private set
|
||||
|
||||
var customEq: CustomEq = CustomEq(state = 1, low = 50, mid = 50, high = 50)
|
||||
private set
|
||||
|
||||
var customEqCallback: ((CustomEq) -> Unit)? = null
|
||||
|
||||
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
|
||||
return controlCommandStatusList.find { it.identifier == identifier }
|
||||
}
|
||||
@@ -207,10 +215,7 @@ class AACPManager {
|
||||
identifier: ControlCommandIdentifiers, value: ByteArray
|
||||
) {
|
||||
val existingStatus = getControlCommandStatus(identifier)
|
||||
if (existingStatus == value) {
|
||||
controlCommandStatusList.remove(existingStatus)
|
||||
}
|
||||
if (existingStatus != null) {
|
||||
if (existingStatus?.value.contentEquals(value)) {
|
||||
controlCommandStatusList.remove(existingStatus)
|
||||
}
|
||||
controlCommandListeners[identifier]?.forEach { listener ->
|
||||
@@ -238,7 +243,9 @@ class AACPManager {
|
||||
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
|
||||
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
|
||||
fun onShowNearbyUI(sender: String)
|
||||
fun onEQPacketReceived(eqData: FloatArray)
|
||||
fun onHeadphoneAccommodationReceived(eqData: FloatArray)
|
||||
fun onCustomEqReceived(customEq: CustomEq)
|
||||
fun onCapabilitiesReceived(capabilities: List<Capability>)
|
||||
}
|
||||
|
||||
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
||||
@@ -414,7 +421,13 @@ class AACPManager {
|
||||
}
|
||||
|
||||
Opcodes.CONTROL_COMMAND -> {
|
||||
val controlCommand = ControlCommand.fromByteArray(packet)
|
||||
val controlCommand = try {
|
||||
ControlCommand.fromByteArray(packet)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse control command: ${e.message}")
|
||||
callback?.onUnknownPacketReceived(packet)
|
||||
return
|
||||
}
|
||||
setControlCommandStatusValue(
|
||||
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return,
|
||||
controlCommand.value
|
||||
@@ -545,18 +558,18 @@ class AACPManager {
|
||||
}
|
||||
}
|
||||
|
||||
Opcodes.EQ_DATA -> {
|
||||
Opcodes.HEADPHONE_ACCOMMODATION -> {
|
||||
if (packet.size != 140) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Received EQ_DATA packet of unexpected size: ${packet.size}, expected 140"
|
||||
"Received HEADPHONE_ACCOMMODATION packet of unexpected size: ${packet.size}, expected 140"
|
||||
)
|
||||
return
|
||||
}
|
||||
if (packet[6] != 0x84.toByte()) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Received EQ_DATA packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
|
||||
"Received HEADPHONE_ACCOMMODATION packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -579,7 +592,7 @@ class AACPManager {
|
||||
"EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia"
|
||||
)
|
||||
|
||||
callback?.onEQPacketReceived(eqData)
|
||||
callback?.onHeadphoneAccommodationReceived(eqData)
|
||||
}
|
||||
|
||||
Opcodes.INFORMATION -> {
|
||||
@@ -588,6 +601,13 @@ class AACPManager {
|
||||
callback?.onDeviceInformationReceived(information)
|
||||
}
|
||||
|
||||
Opcodes.CUSTOM_EQ -> {
|
||||
Log.d(TAG, "Parsing CUSTOM_EQ: ${packet.toHexString()}")
|
||||
customEq = parseCustomEqPacket(packet)
|
||||
customEqCallback?.invoke(customEq)
|
||||
callback?.onCustomEqReceived(customEq)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
|
||||
callback?.onUnknownPacketReceived(packet)
|
||||
@@ -1078,25 +1098,25 @@ class AACPManager {
|
||||
|
||||
companion object {
|
||||
fun fromByteArray(data: ByteArray): ControlCommand {
|
||||
if (data.size < 4) {
|
||||
throw IllegalArgumentException("Data array too short to parse ControlCommand")
|
||||
var offset = 0
|
||||
while (data.size - offset >= 4 &&
|
||||
data[offset] == 0x04.toByte() &&
|
||||
data[offset + 1] == 0x00.toByte() &&
|
||||
data[offset + 2] == 0x04.toByte() &&
|
||||
data[offset + 3] == 0x00.toByte()
|
||||
) {
|
||||
offset += 4
|
||||
}
|
||||
if (data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() && data[3] == 0x00.toByte()) {
|
||||
val newData = ByteArray(data.size - 4)
|
||||
System.arraycopy(data, 4, newData, 0, data.size - 4)
|
||||
return fromByteArray(newData)
|
||||
if (data.size - offset < 7) {
|
||||
throw IllegalArgumentException("Too short for ControlCommand")
|
||||
}
|
||||
if (data[0] != Opcodes.CONTROL_COMMAND) {
|
||||
throw IllegalArgumentException("Data array does not start with CONTROL_COMMAND opcode")
|
||||
if (data[offset] != Opcodes.CONTROL_COMMAND) {
|
||||
throw IllegalArgumentException("Invalid opcode")
|
||||
}
|
||||
val identifier = data[2]
|
||||
|
||||
val value = ByteArray(4)
|
||||
System.arraycopy(data, 3, value, 0, 4)
|
||||
|
||||
val trimmedValue = value.dropLastWhile { it == 0x00.toByte() }.toByteArray()
|
||||
val finalValue = if (trimmedValue.isEmpty()) byteArrayOf(0x00) else trimmedValue
|
||||
return ControlCommand(identifier, finalValue)
|
||||
val identifier = data[offset + 2]
|
||||
val value = data.copyOfRange(offset + 3, offset + 7)
|
||||
val trimmed = value.dropLastWhile { it == 0x00.toByte() }.toByteArray()
|
||||
return ControlCommand(identifier, if (trimmed.isEmpty()) byteArrayOf(0x00) else trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1122,7 +1142,13 @@ class AACPManager {
|
||||
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||
|
||||
if (packet[4] == Opcodes.CONTROL_COMMAND) {
|
||||
val controlCommand = ControlCommand.fromByteArray(packet)
|
||||
val controlCommand = try {
|
||||
ControlCommand.fromByteArray(packet)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Invalid control command: ${e.message}")
|
||||
callback?.onUnknownPacketReceived(packet)
|
||||
return false
|
||||
}
|
||||
Log.d(
|
||||
TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${
|
||||
controlCommand.value.joinToString(" ") { "%02X".format(it) }
|
||||
@@ -1133,7 +1159,7 @@ class AACPManager {
|
||||
)
|
||||
}
|
||||
|
||||
val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
|
||||
val socket = BluetoothConnectionManager.aacpSocket ?: return false
|
||||
|
||||
if (socket.isConnected) {
|
||||
socket.outputStream?.write(packet)
|
||||
@@ -1287,4 +1313,38 @@ class AACPManager {
|
||||
version3 = strings.getOrNull(10) ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
fun sendCustomEqPacket(customEq: CustomEq): Boolean {
|
||||
return sendDataPacket(customEq.toPacket())
|
||||
}
|
||||
|
||||
fun parseCustomEqPacket(packet: ByteArray): CustomEq {
|
||||
val data = packet.sliceArray(6 until packet.size)
|
||||
|
||||
if (data.size < 7) {
|
||||
Log.e(TAG, "custom EQ packet length less than 7, returning default")
|
||||
return CustomEq(1, 50, 50, 50)
|
||||
}
|
||||
|
||||
val lengthLow = data[0].toInt() and 0xFF
|
||||
val lengthHigh = data[1].toInt() and 0xFF
|
||||
|
||||
val length = (lengthHigh shl 8) or lengthLow
|
||||
|
||||
if (length != 5) {
|
||||
Log.w(TAG, "parseCustomEqPacket: unexpected length ($length). parsing normally")
|
||||
}
|
||||
|
||||
val state = data[3].toInt()
|
||||
val low = data[4].toInt()
|
||||
val mid = data[5].toInt()
|
||||
val high = data[6].toInt()
|
||||
|
||||
return CustomEq(
|
||||
state,
|
||||
low,
|
||||
mid,
|
||||
high
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,233 +16,196 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/* This is a very basic ATT (Attribute Protocol) implementation. I have only implemented
|
||||
* what is necessary for LibrePods to function, i.e. reading and writing characteristics,
|
||||
* and receiving notifications. It is not a complete implementation of the ATT protocol.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.bluetooth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
private const val TAG = "ATTManager"
|
||||
|
||||
enum class ATTHandles(val value: Int) {
|
||||
TRANSPARENCY(0x18),
|
||||
LOUD_SOUND_REDUCTION(0x1B),
|
||||
HEARING_AID(0x2A),
|
||||
HEARING_AID(0x2A)
|
||||
}
|
||||
|
||||
enum class ATTCCCDHandles(val value: Int) {
|
||||
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
|
||||
LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1),
|
||||
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
|
||||
// LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work
|
||||
HEARING_AID(ATTHandles.HEARING_AID.value + 1)
|
||||
}
|
||||
|
||||
class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) {
|
||||
companion object {
|
||||
private const val TAG = "ATTManager"
|
||||
class ATTManagerv2 {
|
||||
val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
|
||||
|
||||
private const val OPCODE_READ_REQUEST: Byte = 0x0A
|
||||
private const val OPCODE_WRITE_REQUEST: Byte = 0x12
|
||||
private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B
|
||||
private val responseQueues = ConcurrentHashMap<Byte, LinkedBlockingQueue<ByteArray>>()
|
||||
|
||||
private val readerRunning = AtomicBoolean(false)
|
||||
private var readerThread: Thread? = null
|
||||
|
||||
private var onNotificationReceived: ((handle: Byte, value: ByteArray) -> Unit)? = null
|
||||
|
||||
fun startReader() {
|
||||
if (readerRunning.getAndSet(true)) return
|
||||
|
||||
readerThread = Thread {
|
||||
try {
|
||||
runReaderLoop()
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "reader thread crashed: ${t.message}", t)
|
||||
} finally {
|
||||
readerRunning.set(false)
|
||||
Log.d(TAG, "reader thread stopped")
|
||||
}
|
||||
}.also { it.name = "ATT-Reader"; it.isDaemon = true; it.start() }
|
||||
Log.d(TAG, "reader started")
|
||||
}
|
||||
|
||||
var socket: BluetoothSocket? = null
|
||||
private var input: InputStream? = null
|
||||
private var output: OutputStream? = null
|
||||
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
|
||||
private var notificationJob: Job? = null
|
||||
fun stopReader() {
|
||||
readerRunning.set(false)
|
||||
readerThread?.interrupt()
|
||||
readerThread = null
|
||||
}
|
||||
|
||||
// queue for non-notification PDUs (responses to requests)
|
||||
private val responses = LinkedBlockingQueue<ByteArray>()
|
||||
fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) {
|
||||
onNotificationReceived = listener
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun connect() {
|
||||
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
||||
fun enableNotification(handle: ATTCCCDHandles) {
|
||||
writeCharacteristic(handle.value.toByte(), byteArrayOf(0x01))
|
||||
}
|
||||
|
||||
fun getCharacteristic(handle: ATTHandles): ByteArray? {
|
||||
val storedValue = characteristicList[handle]
|
||||
return if (storedValue?.isNotEmpty() != true) {
|
||||
readCharacteristic(handle)
|
||||
} else storedValue
|
||||
}
|
||||
|
||||
fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? {
|
||||
val socket = BluetoothConnectionManager.attSocket ?: return null
|
||||
try {
|
||||
socket = createBluetoothSocket(adapter, device, uuid)
|
||||
val output = socket.outputStream
|
||||
val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00)
|
||||
synchronized(output) {
|
||||
output.write(pdu)
|
||||
output.flush()
|
||||
}
|
||||
Log.d(TAG, "sending read request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
|
||||
|
||||
val resp = waitForResponse(0x0B, timeoutMillis) ?: run {
|
||||
Log.e(TAG, "Timeout waiting for Read Response (0x0B) for handle ${handle.value}")
|
||||
return null
|
||||
}
|
||||
|
||||
Log.d(TAG, "read response: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
||||
val value = resp.copyOfRange(1, resp.size)
|
||||
characteristicList[handle] = value
|
||||
return value
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to create socket")
|
||||
Log.e(TAG, "error reading characteristic: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun writeCharacteristic(handle: ATTHandles, data: ByteArray, timeoutMillis: Long = 2000) {
|
||||
characteristicList[handle] = data
|
||||
writeCharacteristic(handle.value.toByte(), data, timeoutMillis)
|
||||
}
|
||||
|
||||
fun writeCharacteristic(handle: Byte, data: ByteArray, timeoutMillis: Long = 2000) {
|
||||
val socket = BluetoothConnectionManager.attSocket ?: return
|
||||
try {
|
||||
socket!!.connect()
|
||||
val output = socket.outputStream
|
||||
val pdu = byteArrayOf(0x12, handle, 0x00) + data // 0x00 for LE
|
||||
synchronized(output) {
|
||||
output.write(pdu)
|
||||
output.flush()
|
||||
}
|
||||
Log.d(TAG, "sending write request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
|
||||
|
||||
val resp = waitForResponse(0x13, timeoutMillis) ?: run {
|
||||
Log.e(TAG, "timeout waiting for response (0x13) for handle ${String.format("%02X", handle)}")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "ATT socket failed to connect")
|
||||
Log.e(TAG, "error writing characteristic: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnected() {
|
||||
characteristicList.clear()
|
||||
stopReader()
|
||||
val socket = BluetoothConnectionManager.attSocket?: return
|
||||
try {
|
||||
socket.close()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "error closing socket: ${e.message}")
|
||||
}
|
||||
Log.d(TAG, "ATT disconnected")
|
||||
}
|
||||
|
||||
private fun runReaderLoop() {
|
||||
val socket = BluetoothConnectionManager.attSocket ?: run {
|
||||
Log.w(TAG, "ATT socket not available. stopping reader")
|
||||
readerRunning.set(false)
|
||||
return
|
||||
}
|
||||
input = socket!!.inputStream
|
||||
output = socket!!.outputStream
|
||||
Log.d(TAG, "Connected to ATT")
|
||||
|
||||
notificationJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
while (socket?.isConnected == true) {
|
||||
try {
|
||||
val pdu = readPDU()
|
||||
if (pdu.isNotEmpty() && pdu[0] == OPCODE_HANDLE_VALUE_NTF) {
|
||||
// notification -> dispatch to listeners
|
||||
val handle = (pdu[1].toInt() and 0xFF) or ((pdu[2].toInt() and 0xFF) shl 8)
|
||||
val value = pdu.copyOfRange(3, pdu.size)
|
||||
listeners[handle]?.forEach { listener ->
|
||||
try {
|
||||
listener(value)
|
||||
Log.d(TAG, "Dispatched notification for handle $handle to listener, with value ${value.joinToString(" ") { String.format("%02X", it) }}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error in listener for handle $handle: ${e.message}")
|
||||
}
|
||||
val input = socket.inputStream
|
||||
val buffer = ByteArray(512)
|
||||
|
||||
while (readerRunning.get()) {
|
||||
try {
|
||||
val len = input.read(buffer)
|
||||
if (len == -1) {
|
||||
Log.w(TAG, "ATT input stream ended")
|
||||
break
|
||||
}
|
||||
val data = buffer.copyOfRange(0, len)
|
||||
if (data.isEmpty()) continue
|
||||
|
||||
val opcode = data[0]
|
||||
Log.d(TAG, "pdu received ${data.joinToString(" ") { String.format("%02X", it) }}")
|
||||
|
||||
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
|
||||
queue.offer(data)
|
||||
|
||||
if (opcode == 0x1B.toByte()) {
|
||||
if (data.size >= 3) {
|
||||
val handle = data[1]
|
||||
val value = if (data.size > 3) data.copyOfRange(3, data.size) else ByteArray(0)
|
||||
Log.d(TAG, "notification/indication handle=0x${String.format("%02X", handle)} value=${value.toHexString()}")
|
||||
try {
|
||||
onNotificationReceived?.invoke(handle, value)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "onNotificationReceived threw: ${t.message}", t)
|
||||
}
|
||||
} else {
|
||||
// not a notification -> treat as a response for pending request(s)
|
||||
responses.put(pdu)
|
||||
Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error reading notification/response: ${e.message}")
|
||||
if (socket?.isConnected != true) break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
try {
|
||||
notificationJob?.cancel()
|
||||
socket?.close()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error closing socket: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun registerListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
|
||||
listeners.getOrPut(handle.value) { mutableListOf() }.add(listener)
|
||||
}
|
||||
|
||||
fun unregisterListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
|
||||
listeners[handle.value]?.remove(listener)
|
||||
}
|
||||
|
||||
fun enableNotifications(handle: ATTHandles) {
|
||||
write(ATTCCCDHandles.valueOf(handle.name), byteArrayOf(0x01, 0x00))
|
||||
}
|
||||
|
||||
fun read(handle: ATTHandles): ByteArray {
|
||||
val lsb = (handle.value and 0xFF).toByte()
|
||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
||||
val pdu = byteArrayOf(OPCODE_READ_REQUEST, lsb, msb)
|
||||
writeRaw(pdu)
|
||||
// wait for response placed into responses queue by the reader coroutine
|
||||
return readResponse()
|
||||
}
|
||||
|
||||
fun write(handle: ATTHandles, value: ByteArray) {
|
||||
val lsb = (handle.value and 0xFF).toByte()
|
||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
||||
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
|
||||
writeRaw(pdu)
|
||||
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
|
||||
try {
|
||||
readResponse()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "No write response received: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun write(handle: ATTCCCDHandles, value: ByteArray) {
|
||||
val lsb = (handle.value and 0xFF).toByte()
|
||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
||||
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
|
||||
writeRaw(pdu)
|
||||
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
|
||||
try {
|
||||
readResponse()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "No write response received: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeRaw(pdu: ByteArray) {
|
||||
output?.write(pdu)
|
||||
output?.flush()
|
||||
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
|
||||
}
|
||||
|
||||
// rename / specialize: read raw PDU directly from input stream (blocking)
|
||||
private fun readPDU(): ByteArray {
|
||||
val inp = input ?: throw IllegalStateException("Not connected")
|
||||
val buffer = ByteArray(512)
|
||||
val len = inp.read(buffer)
|
||||
if (len == -1) {
|
||||
disconnect()
|
||||
throw IllegalStateException("End of stream reached")
|
||||
}
|
||||
val data = buffer.copyOfRange(0, len)
|
||||
Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}")
|
||||
return data
|
||||
}
|
||||
|
||||
// wait for a response PDU produced by the background reader
|
||||
private fun readResponse(timeoutMs: Long = 2000): ByteArray {
|
||||
try {
|
||||
val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
?: throw IllegalStateException("No response read from ATT socket within $timeoutMs ms")
|
||||
Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
||||
return resp.copyOfRange(1, resp.size)
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
throw IllegalStateException("Interrupted while waiting for ATT response", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
|
||||
val type = 3 // L2CAP
|
||||
val constructorSpecs = listOf(
|
||||
arrayOf(adapter, device, type, true, true, 31, uuid),
|
||||
arrayOf(device, type, true, true, 31, uuid),
|
||||
arrayOf(device, type, 1, true, true, 31, uuid),
|
||||
arrayOf(type, 1, true, true, device, 31, uuid),
|
||||
arrayOf(type, true, true, device, 31, uuid)
|
||||
)
|
||||
|
||||
val constructors = BluetoothSocket::class.java.declaredConstructors
|
||||
Log.d("ATTManager", "BluetoothSocket has ${constructors.size} constructors:")
|
||||
|
||||
constructors.forEachIndexed { index, constructor ->
|
||||
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
|
||||
Log.d("ATTManager", "Constructor $index: ($params)")
|
||||
}
|
||||
|
||||
var lastException: Exception? = null
|
||||
var attemptedConstructors = 0
|
||||
|
||||
for ((index, params) in constructorSpecs.withIndex()) {
|
||||
try {
|
||||
Log.d("ATTManager", "Trying constructor signature #${index + 1}")
|
||||
attemptedConstructors++
|
||||
|
||||
val paramTypes = params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
|
||||
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
|
||||
constructor.isAccessible = true
|
||||
return constructor.newInstance(*params) as BluetoothSocket
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
|
||||
lastException = e
|
||||
Log.e(TAG, "error in reader loop: ${e.message}", e)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
|
||||
Log.e("ATTManager", errorMessage)
|
||||
throw lastException ?: IllegalStateException(errorMessage)
|
||||
readerRunning.set(false)
|
||||
}
|
||||
|
||||
private fun waitForResponse(opcode: Byte, timeoutMillis: Long): ByteArray? {
|
||||
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
|
||||
return try {
|
||||
queue.poll(timeoutMillis, TimeUnit.MILLISECONDS)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,23 +18,59 @@
|
||||
|
||||
package me.kavishdevar.librepods.bluetooth
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
|
||||
object BluetoothConnectionManager {
|
||||
private const val TAG = "BluetoothConnectionManager"
|
||||
|
||||
private var currentSocket: BluetoothSocket? = null
|
||||
private var currentDevice: BluetoothDevice? = null
|
||||
|
||||
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
|
||||
currentSocket = socket
|
||||
currentDevice = device
|
||||
Log.d(TAG, "Current connection set to device: ${device.address}")
|
||||
}
|
||||
|
||||
fun getCurrentSocket(): BluetoothSocket? {
|
||||
return currentSocket
|
||||
}
|
||||
var aacpSocket: BluetoothSocket? = null
|
||||
var attSocket: BluetoothSocket? = null
|
||||
}
|
||||
|
||||
fun createBluetoothSocket(
|
||||
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
|
||||
): BluetoothSocket {
|
||||
val type = 3 // L2CAP
|
||||
val constructorSpecs = listOf(
|
||||
arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
|
||||
arrayOf(device, type, true, true, psm, uuid),
|
||||
arrayOf(device, type, 1, true, true, psm, uuid),
|
||||
arrayOf(type, 1, true, true, device, psm, uuid),
|
||||
arrayOf(type, true, true, device, psm, uuid)
|
||||
)
|
||||
|
||||
val constructors = BluetoothSocket::class.java.declaredConstructors
|
||||
Log.d("createSocket<psm>", "BluetoothSocket has ${constructors.size} constructors:")
|
||||
|
||||
constructors.forEachIndexed { index, constructor ->
|
||||
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
|
||||
Log.d("createSocket<psm>", "Constructor $index: ($params)")
|
||||
}
|
||||
|
||||
var lastException: Exception? = null
|
||||
var attemptedConstructors = 0
|
||||
|
||||
for ((index, params) in constructorSpecs.withIndex()) {
|
||||
try {
|
||||
Log.d("createSocket<psm>", "Trying constructor signature #${index + 1}")
|
||||
attemptedConstructors++
|
||||
|
||||
val paramTypes =
|
||||
params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
|
||||
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
|
||||
constructor.isAccessible = true
|
||||
return constructor.newInstance(*params) as BluetoothSocket
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("createSocket<psm>", "Constructor signature #${index + 1} failed: ${e.message}")
|
||||
lastException = e
|
||||
}
|
||||
}
|
||||
|
||||
val errorMessage =
|
||||
"Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
|
||||
Log.e("createSocket<psm>", errorMessage)
|
||||
throw lastException ?: IllegalStateException(errorMessage)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package me.kavishdevar.librepods.data
|
||||
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
|
||||
enum class CustomEqBand { LOW, MID, HIGH }
|
||||
|
||||
data class CustomEq(val state: Int, val low: Int, val mid: Int, val high: Int) {
|
||||
|
||||
fun isEnabled(): Boolean {
|
||||
return state == 2
|
||||
}
|
||||
|
||||
fun toPacket(): ByteArray {
|
||||
return byteArrayOf(
|
||||
AACPManager.Companion.Opcodes.CUSTOM_EQ, 0x00,
|
||||
0x05, 0x00, // length (LE)
|
||||
0x01, state.toByte(),
|
||||
low.toByte(), mid.toByte(), high.toByte()
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
require(low in 0..100) { "low must be between 0 and 100, was $low" }
|
||||
require(mid in 0..100) { "mid must be between 0 and 100, was $mid" }
|
||||
require(high in 0..100) { "high must be between 0 and 100, was $high" }
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,10 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||
import me.kavishdevar.librepods.bluetooth.ATTManager
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private const val TAG = "HearingAidUtils"
|
||||
|
||||
@@ -138,15 +138,15 @@ fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
|
||||
}
|
||||
|
||||
fun sendHearingAidSettings(
|
||||
attManager: ATTManager,
|
||||
currentData: ByteArray,
|
||||
hearingAidSettings: HearingAidSettings,
|
||||
debounceJob: MutableState<Job?>
|
||||
debounceJob: MutableState<Job?>,
|
||||
sender: (ATTHandles, ByteArray) -> Unit
|
||||
) {
|
||||
debounceJob.value?.cancel()
|
||||
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(100)
|
||||
delay(100.milliseconds)
|
||||
try {
|
||||
val currentData = attManager.read(ATTHandles.HEARING_AID)
|
||||
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
||||
if (currentData.size < 104) {
|
||||
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
|
||||
@@ -184,7 +184,7 @@ fun sendHearingAidSettings(
|
||||
|
||||
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
||||
|
||||
attManager.write(ATTHandles.HEARING_AID, currentData)
|
||||
sender(ATTHandles.HEARING_AID, currentData)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
@@ -22,8 +22,10 @@ import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
// TODO: Remove everything but Battery-related stuff
|
||||
|
||||
enum class Enums(val value: ByteArray) {
|
||||
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
|
||||
NOISE_CANCELLATION(byteArrayOf(0x0d)),
|
||||
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
||||
SETTINGS(byteArrayOf(0x09, 0x00)),
|
||||
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
|
||||
@@ -81,12 +83,12 @@ class AirPodsNotifications {
|
||||
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
|
||||
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
|
||||
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
|
||||
const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA"
|
||||
const val EQ_DATA = "me.kavishdevar.librepods.HEADPHONE_ACCOMMODATION"
|
||||
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
|
||||
}
|
||||
|
||||
class EarDetection {
|
||||
private val notificationBit = Capabilities.EAR_DETECTION
|
||||
private val notificationBit = 6.toByte()
|
||||
private val notificationPrefix = Enums.PREFIX.value + notificationBit
|
||||
|
||||
var status: List<Byte> = listOf(0x01, 0x01)
|
||||
@@ -243,13 +245,6 @@ class AirPodsNotifications {
|
||||
}
|
||||
}
|
||||
|
||||
class Capabilities {
|
||||
companion object {
|
||||
val NOISE_CANCELLATION = byteArrayOf(0x0d)
|
||||
val EAR_DETECTION = byteArrayOf(0x06)
|
||||
}
|
||||
}
|
||||
|
||||
fun isHeadTrackingData(data: ByteArray): Boolean {
|
||||
if (data.size <= 60) return false
|
||||
|
||||
|
||||
@@ -83,7 +83,8 @@ data class TransparencySettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
|
||||
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
|
||||
if (data.size < 50) return null // 50 is arbitrary, too lazy to count
|
||||
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
val enabled = buffer.float
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package me.kavishdevar.librepods.data.updates
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
data class UpdateItem(
|
||||
val titleRes: Int,
|
||||
val descriptionRes: Int,
|
||||
val demoComposeable: @Composable () -> Unit
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
package me.kavishdevar.librepods.data.updates
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreenPreviewMaterial
|
||||
import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewApple
|
||||
import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewMaterial
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
|
||||
val update1_0_0 = listOf(
|
||||
UpdateItem(
|
||||
titleRes = R.string.material3e,
|
||||
descriptionRes = R.string.update_m3e_description,
|
||||
demoComposeable = @Composable {
|
||||
AirPodsSettingsScreenPreviewMaterial()
|
||||
}
|
||||
),
|
||||
UpdateItem(
|
||||
titleRes = R.string.equalizer,
|
||||
descriptionRes = R.string.update_equalizer_description,
|
||||
demoComposeable = @Composable {
|
||||
when (LocalDesignSystem.current) {
|
||||
DesignSystem.Apple -> {
|
||||
EqualizerScreenPreviewApple()
|
||||
}
|
||||
DesignSystem.Material -> {
|
||||
EqualizerScreenPreviewMaterial()
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
val updates = update1_0_0
|
||||
@@ -0,0 +1,528 @@
|
||||
package me.kavishdevar.librepods.presentation
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object MaterialIcons {
|
||||
val notifications: ImageVector
|
||||
get() {
|
||||
if (_notifications != null) {
|
||||
return _notifications!!
|
||||
}
|
||||
_notifications =
|
||||
ImageVector.Builder(
|
||||
name = "notifications",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
)
|
||||
.apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Black),
|
||||
fillAlpha = 1f,
|
||||
stroke = null,
|
||||
strokeAlpha = 1f,
|
||||
strokeLineWidth = 1f,
|
||||
strokeLineCap = StrokeCap.Butt,
|
||||
strokeLineJoin = StrokeJoin.Bevel,
|
||||
strokeLineMiter = 1f,
|
||||
pathFillType = PathFillType.NonZero,
|
||||
) {
|
||||
moveTo(4f, 19f)
|
||||
verticalLineTo(17f)
|
||||
horizontalLineTo(6f)
|
||||
verticalLineTo(10f)
|
||||
quadTo(6f, 7.93f, 7.25f, 6.31f)
|
||||
reflectiveQuadTo(10.5f, 4.2f)
|
||||
verticalLineTo(3.5f)
|
||||
quadToRelative(0f, -0.63f, 0.44f, -1.06f)
|
||||
reflectiveQuadTo(12f, 2f)
|
||||
reflectiveQuadToRelative(1.06f, 0.44f)
|
||||
reflectiveQuadTo(13.5f, 3.5f)
|
||||
verticalLineTo(4.2f)
|
||||
quadToRelative(2f, 0.5f, 3.25f, 2.11f)
|
||||
reflectiveQuadTo(18f, 10f)
|
||||
verticalLineToRelative(7f)
|
||||
horizontalLineToRelative(2f)
|
||||
verticalLineToRelative(2f)
|
||||
horizontalLineTo(4f)
|
||||
close()
|
||||
moveToRelative(8f, -7.5f)
|
||||
close()
|
||||
moveTo(12f, 22f)
|
||||
quadToRelative(-0.82f, 0f, -1.41f, -0.59f)
|
||||
reflectiveQuadTo(10f, 20f)
|
||||
horizontalLineToRelative(4f)
|
||||
quadToRelative(0f, 0.82f, -0.59f, 1.41f)
|
||||
reflectiveQuadTo(12f, 22f)
|
||||
close()
|
||||
moveTo(8f, 17f)
|
||||
horizontalLineToRelative(8f)
|
||||
verticalLineTo(10f)
|
||||
quadTo(16f, 8.35f, 14.83f, 7.18f)
|
||||
reflectiveQuadTo(12f, 6f)
|
||||
reflectiveQuadTo(9.18f, 7.18f)
|
||||
reflectiveQuadTo(8f, 10f)
|
||||
verticalLineToRelative(7f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
return _notifications!!
|
||||
}
|
||||
|
||||
private var _notifications: ImageVector? = null
|
||||
|
||||
val headset_off: ImageVector
|
||||
get() {
|
||||
if (_headset_off != null) {
|
||||
return _headset_off!!
|
||||
}
|
||||
_headset_off =
|
||||
ImageVector.Builder(
|
||||
name = "headset_off",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
)
|
||||
.apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Black),
|
||||
fillAlpha = 1f,
|
||||
stroke = null,
|
||||
strokeAlpha = 1f,
|
||||
strokeLineWidth = 1f,
|
||||
strokeLineCap = StrokeCap.Butt,
|
||||
strokeLineJoin = StrokeJoin.Bevel,
|
||||
strokeLineMiter = 1f,
|
||||
pathFillType = PathFillType.NonZero,
|
||||
) {
|
||||
moveTo(21f, 18.15f)
|
||||
lineToRelative(-2f, -2f)
|
||||
verticalLineTo(14f)
|
||||
horizontalLineTo(16.85f)
|
||||
lineToRelative(-2f, -2f)
|
||||
horizontalLineTo(19f)
|
||||
verticalLineTo(11f)
|
||||
quadTo(19f, 8.05f, 16.95f, 6.02f)
|
||||
reflectiveQuadTo(12f, 4f)
|
||||
quadTo(10.9f, 4f, 9.91f, 4.31f)
|
||||
reflectiveQuadTo(8.1f, 5.2f)
|
||||
lineTo(6.65f, 3.8f)
|
||||
quadTo(7.78f, 2.92f, 9.14f, 2.46f)
|
||||
reflectiveQuadTo(12f, 2f)
|
||||
quadToRelative(1.85f, 0f, 3.49f, 0.7f)
|
||||
reflectiveQuadToRelative(2.86f, 1.93f)
|
||||
reflectiveQuadToRelative(1.94f, 2.86f)
|
||||
reflectiveQuadTo(21f, 11f)
|
||||
verticalLineToRelative(7.15f)
|
||||
close()
|
||||
moveTo(12f, 23f)
|
||||
verticalLineTo(21f)
|
||||
horizontalLineToRelative(6.18f)
|
||||
lineToRelative(-1f, -1f)
|
||||
horizontalLineTo(15f)
|
||||
verticalLineTo(17.83f)
|
||||
lineTo(5.53f, 8.35f)
|
||||
quadTo(5.3f, 8.95f, 5.15f, 9.64f)
|
||||
reflectiveQuadTo(5f, 11f)
|
||||
verticalLineToRelative(1f)
|
||||
horizontalLineTo(9f)
|
||||
verticalLineToRelative(8f)
|
||||
horizontalLineTo(5f)
|
||||
quadTo(4.18f, 20f, 3.59f, 19.41f)
|
||||
reflectiveQuadTo(3f, 18f)
|
||||
verticalLineTo(11f)
|
||||
quadTo(3f, 9.88f, 3.26f, 8.82f)
|
||||
reflectiveQuadToRelative(0.76f, -2f)
|
||||
lineTo(0.68f, 3.5f)
|
||||
lineTo(2.1f, 2.1f)
|
||||
lineTo(21.88f, 21.9f)
|
||||
verticalLineTo(23f)
|
||||
horizontalLineTo(12f)
|
||||
close()
|
||||
moveTo(5f, 18f)
|
||||
horizontalLineTo(7f)
|
||||
verticalLineTo(14f)
|
||||
horizontalLineTo(5f)
|
||||
verticalLineToRelative(4f)
|
||||
close()
|
||||
moveTo(5f, 14f)
|
||||
horizontalLineTo(7f)
|
||||
horizontalLineTo(5f)
|
||||
close()
|
||||
moveToRelative(11.85f, 0f)
|
||||
horizontalLineTo(19f)
|
||||
horizontalLineTo(16.85f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
return _headset_off!!
|
||||
}
|
||||
|
||||
private var _headset_off: ImageVector? = null
|
||||
|
||||
val pause: ImageVector
|
||||
get() {
|
||||
if (_pause != null) {
|
||||
return _pause!!
|
||||
}
|
||||
_pause =
|
||||
ImageVector.Builder(
|
||||
name = "pause",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
)
|
||||
.apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Black),
|
||||
fillAlpha = 1f,
|
||||
stroke = null,
|
||||
strokeAlpha = 1f,
|
||||
strokeLineWidth = 1f,
|
||||
strokeLineCap = StrokeCap.Butt,
|
||||
strokeLineJoin = StrokeJoin.Bevel,
|
||||
strokeLineMiter = 1f,
|
||||
pathFillType = PathFillType.NonZero,
|
||||
) {
|
||||
moveTo(13f, 19f)
|
||||
verticalLineTo(5f)
|
||||
horizontalLineToRelative(6f)
|
||||
verticalLineTo(19f)
|
||||
horizontalLineTo(13f)
|
||||
close()
|
||||
moveTo(5f, 19f)
|
||||
verticalLineTo(5f)
|
||||
horizontalLineToRelative(6f)
|
||||
verticalLineTo(19f)
|
||||
horizontalLineTo(5f)
|
||||
close()
|
||||
moveTo(15f, 17f)
|
||||
horizontalLineToRelative(2f)
|
||||
verticalLineTo(7f)
|
||||
horizontalLineTo(15f)
|
||||
verticalLineTo(17f)
|
||||
close()
|
||||
moveTo(7f, 17f)
|
||||
horizontalLineTo(9f)
|
||||
verticalLineTo(7f)
|
||||
horizontalLineTo(7f)
|
||||
verticalLineTo(17f)
|
||||
close()
|
||||
moveTo(7f, 7f)
|
||||
verticalLineTo(17f)
|
||||
verticalLineTo(7f)
|
||||
close()
|
||||
moveToRelative(8f, 0f)
|
||||
verticalLineTo(17f)
|
||||
verticalLineTo(7f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
return _pause!!
|
||||
}
|
||||
|
||||
private var _pause: ImageVector? = null
|
||||
|
||||
val bluetooth: ImageVector
|
||||
get() {
|
||||
if (_bluetooth != null) {
|
||||
return _bluetooth!!
|
||||
}
|
||||
_bluetooth =
|
||||
ImageVector.Builder(
|
||||
name = "bluetooth",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
)
|
||||
.apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Black),
|
||||
fillAlpha = 1f,
|
||||
stroke = null,
|
||||
strokeAlpha = 1f,
|
||||
strokeLineWidth = 1f,
|
||||
strokeLineCap = StrokeCap.Butt,
|
||||
strokeLineJoin = StrokeJoin.Bevel,
|
||||
strokeLineMiter = 1f,
|
||||
pathFillType = PathFillType.NonZero,
|
||||
) {
|
||||
moveTo(11f, 22f)
|
||||
verticalLineTo(14.4f)
|
||||
lineTo(6.4f, 19f)
|
||||
lineTo(5f, 17.6f)
|
||||
lineTo(10.6f, 12f)
|
||||
lineTo(5f, 6.4f)
|
||||
lineTo(6.4f, 5f)
|
||||
lineTo(11f, 9.6f)
|
||||
verticalLineTo(2f)
|
||||
horizontalLineToRelative(1f)
|
||||
lineToRelative(5.7f, 5.7f)
|
||||
lineTo(13.4f, 12f)
|
||||
lineToRelative(4.3f, 4.3f)
|
||||
lineTo(12f, 22f)
|
||||
horizontalLineTo(11f)
|
||||
close()
|
||||
moveTo(13f, 9.6f)
|
||||
lineTo(14.9f, 7.7f)
|
||||
lineTo(13f, 5.85f)
|
||||
verticalLineTo(9.6f)
|
||||
close()
|
||||
moveToRelative(0f, 8.55f)
|
||||
lineTo(14.9f, 16.3f)
|
||||
lineTo(13f, 14.4f)
|
||||
verticalLineToRelative(3.75f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
return _bluetooth!!
|
||||
}
|
||||
|
||||
private var _bluetooth: ImageVector? = null
|
||||
|
||||
val bluetooth_searching: ImageVector
|
||||
get() {
|
||||
if (_bluetooth_searching != null) {
|
||||
return _bluetooth_searching!!
|
||||
}
|
||||
_bluetooth_searching =
|
||||
ImageVector.Builder(
|
||||
name = "bluetooth_searching",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
)
|
||||
.apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Black),
|
||||
fillAlpha = 1f,
|
||||
stroke = null,
|
||||
strokeAlpha = 1f,
|
||||
strokeLineWidth = 1f,
|
||||
strokeLineCap = StrokeCap.Butt,
|
||||
strokeLineJoin = StrokeJoin.Bevel,
|
||||
strokeLineMiter = 1f,
|
||||
pathFillType = PathFillType.NonZero,
|
||||
) {
|
||||
moveTo(9f, 22f)
|
||||
verticalLineTo(14.4f)
|
||||
lineTo(4.4f, 19f)
|
||||
lineTo(3f, 17.6f)
|
||||
lineTo(8.6f, 12f)
|
||||
lineTo(3f, 6.4f)
|
||||
lineTo(4.4f, 5f)
|
||||
lineTo(9f, 9.6f)
|
||||
verticalLineTo(2f)
|
||||
horizontalLineToRelative(1f)
|
||||
lineToRelative(5.7f, 5.7f)
|
||||
lineTo(11.4f, 12f)
|
||||
lineToRelative(4.3f, 4.3f)
|
||||
lineTo(10f, 22f)
|
||||
horizontalLineTo(9f)
|
||||
close()
|
||||
moveTo(11f, 9.6f)
|
||||
lineTo(12.9f, 7.7f)
|
||||
lineTo(11f, 5.85f)
|
||||
verticalLineTo(9.6f)
|
||||
close()
|
||||
moveToRelative(0f, 8.55f)
|
||||
lineTo(12.9f, 16.3f)
|
||||
lineTo(11f, 14.4f)
|
||||
verticalLineToRelative(3.75f)
|
||||
close()
|
||||
moveToRelative(5.55f, -3.8f)
|
||||
lineTo(14.25f, 12f)
|
||||
lineToRelative(2.3f, -2.3f)
|
||||
quadToRelative(0.23f, 0.55f, 0.36f, 1.13f)
|
||||
reflectiveQuadTo(17.05f, 12f)
|
||||
reflectiveQuadToRelative(-0.14f, 1.19f)
|
||||
quadToRelative(-0.14f, 0.59f, -0.36f, 1.16f)
|
||||
close()
|
||||
moveTo(19.5f, 17.2f)
|
||||
lineTo(18.25f, 16f)
|
||||
quadToRelative(0.5f, -0.93f, 0.78f, -1.94f)
|
||||
reflectiveQuadTo(19.3f, 12f)
|
||||
reflectiveQuadTo(19.03f, 9.94f)
|
||||
quadTo(18.75f, 8.92f, 18.25f, 8f)
|
||||
lineTo(19.5f, 6.75f)
|
||||
quadToRelative(0.73f, 1.2f, 1.11f, 2.52f)
|
||||
reflectiveQuadTo(21f, 12f)
|
||||
reflectiveQuadToRelative(-0.39f, 2.71f)
|
||||
quadTo(20.23f, 16.02f, 19.5f, 17.2f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
return _bluetooth_searching!!
|
||||
}
|
||||
|
||||
private var _bluetooth_searching: ImageVector? = null
|
||||
|
||||
val call: ImageVector
|
||||
get() {
|
||||
if (_call != null) {
|
||||
return _call!!
|
||||
}
|
||||
_call =
|
||||
ImageVector.Builder(
|
||||
name = "call",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
)
|
||||
.apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Black),
|
||||
fillAlpha = 1f,
|
||||
stroke = null,
|
||||
strokeAlpha = 1f,
|
||||
strokeLineWidth = 1f,
|
||||
strokeLineCap = StrokeCap.Butt,
|
||||
strokeLineJoin = StrokeJoin.Bevel,
|
||||
strokeLineMiter = 1f,
|
||||
pathFillType = PathFillType.Companion.NonZero,
|
||||
) {
|
||||
moveTo(19.95f, 21f)
|
||||
quadToRelative(-3.13f, 0f, -6.18f, -1.36f)
|
||||
reflectiveQuadTo(8.23f, 15.78f)
|
||||
quadTo(5.73f, 13.27f, 4.36f, 10.23f)
|
||||
reflectiveQuadTo(3f, 4.05f)
|
||||
quadTo(3f, 3.6f, 3.3f, 3.3f)
|
||||
reflectiveQuadTo(4.05f, 3f)
|
||||
horizontalLineTo(8.1f)
|
||||
quadTo(8.45f, 3f, 8.73f, 3.24f)
|
||||
reflectiveQuadTo(9.05f, 3.8f)
|
||||
lineTo(9.7f, 7.3f)
|
||||
quadTo(9.75f, 7.7f, 9.68f, 7.97f)
|
||||
reflectiveQuadTo(9.4f, 8.45f)
|
||||
lineTo(6.98f, 10.9f)
|
||||
quadToRelative(0.5f, 0.93f, 1.19f, 1.79f)
|
||||
reflectiveQuadToRelative(1.51f, 1.66f)
|
||||
quadToRelative(0.78f, 0.78f, 1.63f, 1.44f)
|
||||
reflectiveQuadTo(13.1f, 17f)
|
||||
lineToRelative(2.35f, -2.35f)
|
||||
quadToRelative(0.22f, -0.23f, 0.59f, -0.34f)
|
||||
reflectiveQuadToRelative(0.71f, -0.06f)
|
||||
lineToRelative(3.45f, 0.7f)
|
||||
quadToRelative(0.35f, 0.1f, 0.57f, 0.36f)
|
||||
reflectiveQuadTo(21f, 15.9f)
|
||||
verticalLineToRelative(4.05f)
|
||||
quadToRelative(0f, 0.45f, -0.3f, 0.75f)
|
||||
reflectiveQuadTo(19.95f, 21f)
|
||||
close()
|
||||
moveTo(6.03f, 9f)
|
||||
lineTo(7.68f, 7.35f)
|
||||
lineTo(7.25f, 5f)
|
||||
horizontalLineTo(5.03f)
|
||||
quadTo(5.15f, 6.02f, 5.38f, 7.02f)
|
||||
reflectiveQuadTo(6.03f, 9f)
|
||||
close()
|
||||
moveToRelative(8.95f, 8.95f)
|
||||
quadToRelative(0.97f, 0.43f, 1.99f, 0.68f)
|
||||
reflectiveQuadTo(19f, 18.95f)
|
||||
verticalLineToRelative(-2.2f)
|
||||
lineTo(16.65f, 16.27f)
|
||||
lineToRelative(-1.68f, 1.68f)
|
||||
close()
|
||||
moveTo(6.03f, 9f)
|
||||
close()
|
||||
moveToRelative(8.95f, 8.95f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
return _call!!
|
||||
}
|
||||
|
||||
private var _call: ImageVector? = null
|
||||
|
||||
val stack: ImageVector
|
||||
get() {
|
||||
if (_stack != null) {
|
||||
return _stack!!
|
||||
}
|
||||
_stack =
|
||||
ImageVector.Builder(
|
||||
name = "stack",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
)
|
||||
.apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Black),
|
||||
fillAlpha = 1f,
|
||||
stroke = null,
|
||||
strokeAlpha = 1f,
|
||||
strokeLineWidth = 1f,
|
||||
strokeLineCap = StrokeCap.Butt,
|
||||
strokeLineJoin = StrokeJoin.Bevel,
|
||||
strokeLineMiter = 1f,
|
||||
pathFillType = PathFillType.Companion.NonZero,
|
||||
) {
|
||||
moveTo(6f, 14f)
|
||||
verticalLineToRelative(2f)
|
||||
horizontalLineTo(4f)
|
||||
quadTo(3.18f, 16f, 2.59f, 15.41f)
|
||||
reflectiveQuadTo(2f, 14f)
|
||||
verticalLineTo(4f)
|
||||
quadTo(2f, 3.17f, 2.59f, 2.59f)
|
||||
reflectiveQuadTo(4f, 2f)
|
||||
horizontalLineTo(14f)
|
||||
quadToRelative(0.83f, 0f, 1.41f, 0.59f)
|
||||
reflectiveQuadTo(16f, 4f)
|
||||
verticalLineTo(6f)
|
||||
horizontalLineTo(14f)
|
||||
verticalLineTo(4f)
|
||||
horizontalLineTo(4f)
|
||||
verticalLineTo(14f)
|
||||
horizontalLineTo(6f)
|
||||
close()
|
||||
moveToRelative(4f, 8f)
|
||||
quadTo(9.18f, 22f, 8.59f, 21.41f)
|
||||
reflectiveQuadTo(8f, 20f)
|
||||
verticalLineTo(10f)
|
||||
quadTo(8f, 9.17f, 8.59f, 8.59f)
|
||||
reflectiveQuadTo(10f, 8f)
|
||||
horizontalLineTo(20f)
|
||||
quadToRelative(0.83f, 0f, 1.41f, 0.59f)
|
||||
reflectiveQuadTo(22f, 10f)
|
||||
verticalLineTo(20f)
|
||||
quadToRelative(0f, 0.82f, -0.59f, 1.41f)
|
||||
reflectiveQuadTo(20f, 22f)
|
||||
horizontalLineTo(10f)
|
||||
close()
|
||||
moveToRelative(0f, -2f)
|
||||
horizontalLineTo(20f)
|
||||
verticalLineTo(10f)
|
||||
horizontalLineTo(10f)
|
||||
verticalLineTo(20f)
|
||||
close()
|
||||
moveToRelative(5f, -5f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
return _stack!!
|
||||
}
|
||||
|
||||
private var _stack: ImageVector? = null
|
||||
}
|
||||
@@ -20,187 +20,60 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun AboutCard(
|
||||
navController: NavController,
|
||||
modelName: String,
|
||||
actualModel: String,
|
||||
serialNumbers: List<String>,
|
||||
version: String?
|
||||
version: String?,
|
||||
navigateToVersion: () -> Unit
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.about),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val rowHeight = remember { mutableStateOf(0.dp) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
rowHeight.value = with(density) { coordinates.size.height.toDp() }
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.model_name),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = modelName,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.model_name),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = actualModel,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
val serialNumbers = listOf(
|
||||
val serialNumbers = when (LocalDesignSystem.current) {
|
||||
DesignSystem.Apple -> listOf(
|
||||
serialNumbers[0],
|
||||
" ${serialNumbers[1]}",
|
||||
" ${serialNumbers[2]}"
|
||||
)
|
||||
val serialNumber = remember { mutableIntStateOf(0) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.serial_number),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = serialNumbers[serialNumber.intValue],
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size
|
||||
}
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
|
||||
DesignSystem.Material -> listOf(
|
||||
serialNumbers[0],
|
||||
stringResource(R.string.left) + " " + serialNumbers[1],
|
||||
stringResource(R.string.right) + " " + serialNumbers[2],
|
||||
)
|
||||
NavigationButton(
|
||||
to = "version_info",
|
||||
navController = navController,
|
||||
}
|
||||
|
||||
val serialNumber = remember { mutableIntStateOf(0) }
|
||||
|
||||
StyledList (title = stringResource(R.string.about)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.model_name),
|
||||
description = modelName
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.model_number),
|
||||
description = actualModel
|
||||
)
|
||||
|
||||
StyledListItem (
|
||||
name = stringResource(R.string.serial_number),
|
||||
description = serialNumbers[serialNumber.intValue],
|
||||
onClick = { serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size }
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.version),
|
||||
currentState = version,
|
||||
independent = false,
|
||||
height = rowHeight.value + 32.dp
|
||||
description = version,
|
||||
onClick = navigateToVersion,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,176 +18,35 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@Composable
|
||||
fun AppInfoCard() {
|
||||
val rowHeight = remember { mutableStateOf(0.dp) }
|
||||
val density = LocalDensity.current
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
fun AppInfoCard(
|
||||
navigateToReleaseNotesScreen: (() -> Unit)? = null,
|
||||
) {
|
||||
StyledList(title = stringResource(R.string.about)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.version),
|
||||
description = BuildConfig.VERSION_NAME,
|
||||
onClick = navigateToReleaseNotesScreen
|
||||
)
|
||||
|
||||
Column {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
||||
.padding(start = 16.dp, bottom = 8.dp, end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.about), style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.version_code),
|
||||
description = BuildConfig.VERSION_CODE.toString(),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
rowHeight.value = with(density) { coordinates.size.height.toDp() }
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.version), style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = BuildConfig.VERSION_NAME, style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||
alpha = 0.8f
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.version_code), style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = BuildConfig.VERSION_CODE.toString(), style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||
alpha = 0.8f
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.flavor), style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = BuildConfig.FLAVOR, style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||
alpha = 0.8f
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.build_type), style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = BuildConfig.BUILD_TYPE,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||
alpha = 0.8f
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.flavor),
|
||||
description = BuildConfig.FLAVOR,
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.build_type),
|
||||
description = BuildConfig.BUILD_TYPE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,39 +20,18 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import me.kavishdevar.librepods.R
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun AudioSettings(
|
||||
navController: NavController,
|
||||
adaptiveVolumeCapability: Boolean,
|
||||
conversationalAwarenessCapability: Boolean,
|
||||
loudSoundReductionCapability: Boolean,
|
||||
adaptiveAudioCapability: Boolean,
|
||||
customEqCapability: Boolean,
|
||||
|
||||
adaptiveVolumeChecked: Boolean,
|
||||
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
|
||||
@@ -63,120 +42,57 @@ fun AudioSettings(
|
||||
loudSoundReductionChecked: Boolean,
|
||||
onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
|
||||
|
||||
navigateToAdaptiveStrength: () -> Unit,
|
||||
navigateToEqualizer: () -> Unit,
|
||||
|
||||
vendorIdHook: Boolean,
|
||||
isPremium: Boolean
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
if (adaptiveVolumeCapability || conversationalAwarenessCapability || loudSoundReductionCapability || adaptiveAudioCapability) {
|
||||
StyledList(title = stringResource(R.string.audio)) {
|
||||
if (adaptiveVolumeCapability) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.personalized_volume),
|
||||
description = stringResource(R.string.personalized_volume_description),
|
||||
checked = adaptiveVolumeChecked,
|
||||
onCheckedChange = onAdaptiveVolumeCheckedChange,
|
||||
enabled = isPremium,
|
||||
)
|
||||
}
|
||||
|
||||
if (!adaptiveVolumeCapability && !conversationalAwarenessCapability && !loudSoundReductionCapability && !adaptiveAudioCapability) {
|
||||
return
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.audio),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
if (conversationalAwarenessCapability) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversational_awareness),
|
||||
description = stringResource(R.string.conversational_awareness_description),
|
||||
checked = conversationalAwarenessChecked,
|
||||
onCheckedChange = onConversationalAwarenessCheckedChange,
|
||||
enabled = isPremium,
|
||||
)
|
||||
}
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
if (loudSoundReductionCapability && vendorIdHook) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
checked = loudSoundReductionChecked,
|
||||
onCheckedChange = onLoudSoundReductionCheckedChange,
|
||||
enabled = isPremium,
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
if (adaptiveAudioCapability) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.adaptive_audio),
|
||||
onClick = navigateToAdaptiveStrength,
|
||||
)
|
||||
}
|
||||
|
||||
if (adaptiveVolumeCapability) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.personalized_volume),
|
||||
description = stringResource(R.string.personalized_volume_description),
|
||||
independent = false,
|
||||
checked = adaptiveVolumeChecked,
|
||||
onCheckedChange = onAdaptiveVolumeCheckedChange,
|
||||
enabled = isPremium
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (conversationalAwarenessCapability) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversational_awareness),
|
||||
description = stringResource(R.string.conversational_awareness_description),
|
||||
independent = false,
|
||||
checked = conversationalAwarenessChecked,
|
||||
onCheckedChange = onConversationalAwarenessCheckedChange,
|
||||
enabled = isPremium
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (loudSoundReductionCapability && vendorIdHook){
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
independent = false,
|
||||
checked = loudSoundReductionChecked,
|
||||
onCheckedChange = onLoudSoundReductionCheckedChange,
|
||||
enabled = isPremium
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (adaptiveAudioCapability) {
|
||||
NavigationButton(
|
||||
to = "adaptive_strength",
|
||||
name = stringResource(R.string.adaptive_audio),
|
||||
navController = navController,
|
||||
independent = false
|
||||
)
|
||||
if (customEqCapability) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.equalizer),
|
||||
onClick = navigateToEqualizer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AudioSettingsPreview() {
|
||||
AudioSettings(
|
||||
navController = rememberNavController(),
|
||||
adaptiveVolumeCapability = true,
|
||||
conversationalAwarenessCapability = true,
|
||||
loudSoundReductionCapability = true,
|
||||
adaptiveAudioCapability = true,
|
||||
adaptiveVolumeChecked = true,
|
||||
onAdaptiveVolumeCheckedChange = { },
|
||||
conversationalAwarenessChecked = true,
|
||||
onConversationalAwarenessCheckedChange = { },
|
||||
loudSoundReductionChecked = true,
|
||||
onLoudSoundReductionCheckedChange = { },
|
||||
vendorIdHook = true,
|
||||
isPremium = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -53,6 +54,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.data.BatteryStatus
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
@@ -66,7 +68,6 @@ fun BatteryIndicator(
|
||||
previousCharging: Boolean = false,
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
|
||||
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val batteryFillColor =
|
||||
if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
|
||||
@@ -82,7 +83,7 @@ fun BatteryIndicator(
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).padding(4.dp), // just for haze to work
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer).padding(4.dp), // just for haze to work
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
@@ -200,10 +201,7 @@ fun BatteryIndicator(
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun BatteryIndicatorPreview() {
|
||||
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
||||
Box(
|
||||
modifier = Modifier.background(bg)
|
||||
) {
|
||||
LibrePodsTheme(m3eEnabled = false) {
|
||||
BatteryIndicator(
|
||||
batteryPercentage = 50,
|
||||
status = BatteryStatus.OPTIMIZED_CHARGING,
|
||||
|
||||
@@ -64,9 +64,6 @@ fun BatteryView(
|
||||
val rightLevel = right?.level ?: 0
|
||||
val caseLevel = case?.level ?: 0
|
||||
|
||||
val caseCharging = case?.status == BatteryStatus.CHARGING ||
|
||||
case?.status == BatteryStatus.OPTIMIZED_CHARGING
|
||||
|
||||
val singleDisplayed = remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
|
||||
@@ -20,413 +20,70 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun CallControlSettings(
|
||||
hazeState: HazeState,
|
||||
flipped: Boolean,
|
||||
onCallControlValueChanged: (Boolean) -> Unit
|
||||
navigateToCallControlScreen: (action: String) -> Unit,
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.call_controls),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
val pressOnceText = stringResource(R.string.press_once)
|
||||
val pressTwiceText = stringResource(R.string.press_twice)
|
||||
|
||||
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
|
||||
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
|
||||
|
||||
val muteUnmuteText = stringResource(R.string.mute_unmute)
|
||||
val hangUpText = stringResource(R.string.hang_up)
|
||||
|
||||
StyledList(title = stringResource(R.string.call_controls)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.answer_call),
|
||||
description = stringResource(R.string.press_once),
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
StyledListItem(
|
||||
name = muteUnmuteText,
|
||||
description = singlePressAction,
|
||||
onClick = { navigateToCallControlScreen(muteUnmuteText) } ,
|
||||
)
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
StyledListItem(
|
||||
name = hangUpText,
|
||||
description = doublePressAction,
|
||||
onClick = { navigateToCallControlScreen(hangUpText) }
|
||||
)
|
||||
|
||||
val pressOnceText = stringResource(R.string.press_once)
|
||||
val pressTwiceText = stringResource(R.string.press_twice)
|
||||
|
||||
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
|
||||
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
|
||||
|
||||
var showSinglePressDropdown by remember { mutableStateOf(false) }
|
||||
var touchOffsetSingle by remember { mutableStateOf<Offset?>(null) }
|
||||
var boxPositionSingle by remember { mutableStateOf(Offset.Zero) }
|
||||
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
|
||||
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
|
||||
var parentDragActiveSingle by remember { mutableStateOf(false) }
|
||||
var previousIdxSingle by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
var showDoublePressDropdown by remember { mutableStateOf(false) }
|
||||
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
|
||||
var boxPositionDouble by remember { mutableStateOf(Offset.Zero) }
|
||||
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
|
||||
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
|
||||
var parentDragActiveDouble by remember { mutableStateOf(false) }
|
||||
var previousIdxDouble by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
LaunchedEffect(flipped) {
|
||||
Log.d("CallControlSettings", "Call control flipped: $flipped")
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val itemHeightPx = with(density) { 48.dp.toPx() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(58.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.answer_call),
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.press_once),
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(58.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (showSinglePressDropdown) {
|
||||
showSinglePressDropdown = false
|
||||
lastDismissTimeSingle = now
|
||||
} else {
|
||||
if (now - lastDismissTimeSingle > 250L) {
|
||||
touchOffsetSingle = offset
|
||||
showSinglePressDropdown = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
touchOffsetSingle = offset
|
||||
if (!showSinglePressDropdown && now - lastDismissTimeSingle > 250L) {
|
||||
showSinglePressDropdown = true
|
||||
}
|
||||
lastDismissTimeSingle = now
|
||||
parentDragActiveSingle = true
|
||||
parentHoveredIndexSingle = 0
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val current = change.position
|
||||
val touch = touchOffsetSingle ?: current
|
||||
val posInPopupY = current.y - touch.y
|
||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||
if (idx != previousIdxSingle) {
|
||||
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
|
||||
}
|
||||
parentHoveredIndexSingle = idx
|
||||
previousIdxSingle = idx
|
||||
},
|
||||
onDragEnd = {
|
||||
parentDragActiveSingle = false
|
||||
parentHoveredIndexSingle?.let { idx ->
|
||||
val options = listOf(pressOnceText, pressTwiceText)
|
||||
if (idx in options.indices) {
|
||||
val option = options[idx]
|
||||
singlePressAction = option
|
||||
doublePressAction =
|
||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||
showSinglePressDropdown = false
|
||||
lastDismissTimeSingle = System.currentTimeMillis()
|
||||
onCallControlValueChanged(option != pressOnceText)
|
||||
|
||||
}
|
||||
}
|
||||
if (parentHoveredIndexSingle != null && parentHoveredIndexSingle in 0..1) {
|
||||
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
|
||||
}
|
||||
parentHoveredIndexSingle = null
|
||||
},
|
||||
onDragCancel = {
|
||||
parentDragActiveSingle = false
|
||||
parentHoveredIndexSingle = null
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.mute_unmute),
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
boxPositionSingle = coordinates.positionInParent()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = singlePressAction,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
StyledDropdown(
|
||||
expanded = showSinglePressDropdown,
|
||||
onDismissRequest = {
|
||||
showSinglePressDropdown = false
|
||||
lastDismissTimeSingle = System.currentTimeMillis()
|
||||
},
|
||||
options = listOf(pressOnceText, pressTwiceText),
|
||||
selectedOption = singlePressAction,
|
||||
touchOffset = touchOffsetSingle,
|
||||
boxPosition = boxPositionSingle,
|
||||
externalHoveredIndex = parentHoveredIndexSingle,
|
||||
externalDragActive = parentDragActiveSingle,
|
||||
onOptionSelected = { option ->
|
||||
singlePressAction = option
|
||||
doublePressAction =
|
||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||
showSinglePressDropdown = false
|
||||
val flipped = option != pressOnceText
|
||||
onCallControlValueChanged(flipped)
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(58.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (showDoublePressDropdown) {
|
||||
showDoublePressDropdown = false
|
||||
lastDismissTimeDouble = now
|
||||
} else {
|
||||
if (now - lastDismissTimeDouble > 250L) {
|
||||
touchOffsetDouble = offset
|
||||
showDoublePressDropdown = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
touchOffsetDouble = offset
|
||||
if (!showDoublePressDropdown && now - lastDismissTimeDouble > 250L) {
|
||||
showDoublePressDropdown = true
|
||||
}
|
||||
lastDismissTimeDouble = now
|
||||
parentDragActiveDouble = true
|
||||
parentHoveredIndexDouble = 0
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val current = change.position
|
||||
val touch = touchOffsetDouble ?: current
|
||||
val posInPopupY = current.y - touch.y
|
||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||
if (idx != previousIdxDouble) {
|
||||
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
|
||||
}
|
||||
parentHoveredIndexDouble = idx
|
||||
previousIdxDouble = idx
|
||||
},
|
||||
onDragEnd = {
|
||||
parentDragActiveDouble = false
|
||||
parentHoveredIndexDouble?.let { idx ->
|
||||
val options = listOf(pressOnceText, pressTwiceText)
|
||||
if (idx in options.indices) {
|
||||
val option = options[idx]
|
||||
doublePressAction = option
|
||||
singlePressAction =
|
||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||
showDoublePressDropdown = false
|
||||
lastDismissTimeDouble = System.currentTimeMillis()
|
||||
val flipped = option == pressOnceText
|
||||
onCallControlValueChanged(flipped)
|
||||
}
|
||||
}
|
||||
if (parentHoveredIndexDouble != null && parentHoveredIndexDouble in 0..1) {
|
||||
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
|
||||
}
|
||||
parentHoveredIndexDouble = null
|
||||
},
|
||||
onDragCancel = {
|
||||
parentDragActiveDouble = false
|
||||
parentHoveredIndexDouble = null
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.hang_up),
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
boxPositionDouble = coordinates.positionInParent()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = doublePressAction,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
StyledDropdown(
|
||||
expanded = showDoublePressDropdown,
|
||||
onDismissRequest = {
|
||||
showDoublePressDropdown = false
|
||||
lastDismissTimeDouble = System.currentTimeMillis()
|
||||
},
|
||||
options = listOf(pressOnceText, pressTwiceText),
|
||||
selectedOption = doublePressAction,
|
||||
touchOffset = touchOffsetDouble,
|
||||
boxPosition = boxPositionDouble,
|
||||
externalHoveredIndex = parentHoveredIndexDouble,
|
||||
externalDragActive = parentDragActiveDouble,
|
||||
onOptionSelected = { option ->
|
||||
doublePressAction = option
|
||||
singlePressAction =
|
||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||
showDoublePressDropdown = false
|
||||
val flipped = option == pressOnceText
|
||||
onCallControlValueChanged(flipped)
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// StyledListItem(
|
||||
// name = pressOnceText,
|
||||
// selected = doublePressAction == pressOnceText,
|
||||
// onClick = {
|
||||
// doublePressAction = pressOnceText
|
||||
// singlePressAction = pressTwiceText
|
||||
//
|
||||
// onCallControlValueChanged(true)
|
||||
// }
|
||||
// )
|
||||
//
|
||||
// StyledListItem(
|
||||
// name = pressTwiceText,
|
||||
// selected = doublePressAction == pressTwiceText,
|
||||
// onClick = {
|
||||
// doublePressAction = pressTwiceText
|
||||
// singlePressAction = pressOnceText
|
||||
//
|
||||
// onCallControlValueChanged(false)
|
||||
// }
|
||||
// )
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,11 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredWidthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -51,6 +55,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
@@ -59,7 +64,10 @@ import com.kyant.backdrop.effects.lens
|
||||
import com.kyant.backdrop.effects.vibrancy
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun ConfirmationDialog(
|
||||
@@ -72,105 +80,136 @@ fun ConfirmationDialog(
|
||||
onDismiss: () -> Unit = { showDialog.value = false },
|
||||
backdrop: LayerBackdrop,
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val accentColor = if (isDarkTheme) Color(0xFF0091FF) else Color(0xFF0088FF)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showDialog.value,
|
||||
enter = scaleIn(initialScale = 1.05f) + fadeIn(),
|
||||
exit = scaleOut(targetScale = 1.05f) + fadeOut()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val innerBackdrop = rememberLayerBackdrop()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.clickable(enabled = false, onClick = {}),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.requiredWidthIn(min = 200.dp, max = 360.dp)
|
||||
.clip(RoundedCornerShape(48.dp))
|
||||
.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
exportedBackdrop = innerBackdrop,
|
||||
shape = { RoundedCornerShape(48.dp) },
|
||||
effects = {
|
||||
vibrancy()
|
||||
blur(4f.dp.toPx())
|
||||
lens(12f.dp.toPx(), 48f.dp.toPx(), true)
|
||||
},
|
||||
onDrawSurface = {
|
||||
drawRect(
|
||||
if (isDarkTheme) Color(0xFF1F1F1F).copy(alpha = 0.35f) else Color(0xFFE0E0E0).copy(alpha = 0.7f)
|
||||
)
|
||||
})) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
when (LocalDesignSystem.current) {
|
||||
DesignSystem.Material -> {
|
||||
BasicAlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
message,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(0.9f),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
StyledButton(
|
||||
onClick = onDismiss,
|
||||
backdrop = innerBackdrop,
|
||||
modifier = Modifier.weight(1f),
|
||||
Row(modifier = Modifier.align(Alignment.End)) {
|
||||
TextButton(
|
||||
onClick = onDismiss
|
||||
) {
|
||||
Text(
|
||||
text = dismissText, style = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
color = textColor
|
||||
)
|
||||
text = dismissText,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
StyledButton(
|
||||
onClick = onConfirm,
|
||||
backdrop = innerBackdrop,
|
||||
modifier = Modifier.weight(1f),
|
||||
surfaceColor = accentColor
|
||||
TextButton(
|
||||
onClick = onConfirm
|
||||
) {
|
||||
Text(
|
||||
text = confirmText, style = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
color = Color.White
|
||||
)
|
||||
text = confirmText,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
DesignSystem.Apple -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val innerBackdrop = rememberLayerBackdrop()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.clickable(enabled = false, onClick = {}),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.requiredWidthIn(min = 200.dp, max = 360.dp)
|
||||
.clip(RoundedCornerShape(48.dp))
|
||||
.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
exportedBackdrop = innerBackdrop,
|
||||
shape = { RoundedCornerShape(48.dp) },
|
||||
effects = {
|
||||
vibrancy()
|
||||
blur(4f.dp.toPx())
|
||||
lens(12f.dp.toPx(), 48f.dp.toPx(), true)
|
||||
},
|
||||
onDrawSurface = {
|
||||
drawRect(
|
||||
if (isDarkTheme) Color(0xFF1F1F1F).copy(alpha = 0.35f) else Color(
|
||||
0xFFE0E0E0
|
||||
).copy(alpha = 0.7f)
|
||||
)
|
||||
})
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(0.9f),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
StyledButton(
|
||||
onClick = onDismiss,
|
||||
backdrop = innerBackdrop,
|
||||
modifier = Modifier.weight(1f),
|
||||
materialButtonStyle = MaterialButtonStyle.Outlined,
|
||||
) {
|
||||
Text(
|
||||
text = dismissText,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
StyledButton(
|
||||
onClick = onConfirm,
|
||||
backdrop = innerBackdrop,
|
||||
modifier = Modifier.weight(1f),
|
||||
materialButtonStyle = MaterialButtonStyle.Filled,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Text(
|
||||
text = confirmText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,18 +20,8 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.R
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@@ -42,32 +32,16 @@ fun ConnectionSettings(
|
||||
automaticConnectionEnabled: Boolean,
|
||||
onAutomaticConnectionChanged: (Boolean) -> Unit,
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
StyledList {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.ear_detection),
|
||||
independent = false,
|
||||
checked = automaticEarDetectionEnabled,
|
||||
onCheckedChange = onAutomaticEarDetectionChanged
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.automatically_connect),
|
||||
description = stringResource(R.string.automatically_connect_description),
|
||||
independent = false,
|
||||
checked = automaticConnectionEnabled,
|
||||
onCheckedChange = onAutomaticConnectionChanged
|
||||
)
|
||||
|
||||
@@ -1,235 +1,56 @@
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.XposedState
|
||||
|
||||
@Composable
|
||||
fun DeviceInfoCard() {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
StyledList(title = stringResource(R.string.device_info)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.manufacturer),
|
||||
description = Build.MANUFACTURER,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.model_number),
|
||||
description = Build.MODEL,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
val rowHeight = remember { mutableStateOf(0.dp) }
|
||||
val density = LocalDensity.current
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.build_id),
|
||||
description = Build.DISPLAY,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
Column (
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
||||
.padding(start = 16.dp, top = 24.dp, end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.device_info), style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
rowHeight.value = with(density) { coordinates.size.height.toDp() }
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.manufacturer), style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = Build.MANUFACTURER, style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||
alpha = 0.8f
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.model_number), style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = Build.MODEL, style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||
alpha = 0.8f
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.build_id), style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = Build.DISPLAY, style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||
alpha = 0.8f
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.version), style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = Build.ID + " (${Build.VERSION.SDK_INT_FULL})",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||
alpha = 0.8f
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.xposed_available), style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = if (XposedState.isAvailable) stringResource(R.string.yes) else stringResource(R.string.no), style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||
alpha = 0.8f
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_enabled_in_xposed), style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = if (XposedState.bluetoothScopeEnabled) stringResource(R.string.yes) else stringResource(R.string.no), style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||
alpha = 0.8f
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.version),
|
||||
description = "${Build.ID} (${Build.VERSION.SDK_INT_FULL})",
|
||||
enabled = false
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.xposed_available),
|
||||
description = if (XposedState.isAvailable) {
|
||||
stringResource(R.string.yes)
|
||||
} else {
|
||||
stringResource(R.string.no)
|
||||
},
|
||||
enabled = false
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.app_enabled_in_xposed),
|
||||
description = if (XposedState.bluetoothScopeEnabled) {
|
||||
stringResource(R.string.yes)
|
||||
} else {
|
||||
stringResource(R.string.no)
|
||||
},
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,99 +20,43 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.librepods.R
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun HearingHealthSettings(
|
||||
navController: NavController,
|
||||
hasPPECapability: Boolean,
|
||||
hasHearingAidCapability: Boolean,
|
||||
vendorIdHook: Boolean
|
||||
vendorIdHook: Boolean,
|
||||
navigateToHearingProtection: () -> Unit,
|
||||
navigateToHearingAid: () -> Unit
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook
|
||||
|
||||
if (hasPPECapability && shouldShowHearingAid) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_health),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
NavigationButton(
|
||||
to = "hearing_protection",
|
||||
StyledList(title = stringResource(R.string.hearing_health)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.hearing_protection),
|
||||
navController = navController,
|
||||
independent = false
|
||||
onClick = navigateToHearingProtection
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
|
||||
NavigationButton(
|
||||
to = "hearing_aid",
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.hearing_aid),
|
||||
navController = navController,
|
||||
independent = false
|
||||
onClick = navigateToHearingAid
|
||||
)
|
||||
}
|
||||
} else if (shouldShowHearingAid) {
|
||||
NavigationButton(
|
||||
to = "hearing_aid",
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.hearing_aid),
|
||||
navController = navController
|
||||
onClick = navigateToHearingAid
|
||||
)
|
||||
} else if (hasPPECapability) {
|
||||
NavigationButton(
|
||||
to = "hearing_protection",
|
||||
name = stringResource(R.string.hearing_protection),
|
||||
StyledListItem(
|
||||
title = stringResource(R.string.hearing_health),
|
||||
navController = navController
|
||||
name = stringResource(R.string.hearing_protection),
|
||||
onClick = navigateToHearingProtection
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
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
|
||||
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/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun MicrophoneSettings(
|
||||
hazeState: HazeState,
|
||||
micModeValue: Byte,
|
||||
onMicModeValueChanged: (Byte) -> Unit
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
var selectedMode by remember {
|
||||
mutableStateOf(
|
||||
when (micModeValue) {
|
||||
0x00.toByte() -> "Automatic"
|
||||
0x01.toByte() -> "Always Right"
|
||||
0x02.toByte() -> "Always Left"
|
||||
else -> "Automatic"
|
||||
}
|
||||
)
|
||||
}
|
||||
var showDropdown by remember { mutableStateOf(false) }
|
||||
var touchOffset by remember { mutableStateOf<Offset?>(null) }
|
||||
var boxPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
||||
val reopenThresholdMs = 250L
|
||||
|
||||
val density = LocalDensity.current
|
||||
val itemHeightPx = with(density) { 48.dp.toPx() }
|
||||
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var parentDragActive by remember { mutableStateOf(false) }
|
||||
var previousIdx by remember { mutableStateOf<Int?>(null) }
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
|
||||
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
|
||||
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(58.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (showDropdown) {
|
||||
showDropdown = false
|
||||
lastDismissTime = now
|
||||
} else {
|
||||
if (now - lastDismissTime > reopenThresholdMs) {
|
||||
touchOffset = offset
|
||||
showDropdown = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
touchOffset = offset
|
||||
if (!showDropdown && now - lastDismissTime > reopenThresholdMs) {
|
||||
showDropdown = true
|
||||
}
|
||||
lastDismissTime = now
|
||||
parentDragActive = true
|
||||
parentHoveredIndex = 0
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val current = change.position
|
||||
val touch = touchOffset ?: current
|
||||
val posInPopupY = current.y - touch.y
|
||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||
if (idx != previousIdx) {
|
||||
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
|
||||
}
|
||||
parentHoveredIndex = idx
|
||||
previousIdx = idx
|
||||
},
|
||||
onDragEnd = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex?.let { idx ->
|
||||
val options = listOf(
|
||||
microphoneAutomaticText,
|
||||
microphoneAlwaysRightText,
|
||||
microphoneAlwaysLeftText
|
||||
)
|
||||
if (idx in options.indices) {
|
||||
val option = options[idx]
|
||||
selectedMode = option
|
||||
showDropdown = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
val byteValue = when (option) {
|
||||
options[0] -> 0x00
|
||||
options[1] -> 0x01
|
||||
options[2] -> 0x02
|
||||
else -> 0x00
|
||||
}
|
||||
// service.aacpManager.sendControlCommand(
|
||||
// AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
||||
// byteArrayOf(byteValue.toByte())
|
||||
// )
|
||||
onMicModeValueChanged(byteValue.toByte())
|
||||
}
|
||||
}
|
||||
if (parentHoveredIndex != null && parentHoveredIndex in 0..2) {
|
||||
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
|
||||
}
|
||||
parentHoveredIndex = null
|
||||
},
|
||||
onDragCancel = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex = null
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.microphone_mode),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
boxPosition = coordinates.positionInParent()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = selectedMode,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
|
||||
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
|
||||
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
|
||||
|
||||
StyledDropdown(
|
||||
expanded = showDropdown,
|
||||
onDismissRequest = {
|
||||
showDropdown = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
},
|
||||
options = listOf(
|
||||
microphoneAutomaticText,
|
||||
microphoneAlwaysRightText,
|
||||
microphoneAlwaysLeftText
|
||||
),
|
||||
selectedOption = selectedMode,
|
||||
touchOffset = touchOffset,
|
||||
boxPosition = boxPosition,
|
||||
externalHoveredIndex = parentHoveredIndex,
|
||||
externalDragActive = parentDragActive,
|
||||
onOptionSelected = { option ->
|
||||
selectedMode = option
|
||||
showDropdown = false
|
||||
val byteValue = when (option) {
|
||||
microphoneAutomaticText -> 0x00
|
||||
microphoneAlwaysRightText -> 0x01
|
||||
microphoneAlwaysLeftText -> 0x02
|
||||
else -> 0x00
|
||||
}
|
||||
onMicModeValueChanged(byteValue.toByte())
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
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
|
||||
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 me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.rememberCoroutineScope
|
||||
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.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@Composable
|
||||
fun NavigationButton(
|
||||
to: String,
|
||||
name: String,
|
||||
navController: NavController, onClick: (() -> Unit)? = null,
|
||||
independent: Boolean = true,
|
||||
title: String? = null,
|
||||
description: String? = null,
|
||||
currentState: String? = null,
|
||||
height: Dp = 58.dp,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column {
|
||||
if (title != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
animatedBackgroundColor,
|
||||
RoundedCornerShape(if (independent) 28.dp else 0.dp)
|
||||
)
|
||||
.height(height)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
if (enabled) {
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
}
|
||||
},
|
||||
onTap = {
|
||||
if (enabled) {
|
||||
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
|
||||
if (onClick != null) onClick() else navController.navigate(to)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isDarkTheme) Color.White else Color.Black,
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (currentState != null) {
|
||||
Text(
|
||||
text = currentState,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||
)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = if (currentState != null) 6.dp else 0.dp)
|
||||
)
|
||||
}
|
||||
if (description != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) // because blur effect doesn't work for some reason
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
// modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NavigationButtonPreview() {
|
||||
NavigationButton("to", "Name", NavController(LocalContext.current))
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -40,10 +41,16 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
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.ButtonGroupDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ToggleButton
|
||||
import androidx.compose.material3.ToggleButtonDefaults
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -51,6 +58,7 @@ import androidx.compose.runtime.mutableFloatStateOf
|
||||
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.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -59,19 +67,22 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.sectionHeader
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
||||
@Composable
|
||||
fun NoiseControlSettings(
|
||||
@@ -79,319 +90,432 @@ fun NoiseControlSettings(
|
||||
noiseControlModeValue: Int,
|
||||
onNoiseControlModeChanged: (Int) -> Unit
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
|
||||
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
|
||||
|
||||
|
||||
|
||||
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
|
||||
|
||||
val d1a = remember { mutableFloatStateOf(0f) }
|
||||
val d2a = remember { mutableFloatStateOf(0f) }
|
||||
val d3a = remember { mutableFloatStateOf(0f) }
|
||||
|
||||
// this function exists solely for the dividers, should get rid of it
|
||||
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
||||
val previousMode = noiseControlMode.value
|
||||
|
||||
val targetMode = if (!showOffListeningMode && mode == NoiseControlMode.OFF) {
|
||||
NoiseControlMode.TRANSPARENCY
|
||||
} else {
|
||||
mode
|
||||
}
|
||||
|
||||
noiseControlMode.value = targetMode
|
||||
|
||||
if (!received && targetMode != previousMode) onNoiseControlModeChanged(targetMode.ordinal + 1)
|
||||
|
||||
|
||||
when (noiseControlMode.value) {
|
||||
NoiseControlMode.NOISE_CANCELLATION -> {
|
||||
d1a.floatValue = 1f
|
||||
d2a.floatValue = 1f
|
||||
d3a.floatValue = 0f
|
||||
}
|
||||
NoiseControlMode.OFF -> {
|
||||
d1a.floatValue = 0f
|
||||
d2a.floatValue = 1f
|
||||
d3a.floatValue = 1f
|
||||
}
|
||||
NoiseControlMode.ADAPTIVE -> {
|
||||
d1a.floatValue = 1f
|
||||
d2a.floatValue = 0f
|
||||
d3a.floatValue = 0f
|
||||
}
|
||||
NoiseControlMode.TRANSPARENCY -> {
|
||||
d1a.floatValue = 0f
|
||||
d2a.floatValue = 0f
|
||||
d3a.floatValue = 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val index = (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1)
|
||||
noiseControlMode.value = NoiseControlMode.entries[index]
|
||||
|
||||
onModeSelected(noiseControlMode.value, received = true)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val buttonCount = if (showOffListeningMode) 4 else 3
|
||||
val buttonWidth = maxWidth / buttonCount
|
||||
|
||||
val isDragging = remember { mutableStateOf(false) }
|
||||
var dragOffset by remember {
|
||||
mutableFloatStateOf(
|
||||
with(density) {
|
||||
when(noiseControlMode.value) {
|
||||
NoiseControlMode.OFF -> if (showOffListeningMode) 0f else buttonWidth.toPx()
|
||||
NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) buttonWidth.toPx() else 0f
|
||||
NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) (buttonWidth * 2).toPx() else buttonWidth.toPx()
|
||||
NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val animationSpec: AnimationSpec<Float> = SpringSpec(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
visibilityThreshold = 0.01f
|
||||
)
|
||||
|
||||
val targetOffset = buttonWidth * when(noiseControlMode.value) {
|
||||
NoiseControlMode.OFF -> if (showOffListeningMode) 0 else 1
|
||||
NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) 1 else 0
|
||||
NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) 2 else 1
|
||||
NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) 3 else 2
|
||||
}
|
||||
|
||||
val animatedOffset by animateFloatAsState(
|
||||
targetValue = with(density) {
|
||||
if (isDragging.value) dragOffset else targetOffset.toPx()
|
||||
},
|
||||
animationSpec = animationSpec,
|
||||
label = "selector"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp)
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (showOffListeningMode) {
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
when (LocalDesignSystem.current) {
|
||||
DesignSystem.Material -> {
|
||||
val options = buildList {
|
||||
if (showOffListeningMode) {
|
||||
add(
|
||||
Triple(
|
||||
NoiseControlMode.OFF,
|
||||
R.string.off,
|
||||
R.drawable.noise_cancellation
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d1a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
}
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.transparency),
|
||||
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d2a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.adaptive),
|
||||
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d3a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
}
|
||||
|
||||
add(
|
||||
Triple(
|
||||
NoiseControlMode.TRANSPARENCY,
|
||||
R.string.transparency,
|
||||
R.drawable.transparency
|
||||
)
|
||||
)
|
||||
add(
|
||||
Triple(
|
||||
NoiseControlMode.ADAPTIVE,
|
||||
R.string.adaptive,
|
||||
R.drawable.adaptive
|
||||
)
|
||||
)
|
||||
add(
|
||||
Triple(
|
||||
NoiseControlMode.NOISE_CANCELLATION,
|
||||
R.string.noise_cancellation,
|
||||
R.drawable.noise_cancellation
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val selectedMode = NoiseControlMode.entries[(noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.lastIndex)]
|
||||
|
||||
Column {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(buttonWidth)
|
||||
.fillMaxHeight()
|
||||
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
|
||||
.zIndex(0f)
|
||||
.draggable(
|
||||
orientation = Orientation.Horizontal,
|
||||
state = rememberDraggableState { delta ->
|
||||
dragOffset = (dragOffset + delta).coerceIn(
|
||||
0f,
|
||||
with(density) { (buttonWidth * (buttonCount - 1)).toPx() }
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 4.dp, bottom = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.labelSmallEmphasized
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
|
||||
) {
|
||||
options.forEachIndexed { index, (mode, labelRes, iconRes) ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
ToggleButton(
|
||||
checked = selectedMode == mode,
|
||||
onCheckedChange = {
|
||||
if (it) {
|
||||
onNoiseControlModeChanged(mode.ordinal + 1)
|
||||
}
|
||||
},
|
||||
shapes = when (index) {
|
||||
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
|
||||
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
|
||||
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
|
||||
},
|
||||
colors = ToggleButtonDefaults.toggleButtonColors()
|
||||
.copy(containerColor = MaterialTheme.colorScheme.surface),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
bitmap = ImageBitmap.imageResource(iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(42.dp)
|
||||
)
|
||||
},
|
||||
onDragStarted = { isDragging.value = true },
|
||||
onDragStopped = {
|
||||
isDragging.value = false
|
||||
val position = dragOffset / with(density) { buttonWidth.toPx() }
|
||||
val newIndex = position.roundToInt()
|
||||
val newMode = when(newIndex) {
|
||||
0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
|
||||
1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
|
||||
2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
|
||||
3 -> NoiseControlMode.NOISE_CANCELLATION
|
||||
else -> noiseControlMode.value // Keep current if index is invalid
|
||||
}
|
||||
onModeSelected(newMode)
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(labelRes),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DesignSystem.Apple -> {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
|
||||
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
|
||||
|
||||
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
|
||||
|
||||
val d1a = remember { mutableFloatStateOf(0f) }
|
||||
val d2a = remember { mutableFloatStateOf(0f) }
|
||||
val d3a = remember { mutableFloatStateOf(0f) }
|
||||
|
||||
// this function exists solely for the dividers, should get rid of it
|
||||
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
||||
val previousMode = noiseControlMode.value
|
||||
|
||||
val targetMode = if (!showOffListeningMode && mode == NoiseControlMode.OFF) {
|
||||
NoiseControlMode.TRANSPARENCY
|
||||
} else {
|
||||
mode
|
||||
}
|
||||
|
||||
noiseControlMode.value = targetMode
|
||||
|
||||
if (!received && targetMode != previousMode) onNoiseControlModeChanged(targetMode.ordinal + 1)
|
||||
|
||||
|
||||
when (noiseControlMode.value) {
|
||||
NoiseControlMode.NOISE_CANCELLATION -> {
|
||||
d1a.floatValue = 1f
|
||||
d2a.floatValue = 1f
|
||||
d3a.floatValue = 0f
|
||||
}
|
||||
NoiseControlMode.OFF -> {
|
||||
d1a.floatValue = 0f
|
||||
d2a.floatValue = 1f
|
||||
d3a.floatValue = 1f
|
||||
}
|
||||
NoiseControlMode.ADAPTIVE -> {
|
||||
d1a.floatValue = 1f
|
||||
d2a.floatValue = 0f
|
||||
d3a.floatValue = 0f
|
||||
}
|
||||
NoiseControlMode.TRANSPARENCY -> {
|
||||
d1a.floatValue = 0f
|
||||
d2a.floatValue = 0f
|
||||
d3a.floatValue = 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val index = (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1)
|
||||
noiseControlMode.value = NoiseControlMode.entries[index]
|
||||
|
||||
onModeSelected(noiseControlMode.value, received = true)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 4.dp, bottom = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
color = MaterialTheme.colorScheme.sectionHeader,
|
||||
style = MaterialTheme.typography.labelSmallEmphasized
|
||||
)
|
||||
}
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val buttonCount = if (showOffListeningMode) 4 else 3
|
||||
val buttonWidth = maxWidth / buttonCount
|
||||
|
||||
val isDragging = remember { mutableStateOf(false) }
|
||||
var dragOffset by remember {
|
||||
mutableFloatStateOf(
|
||||
with(density) {
|
||||
when(noiseControlMode.value) {
|
||||
NoiseControlMode.OFF -> if (showOffListeningMode) 0f else buttonWidth.toPx()
|
||||
NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) buttonWidth.toPx() else 0f
|
||||
NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) (buttonWidth * 2).toPx() else buttonWidth.toPx()
|
||||
NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val animationSpec: AnimationSpec<Float> = SpringSpec(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
visibilityThreshold = 0.01f
|
||||
)
|
||||
|
||||
val targetOffset = buttonWidth * when(noiseControlMode.value) {
|
||||
NoiseControlMode.OFF -> if (showOffListeningMode) 0 else 1
|
||||
NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) 1 else 0
|
||||
NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) 2 else 1
|
||||
NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) 3 else 2
|
||||
}
|
||||
|
||||
val animatedOffset by animateFloatAsState(
|
||||
targetValue = with(density) {
|
||||
if (isDragging.value) dragOffset else targetOffset.toPx()
|
||||
},
|
||||
animationSpec = animationSpec,
|
||||
label = "selector"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(3.dp)
|
||||
.background(selectedBackground, RoundedCornerShape(26.dp))
|
||||
)
|
||||
}
|
||||
.fillMaxWidth()
|
||||
.height(60.dp)
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (showOffListeningMode) {
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d1a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
}
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.transparency),
|
||||
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d2a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.adaptive),
|
||||
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d3a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.zIndex(1f)
|
||||
) {
|
||||
if (showOffListeningMode) {
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d1a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
.width(buttonWidth)
|
||||
.fillMaxHeight()
|
||||
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
|
||||
.zIndex(0f)
|
||||
.draggable(
|
||||
orientation = Orientation.Horizontal,
|
||||
state = rememberDraggableState { delta ->
|
||||
dragOffset = (dragOffset + delta).coerceIn(
|
||||
0f,
|
||||
with(density) { (buttonWidth * (buttonCount - 1)).toPx() }
|
||||
)
|
||||
},
|
||||
onDragStarted = { isDragging.value = true },
|
||||
onDragStopped = {
|
||||
isDragging.value = false
|
||||
val position =
|
||||
dragOffset / with(density) { buttonWidth.toPx() }
|
||||
val newIndex = position.roundToInt()
|
||||
val newMode = when (newIndex) {
|
||||
0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
|
||||
1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
|
||||
2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
|
||||
3 -> NoiseControlMode.NOISE_CANCELLATION
|
||||
else -> noiseControlMode.value // Keep current if index is invalid
|
||||
}
|
||||
onModeSelected(newMode)
|
||||
}
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(3.dp)
|
||||
.background(selectedBackground, RoundedCornerShape(26.dp))
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.zIndex(1f)
|
||||
) {
|
||||
if (showOffListeningMode) {
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d1a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
}
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.transparency),
|
||||
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d2a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.adaptive),
|
||||
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d3a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp)
|
||||
) {
|
||||
if (showOffListeningMode) {
|
||||
Text(
|
||||
text = stringResource(R.string.off),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.transparency),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.adaptive),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.noise_cancellation),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.transparency),
|
||||
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d2a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.adaptive),
|
||||
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d3a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp)
|
||||
) {
|
||||
if (showOffListeningMode) {
|
||||
Text(
|
||||
text = stringResource(R.string.off),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.transparency),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.adaptive),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.noise_cancellation),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NoiseControlSettingsPreview() {
|
||||
LibrePodsTheme(
|
||||
m3eEnabled = true
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
NoiseControlSettings(
|
||||
showOffListeningMode = false,
|
||||
noiseControlModeValue = 2,
|
||||
onNoiseControlModeChanged = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,40 +18,18 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.data.StemAction
|
||||
|
||||
@Composable
|
||||
fun PressAndHoldSettings(
|
||||
navController: NavController,
|
||||
leftAction: StemAction,
|
||||
rightAction: StemAction
|
||||
rightAction: StemAction,
|
||||
navigateToLeftLongPress: () -> Unit,
|
||||
navigateToRightLongPress: () -> Unit
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val dividerColor = Color(0x40888888)
|
||||
|
||||
val leftActionText = when (leftAction) {
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||
@@ -63,46 +41,19 @@ fun PressAndHoldSettings(
|
||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||
else -> "INVALID!!"
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.press_and_hold_airpods),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), RoundedCornerShape(28.dp))
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
|
||||
StyledList(
|
||||
title = stringResource(R.string.press_and_hold_airpods)
|
||||
) {
|
||||
NavigationButton(
|
||||
to = "long_press/Left",
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.left),
|
||||
navController = navController,
|
||||
independent = false,
|
||||
currentState = leftActionText,
|
||||
description = leftActionText,
|
||||
onClick = navigateToLeftLongPress
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = dividerColor,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
NavigationButton(
|
||||
to = "long_press/Right",
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.right),
|
||||
navController = navController,
|
||||
independent = false,
|
||||
currentState = rightActionText,
|
||||
description = rightActionText,
|
||||
onClick = navigateToRightLongPress,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -61,6 +66,8 @@ import com.kyant.backdrop.effects.lens
|
||||
import com.kyant.backdrop.effects.vibrancy
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.utils.inspectDragGestures
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
@@ -68,6 +75,13 @@ import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.tanh
|
||||
|
||||
enum class MaterialButtonStyle {
|
||||
Tonal,
|
||||
Normal,
|
||||
Outlined,
|
||||
Filled
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StyledButton(
|
||||
onClick: () -> Unit,
|
||||
@@ -78,20 +92,58 @@ fun StyledButton(
|
||||
surfaceColor: Color = Color.Unspecified,
|
||||
maxScale: Float = 0.1f,
|
||||
enabled: Boolean = true,
|
||||
materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Tonal, // picking tonal because most usages assume a transparent/gray background, tonal will give a slightly less vibrant background
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val isInteractive = enabled && isInteractive
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val progressAnimation = remember { Animatable(0f) }
|
||||
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
var isPressed by remember { mutableStateOf(false) }
|
||||
when (LocalDesignSystem.current) {
|
||||
DesignSystem.Material -> {
|
||||
when (materialButtonStyle) {
|
||||
MaterialButtonStyle.Filled -> {
|
||||
Button(
|
||||
modifier = modifier.height(48.dp),
|
||||
onClick = onClick,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
MaterialButtonStyle.Tonal -> {
|
||||
FilledTonalButton(
|
||||
modifier = modifier.height(48.dp),
|
||||
onClick = onClick,
|
||||
content = content,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(containerColor = surfaceColor)
|
||||
)
|
||||
}
|
||||
|
||||
val interactiveHighlightShader = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
RuntimeShader(
|
||||
"""
|
||||
MaterialButtonStyle.Outlined -> {
|
||||
OutlinedButton(
|
||||
modifier = modifier.height(48.dp),
|
||||
onClick = onClick,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
MaterialButtonStyle.Normal -> {
|
||||
TextButton(
|
||||
modifier = modifier.height(48.dp),
|
||||
onClick = onClick,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DesignSystem.Apple -> {
|
||||
val isInteractive = enabled && isInteractive
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val progressAnimation = remember { Animatable(0f) }
|
||||
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
var isPressed by remember { mutableStateOf(false) }
|
||||
|
||||
val interactiveHighlightShader = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
RuntimeShader(
|
||||
"""
|
||||
uniform float2 size;
|
||||
layout(color) uniform half4 color;
|
||||
uniform float radius;
|
||||
@@ -103,227 +155,250 @@ half4 main(float2 coord) {
|
||||
float intensity = smoothstep(radius, radius * 0.5, dist);
|
||||
return color * intensity;
|
||||
}"""
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier
|
||||
.then(
|
||||
if (!isInteractive) {
|
||||
Modifier.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { RoundedCornerShape(28f.dp) },
|
||||
effects = {
|
||||
blur(16f.dp.toPx())
|
||||
},
|
||||
layerBlock = null,
|
||||
onDrawSurface = {
|
||||
if (tint.isSpecified) {
|
||||
drawRect(tint, blendMode = BlendMode.Hue)
|
||||
drawRect(tint.copy(alpha = 0.75f))
|
||||
} else {
|
||||
drawRect(Color.White.copy(0.1f))
|
||||
}
|
||||
if (surfaceColor.isSpecified && enabled) {
|
||||
val color = if (isPressed) {
|
||||
Color(
|
||||
red = surfaceColor.red * 0.5f,
|
||||
green = surfaceColor.green * 0.5f,
|
||||
blue = surfaceColor.blue * 0.5f,
|
||||
alpha = surfaceColor.alpha
|
||||
)
|
||||
} else {
|
||||
surfaceColor
|
||||
}
|
||||
drawRect(color)
|
||||
} else {
|
||||
if (isPressed && enabled) {
|
||||
drawRect(Color.Black.copy(alpha = 0.4f))
|
||||
drawRect(Color.White.copy(alpha = 0.2f))
|
||||
}
|
||||
}
|
||||
},
|
||||
onDrawFront = null,
|
||||
highlight = { Highlight.Ambient.copy(alpha = 0f) }
|
||||
)
|
||||
} else {
|
||||
Modifier.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { RoundedCornerShape(28f.dp) },
|
||||
effects = {
|
||||
vibrancy()
|
||||
blur(2f.dp.toPx())
|
||||
lens(
|
||||
refractionHeight = 12f.dp.toPx(),
|
||||
refractionAmount = 24f.dp.toPx(),
|
||||
depthEffect = true,
|
||||
chromaticAberration = true
|
||||
)
|
||||
},
|
||||
layerBlock = {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
|
||||
val progress = progressAnimation.value
|
||||
val scale = lerp(1f, 1f + maxScale, progress)
|
||||
|
||||
val maxOffset = size.minDimension
|
||||
val initialDerivative = 0.05f
|
||||
val offset = offsetAnimation.value
|
||||
translationX =
|
||||
maxOffset * tanh(initialDerivative * offset.x / maxOffset)
|
||||
translationY =
|
||||
maxOffset * tanh(initialDerivative * offset.y / maxOffset)
|
||||
|
||||
val maxDragScale = 0.1f
|
||||
val offsetAngle = atan2(offset.y, offset.x)
|
||||
scaleX =
|
||||
scale +
|
||||
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
||||
(width / height).fastCoerceAtMost(1f)
|
||||
scaleY =
|
||||
scale +
|
||||
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
||||
(height / width).fastCoerceAtMost(1f)
|
||||
},
|
||||
onDrawSurface = {
|
||||
if (tint.isSpecified) {
|
||||
drawRect(tint, blendMode = BlendMode.Hue)
|
||||
drawRect(tint.copy(alpha = 0.75f))
|
||||
} else {
|
||||
drawRect(Color.White.copy(0.1f))
|
||||
}
|
||||
if (surfaceColor.isSpecified) {
|
||||
val color = if (!isInteractive && isPressed) {
|
||||
Color(
|
||||
red = surfaceColor.red * 0.5f,
|
||||
green = surfaceColor.green * 0.5f,
|
||||
blue = surfaceColor.blue * 0.5f,
|
||||
alpha = surfaceColor.alpha
|
||||
)
|
||||
} else {
|
||||
surfaceColor
|
||||
}
|
||||
drawRect(color)
|
||||
}
|
||||
},
|
||||
onDrawFront = {
|
||||
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
|
||||
if (progress > 0f) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
|
||||
drawRect(
|
||||
Color.White.copy(0.1f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
interactiveHighlightShader.apply {
|
||||
val offset =
|
||||
pressStartPosition + offsetAnimation.value
|
||||
setFloatUniform("size", size.width, size.height)
|
||||
setColorUniform(
|
||||
"color",
|
||||
Color.White.copy(0.15f * progress).toArgb()
|
||||
)
|
||||
setFloatUniform("radius", size.maxDimension)
|
||||
setFloatUniform(
|
||||
"offset",
|
||||
offset.x.fastCoerceIn(0f, size.width),
|
||||
offset.y.fastCoerceIn(0f, size.height)
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
ShaderBrush(interactiveHighlightShader),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
} else {
|
||||
drawRect(
|
||||
Color.White.copy(0.25f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
.clickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
role = Role.Button,
|
||||
onClick = {
|
||||
if (enabled) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (isInteractive) {
|
||||
Modifier.pointerInput(scope) {
|
||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||
val offsetAnimationSpec =
|
||||
spring(1f, 300f, Offset.VisibilityThreshold)
|
||||
val onDragStop: () -> Unit = {
|
||||
if (enabled) {
|
||||
scope.launch {
|
||||
launch {
|
||||
haptics.performHapticFeedback(
|
||||
HapticFeedbackType.Reject
|
||||
)
|
||||
}
|
||||
launch {
|
||||
progressAnimation.animateTo(
|
||||
0f,
|
||||
progressAnimationSpec
|
||||
)
|
||||
}
|
||||
launch {
|
||||
offsetAnimation.animateTo(
|
||||
Offset.Zero,
|
||||
offsetAnimationSpec
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
inspectDragGestures(
|
||||
onDragStart = { down ->
|
||||
pressStartPosition = down.position
|
||||
if (enabled) {
|
||||
scope.launch {
|
||||
launch {
|
||||
haptics.performHapticFeedback(
|
||||
HapticFeedbackType.SegmentFrequentTick
|
||||
)
|
||||
}
|
||||
launch {
|
||||
progressAnimation.animateTo(
|
||||
1f,
|
||||
progressAnimationSpec
|
||||
)
|
||||
}
|
||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
onDragStop()
|
||||
},
|
||||
onDragCancel = onDragStop
|
||||
) { _, dragAmount ->
|
||||
if (enabled) {
|
||||
scope.launch {
|
||||
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
||||
HapticFeedbackType.SegmentFrequentTick
|
||||
)
|
||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed = true
|
||||
tryAwaitRelease()
|
||||
isPressed = false
|
||||
},
|
||||
onTap = {
|
||||
if (enabled) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
.height(48f.dp)
|
||||
.padding(horizontal = 16f.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8f.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier
|
||||
.then(
|
||||
if (!isInteractive) {
|
||||
Modifier.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { RoundedCornerShape(28f.dp) },
|
||||
effects = {
|
||||
blur(16f.dp.toPx())
|
||||
},
|
||||
layerBlock = null,
|
||||
onDrawSurface = {
|
||||
if (tint.isSpecified) {
|
||||
drawRect(tint, blendMode = BlendMode.Hue)
|
||||
drawRect(tint.copy(alpha = 0.75f))
|
||||
} else {
|
||||
drawRect(Color.White.copy(0.1f))
|
||||
}
|
||||
if (surfaceColor.isSpecified && enabled) {
|
||||
val color = if (isPressed) {
|
||||
Color(
|
||||
red = surfaceColor.red * 0.5f,
|
||||
green = surfaceColor.green * 0.5f,
|
||||
blue = surfaceColor.blue * 0.5f,
|
||||
alpha = surfaceColor.alpha
|
||||
)
|
||||
} else {
|
||||
surfaceColor
|
||||
}
|
||||
drawRect(color)
|
||||
} else {
|
||||
if (isPressed) {
|
||||
drawRect(Color.Black.copy(alpha = 0.4f))
|
||||
drawRect(Color.White.copy(alpha = 0.2f))
|
||||
}
|
||||
}
|
||||
},
|
||||
onDrawFront = null,
|
||||
highlight = { Highlight.Ambient.copy(alpha = 0f) }
|
||||
)
|
||||
} else {
|
||||
Modifier.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { RoundedCornerShape(28f.dp) },
|
||||
effects = {
|
||||
vibrancy()
|
||||
blur(2f.dp.toPx())
|
||||
lens(
|
||||
refractionHeight = 12f.dp.toPx(),
|
||||
refractionAmount = 24f.dp.toPx(),
|
||||
depthEffect = true,
|
||||
chromaticAberration = true
|
||||
)
|
||||
},
|
||||
layerBlock = {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
|
||||
val progress = progressAnimation.value
|
||||
val scale = lerp(1f, 1f + maxScale, progress)
|
||||
|
||||
val maxOffset = size.minDimension
|
||||
val initialDerivative = 0.05f
|
||||
val offset = offsetAnimation.value
|
||||
translationX =
|
||||
maxOffset * tanh(initialDerivative * offset.x / maxOffset)
|
||||
translationY =
|
||||
maxOffset * tanh(initialDerivative * offset.y / maxOffset)
|
||||
|
||||
val maxDragScale = 0.1f
|
||||
val offsetAngle = atan2(offset.y, offset.x)
|
||||
scaleX =
|
||||
scale +
|
||||
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
||||
(width / height).fastCoerceAtMost(1f)
|
||||
scaleY =
|
||||
scale +
|
||||
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
||||
(height / width).fastCoerceAtMost(1f)
|
||||
},
|
||||
onDrawSurface = {
|
||||
if (tint.isSpecified) {
|
||||
drawRect(tint, blendMode = BlendMode.Hue)
|
||||
drawRect(tint.copy(alpha = 0.75f))
|
||||
} else {
|
||||
drawRect(Color.White.copy(0.1f))
|
||||
}
|
||||
if (surfaceColor.isSpecified) {
|
||||
val color = if (!isInteractive && isPressed) {
|
||||
Color(
|
||||
red = surfaceColor.red * 0.5f,
|
||||
green = surfaceColor.green * 0.5f,
|
||||
blue = surfaceColor.blue * 0.5f,
|
||||
alpha = surfaceColor.alpha
|
||||
)
|
||||
} else {
|
||||
surfaceColor
|
||||
}
|
||||
drawRect(color)
|
||||
}
|
||||
},
|
||||
onDrawFront = {
|
||||
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
|
||||
if (progress > 0f) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
|
||||
drawRect(
|
||||
Color.White.copy(0.1f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
interactiveHighlightShader.apply {
|
||||
val offset = pressStartPosition + offsetAnimation.value
|
||||
setFloatUniform("size", size.width, size.height)
|
||||
setColorUniform(
|
||||
"color",
|
||||
Color.White.copy(0.15f * progress).toArgb()
|
||||
)
|
||||
setFloatUniform("radius", size.maxDimension)
|
||||
setFloatUniform(
|
||||
"offset",
|
||||
offset.x.fastCoerceIn(0f, size.width),
|
||||
offset.y.fastCoerceIn(0f, size.height)
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
ShaderBrush(interactiveHighlightShader),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
} else {
|
||||
drawRect(
|
||||
Color.White.copy(0.25f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
.clickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
role = Role.Button,
|
||||
onClick = {
|
||||
if (enabled) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (isInteractive) {
|
||||
Modifier.pointerInput(scope) {
|
||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||
val onDragStop: () -> Unit = {
|
||||
scope.launch {
|
||||
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch {
|
||||
offsetAnimation.animateTo(
|
||||
Offset.Zero,
|
||||
offsetAnimationSpec
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
inspectDragGestures(
|
||||
onDragStart = { down ->
|
||||
pressStartPosition = down.position
|
||||
scope.launch {
|
||||
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
||||
launch {
|
||||
progressAnimation.animateTo(
|
||||
1f,
|
||||
progressAnimationSpec
|
||||
)
|
||||
}
|
||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
onDragStop()
|
||||
},
|
||||
onDragCancel = onDragStop
|
||||
) { _, dragAmount ->
|
||||
scope.launch {
|
||||
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
||||
HapticFeedbackType.SegmentFrequentTick
|
||||
)
|
||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed = true
|
||||
tryAwaitRelease()
|
||||
isPressed = false
|
||||
},
|
||||
onTap = {
|
||||
if (enabled) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
.height(48f.dp)
|
||||
.padding(horizontal = 16f.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8f.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
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
|
||||
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 me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import dev.chrisbanes.haze.HazeEffectScope
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun StyledDropdown(
|
||||
expanded: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
options: List<String>,
|
||||
selectedOption: String,
|
||||
touchOffset: Offset?,
|
||||
boxPosition: Offset,
|
||||
onOptionSelected: (String) -> Unit,
|
||||
externalHoveredIndex: Int? = null,
|
||||
externalDragActive: Boolean = false,
|
||||
hazeState: HazeState,
|
||||
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier
|
||||
) {
|
||||
if (expanded) {
|
||||
val relativeOffset = touchOffset?.let { it - boxPosition } ?: Offset.Zero
|
||||
Popup(
|
||||
offset = IntOffset(relativeOffset.x.toInt(), relativeOffset.y.toInt()),
|
||||
onDismissRequest = onDismissRequest
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut()
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.padding(8.dp)
|
||||
.width(300.dp)
|
||||
.background(Color.Transparent)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
var hoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||
val itemHeight = 48.dp
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
|
||||
var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
|
||||
var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
|
||||
|
||||
LaunchedEffect(externalHoveredIndex, externalDragActive) {
|
||||
if (externalDragActive) {
|
||||
hoveredIndex = externalHoveredIndex
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned { coordinates ->
|
||||
popupSize = coordinates.size
|
||||
}
|
||||
.pointerInput(popupSize) {
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
hoveredIndex = (offset.y / itemHeight.toPx()).toInt()
|
||||
lastDragPosition = offset
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val y = change.position.y
|
||||
val newHoveredIndex = (y / itemHeight.toPx()).toInt()
|
||||
if (newHoveredIndex != hoveredIndex) {
|
||||
scope.launch { haptics.performHapticFeedback(
|
||||
HapticFeedbackType.SegmentTick) }
|
||||
}
|
||||
hoveredIndex = newHoveredIndex
|
||||
lastDragPosition = change.position
|
||||
},
|
||||
onDragEnd = {
|
||||
val pos = lastDragPosition
|
||||
val withinBounds = pos != null &&
|
||||
pos.x >= 0f && pos.y >= 0f &&
|
||||
pos.x <= popupSize.width.toFloat() && pos.y <= popupSize.height.toFloat()
|
||||
|
||||
if (withinBounds) {
|
||||
hoveredIndex?.let { idx ->
|
||||
if (idx in options.indices) {
|
||||
scope.launch { haptics.performHapticFeedback(
|
||||
HapticFeedbackType.GestureEnd) }
|
||||
onOptionSelected(options[idx])
|
||||
}
|
||||
}
|
||||
onDismissRequest()
|
||||
} else {
|
||||
hoveredIndex = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
options.forEachIndexed { index, text ->
|
||||
val isHovered =
|
||||
if (externalDragActive && externalHoveredIndex != null) {
|
||||
index == externalHoveredIndex
|
||||
} else {
|
||||
index == hoveredIndex
|
||||
}
|
||||
val isSystemInDarkTheme = isSystemInDarkTheme()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(itemHeight)
|
||||
.background(
|
||||
Color.Transparent
|
||||
)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
|
||||
onOptionSelected(text)
|
||||
onDismissRequest()
|
||||
}
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = CupertinoMaterials.regular(),
|
||||
block = fun HazeEffectScope.() {
|
||||
alpha = 1f
|
||||
backgroundColor = if (isSystemInDarkTheme) {
|
||||
Color(0xB02C2C2E)
|
||||
} else {
|
||||
Color(0xB0FFFFFF)
|
||||
}
|
||||
tints = if (isHovered) listOf(
|
||||
HazeTint(
|
||||
color = if (isSystemInDarkTheme) Color(0x338A8A8A) else Color(0x40D9D9D9)
|
||||
)
|
||||
) else listOf()
|
||||
})
|
||||
.padding(horizontal = 12.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.75f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Checkbox(
|
||||
checked = text == selectedOption,
|
||||
onCheckedChange = { onOptionSelected(text) },
|
||||
colors = CheckboxDefaults.colors().copy(
|
||||
checkedCheckmarkColor = Color(0xFF007AFF),
|
||||
uncheckedCheckmarkColor = Color.Transparent,
|
||||
checkedBoxColor = Color.Transparent,
|
||||
uncheckedBoxColor = Color.Transparent,
|
||||
checkedBorderColor = Color.Transparent,
|
||||
uncheckedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent,
|
||||
disabledCheckedBoxColor = Color.Transparent,
|
||||
disabledUncheckedBoxColor = Color.Transparent,
|
||||
disabledUncheckedBorderColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (index != options.lastIndex) {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(start = 12.dp, end = 0.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,16 +18,26 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
import android.graphics.RuntimeShader
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -36,6 +46,7 @@ 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.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
@@ -44,6 +55,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
@@ -58,6 +70,8 @@ import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.fastCoerceAtMost
|
||||
@@ -68,9 +82,11 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.lens
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.kyant.backdrop.shadow.Shadow
|
||||
import com.kyant.backdrop.shadow.InnerShadow
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.utils.inspectDragGestures
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
@@ -86,25 +102,92 @@ fun StyledIconButton(
|
||||
surfaceColor: Color = Color.Unspecified,
|
||||
backdrop: LayerBackdrop = rememberLayerBackdrop(),
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true
|
||||
enabled: Boolean = true,
|
||||
materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Normal
|
||||
) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val scope = rememberCoroutineScope()
|
||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||
val progressAnimation = remember { Animatable(0f) }
|
||||
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
val innerShadowLayer = rememberGraphicsLayer().apply {
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
when (LocalDesignSystem.current) {
|
||||
DesignSystem.Material -> {
|
||||
when (materialButtonStyle) {
|
||||
MaterialButtonStyle.Tonal -> {
|
||||
FilledTonalIconButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
Text(
|
||||
text = icon,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
MaterialButtonStyle.Filled -> {
|
||||
FilledIconButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
Text(
|
||||
text = icon,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
MaterialButtonStyle.Outlined -> {
|
||||
OutlinedIconButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
Text(
|
||||
text = icon,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
MaterialButtonStyle.Normal -> {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
Text(
|
||||
text = icon,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DesignSystem.Apple -> {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val scope = rememberCoroutineScope()
|
||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||
val progressAnimation = remember { Animatable(0f) }
|
||||
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
val innerShadowLayer = rememberGraphicsLayer().apply {
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
|
||||
val interactiveHighlightShader = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
RuntimeShader(
|
||||
"""
|
||||
val interactiveHighlightShader = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
RuntimeShader(
|
||||
"""
|
||||
uniform float2 size;
|
||||
layout(color) uniform half4 color;
|
||||
uniform float radius;
|
||||
@@ -116,191 +199,239 @@ half4 main(float2 coord) {
|
||||
float intensity = smoothstep(radius, radius * 0.5, dist);
|
||||
return color * intensity;
|
||||
}"""
|
||||
)
|
||||
} else {
|
||||
null
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (enabled) {
|
||||
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
|
||||
onClick()
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(56.dp),
|
||||
modifier = modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { RoundedCornerShape(56.dp) },
|
||||
highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) },
|
||||
innerShadow = {
|
||||
if (isDarkTheme) {
|
||||
InnerShadow(
|
||||
radius = 0.5.dp,
|
||||
offset = DpOffset(1.dp, 1.dp),
|
||||
color = Color.White.copy(0.6f),
|
||||
)
|
||||
} else InnerShadow()
|
||||
},
|
||||
layerBlock = {
|
||||
if (!enabled) return@drawBackdrop
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
|
||||
val progress = progressAnimation.value
|
||||
val scale = lerp(1f, 1.5f, progress)
|
||||
|
||||
val maxOffset = size.minDimension
|
||||
val initialDerivative = 0.05f
|
||||
val offset = offsetAnimation.value
|
||||
translationX =
|
||||
maxOffset * tanh(initialDerivative * offset.x / maxOffset)
|
||||
translationY =
|
||||
maxOffset * tanh(initialDerivative * offset.y / maxOffset)
|
||||
|
||||
val maxDragScale = 0.1f
|
||||
val offsetAngle = atan2(offset.y, offset.x)
|
||||
scaleX =
|
||||
scale +
|
||||
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
||||
(width / height).fastCoerceAtMost(1f)
|
||||
scaleY =
|
||||
scale +
|
||||
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
||||
(height / width).fastCoerceAtMost(1f)
|
||||
},
|
||||
onDrawSurface = {
|
||||
if (!enabled) {
|
||||
drawRect(
|
||||
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(0.5f)
|
||||
)
|
||||
return@drawBackdrop
|
||||
}
|
||||
val progress = progressAnimation.value.coerceIn(0f, 1f)
|
||||
|
||||
val shape = RoundedCornerShape(56.dp)
|
||||
val outline = shape.createOutline(size, layoutDirection, this)
|
||||
val innerShadowOffset = 4f.dp.toPx()
|
||||
val innerShadowBlurRadius = 4f.dp.toPx()
|
||||
|
||||
innerShadowLayer.alpha = progress
|
||||
innerShadowLayer.renderEffect =
|
||||
BlurEffect(
|
||||
innerShadowBlurRadius,
|
||||
innerShadowBlurRadius,
|
||||
TileMode.Decal
|
||||
)
|
||||
innerShadowLayer.record {
|
||||
drawOutline(outline, Color.Black.copy(0.2f))
|
||||
translate(0f, innerShadowOffset) {
|
||||
drawOutline(
|
||||
outline,
|
||||
Color.Transparent,
|
||||
blendMode = BlendMode.Clear
|
||||
)
|
||||
}
|
||||
}
|
||||
drawLayer(innerShadowLayer)
|
||||
|
||||
if (surfaceColor.isSpecified) {
|
||||
drawRect(surfaceColor)
|
||||
}
|
||||
|
||||
if (!isDarkTheme) {
|
||||
drawOutline(
|
||||
outline = outline,
|
||||
color = Color.Black.copy(0.4f),
|
||||
style = Stroke(1f),
|
||||
)
|
||||
}
|
||||
|
||||
drawRect(
|
||||
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(
|
||||
progress.coerceIn(
|
||||
0.15f,
|
||||
0.35f
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
onDrawFront = {
|
||||
if (!enabled) return@drawBackdrop
|
||||
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
|
||||
if (progress > 0f) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
|
||||
drawRect(
|
||||
Color.White.copy(0.1f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
interactiveHighlightShader.apply {
|
||||
val offset = pressStartPosition + offsetAnimation.value
|
||||
setFloatUniform("size", size.width, size.height)
|
||||
setColorUniform(
|
||||
"color",
|
||||
Color.White.copy(0.15f * progress).toArgb()
|
||||
)
|
||||
setFloatUniform("radius", size.maxDimension)
|
||||
setFloatUniform(
|
||||
"offset",
|
||||
offset.x.fastCoerceIn(0f, size.width),
|
||||
offset.y.fastCoerceIn(0f, size.height)
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
ShaderBrush(interactiveHighlightShader),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
} else {
|
||||
drawRect(
|
||||
Color.White.copy(0.25f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
effects = {
|
||||
lens(
|
||||
refractionHeight = 6f.dp.toPx(),
|
||||
refractionAmount = size.height / 2f,
|
||||
depthEffect = true,
|
||||
chromaticAberration = true
|
||||
)
|
||||
},
|
||||
)
|
||||
.pointerInput(scope) {
|
||||
val onDragStop: () -> Unit = {
|
||||
if (enabled) {
|
||||
scope.launch {
|
||||
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
||||
launch {
|
||||
progressAnimation.animateTo(
|
||||
0f,
|
||||
progressAnimationSpec
|
||||
)
|
||||
}
|
||||
launch {
|
||||
offsetAnimation.animateTo(
|
||||
Offset.Zero,
|
||||
offsetAnimationSpec
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
inspectDragGestures(
|
||||
onDragStart = { down ->
|
||||
if (enabled) {
|
||||
pressStartPosition = down.position
|
||||
scope.launch {
|
||||
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
||||
launch {
|
||||
progressAnimation.animateTo(
|
||||
1f,
|
||||
progressAnimationSpec
|
||||
)
|
||||
}
|
||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragEnd = { onDragStop() },
|
||||
onDragCancel = onDragStop
|
||||
) { _, dragAmount ->
|
||||
scope.launch {
|
||||
if (enabled) {
|
||||
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
||||
HapticFeedbackType.SegmentFrequentTick
|
||||
)
|
||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.size(with(density) { 48.sp.toDp() }),
|
||||
) {
|
||||
Text(
|
||||
text = icon,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = if (iconTint.isSpecified) iconTint else if (darkMode) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (enabled) {
|
||||
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
|
||||
onClick()
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(56.dp),
|
||||
modifier = modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { RoundedCornerShape(56.dp) },
|
||||
highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) },
|
||||
shadow = {
|
||||
Shadow(
|
||||
radius = 12f.dp,
|
||||
color = Color.Black.copy(if (isDarkTheme) 0.08f else 0.2f)
|
||||
)
|
||||
},
|
||||
layerBlock = {
|
||||
if (!enabled) return@drawBackdrop
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
}
|
||||
|
||||
val progress = progressAnimation.value
|
||||
val scale = lerp(1f, 1.5f, progress)
|
||||
|
||||
val maxOffset = size.minDimension
|
||||
val initialDerivative = 0.05f
|
||||
val offset = offsetAnimation.value
|
||||
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
|
||||
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset)
|
||||
|
||||
val maxDragScale = 0.1f
|
||||
val offsetAngle = atan2(offset.y, offset.x)
|
||||
scaleX =
|
||||
scale +
|
||||
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
||||
(width / height).fastCoerceAtMost(1f)
|
||||
scaleY =
|
||||
scale +
|
||||
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
||||
(height / width).fastCoerceAtMost(1f)
|
||||
},
|
||||
onDrawSurface = {
|
||||
if (!enabled) {
|
||||
drawRect(
|
||||
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(0.5f)
|
||||
)
|
||||
return@drawBackdrop
|
||||
}
|
||||
val progress = progressAnimation.value.coerceIn(0f, 1f)
|
||||
|
||||
val shape = RoundedCornerShape(56.dp)
|
||||
val outline = shape.createOutline(size, layoutDirection, this)
|
||||
val innerShadowOffset = 4f.dp.toPx()
|
||||
val innerShadowBlurRadius = 4f.dp.toPx()
|
||||
|
||||
innerShadowLayer.alpha = progress
|
||||
innerShadowLayer.renderEffect =
|
||||
BlurEffect(
|
||||
innerShadowBlurRadius,
|
||||
innerShadowBlurRadius,
|
||||
TileMode.Decal
|
||||
)
|
||||
innerShadowLayer.record {
|
||||
drawOutline(outline, Color.Black.copy(0.2f))
|
||||
translate(0f, innerShadowOffset) {
|
||||
drawOutline(
|
||||
outline,
|
||||
Color.Transparent,
|
||||
blendMode = BlendMode.Clear
|
||||
)
|
||||
}
|
||||
}
|
||||
drawLayer(innerShadowLayer)
|
||||
|
||||
if (surfaceColor.isSpecified) {
|
||||
drawRect(surfaceColor)
|
||||
}
|
||||
|
||||
drawRect(
|
||||
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(
|
||||
progress.coerceIn(
|
||||
0.15f,
|
||||
0.35f
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
onDrawFront = {
|
||||
if (!enabled) return@drawBackdrop
|
||||
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
|
||||
if (progress > 0f) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
|
||||
drawRect(
|
||||
Color.White.copy(0.1f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
interactiveHighlightShader.apply {
|
||||
val offset = pressStartPosition + offsetAnimation.value
|
||||
setFloatUniform("size", size.width, size.height)
|
||||
setColorUniform(
|
||||
"color",
|
||||
Color.White.copy(0.15f * progress).toArgb()
|
||||
)
|
||||
setFloatUniform("radius", size.maxDimension)
|
||||
setFloatUniform(
|
||||
"offset",
|
||||
offset.x.fastCoerceIn(0f, size.width),
|
||||
offset.y.fastCoerceIn(0f, size.height)
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
ShaderBrush(interactiveHighlightShader),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
} else {
|
||||
drawRect(
|
||||
Color.White.copy(0.25f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
effects = {
|
||||
lens(
|
||||
refractionHeight = 6f.dp.toPx(),
|
||||
refractionAmount = size.height / 2f,
|
||||
depthEffect = true,
|
||||
chromaticAberration = true
|
||||
)
|
||||
},
|
||||
)
|
||||
.pointerInput(scope) {
|
||||
val onDragStop: () -> Unit = {
|
||||
if (enabled) {
|
||||
scope.launch {
|
||||
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
||||
}
|
||||
}
|
||||
}
|
||||
inspectDragGestures(
|
||||
onDragStart = { down ->
|
||||
if (enabled) {
|
||||
pressStartPosition = down.position
|
||||
scope.launch {
|
||||
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragEnd = { onDragStop() },
|
||||
onDragCancel = onDragStop
|
||||
) { _, dragAmount ->
|
||||
scope.launch {
|
||||
if (enabled) {
|
||||
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
||||
HapticFeedbackType.SegmentFrequentTick
|
||||
)
|
||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.size(with(density) { 48.sp.toDp() }),
|
||||
) {
|
||||
Text(
|
||||
text = icon,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = if (iconTint.isSpecified) iconTint else if (darkMode) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
@Preview(uiMode = UI_MODE_NIGHT_NO, name = "Light")
|
||||
@Preview(uiMode = UI_MODE_NIGHT_YES, name = "Dark")
|
||||
@Composable
|
||||
fun StyledIconButtonPreview() {
|
||||
Box(modifier = Modifier
|
||||
.height(120.dp)
|
||||
.width(200.dp)
|
||||
.background(
|
||||
if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
|
||||
RoundedCornerShape(28.dp)
|
||||
), contentAlignment = Alignment.Center) {
|
||||
StyledIconButton(
|
||||
icon = "",
|
||||
onClick = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import android.R.attr.singleLine
|
||||
import androidx.compose.animation.core.animateDp
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
@@ -21,7 +20,9 @@ import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -39,6 +40,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
|
||||
|
||||
@Composable
|
||||
@@ -46,109 +49,130 @@ fun StyledInputField(
|
||||
inputState: TextFieldState,
|
||||
focusRequester: FocusRequester,
|
||||
placeholder: String = "",
|
||||
singleLine: Boolean = true
|
||||
){
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val minHeight = if (singleLine) 58.dp else 120.dp
|
||||
val verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top
|
||||
val hasText = inputState.text.isNotEmpty()
|
||||
val density = LocalDensity.current
|
||||
val spacerHeight by animateDpAsState(
|
||||
targetValue = if (hasText) with(density) { 32.sp.toDp() } else 0.dp,
|
||||
label = "labelSpacer"
|
||||
)
|
||||
singleLine: Boolean = true,
|
||||
forceApple: Boolean = false
|
||||
) {
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material && !forceApple
|
||||
|
||||
val transition = updateTransition(hasText, label = "floating")
|
||||
val yOffset by transition.animateDp(label = "y") {
|
||||
if (it) with (density) { (-48).sp.toDp() } else 0.dp
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = verticalAlignment,
|
||||
if(m3eEnabled) {
|
||||
TextField(
|
||||
state = inputState,
|
||||
placeholder = {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
},
|
||||
lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = minHeight)
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
) {
|
||||
BasicTextField(
|
||||
state = inputState,
|
||||
lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
cursorBrush = SolidColor(textColor),
|
||||
decorator = { innerTextField ->
|
||||
Row(
|
||||
modifier = Modifier.padding(top = if (singleLine) 0.dp else 16.dp),
|
||||
verticalAlignment = verticalAlignment,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f),
|
||||
contentAlignment = if (singleLine) Alignment.CenterStart else Alignment.TopStart
|
||||
) {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.8f)
|
||||
),
|
||||
modifier = Modifier
|
||||
.offset(y = yOffset)
|
||||
)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
else {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val minHeight = if (singleLine) 58.dp else 120.dp
|
||||
val verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top
|
||||
val hasText = inputState.text.isNotEmpty()
|
||||
val density = LocalDensity.current
|
||||
val spacerHeight by animateDpAsState(
|
||||
targetValue = if (hasText) with(density) { 32.sp.toDp() } else 0.dp,
|
||||
label = "labelSpacer"
|
||||
)
|
||||
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
if (singleLine && !inputState.text.isEmpty()) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
inputState.clearText()
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||
alpha = 0.6f
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
val transition = updateTransition(hasText, label = "floating")
|
||||
val yOffset by transition.animateDp(label = "y") {
|
||||
if (it) with(density) { (-48).sp.toDp() } else 0.dp
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = verticalAlignment,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
.heightIn(min = minHeight)
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
) {
|
||||
BasicTextField(
|
||||
state = inputState,
|
||||
lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
cursorBrush = SolidColor(textColor),
|
||||
decorator = { innerTextField ->
|
||||
Row(
|
||||
modifier = Modifier.padding(top = if (singleLine) 0.dp else 16.dp),
|
||||
verticalAlignment = verticalAlignment,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f),
|
||||
contentAlignment = if (singleLine) Alignment.CenterStart else Alignment.TopStart
|
||||
) {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.8f)
|
||||
),
|
||||
modifier = Modifier
|
||||
.offset(y = yOffset)
|
||||
)
|
||||
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
if (singleLine && !inputState.text.isEmpty()) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
inputState.clearText()
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||
alpha = 0.6f
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.sectionHeader
|
||||
|
||||
@Composable
|
||||
fun StyledList(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
description: String? = null,
|
||||
content: @Composable StyledListScope.() -> Unit
|
||||
) {
|
||||
val scope = StyledListScope()
|
||||
scope.content()
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
|
||||
Column (modifier = modifier) {
|
||||
title?.let {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 4.dp, bottom = if (m3eEnabled) 12.dp else 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = it,
|
||||
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
|
||||
style = MaterialTheme.typography.labelSmallEmphasized
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface, RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp))
|
||||
.clip(RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp))
|
||||
) {
|
||||
if (m3eEnabled && description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(0.8f),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
scope.items.forEachIndexed { index, item ->
|
||||
item(index, scope.items.size)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(if(m3eEnabled) 4.dp else 0.dp))
|
||||
}
|
||||
}
|
||||
if (!m3eEnabled && description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmallEmphasized,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(0.6f),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
class StyledListScope {
|
||||
internal val items =
|
||||
mutableListOf<@Composable (Int, Int) -> Unit>()
|
||||
|
||||
fun item(
|
||||
content: @Composable (index: Int, count: Int) -> Unit
|
||||
) {
|
||||
items += content
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, wallpaper = GREEN_DOMINATED_EXAMPLE, uiMode = UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun StyledListDemo() {
|
||||
LibrePodsTheme(
|
||||
m3eEnabled = true
|
||||
) {
|
||||
val backgroundC = MaterialTheme.colorScheme.background
|
||||
StyledScaffold(
|
||||
title = "${backgroundC.red}, ${backgroundC.green}, ${backgroundC.blue}"
|
||||
) {
|
||||
Column (
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(56.dp))
|
||||
StyledList(
|
||||
title = "hello"
|
||||
) {
|
||||
for (i in 0..2) {
|
||||
StyledListItem(
|
||||
name = i.toString(),
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
val checked = remember { mutableStateOf(false) }
|
||||
StyledToggle(
|
||||
label = "Test",
|
||||
description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit mollit anim id est laborum.",
|
||||
checked = checked.value,
|
||||
onCheckedChange = { checked.value = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
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
|
||||
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 me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.sectionHeader
|
||||
|
||||
@Composable
|
||||
fun StyledListItem(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
name: String,
|
||||
onClick: (() -> Unit)?,
|
||||
description: String? = null,
|
||||
height: Dp = 58.dp,
|
||||
enabled: Boolean = true,
|
||||
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
|
||||
leadingContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: (@Composable () -> Unit)? = null
|
||||
) {
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
Column {
|
||||
title?.let {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 4.dp, bottom = if (m3eEnabled) 8.dp else 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = it,
|
||||
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
|
||||
style = MaterialTheme.typography.labelSmallEmphasized
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.background(
|
||||
if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface,
|
||||
RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp))
|
||||
) {
|
||||
StyledListItemContent(
|
||||
name = name,
|
||||
onClick = onClick,
|
||||
description = description,
|
||||
height = height,
|
||||
enabled = enabled,
|
||||
index = 0,
|
||||
count = 1,
|
||||
orientation = orientation,
|
||||
leadingContent = leadingContent,
|
||||
trailingContent = trailingContent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StyledListScope.StyledListItem(
|
||||
modifier: Modifier = Modifier,
|
||||
name: String,
|
||||
onClick: (() -> Unit)? = null,
|
||||
description: String? = null,
|
||||
enabled: Boolean = onClick != null,
|
||||
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
|
||||
selected: Boolean? = null,
|
||||
leadingContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: (@Composable () -> Unit)? = null
|
||||
) {
|
||||
item { index, count ->
|
||||
StyledListItemContent(
|
||||
name = name,
|
||||
onClick = onClick,
|
||||
description = description,
|
||||
enabled = enabled,
|
||||
index = index,
|
||||
count = count,
|
||||
orientation = orientation,
|
||||
modifier = modifier,
|
||||
selected = selected,
|
||||
leadingContent = leadingContent,
|
||||
trailingContent = trailingContent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class ListItemOrientation{
|
||||
Horizontal,
|
||||
Vertical
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun StyledListItemContent(
|
||||
modifier: Modifier = Modifier,
|
||||
name: String,
|
||||
onClick: (() -> Unit)?,
|
||||
description: String? = null,
|
||||
height: Dp = 58.dp,
|
||||
enabled: Boolean = true,
|
||||
index: Int,
|
||||
count: Int,
|
||||
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
|
||||
selected: Boolean? = null,
|
||||
leadingContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: (@Composable () -> Unit)? = null
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val surfaceColor = MaterialTheme.colorScheme.surface
|
||||
val surfaceDimColor = MaterialTheme.colorScheme.surfaceDim
|
||||
var backgroundColor by remember { mutableStateOf(surfaceColor) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
when (LocalDesignSystem.current) {
|
||||
DesignSystem.Apple -> {
|
||||
val trailingContentDefault: @Composable () -> Unit = {
|
||||
if (trailingContent == null) {
|
||||
if (onClick != null) {
|
||||
if (selected != null) {
|
||||
val floatAnimateState by animateFloatAsState(
|
||||
targetValue = if (selected) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = floatAnimateState),
|
||||
),
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(0.6f),
|
||||
modifier = Modifier
|
||||
.padding(start = if (description != null) 6.dp else 0.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
trailingContent()
|
||||
}
|
||||
}
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.background(
|
||||
animatedBackgroundColor,
|
||||
when {
|
||||
(index == 0 && count == 1) -> {
|
||||
RoundedCornerShape(28.dp)
|
||||
}
|
||||
|
||||
(index == 0) -> {
|
||||
RoundedCornerShape(
|
||||
topStart = 28.dp,
|
||||
topEnd = 28.dp,
|
||||
bottomStart = 0.dp,
|
||||
bottomEnd = 0.dp
|
||||
)
|
||||
}
|
||||
|
||||
(index + 1 == count) -> {
|
||||
RoundedCornerShape(
|
||||
topStart = 0.dp,
|
||||
topEnd = 0.dp,
|
||||
bottomStart = 28.dp,
|
||||
bottomEnd = 28.dp
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
RectangleShape
|
||||
}
|
||||
}
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
if (enabled) {
|
||||
backgroundColor = surfaceDimColor
|
||||
tryAwaitRelease()
|
||||
backgroundColor = surfaceColor
|
||||
}
|
||||
},
|
||||
onTap = {
|
||||
if (enabled) {
|
||||
scope.launch {
|
||||
haptics.performHapticFeedback(
|
||||
HapticFeedbackType.ContextClick
|
||||
)
|
||||
}
|
||||
onClick?.invoke()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.heightIn(min = height)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.heightIn(min = height)
|
||||
.padding(vertical = if (orientation == ListItemOrientation.Vertical) 12.dp else 0.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (leadingContent != null) {
|
||||
leadingContent()
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
}
|
||||
Column (verticalArrangement = Arrangement.Center) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (description != null && orientation == ListItemOrientation.Vertical) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f), // TODO: move to color scheme
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (orientation == ListItemOrientation.Horizontal && description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f) // TODO: move to color scheme
|
||||
)
|
||||
}
|
||||
|
||||
trailingContentDefault()
|
||||
}
|
||||
if (index+1 != count) {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(start = if (leadingContent != null) 12.dp else 0.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DesignSystem.Material -> {
|
||||
val defaultShape = when {
|
||||
count == 1 -> RoundedCornerShape(24.dp)
|
||||
|
||||
index == 0 -> RoundedCornerShape(
|
||||
topStart = 24.dp,
|
||||
topEnd = 24.dp,
|
||||
bottomStart = 8.dp,
|
||||
bottomEnd = 8.dp
|
||||
)
|
||||
|
||||
index == count - 1 -> RoundedCornerShape(
|
||||
topStart = 8.dp,
|
||||
topEnd = 8.dp,
|
||||
bottomStart = 24.dp,
|
||||
bottomEnd = 24.dp
|
||||
)
|
||||
|
||||
else -> RoundedCornerShape(8.dp)
|
||||
}
|
||||
Column {
|
||||
SegmentedListItem(
|
||||
modifier = modifier.heightIn(min = 64.dp),
|
||||
shapes = ListItemDefaults.shapes().copy(
|
||||
shape = defaultShape,
|
||||
pressedShape = RoundedCornerShape(24.dp),
|
||||
selectedShape = RoundedCornerShape(24.dp),
|
||||
hoveredShape = RoundedCornerShape(24.dp),
|
||||
),
|
||||
onClick = onClick ?: {},
|
||||
leadingContent = leadingContent,
|
||||
trailingContent = {
|
||||
if (trailingContent == null) {
|
||||
if (onClick != null) {
|
||||
if (selected == true) {
|
||||
Icon(Icons.Default.Check, contentDescription = null)
|
||||
} else if (selected == null) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Default.KeyboardArrowRight,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
trailingContent()
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
if (description != null) Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
},
|
||||
content = {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.labelMediumEmphasized,
|
||||
modifier = Modifier.padding(
|
||||
top = 4.dp,
|
||||
bottom = if (description != null) 0.dp else 4.dp
|
||||
)
|
||||
)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
colors = if (onClick == null) {
|
||||
ListItemDefaults.segmentedColors().run {
|
||||
copy(
|
||||
disabledContentColor = contentColor,
|
||||
disabledSupportingContentColor = supportingContentColor,
|
||||
disabledTrailingContentColor = trailingContentColor
|
||||
)
|
||||
}
|
||||
} else ListItemDefaults.segmentedColors(),
|
||||
enabled = onClick != null && enabled,
|
||||
selected = selected ?: false
|
||||
)
|
||||
if (index+1 != count) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,15 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -27,11 +36,23 @@ 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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -45,119 +66,230 @@ import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StyledScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
visible: Boolean = true,
|
||||
title: String,
|
||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
content: @Composable (spacerValue: Dp, hazeState: HazeState, bottomPadding: Dp) -> Unit
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val hazeState = rememberHazeState(blurEnabled = true)
|
||||
|
||||
Scaffold(
|
||||
containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
modifier = Modifier
|
||||
.then(if (!isDarkTheme) Modifier.shadow(elevation = 36.dp, shape = RoundedCornerShape(52.dp), ambientColor = Color.Black, spotColor = Color.Black) else Modifier)
|
||||
.clip(RoundedCornerShape(52.dp))
|
||||
) { paddingValues ->
|
||||
val topPadding = paddingValues.calculateTopPadding()
|
||||
val bottomPadding = paddingValues.calculateBottomPadding()
|
||||
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
|
||||
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = startPadding, end = endPadding)
|
||||
) {
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
.height(64.dp + topPadding)
|
||||
.fillMaxWidth()
|
||||
.layerBackdrop(backdrop)
|
||||
.hazeEffect(state = hazeState) {
|
||||
tints = listOf(HazeTint(color = if (isDarkTheme) Color.Black else Color.White))
|
||||
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Spacer(modifier = Modifier.height(topPadding + 12.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isDarkTheme) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.zIndex(3f)
|
||||
.padding(top = topPadding, end = 8.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
actionButtons.forEach { actionButton ->
|
||||
actionButton(backdrop)
|
||||
}
|
||||
}
|
||||
|
||||
content(topPadding + 64.dp, hazeState, bottomPadding + 12.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun StyledScaffold(
|
||||
title: String,
|
||||
showBackButton: Boolean = false,
|
||||
onNavigateBack: () -> Unit = {},
|
||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
StyledScaffold(
|
||||
title = title,
|
||||
actionButtons = actionButtons,
|
||||
snackbarHostState = snackbarHostState,
|
||||
) { _, _, _->
|
||||
content()
|
||||
}
|
||||
}
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val hazeState = rememberHazeState(blurEnabled = true)
|
||||
|
||||
@Composable
|
||||
fun StyledScaffold(
|
||||
title: String,
|
||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
content: @Composable (spacerValue: Dp) -> Unit
|
||||
) {
|
||||
StyledScaffold(
|
||||
title = title,
|
||||
actionButtons = actionButtons,
|
||||
snackbarHostState = snackbarHostState,
|
||||
) { spacerValue, _, _ ->
|
||||
content(spacerValue)
|
||||
when (LocalDesignSystem.current) {
|
||||
DesignSystem.Material -> {
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn() + slideInVertically(initialOffsetY = { -it }),
|
||||
exit = fadeOut() + slideOutVertically(targetOffsetY = { -it })
|
||||
) {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (showBackButton) {
|
||||
Row {
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
FilledTonalIconButton(
|
||||
onClick = onNavigateBack,
|
||||
modifier = Modifier
|
||||
.minimumInteractiveComponentSize()
|
||||
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)),
|
||||
shape = IconButtonDefaults.mediumRoundShape
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Default.ArrowBack,
|
||||
contentDescription = "",
|
||||
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Crossfade(targetState = title) {
|
||||
Text(
|
||||
text = it,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = if (showBackButton) 8.dp else 12.dp, end = 12.dp),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
actionButtons.forEach { actionButton ->
|
||||
actionButton(rememberLayerBackdrop())
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(if (visible) Modifier.padding(paddingValues) else Modifier)
|
||||
.fillMaxSize()
|
||||
.hazeSource(hazeState)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
DesignSystem.Apple -> {
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (!isDarkTheme) Modifier.shadow(
|
||||
elevation = 36.dp,
|
||||
shape = RoundedCornerShape(52.dp),
|
||||
ambientColor = Color.Black,
|
||||
spotColor = Color.Black
|
||||
) else Modifier
|
||||
)
|
||||
.clip(RoundedCornerShape(52.dp))
|
||||
) { paddingValues ->
|
||||
val topPadding = paddingValues.calculateTopPadding()
|
||||
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
|
||||
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = startPadding, end = endPadding)
|
||||
) {
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
val bgColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
AnimatedVisibility(
|
||||
visible = showBackButton,
|
||||
enter = fadeIn() + scaleIn(
|
||||
initialScale = 0f,
|
||||
animationSpec = tween()
|
||||
),
|
||||
exit = fadeOut() + scaleOut(
|
||||
targetScale = 0.5f,
|
||||
animationSpec = tween(100)
|
||||
),
|
||||
modifier = Modifier
|
||||
.zIndex(3f)
|
||||
.padding(top = topPadding, start = 8.dp)
|
||||
.align(Alignment.TopStart)
|
||||
) {
|
||||
StyledIconButton(
|
||||
onClick = onNavigateBack,
|
||||
icon = "",
|
||||
backdrop = backdrop
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn() + scaleIn(
|
||||
initialScale = 0f,
|
||||
animationSpec = tween()
|
||||
),
|
||||
exit = fadeOut() + scaleOut(
|
||||
targetScale = 0.5f,
|
||||
animationSpec = tween(100)
|
||||
),
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
.height(64.dp + topPadding)
|
||||
.fillMaxWidth()
|
||||
.layerBackdrop(backdrop)
|
||||
){
|
||||
Box(
|
||||
modifier = Modifier.hazeEffect(
|
||||
state = hazeState,
|
||||
) {
|
||||
backgroundColor = bgColor
|
||||
tints = listOf(
|
||||
HazeTint(
|
||||
if (isDarkTheme) Color.Black.copy(0.55f) else Color(
|
||||
0xFFF2F2F7
|
||||
).copy(alpha = 0.85f)
|
||||
)
|
||||
)
|
||||
blurRadius = 6.dp
|
||||
}
|
||||
) {
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Spacer(modifier = Modifier.height(topPadding + 12.dp))
|
||||
Crossfade(targetState = title) {
|
||||
Text(
|
||||
text = it,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (isDarkTheme) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible && actionButtons.isNotEmpty(),
|
||||
enter = fadeIn() + scaleIn(
|
||||
initialScale = 0f,
|
||||
animationSpec = tween()
|
||||
),
|
||||
exit = fadeOut() + scaleOut(
|
||||
targetScale = 0.5f,
|
||||
animationSpec = tween(100)
|
||||
),
|
||||
modifier = Modifier
|
||||
.zIndex(3f)
|
||||
.padding(top = topPadding, end = 8.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
Row{
|
||||
actionButtons.forEach { actionButton ->
|
||||
actionButton(backdrop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.hazeSource(hazeState)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
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
|
||||
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 me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
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.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
data class SelectItem(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val iconRes: Int? = null,
|
||||
val selected: Boolean,
|
||||
val onClick: () -> Unit,
|
||||
val visible: Boolean = true,
|
||||
val enabled: Boolean = true
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun StyledSelectList(
|
||||
items: List<SelectItem>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val haptics = LocalHapticFeedback.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp)),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val visibleItems = items.filter { it.visible }
|
||||
visibleItems.forEachIndexed { index, item ->
|
||||
val isFirst = index == 0
|
||||
val isLast = index == visibleItems.size - 1
|
||||
val hasIcon = item.iconRes != null
|
||||
|
||||
val shape = when {
|
||||
isFirst && isLast -> RoundedCornerShape(28.dp)
|
||||
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
||||
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
var itemBackgroundColor by remember { mutableStateOf(if (item.enabled) backgroundColor else if (isDarkTheme) Color(0x40050505) else Color(0x40D9D9D9)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.heightIn(min = if (hasIcon) 72.dp else 55.dp)
|
||||
.background(animatedBackgroundColor, shape)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
if (item.enabled) {
|
||||
itemBackgroundColor =
|
||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
itemBackgroundColor = backgroundColor
|
||||
}
|
||||
},
|
||||
onTap = {
|
||||
if (item.enabled) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
item.onClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (hasIcon) {
|
||||
Icon(
|
||||
painter = painterResource(item.iconRes),
|
||||
contentDescription = "Icon",
|
||||
tint = Color(0xFF007AFF),
|
||||
modifier = Modifier
|
||||
.heightIn(min = 48.dp)
|
||||
.wrapContentWidth()
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 2.dp)
|
||||
.padding(start = if (hasIcon) 8.dp else 4.dp)
|
||||
) {
|
||||
Text(
|
||||
item.name,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
item.description?.let {
|
||||
Text(
|
||||
it,
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
}
|
||||
}
|
||||
val floatAnimateState by animateFloatAsState(
|
||||
targetValue = if (item.selected) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color(0xFF007AFF).copy(alpha = floatAnimateState),
|
||||
),
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
if (!isLast) {
|
||||
if (hasIcon) {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(start = 72.dp, end = 20.dp)
|
||||
)
|
||||
} else {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(start = 20.dp, end = 20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,14 +34,18 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -71,6 +75,7 @@ import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -90,6 +95,9 @@ import com.kyant.backdrop.shadow.Shadow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.utils.inspectDragGestures
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
@@ -221,363 +229,530 @@ fun StyledSlider(
|
||||
endLabel: String? = null,
|
||||
independent: Boolean = false,
|
||||
description: String? = null,
|
||||
enabled: Boolean = true
|
||||
enabled: Boolean = true,
|
||||
index: Int = 0,
|
||||
count: Int = 1
|
||||
) {
|
||||
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val isLightTheme = !isSystemInDarkTheme()
|
||||
val trackColor =
|
||||
if (isLightTheme) Color(0xFF787878).copy(0.2f)
|
||||
else Color(0xFF787880).copy(0.36f)
|
||||
val accentColor =
|
||||
if (enabled) {
|
||||
if (isLightTheme) Color(0xFF0088FF)
|
||||
else Color(0xFF0091FF)
|
||||
} else {
|
||||
trackColor
|
||||
}
|
||||
val labelTextColor = if (isLightTheme) Color.Black else Color.White
|
||||
when (LocalDesignSystem.current) {
|
||||
DesignSystem.Material -> {
|
||||
val defaultShape = when {
|
||||
count == 1 -> RoundedCornerShape(24.dp)
|
||||
|
||||
val fraction by derivedStateOf {
|
||||
((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
||||
.fastCoerceIn(0f, 1f)
|
||||
}
|
||||
index == 0 -> RoundedCornerShape(
|
||||
topStart = 24.dp,
|
||||
topEnd = 24.dp,
|
||||
bottomStart = 8.dp,
|
||||
bottomEnd = 8.dp
|
||||
)
|
||||
|
||||
val sliderBackdrop = rememberLayerBackdrop()
|
||||
val trackWidthState = remember { mutableFloatStateOf(0f) }
|
||||
val trackPositionState = remember { mutableFloatStateOf(0f) }
|
||||
val startIconWidthState = remember { mutableFloatStateOf(0f) }
|
||||
val endIconWidthState = remember { mutableFloatStateOf(0f) }
|
||||
val density = LocalDensity.current
|
||||
val haptics = LocalHapticFeedback.current
|
||||
var lastDragValue by remember { mutableFloatStateOf(value) }
|
||||
index == count - 1 -> RoundedCornerShape(
|
||||
topStart = 8.dp,
|
||||
topEnd = 8.dp,
|
||||
bottomStart = 24.dp,
|
||||
bottomEnd = 24.dp
|
||||
)
|
||||
|
||||
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
|
||||
else -> RoundedCornerShape(8.dp)
|
||||
}
|
||||
|
||||
val content = @Composable {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.layerBackdrop(sliderBackdrop)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(1f)
|
||||
.padding(vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (startLabel != null || endLabel != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = startLabel ?: "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = labelTextColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = endLabel ?: "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = labelTextColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
Column(
|
||||
Column {
|
||||
label?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.labelSmallEmphasized,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.then(if (startIcon == null && endIcon == null) Modifier.padding(horizontal = 8.dp) else Modifier),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(0.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 4.dp, bottom = 12.dp)
|
||||
)
|
||||
}
|
||||
SegmentedListItem(
|
||||
shapes = ListItemDefaults.shapes().copy(
|
||||
shape = defaultShape,
|
||||
pressedShape = RoundedCornerShape(24.dp),
|
||||
selectedShape = RoundedCornerShape(24.dp),
|
||||
hoveredShape = RoundedCornerShape(24.dp),
|
||||
),
|
||||
onClick = {},
|
||||
enabled = enabled,
|
||||
modifier = Modifier.heightIn(min = 58.dp),
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (startIcon != null) {
|
||||
description?.let {
|
||||
Text(
|
||||
text = startIcon,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = accentColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.onGloballyPositioned {
|
||||
startIconWidthState.floatValue = it.size.width.toFloat()
|
||||
}
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onSizeChanged { trackWidthState.floatValue = it.width.toFloat() }
|
||||
.onGloballyPositioned {
|
||||
trackPositionState.floatValue =
|
||||
it.positionInParent().y + it.size.height / 2f
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.background(trackColor)
|
||||
.height(6f.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.background(accentColor)
|
||||
.height(6f.dp)
|
||||
.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
val fraction = fraction
|
||||
val width =
|
||||
(fraction * constraints.maxWidth).fastRoundToInt()
|
||||
layout(width, placeable.height) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (endIcon != null) {
|
||||
Text(
|
||||
text = endIcon,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = accentColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.onGloballyPositioned {
|
||||
endIconWidthState.floatValue = it.size.width.toFloat()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (snapPoints.isNotEmpty() && startLabel != null && endLabel != null) Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (snapPoints.isNotEmpty()) {
|
||||
val trackWidth = if (startIcon != null && endIcon != null) trackWidthState.floatValue - with(density) { 6.dp.toPx() } * 2 else trackWidthState.floatValue- with(density) { 22.dp.toPx() }
|
||||
val startOffset =
|
||||
if (startIcon != null) startIconWidthState.floatValue + with(
|
||||
density
|
||||
) { 34.dp.toPx() } else with(density) { 14.dp.toPx() }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
if (startLabel != null || endLabel != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
snapPoints.forEach { point ->
|
||||
val pointFraction =
|
||||
((point - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
||||
.fastCoerceIn(0f, 1f)
|
||||
Box(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
translationX =
|
||||
startOffset + pointFraction * trackWidth - 4.dp.toPx()
|
||||
}
|
||||
.size(2.dp)
|
||||
.background(
|
||||
trackColor,
|
||||
CircleShape
|
||||
)
|
||||
startLabel?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
endLabel?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
startIcon?.let {
|
||||
Text(it, fontFamily = FontFamily(Font(R.font.sf_pro)))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
|
||||
Slider(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = value,
|
||||
onValueChange = { newValue ->
|
||||
val snapped =
|
||||
if (snapPoints.isNotEmpty()) {
|
||||
snapIfClose(
|
||||
newValue,
|
||||
snapPoints,
|
||||
snapThreshold
|
||||
)
|
||||
} else {
|
||||
newValue
|
||||
}
|
||||
|
||||
onValueChange(snapped)
|
||||
},
|
||||
valueRange = valueRange,
|
||||
enabled = enabled
|
||||
)
|
||||
|
||||
endIcon?.let {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(it, fontFamily = FontFamily(Font(R.font.sf_pro)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (index + 1 != count) {
|
||||
Spacer(
|
||||
modifier = Modifier.height(2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DesignSystem.Apple -> {
|
||||
val backgroundColor =
|
||||
if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val trackColor =
|
||||
if (isDarkTheme) Color(0xFF787880).copy(0.36f)
|
||||
else Color(0xFF787878).copy(0.2f)
|
||||
val accentColor =
|
||||
if (enabled) {
|
||||
if (isDarkTheme) Color(0xFF0091FF)
|
||||
else Color(0xFF0088FF)
|
||||
} else {
|
||||
trackColor
|
||||
}
|
||||
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val fraction by derivedStateOf {
|
||||
((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
||||
.fastCoerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
val sliderBackdrop = rememberLayerBackdrop()
|
||||
val trackWidthState = remember { mutableFloatStateOf(0f) }
|
||||
val trackPositionState = remember { mutableFloatStateOf(0f) }
|
||||
val startIconWidthState = remember { mutableFloatStateOf(0f) }
|
||||
val endIconWidthState = remember { mutableFloatStateOf(0f) }
|
||||
val density = LocalDensity.current
|
||||
val haptics = LocalHapticFeedback.current
|
||||
var lastDragValue by remember { mutableFloatStateOf(value) }
|
||||
|
||||
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
|
||||
|
||||
val content = @Composable {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.layerBackdrop(sliderBackdrop)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(1f)
|
||||
.padding(vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (startLabel != null || endLabel != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = startLabel ?: "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = labelTextColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = endLabel ?: "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = labelTextColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.then(
|
||||
if (startIcon == null && endIcon == null) Modifier.padding(
|
||||
horizontal = 8.dp
|
||||
) else Modifier
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
if (startIcon != null) {
|
||||
Text(
|
||||
text = startIcon,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = accentColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.onGloballyPositioned {
|
||||
startIconWidthState.floatValue =
|
||||
it.size.width.toFloat()
|
||||
}
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onSizeChanged {
|
||||
trackWidthState.floatValue = it.width.toFloat()
|
||||
}
|
||||
.onGloballyPositioned {
|
||||
trackPositionState.floatValue =
|
||||
it.positionInParent().y + it.size.height / 2f
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.background(trackColor)
|
||||
.height(6f.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.background(accentColor)
|
||||
.height(6f.dp)
|
||||
.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
val fraction = fraction
|
||||
val width =
|
||||
(fraction * constraints.maxWidth).fastRoundToInt()
|
||||
layout(width, placeable.height) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (endIcon != null) {
|
||||
Text(
|
||||
text = endIcon,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = accentColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.onGloballyPositioned {
|
||||
endIconWidthState.floatValue =
|
||||
it.size.width.toFloat()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (snapPoints.isNotEmpty() && startLabel != null && endLabel != null) Spacer(
|
||||
modifier = Modifier.height(4.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (snapPoints.isNotEmpty()) {
|
||||
val trackWidth =
|
||||
if (startIcon != null && endIcon != null) trackWidthState.floatValue - with(
|
||||
density
|
||||
) { 6.dp.toPx() } * 2 else trackWidthState.floatValue - with(
|
||||
density
|
||||
) { 22.dp.toPx() }
|
||||
val startOffset =
|
||||
if (startIcon != null) startIconWidthState.floatValue + with(
|
||||
density
|
||||
) { 34.dp.toPx() } else with(density) { 14.dp.toPx() }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
snapPoints.forEach { point ->
|
||||
val pointFraction =
|
||||
((point - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
||||
.fastCoerceIn(0f, 1f)
|
||||
Box(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
translationX =
|
||||
startOffset + pointFraction * trackWidth - 4.dp.toPx()
|
||||
}
|
||||
.size(2.dp)
|
||||
.background(
|
||||
trackColor,
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
val startOffset =
|
||||
if (startIcon != null)
|
||||
startIconWidthState.floatValue + with(density) { 24.dp.toPx() }
|
||||
else
|
||||
with(density) { 8.dp.toPx() }
|
||||
|
||||
translationX =
|
||||
(startOffset + fraction * trackWidthState.floatValue - size.width / 2f)
|
||||
.fastCoerceIn(
|
||||
startOffset - size.width / 4f,
|
||||
startOffset + trackWidthState.floatValue - size.width * 3f / 4f
|
||||
)
|
||||
translationY =
|
||||
if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(
|
||||
density
|
||||
) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(
|
||||
density
|
||||
) { 8.dp.toPx() }
|
||||
}
|
||||
.then(
|
||||
if (enabled) {
|
||||
Modifier
|
||||
.draggable(
|
||||
rememberDraggableState { delta ->
|
||||
val trackWidth = trackWidthState.floatValue
|
||||
if (trackWidth > 0f) {
|
||||
val targetFraction =
|
||||
fraction + delta / trackWidth
|
||||
val targetValue =
|
||||
lerp(
|
||||
valueRange.start,
|
||||
valueRange.endInclusive,
|
||||
targetFraction
|
||||
)
|
||||
.fastCoerceIn(
|
||||
valueRange.start,
|
||||
valueRange.endInclusive
|
||||
)
|
||||
snapPoints.forEach { snap ->
|
||||
if ((lastDragValue < snap && targetValue >= snap) ||
|
||||
(snap in targetValue..<lastDragValue)
|
||||
) {
|
||||
haptics.performHapticFeedback(
|
||||
HapticFeedbackType.SegmentTick
|
||||
)
|
||||
}
|
||||
}
|
||||
lastDragValue = targetValue
|
||||
val snappedValue =
|
||||
if (snapPoints.isNotEmpty()) snapIfClose(
|
||||
targetValue,
|
||||
snapPoints,
|
||||
snapThreshold,
|
||||
) else targetValue
|
||||
onValueChange(snappedValue)
|
||||
}
|
||||
},
|
||||
Orientation.Horizontal,
|
||||
startDragImmediately = true,
|
||||
onDragStarted = {
|
||||
lastDragValue = value
|
||||
},
|
||||
onDragStopped = {
|
||||
onValueChange((value * 100).roundToInt() / 100f)
|
||||
}
|
||||
)
|
||||
.then(momentumAnimation.modifier)
|
||||
.drawBackdrop(
|
||||
rememberCombinedBackdrop(backdrop, sliderBackdrop),
|
||||
{ RoundedCornerShape(28.dp) },
|
||||
highlight = {
|
||||
val progress = momentumAnimation.progress
|
||||
Highlight.Ambient.copy(alpha = progress)
|
||||
},
|
||||
shadow = {
|
||||
Shadow(
|
||||
radius = 4f.dp,
|
||||
color = Color.Black.copy(0.05f)
|
||||
)
|
||||
},
|
||||
innerShadow = {
|
||||
val progress = momentumAnimation.progress
|
||||
InnerShadow(
|
||||
radius = 4f.dp * progress,
|
||||
alpha = progress
|
||||
)
|
||||
},
|
||||
layerBlock = {
|
||||
scaleX = momentumAnimation.scaleX
|
||||
scaleY = momentumAnimation.scaleY
|
||||
val velocity = momentumAnimation.velocity / 5000f
|
||||
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(
|
||||
-0.15f,
|
||||
0.15f
|
||||
)
|
||||
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(
|
||||
-0.15f,
|
||||
0.15f
|
||||
)
|
||||
},
|
||||
onDrawSurface = {
|
||||
val progress = momentumAnimation.progress
|
||||
drawRect(Color.White.copy(alpha = 1f - progress))
|
||||
},
|
||||
effects = {
|
||||
val progress = momentumAnimation.progress
|
||||
blur(8f.dp.toPx() * (1f - progress))
|
||||
lens(
|
||||
refractionHeight = 6f.dp.toPx() * progress,
|
||||
refractionAmount = size.height / 2f * progress,
|
||||
depthEffect = true,
|
||||
chromaticAberration = true
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Modifier.background(trackColor, RoundedCornerShape(28.dp))
|
||||
}
|
||||
)
|
||||
.size(40f.dp, 24f.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
// val startOffset =
|
||||
// if (startIcon != null) startIconWidthState.floatValue + with(density) { 24.dp.toPx() } else with(density) { 12.dp.toPx() }
|
||||
// translationX =
|
||||
// startOffset + fraction * trackWidthState.floatValue - size.width / 2f
|
||||
val startOffset =
|
||||
if (startIcon != null)
|
||||
startIconWidthState.floatValue + with(density) { 24.dp.toPx() }
|
||||
else
|
||||
with(density) { 8.dp.toPx() }
|
||||
|
||||
translationX =
|
||||
(startOffset + fraction * trackWidthState.floatValue - size.width / 2f)
|
||||
.fastCoerceIn(
|
||||
startOffset - size.width / 4f,
|
||||
startOffset + trackWidthState.floatValue - size.width * 3f / 4f
|
||||
)
|
||||
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
|
||||
if (independent) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
if (label != null) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = labelTextColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
.then(
|
||||
if (enabled) {
|
||||
Modifier
|
||||
.draggable(
|
||||
rememberDraggableState { delta ->
|
||||
val trackWidth = trackWidthState.floatValue
|
||||
if (trackWidth > 0f) {
|
||||
val targetFraction = fraction + delta / trackWidth
|
||||
val targetValue =
|
||||
lerp(
|
||||
valueRange.start,
|
||||
valueRange.endInclusive,
|
||||
targetFraction
|
||||
)
|
||||
.fastCoerceIn(
|
||||
valueRange.start,
|
||||
valueRange.endInclusive
|
||||
)
|
||||
snapPoints.forEach { snap ->
|
||||
if ((lastDragValue < snap && targetValue >= snap) ||
|
||||
(snap in targetValue..<lastDragValue)) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
}
|
||||
}
|
||||
lastDragValue = targetValue
|
||||
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
|
||||
targetValue,
|
||||
snapPoints,
|
||||
snapThreshold,
|
||||
) else targetValue
|
||||
onValueChange(snappedValue)
|
||||
}
|
||||
},
|
||||
Orientation.Horizontal,
|
||||
startDragImmediately = true,
|
||||
onDragStarted = {
|
||||
lastDragValue = value
|
||||
},
|
||||
onDragStopped = {
|
||||
onValueChange((value * 100).roundToInt() / 100f)
|
||||
}
|
||||
)
|
||||
.then(momentumAnimation.modifier)
|
||||
.drawBackdrop(
|
||||
rememberCombinedBackdrop(backdrop, sliderBackdrop),
|
||||
{ RoundedCornerShape(28.dp) },
|
||||
highlight = {
|
||||
val progress = momentumAnimation.progress
|
||||
Highlight.Ambient.copy(alpha = progress)
|
||||
},
|
||||
shadow = {
|
||||
Shadow(
|
||||
radius = 4f.dp,
|
||||
color = Color.Black.copy(0.05f)
|
||||
)
|
||||
},
|
||||
innerShadow = {
|
||||
val progress = momentumAnimation.progress
|
||||
InnerShadow(
|
||||
radius = 4f.dp * progress,
|
||||
alpha = progress
|
||||
)
|
||||
},
|
||||
layerBlock = {
|
||||
scaleX = momentumAnimation.scaleX
|
||||
scaleY = momentumAnimation.scaleY
|
||||
val velocity = momentumAnimation.velocity / 5000f
|
||||
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
|
||||
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
|
||||
},
|
||||
onDrawSurface = {
|
||||
val progress = momentumAnimation.progress
|
||||
drawRect(Color.White.copy(alpha = 1f - progress))
|
||||
},
|
||||
effects = {
|
||||
val progress = momentumAnimation.progress
|
||||
blur(8f.dp.toPx() * (1f - progress))
|
||||
lens(
|
||||
refractionHeight = 6f.dp.toPx() * progress,
|
||||
refractionAmount = size.height / 2f * progress,
|
||||
depthEffect = true,
|
||||
chromaticAberration = true
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Modifier.background(trackColor, RoundedCornerShape(28.dp))
|
||||
}
|
||||
)
|
||||
.size(40f.dp, 24f.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (independent) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 0.dp)
|
||||
.heightIn(min = 58.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
if (label != null) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = labelTextColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp)
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||
alpha = 0.6f
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 18.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (label != null) Log.w(
|
||||
"StyledSlider",
|
||||
"Label is ignored when independent is false"
|
||||
)
|
||||
if (description != null) Log.w(
|
||||
"StyledSlider",
|
||||
"Description is ignored when independent is false"
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 0.dp)
|
||||
.heightIn(min = 58.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 18.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (label != null) Log.w("StyledSlider", "Label is ignored when independent is false")
|
||||
if (description != null) Log.w("StyledSlider", "Description is ignored when independent is false")
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,46 +761,48 @@ private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.
|
||||
return if (abs(nearest - value) <= threshold) nearest else value
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, wallpaper = GREEN_DOMINATED_EXAMPLE)
|
||||
@Composable
|
||||
fun StyledSliderPreview() {
|
||||
val a = remember { mutableFloatStateOf(0.5f) }
|
||||
Box(
|
||||
Modifier
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF0F0F0))
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
LibrePodsTheme(
|
||||
m3eEnabled = true
|
||||
) {
|
||||
Column (
|
||||
Modifier.align(Alignment.Center),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
)
|
||||
{
|
||||
StyledSlider(
|
||||
value = a.floatValue,
|
||||
onValueChange = {
|
||||
a.floatValue = it
|
||||
},
|
||||
valueRange = 0f..2f,
|
||||
snapPoints = listOf(1f),
|
||||
snapThreshold = 0.1f,
|
||||
independent = true,
|
||||
startIcon = "A",
|
||||
endIcon = "B",
|
||||
)
|
||||
StyledSlider(
|
||||
value = a.floatValue,
|
||||
onValueChange = {
|
||||
a.floatValue = it
|
||||
},
|
||||
valueRange = 0f..2f,
|
||||
snapPoints = listOf(1f),
|
||||
snapThreshold = 0.1f,
|
||||
independent = true,
|
||||
startIcon = "A",
|
||||
endIcon = "B",
|
||||
enabled = false
|
||||
)
|
||||
StyledScaffold(
|
||||
title = "test",
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(72.dp))
|
||||
StyledSlider(
|
||||
value = a.floatValue,
|
||||
onValueChange = {
|
||||
a.floatValue = it
|
||||
},
|
||||
valueRange = 0f..2f,
|
||||
snapPoints = listOf(1f),
|
||||
snapThreshold = 0.1f,
|
||||
independent = true,
|
||||
startIcon = "A",
|
||||
endIcon = "B",
|
||||
)
|
||||
StyledSlider(
|
||||
label = "Small label",
|
||||
description = "This is a somewhat long descriptionRes",
|
||||
value = a.floatValue,
|
||||
onValueChange = {
|
||||
a.floatValue = it
|
||||
},
|
||||
valueRange = 0f..2f,
|
||||
snapPoints = listOf(1f),
|
||||
snapThreshold = 0.1f,
|
||||
independent = true,
|
||||
startIcon = "A",
|
||||
endIcon = "B",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
@@ -33,8 +31,15 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -42,9 +47,9 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
@@ -52,12 +57,15 @@ import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.sectionHeader
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
@@ -66,9 +74,102 @@ fun StyledToggle(
|
||||
label: String,
|
||||
description: String? = null,
|
||||
checked: Boolean = false,
|
||||
independent: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
header: Boolean = false
|
||||
) {
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
Column(modifier = Modifier.padding(vertical = 12.dp)) {
|
||||
title?.let {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 4.dp, bottom = if (m3eEnabled) 12.dp else 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = it,
|
||||
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
|
||||
style = MaterialTheme.typography.labelSmallEmphasized
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
if (m3eEnabled) if (header) MaterialTheme.colorScheme.primaryContainer else Color.Transparent else MaterialTheme.colorScheme.surface,
|
||||
RoundedCornerShape(if (m3eEnabled) (if (header) 64.dp else 16.dp) else 28.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(if (m3eEnabled) (if (header) 64.dp else 16.dp) else 28.dp))
|
||||
) {
|
||||
if (m3eEnabled) {
|
||||
StyledToggleContent(
|
||||
label = label,
|
||||
description = description,
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = onCheckedChange,
|
||||
index = 0,
|
||||
count = 1,
|
||||
header = header
|
||||
)
|
||||
} else {
|
||||
StyledToggleContent(
|
||||
label = label,
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = onCheckedChange,
|
||||
index = 0,
|
||||
count = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
if (description != null && !m3eEnabled) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = description, style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
), modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StyledListScope.StyledToggle(
|
||||
label: String,
|
||||
description: String? = null,
|
||||
checked: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
item { index, count ->
|
||||
StyledToggleContent(
|
||||
label = label,
|
||||
description = description,
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = onCheckedChange,
|
||||
index = index,
|
||||
count = count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun StyledToggleContent(
|
||||
label: String,
|
||||
description: String? = null,
|
||||
checked: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
index: Int,
|
||||
count: Int,
|
||||
header: Boolean = false
|
||||
) {
|
||||
val currentChecked by rememberUpdatedState(checked)
|
||||
|
||||
@@ -78,196 +179,209 @@ fun StyledToggle(
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var backgroundColor by remember {
|
||||
mutableStateOf(
|
||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
)
|
||||
}
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
|
||||
val animatedBackgroundColor by animateColorAsState(
|
||||
targetValue = backgroundColor,
|
||||
animationSpec = tween(durationMillis = 500)
|
||||
)
|
||||
if (m3eEnabled) {
|
||||
val defaultShape = when {
|
||||
count == 1 -> RoundedCornerShape(24.dp)
|
||||
|
||||
if (independent) {
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
if (title != null) {
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 8.dp,
|
||||
bottom = 4.dp
|
||||
index == 0 -> RoundedCornerShape(
|
||||
topStart = 24.dp,
|
||||
topEnd = 24.dp,
|
||||
bottomStart = 8.dp,
|
||||
bottomEnd = 8.dp
|
||||
)
|
||||
|
||||
index == count - 1 -> RoundedCornerShape(
|
||||
topStart = 8.dp,
|
||||
topEnd = 8.dp,
|
||||
bottomStart = 24.dp,
|
||||
bottomEnd = 24.dp
|
||||
)
|
||||
|
||||
else -> RoundedCornerShape(8.dp)
|
||||
}
|
||||
Column {
|
||||
SegmentedListItem(
|
||||
shapes = ListItemDefaults.shapes().copy(
|
||||
shape = defaultShape,
|
||||
pressedShape = RoundedCornerShape(24.dp),
|
||||
selectedShape = RoundedCornerShape(24.dp),
|
||||
hoveredShape = RoundedCornerShape(24.dp),
|
||||
),
|
||||
onClick = { onCheckedChange(!currentChecked) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = currentChecked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
modifier = Modifier.padding(end = if (header) 8.dp else 0.dp),
|
||||
enabled = enabled
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(4.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
if (enabled) {
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
}
|
||||
},
|
||||
onTap = {
|
||||
if (enabled) {
|
||||
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
||||
onCheckedChange(!currentChecked)
|
||||
}
|
||||
}
|
||||
},
|
||||
supportingContent = description?.let {
|
||||
{
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier
|
||||
.padding(top = if (header) 2.dp else 4.dp, bottom = if (header) 8.dp else 4.dp)
|
||||
.padding(horizontal = if (header) 8.dp else 0.dp),
|
||||
color = if (header && enabled) MaterialTheme.colorScheme.onPrimaryContainer else Color.Unspecified
|
||||
)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
},
|
||||
content = {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = textColor
|
||||
)
|
||||
style = MaterialTheme.typography.labelMediumEmphasized,
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = if (header) 8.dp else 4.dp,
|
||||
bottom = if (header) 2.dp else 4.dp
|
||||
)
|
||||
.padding(horizontal = if (header) 8.dp else 0.dp),
|
||||
color = if (header && enabled) MaterialTheme.colorScheme.onPrimaryContainer else Color.Unspecified
|
||||
)
|
||||
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
scope.launch { haptics.performHapticFeedback(if (it) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
||||
onCheckedChange(it)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (description != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.background(
|
||||
if (isDarkTheme) Color(0xFF000000)
|
||||
else Color(0xFFF2F2F7)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = enabled,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.heightIn(min = 64.dp),
|
||||
colors = if (header) ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else ListItemDefaults.segmentedColors()
|
||||
)
|
||||
if (index+1 != count) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val isPressed = remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
||||
)
|
||||
.padding(16.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed.value = true
|
||||
tryAwaitRelease()
|
||||
isPressed.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
if (enabled) {
|
||||
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
||||
onCheckedChange(!currentChecked)
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = textColor
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
.padding(16.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed.value = true
|
||||
tryAwaitRelease()
|
||||
isPressed.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
if (enabled) {
|
||||
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
||||
onCheckedChange(!currentChecked)
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
onCheckedChange(it)
|
||||
if (description != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = textColor.copy(0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
onCheckedChange(it)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (index+1 != count) {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp,end = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "List", group = "Apple")
|
||||
@Composable
|
||||
fun StyledToggleAppleListPreview() {
|
||||
val checked = remember { mutableStateOf(false) }
|
||||
LibrePodsTheme(m3eEnabled = false) {
|
||||
StyledList {
|
||||
StyledToggle(
|
||||
label = "Apple Styled List",
|
||||
description = "This is an example description for the styled toggle.",
|
||||
checked = checked.value,
|
||||
onCheckedChange = { checked.value = !checked.value }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(name = "Normal", group = "Apple")
|
||||
@Composable
|
||||
fun StyledTogglePreview() {
|
||||
fun StyledToggleApplePreview() {
|
||||
val checked = remember { mutableStateOf(false) }
|
||||
StyledToggle(
|
||||
label = "Example Toggle",
|
||||
description = "This is an example description for the styled toggle.",
|
||||
checked = checked.value,
|
||||
onCheckedChange = { checked.value = !checked.value }
|
||||
)
|
||||
LibrePodsTheme(m3eEnabled = false) {
|
||||
StyledToggle(
|
||||
label = "Apple",
|
||||
description = "This is an example description for the styled toggle.",
|
||||
checked = checked.value,
|
||||
onCheckedChange = { checked.value = !checked.value }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "List", group = "Apple")
|
||||
@Composable
|
||||
fun StyledToggleM3EListPreview() {
|
||||
val checked = remember { mutableStateOf(false) }
|
||||
LibrePodsTheme(m3eEnabled = true) {
|
||||
StyledList {
|
||||
StyledToggle(
|
||||
label = "Apple Styled List",
|
||||
// description = "This is an example description for the styled toggle.",
|
||||
checked = checked.value,
|
||||
onCheckedChange = { checked.value = !checked.value }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Normal", group = "Material")
|
||||
@Composable
|
||||
fun StyledToggleM3EPreview() {
|
||||
val checked = remember { mutableStateOf(false) }
|
||||
LibrePodsTheme(m3eEnabled = true) {
|
||||
StyledToggle(
|
||||
label = "Material",
|
||||
description = "This is an example description for the styled toggle.",
|
||||
checked = checked.value,
|
||||
onCheckedChange = { checked.value = !checked.value }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package me.kavishdevar.librepods.presentation.navigation
|
||||
|
||||
import androidx.activity.BackEventCompat.Companion.EDGE_LEFT
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.data.updates.updates
|
||||
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsRoute
|
||||
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.CallControlScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.EqualizerRoute
|
||||
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.LoadingScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.LongPress
|
||||
import me.kavishdevar.librepods.presentation.screens.MicrophoneSettingsRoute
|
||||
import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.ReleaseNotesScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.RenameScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestRoute
|
||||
import me.kavishdevar.librepods.presentation.screens.VersionScreen
|
||||
import me.kavishdevar.librepods.presentation.screens.onboarding.OnboardingScreen
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun AppNavGraph(
|
||||
showReleaseNotes: Boolean = false,
|
||||
updatesShown: () -> Unit = {},
|
||||
showOnboarding: Boolean = false,
|
||||
onboardingComplete: () -> Unit = {},
|
||||
backStack: SnapshotStateList<Screen>,
|
||||
airPodsViewModel: AirPodsViewModel,
|
||||
) {
|
||||
val navigate: (Screen) -> Unit = { screen ->
|
||||
backStack.add(screen)
|
||||
}
|
||||
|
||||
fun navigateToPurchase() {
|
||||
navigate(Screen.Purchase)
|
||||
}
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
|
||||
SharedTransitionLayout {
|
||||
NavDisplay(
|
||||
sharedTransitionScope = this,
|
||||
backStack = backStack,
|
||||
onBack = {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeAt(backStack.lastIndex)
|
||||
}
|
||||
},
|
||||
entryProvider = { screen ->
|
||||
when (screen) {
|
||||
Screen.Onboarding ->
|
||||
NavEntry(screen) {
|
||||
OnboardingScreen {
|
||||
onboardingComplete()
|
||||
if (showReleaseNotes) navigate(Screen.ReleaseNotes) else navigate(Screen.AirPodsSettings)
|
||||
backStack.remove(screen)
|
||||
}
|
||||
}
|
||||
Screen.AirPodsSettings ->
|
||||
NavEntry(screen) {
|
||||
if (!airPodsViewModel.isReady) LoadingScreen()
|
||||
AirPodsSettingsRoute(
|
||||
viewModel = airPodsViewModel,
|
||||
navigateToRename = { navigate(Screen.Rename) },
|
||||
navigateToHearingProtection = { navigate(Screen.HearingProtection) },
|
||||
navigateToHearingAid = { navigate(Screen.HearingAid) },
|
||||
navigateToLeftLongPress = {
|
||||
navigate(
|
||||
Screen.LongPress("Left")
|
||||
)
|
||||
},
|
||||
navigateToRightLongPress = {
|
||||
navigate(
|
||||
Screen.LongPress("Right")
|
||||
)
|
||||
},
|
||||
navigateToPurchase = { navigate(Screen.Purchase) },
|
||||
navigateToAdaptiveStrength = { navigate(Screen.AdaptiveStrength) },
|
||||
navigateToEqualizer = { navigate(Screen.Equalizer) },
|
||||
navigateToHeadTracking = { navigate(Screen.HeadTracking) },
|
||||
navigateToAccessibility = { navigate(Screen.Accessibility) },
|
||||
navigateToVersion = { navigate(Screen.VersionInfo) },
|
||||
navigateToTroubleshooting = { navigate(Screen.Troubleshooting) },
|
||||
navigateToCallControlScreen = { navigate(Screen.CallControl(it)) },
|
||||
navigateToMicrophoneSettings = { navigate(Screen.MicrophoneSettings) },
|
||||
)
|
||||
}
|
||||
|
||||
Screen.Rename ->
|
||||
NavEntry(screen) {
|
||||
if (!airPodsViewModel.isReady) LoadingScreen()
|
||||
RenameScreen(airPodsViewModel)
|
||||
}
|
||||
|
||||
Screen.AppSettings ->
|
||||
NavEntry(screen) {
|
||||
val vm: AppSettingsViewModel = viewModel()
|
||||
AppSettingsScreen(
|
||||
viewModel = vm,
|
||||
navigateToPurchase = ::navigateToPurchase,
|
||||
navigateToTroubleshooting = { navigate(Screen.Troubleshooting) },
|
||||
navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) },
|
||||
navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) }
|
||||
)
|
||||
}
|
||||
|
||||
Screen.Troubleshooting ->
|
||||
NavEntry(screen) {
|
||||
TroubleshootingScreen()
|
||||
}
|
||||
|
||||
Screen.HeadTracking ->
|
||||
NavEntry(screen) {
|
||||
if (!airPodsViewModel.isReady) LoadingScreen()
|
||||
HeadTrackingScreen(airPodsViewModel, ::navigateToPurchase)
|
||||
}
|
||||
|
||||
Screen.Accessibility ->
|
||||
NavEntry(screen) {
|
||||
if (!airPodsViewModel.isReady) LoadingScreen()
|
||||
AccessibilitySettingsScreen(
|
||||
viewModel = airPodsViewModel,
|
||||
navigateToPurchase = ::navigateToPurchase,
|
||||
navigateToTransparencyCustomization = { navigate(Screen.TransparencyCustomization) }
|
||||
)
|
||||
}
|
||||
|
||||
Screen.TransparencyCustomization ->
|
||||
NavEntry(screen) {
|
||||
if (!airPodsViewModel.isReady) LoadingScreen()
|
||||
TransparencySettingsScreen(airPodsViewModel)
|
||||
}
|
||||
|
||||
Screen.HearingAid ->
|
||||
NavEntry(screen) {
|
||||
if (!airPodsViewModel.isReady) LoadingScreen()
|
||||
HearingAidScreen(
|
||||
viewModel = airPodsViewModel,
|
||||
onNavigateHearingAidAdjustments = { navigate(Screen.HearingAidAdjustments) },
|
||||
onNavigateHearingTest = { navigate(Screen.UpdateHearingTest) },
|
||||
)
|
||||
}
|
||||
|
||||
Screen.HearingAidAdjustments ->
|
||||
NavEntry(screen) {
|
||||
if (!airPodsViewModel.isReady) LoadingScreen()
|
||||
HearingAidAdjustmentsScreen(airPodsViewModel)
|
||||
}
|
||||
|
||||
Screen.AdaptiveStrength ->
|
||||
NavEntry(screen) {
|
||||
if (!airPodsViewModel.isReady) LoadingScreen()
|
||||
AdaptiveStrengthScreen(airPodsViewModel, ::navigateToPurchase)
|
||||
}
|
||||
|
||||
// Screen.CameraControl ->
|
||||
// NavEntry(screen) {
|
||||
// CameraControlScreen(airPodsViewModel)
|
||||
// }
|
||||
|
||||
Screen.OpenSourceLicenses ->
|
||||
NavEntry(screen) {
|
||||
OpenSourceLicensesScreen()
|
||||
}
|
||||
|
||||
Screen.UpdateHearingTest ->
|
||||
NavEntry(screen) {
|
||||
UpdateHearingTestRoute(airPodsViewModel)
|
||||
}
|
||||
|
||||
Screen.VersionInfo ->
|
||||
NavEntry(screen) {
|
||||
if (!airPodsViewModel.isReady) LoadingScreen()
|
||||
VersionScreen(airPodsViewModel)
|
||||
}
|
||||
|
||||
Screen.HearingProtection ->
|
||||
NavEntry(screen) {
|
||||
if (!airPodsViewModel.isReady) LoadingScreen()
|
||||
HearingProtectionScreen(
|
||||
viewModel = airPodsViewModel,
|
||||
navigateToPurchase = ::navigateToPurchase
|
||||
)
|
||||
}
|
||||
|
||||
Screen.Purchase ->
|
||||
NavEntry(screen) {
|
||||
val vm: PurchaseViewModel = viewModel()
|
||||
PurchaseScreen(vm, backStack)
|
||||
}
|
||||
|
||||
Screen.Equalizer ->
|
||||
NavEntry(screen) {
|
||||
if (!airPodsViewModel.isReady) LoadingScreen()
|
||||
EqualizerRoute(airPodsViewModel)
|
||||
}
|
||||
|
||||
is Screen.LongPress ->
|
||||
NavEntry(screen) {
|
||||
if (!airPodsViewModel.isReady) LoadingScreen()
|
||||
LongPress(
|
||||
viewModel = airPodsViewModel,
|
||||
name = screen.bud,
|
||||
navigateToPurchase = ::navigateToPurchase
|
||||
)
|
||||
}
|
||||
|
||||
is Screen.CallControl ->
|
||||
NavEntry(screen) {
|
||||
if (!airPodsViewModel.isReady) LoadingScreen()
|
||||
CallControlScreen(
|
||||
viewModel = airPodsViewModel,
|
||||
action = screen.action,
|
||||
onCallControlValueChanged = { flipped ->
|
||||
airPodsViewModel.setControlCommandValue(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
|
||||
if (flipped) byteArrayOf(0x00, 0x02) else byteArrayOf(
|
||||
0x00,
|
||||
0x03
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is Screen.MicrophoneSettings ->
|
||||
NavEntry(screen) {
|
||||
if (!airPodsViewModel.isReady) LoadingScreen()
|
||||
MicrophoneSettingsRoute(viewModel = airPodsViewModel)
|
||||
}
|
||||
|
||||
is Screen.ReleaseNotes ->
|
||||
NavEntry(screen) {
|
||||
ReleaseNotesScreen(
|
||||
updates = updates,
|
||||
releaseNotesShown = {
|
||||
if (showReleaseNotes) {
|
||||
navigate(Screen.AirPodsSettings)
|
||||
backStack.remove(screen)
|
||||
updatesShown()
|
||||
} else {
|
||||
backStack.removeAt(backStack.lastIndex)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
transitionSpec = {
|
||||
slideInHorizontally { it } togetherWith slideOutHorizontally { -it / 4 }
|
||||
},
|
||||
popTransitionSpec = {
|
||||
slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it }
|
||||
},
|
||||
predictivePopTransitionSpec = { swipeEdge ->
|
||||
if (m3eEnabled) {
|
||||
val enterOffset: (Int) -> Int =
|
||||
if (swipeEdge == EDGE_LEFT) {
|
||||
{ -it / 6 }
|
||||
} else {
|
||||
{ it / 6 }
|
||||
}
|
||||
|
||||
val exitOffset: (Int) -> Int =
|
||||
if (swipeEdge == EDGE_LEFT) {
|
||||
{ it / 8 }
|
||||
} else {
|
||||
{ -it / 8 }
|
||||
}
|
||||
|
||||
fadeIn(
|
||||
animationSpec = tween(250)
|
||||
) +
|
||||
slideInHorizontally(
|
||||
initialOffsetX = enterOffset,
|
||||
animationSpec = tween(250)
|
||||
) togetherWith
|
||||
fadeOut(
|
||||
targetAlpha = 0.75f,
|
||||
animationSpec = tween(250)
|
||||
) +
|
||||
scaleOut(
|
||||
targetScale = 0.85f,
|
||||
animationSpec = tween(250)
|
||||
) +
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = exitOffset,
|
||||
animationSpec = tween(250)
|
||||
)
|
||||
} else {
|
||||
slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package me.kavishdevar.librepods.presentation.navigation
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.FilledTonalIconToggleButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.MaterialIcons
|
||||
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
|
||||
@Composable
|
||||
fun NavigationRoot(
|
||||
showReleaseNotes: Boolean = false,
|
||||
updatesShown: () -> Unit = {},
|
||||
showOnboarding: Boolean = false,
|
||||
onboardingComplete: () -> Unit = {},
|
||||
airPodsViewModel: AirPodsViewModel
|
||||
) {
|
||||
val backStack = remember {
|
||||
mutableStateListOf(
|
||||
when {
|
||||
showOnboarding -> Screen.Onboarding
|
||||
showReleaseNotes -> Screen.ReleaseNotes
|
||||
else -> Screen.AirPodsSettings
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val currentScreen = backStack.last()
|
||||
|
||||
val state by airPodsViewModel.uiState.collectAsState()
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
|
||||
val title = when (currentScreen) {
|
||||
Screen.Onboarding -> ""
|
||||
Screen.AirPodsSettings -> if (state.isLocallyConnected) state.deviceName else stringResource(R.string.app_name)
|
||||
Screen.Accessibility -> stringResource(R.string.accessibility)
|
||||
Screen.AdaptiveStrength -> stringResource(R.string.customize_adaptive_audio)
|
||||
Screen.AppSettings -> stringResource(R.string.settings)
|
||||
// Screen.CameraControl -> stringResource(R.string.camera_control)
|
||||
Screen.Equalizer -> stringResource(R.string.equalizer)
|
||||
Screen.HeadTracking -> stringResource(R.string.head_tracking)
|
||||
Screen.HearingAid -> stringResource(R.string.hearing_aid)
|
||||
Screen.HearingAidAdjustments -> stringResource(R.string.adjustments)
|
||||
Screen.HearingProtection -> stringResource(R.string.hearing_protection)
|
||||
is Screen.LongPress -> currentScreen.bud
|
||||
Screen.OpenSourceLicenses -> stringResource(R.string.open_source_licenses)
|
||||
Screen.Purchase -> stringResource(R.string.unlock_advanced_features)
|
||||
Screen.Rename -> stringResource(R.string.name)
|
||||
Screen.TransparencyCustomization -> stringResource(R.string.customize_transparency_mode)
|
||||
Screen.Troubleshooting -> stringResource(R.string.troubleshooting)
|
||||
Screen.UpdateHearingTest -> stringResource(R.string.update_hearing_test)
|
||||
Screen.VersionInfo -> stringResource(R.string.version)
|
||||
is Screen.CallControl -> currentScreen.action
|
||||
Screen.MicrophoneSettings -> stringResource(R.string.microphone_mode)
|
||||
Screen.ReleaseNotes -> ""
|
||||
}
|
||||
|
||||
// is this a bad idea? probably. I can't think of a better way without having to pass around a shouldShowBackButton to each screen to pass to each scaffold
|
||||
val actionButtons = when (currentScreen) {
|
||||
Screen.AirPodsSettings -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>(
|
||||
{ scaffoldBackdrop ->
|
||||
if (m3eEnabled) {
|
||||
FilledTonalIconButton(
|
||||
onClick = { backStack.add(Screen.AppSettings) },
|
||||
modifier = Modifier
|
||||
.minimumInteractiveComponentSize()
|
||||
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
|
||||
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Settings,
|
||||
contentDescription = "settings",
|
||||
modifier = Modifier.size(IconButtonDefaults.mediumIconSize)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
StyledIconButton(
|
||||
onClick = { backStack.add(Screen.AppSettings) },
|
||||
icon = "",
|
||||
backdrop = scaffoldBackdrop
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
Screen.HeadTracking -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>(
|
||||
{ scaffoldBackdrop ->
|
||||
if (m3eEnabled) {
|
||||
FilledTonalIconToggleButton(
|
||||
checked = state.headTrackingActive,
|
||||
onCheckedChange = { if (it) airPodsViewModel.startHeadTracking() else airPodsViewModel.stopHeadTracking() },
|
||||
modifier = Modifier
|
||||
.minimumInteractiveComponentSize()
|
||||
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
|
||||
shape = IconButtonDefaults.mediumRoundShape
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.headTrackingActive) MaterialIcons.pause else Icons.Default.PlayArrow,
|
||||
contentDescription = "Play/Pause",
|
||||
modifier = Modifier.size(IconButtonDefaults.mediumIconSize)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
StyledIconButton(
|
||||
onClick = {
|
||||
if (!state.headTrackingActive) {
|
||||
airPodsViewModel.startHeadTracking()
|
||||
Log.d("HeadTrackingScreen", "Head tracking started")
|
||||
} else {
|
||||
airPodsViewModel.stopHeadTracking()
|
||||
Log.d("HeadTrackingScreen", "Head tracking stopped")
|
||||
}
|
||||
},
|
||||
icon = if (state.headTrackingActive) "" else "",
|
||||
backdrop = scaffoldBackdrop
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
else -> listOf()
|
||||
}
|
||||
|
||||
StyledScaffold(
|
||||
visible = currentScreen.showTopBar,
|
||||
title = title,
|
||||
showBackButton = backStack.size > 1,
|
||||
onNavigateBack = { backStack.removeAt(backStack.lastIndex) },
|
||||
actionButtons = actionButtons
|
||||
) {
|
||||
AppNavGraph(
|
||||
showReleaseNotes = showReleaseNotes,
|
||||
updatesShown = updatesShown,
|
||||
showOnboarding = showOnboarding,
|
||||
onboardingComplete = onboardingComplete,
|
||||
backStack = backStack,
|
||||
airPodsViewModel = airPodsViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package me.kavishdevar.librepods.presentation.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed interface Screen: NavKey {
|
||||
val showTopBar: Boolean
|
||||
get() = true
|
||||
|
||||
@Serializable
|
||||
data object Onboarding: Screen {
|
||||
override val showTopBar: Boolean = false
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data object AirPodsSettings: Screen
|
||||
|
||||
@Serializable
|
||||
data object Rename: Screen
|
||||
|
||||
@Serializable
|
||||
data object AppSettings: Screen
|
||||
|
||||
@Serializable
|
||||
data object Troubleshooting: Screen
|
||||
|
||||
@Serializable
|
||||
data object HeadTracking: Screen
|
||||
|
||||
@Serializable
|
||||
data object Accessibility: Screen
|
||||
|
||||
@Serializable
|
||||
data object TransparencyCustomization: Screen
|
||||
|
||||
@Serializable
|
||||
data object HearingAid: Screen
|
||||
|
||||
@Serializable
|
||||
data object HearingAidAdjustments: Screen
|
||||
|
||||
@Serializable
|
||||
data object AdaptiveStrength: Screen
|
||||
|
||||
// @Serializable
|
||||
// data object CameraControl: Screen
|
||||
|
||||
@Serializable
|
||||
data object OpenSourceLicenses: Screen
|
||||
|
||||
@Serializable
|
||||
data object UpdateHearingTest: Screen
|
||||
|
||||
@Serializable
|
||||
data object VersionInfo: Screen
|
||||
|
||||
@Serializable
|
||||
data object HearingProtection: Screen
|
||||
|
||||
@Serializable
|
||||
data object Purchase: Screen
|
||||
|
||||
@Serializable
|
||||
data object Equalizer: Screen
|
||||
|
||||
@Serializable
|
||||
data class LongPress(
|
||||
val bud: String
|
||||
): Screen
|
||||
|
||||
@Serializable
|
||||
data class CallControl(
|
||||
val action: String
|
||||
): Screen
|
||||
|
||||
@Serializable
|
||||
data object MicrophoneSettings: Screen
|
||||
|
||||
@Serializable
|
||||
data object ReleaseNotes: Screen {
|
||||
override val showTopBar: Boolean = false
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import android.content.IntentFilter
|
||||
import android.content.res.Resources
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -374,6 +375,7 @@ class IslandWindow(private val context: Context) {
|
||||
|
||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||
val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri()
|
||||
videoView.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
|
||||
videoView.setVideoURI(videoUri)
|
||||
videoView.setOnPreparedListener { mediaPlayer ->
|
||||
mediaPlayer.isLooping = true
|
||||
@@ -712,8 +714,16 @@ class IslandWindow(private val context: Context) {
|
||||
}
|
||||
isClosing = false
|
||||
// Make sure all animations are canceled
|
||||
springAnimation.cancel()
|
||||
flingAnimator.cancel()
|
||||
try {
|
||||
springAnimation.cancel()
|
||||
} catch (e: Exception) {
|
||||
e("IslandWindow", "Error cancelling spring animation $e")
|
||||
}
|
||||
try {
|
||||
flingAnimator.cancel()
|
||||
} catch (e: Exception) {
|
||||
e("IslandWindow", "Error cancelling fling animation $e")
|
||||
}
|
||||
}
|
||||
|
||||
fun forceClose() {
|
||||
|
||||
@@ -29,6 +29,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.PixelFormat
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -137,6 +138,7 @@ class PopupWindow(
|
||||
updateBatteryStatus(batteryNotification)
|
||||
|
||||
val vid = mView.findViewById<VideoView>(R.id.video)
|
||||
vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
|
||||
vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
|
||||
vid.resolveAdjustedSize(vid.width, vid.height)
|
||||
vid.start()
|
||||
|
||||
@@ -22,89 +22,67 @@ package me.kavishdevar.librepods.presentation.screens
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
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.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||
import me.kavishdevar.librepods.data.Capability
|
||||
import me.kavishdevar.librepods.presentation.components.NavigationButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledDropdown
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private var phoneMediaDebounceJob: Job? = null
|
||||
private var toneVolumeDebounceJob: Job? = null
|
||||
//private const val TAG = "AccessibilitySettings"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class, FlowPreview::class)
|
||||
@Composable
|
||||
fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||
fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit, navigateToTransparencyCustomization: () -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val hearingAidEnabled =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
|
||||
1
|
||||
@@ -113,294 +91,261 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
0
|
||||
)?.toInt() == 1
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.accessibility)
|
||||
) { topPadding, hazeState, bottomPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.hazeSource(hazeState)
|
||||
.layerBackdrop(backdrop)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
if (!state.isPremium) {
|
||||
StyledButton(
|
||||
onClick = {
|
||||
navController.navigate("purchase_screen")
|
||||
},
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
if (!state.isPremium) {
|
||||
StyledButton(
|
||||
onClick = navigateToPurchase,
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||
// val phoneEQEnabled = remember { mutableStateOf(false) }
|
||||
// val mediaEQEnabled = remember { mutableStateOf(false) }
|
||||
|
||||
val pressSpeedOptions = mapOf(
|
||||
0.toByte() to stringResource(R.string.default_option),
|
||||
1.toByte() to stringResource(R.string.slower),
|
||||
2.toByte() to stringResource(R.string.slowest)
|
||||
val pressSpeedOptions = mapOf(
|
||||
0.toByte() to stringResource(R.string.default_option),
|
||||
1.toByte() to stringResource(R.string.slower),
|
||||
2.toByte() to stringResource(R.string.slowest)
|
||||
)
|
||||
|
||||
val selectedPressSpeedValue =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(
|
||||
0
|
||||
)
|
||||
|
||||
val selectedPressSpeedValue =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(
|
||||
0
|
||||
)
|
||||
var selectedPressSpeed by remember {
|
||||
mutableStateOf(
|
||||
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
|
||||
)
|
||||
}
|
||||
|
||||
val pressAndHoldDurationOptions = mapOf(
|
||||
0.toByte() to stringResource(R.string.default_option),
|
||||
1.toByte() to stringResource(R.string.slower),
|
||||
2.toByte() to stringResource(R.string.slowest)
|
||||
var selectedPressSpeed by remember {
|
||||
mutableStateOf(
|
||||
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
|
||||
)
|
||||
}
|
||||
|
||||
val selectedPressAndHoldDurationValue =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(
|
||||
0
|
||||
)
|
||||
var selectedPressAndHoldDuration by remember {
|
||||
mutableStateOf(
|
||||
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
|
||||
?: pressAndHoldDurationOptions[0]
|
||||
)
|
||||
}
|
||||
val pressAndHoldDurationOptions = mapOf(
|
||||
0.toByte() to stringResource(R.string.default_option),
|
||||
1.toByte() to stringResource(R.string.slower),
|
||||
2.toByte() to stringResource(R.string.slowest)
|
||||
)
|
||||
|
||||
val volumeSwipeSpeedOptions = mapOf(
|
||||
1.toByte() to stringResource(R.string.default_option),
|
||||
2.toByte() to stringResource(R.string.longer),
|
||||
3.toByte() to stringResource(R.string.longest)
|
||||
val selectedPressAndHoldDurationValue =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(
|
||||
0
|
||||
)
|
||||
val selectedVolumeSwipeSpeedValue =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(
|
||||
0
|
||||
)
|
||||
var selectedVolumeSwipeSpeed by remember {
|
||||
mutableStateOf(
|
||||
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
|
||||
?: volumeSwipeSpeedOptions[1]
|
||||
)
|
||||
}
|
||||
var selectedPressAndHoldDuration by remember {
|
||||
mutableStateOf(
|
||||
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
|
||||
?: pressAndHoldDurationOptions[0]
|
||||
)
|
||||
}
|
||||
|
||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||
val phoneEQEnabled = remember { mutableStateOf(false) }
|
||||
val mediaEQEnabled = remember { mutableStateOf(false) }
|
||||
val volumeSwipeSpeedOptions = mapOf(
|
||||
1.toByte() to stringResource(R.string.default_option),
|
||||
2.toByte() to stringResource(R.string.longer),
|
||||
3.toByte() to stringResource(R.string.longest)
|
||||
)
|
||||
val selectedVolumeSwipeSpeedValue =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(
|
||||
0
|
||||
)
|
||||
var selectedVolumeSwipeSpeed by remember {
|
||||
mutableStateOf(
|
||||
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
|
||||
?: volumeSwipeSpeedOptions[1]
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
|
||||
phoneMediaDebounceJob?.cancel()
|
||||
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(150)
|
||||
try {
|
||||
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
Log.d(
|
||||
"AccessibilitySettingsScreen",
|
||||
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
|
||||
)
|
||||
viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
"AccessibilitySettingsScreen",
|
||||
"Error sending phone/media EQ: ${e.message}"
|
||||
)
|
||||
}
|
||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||
val phoneEQEnabled = remember { mutableStateOf(false) }
|
||||
val mediaEQEnabled = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
|
||||
phoneMediaDebounceJob?.cancel()
|
||||
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(150.milliseconds)
|
||||
try {
|
||||
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
Log.d(
|
||||
"AccessibilitySettingsScreen",
|
||||
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
|
||||
)
|
||||
viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
"AccessibilitySettingsScreen",
|
||||
"Error sending phone/media EQ: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.then(
|
||||
if (!state.isPremium) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else Modifier)) {
|
||||
DropdownMenuComponent(
|
||||
label = stringResource(R.string.press_speed),
|
||||
description = stringResource(R.string.press_speed_description),
|
||||
options = pressSpeedOptions.values.toList(),
|
||||
selectedOption = selectedPressSpeed ?: stringResource(R.string.default_option),
|
||||
onOptionSelected = { newValue ->
|
||||
selectedPressSpeed = newValue
|
||||
}
|
||||
|
||||
StyledList(
|
||||
title = stringResource(R.string.press_speed),
|
||||
description = stringResource(R.string.press_speed_description)
|
||||
) {
|
||||
pressSpeedOptions.forEach { (value, label) ->
|
||||
StyledListItem(
|
||||
name = label,
|
||||
selected = selectedPressSpeed == label,
|
||||
onClick = {
|
||||
selectedPressSpeed = label
|
||||
|
||||
viewModel.setControlCommandByte(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
|
||||
?: 0.toByte()
|
||||
value = value
|
||||
)
|
||||
},
|
||||
textColor = textColor,
|
||||
hazeState = hazeState,
|
||||
independent = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
StyledList(
|
||||
title = stringResource(R.string.press_and_hold_duration),
|
||||
description = stringResource(R.string.press_and_hold_duration_description)
|
||||
) {
|
||||
pressAndHoldDurationOptions.forEach { (value, label) ->
|
||||
StyledListItem(
|
||||
name = label,
|
||||
selected = selectedPressAndHoldDuration == label,
|
||||
onClick = {
|
||||
selectedPressAndHoldDuration = label
|
||||
|
||||
Box(
|
||||
modifier = Modifier.then(
|
||||
if (!state.isPremium) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else Modifier)) {
|
||||
DropdownMenuComponent(
|
||||
label = stringResource(R.string.press_and_hold_duration),
|
||||
description = stringResource(R.string.press_and_hold_duration_description),
|
||||
options = pressAndHoldDurationOptions.values.toList(),
|
||||
selectedOption = selectedPressAndHoldDuration
|
||||
?: stringResource(R.string.default_option),
|
||||
onOptionSelected = { newValue ->
|
||||
selectedPressAndHoldDuration = newValue
|
||||
viewModel.setControlCommandByte(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
|
||||
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
|
||||
?: 0.toByte()
|
||||
value = value
|
||||
)
|
||||
},
|
||||
textColor = textColor,
|
||||
hazeState = hazeState,
|
||||
independent = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.noise_control),
|
||||
label = stringResource(R.string.noise_cancellation_single_airpod),
|
||||
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
|
||||
0
|
||||
) == 0x01.toByte(),
|
||||
onCheckedChange = {
|
||||
viewModel.setControlCommandBoolean(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it
|
||||
)
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) {
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.noise_control),
|
||||
label = stringResource(R.string.noise_cancellation_single_airpod),
|
||||
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
||||
independent = true,
|
||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
|
||||
0
|
||||
) == 0x01.toByte(),
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
checked = state.loudSoundReductionEnabled,
|
||||
onCheckedChange = {
|
||||
viewModel.setControlCommandBoolean(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it
|
||||
viewModel.setATTCharacteristicValue(
|
||||
ATTHandles.LOUD_SOUND_REDUCTION,
|
||||
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
|
||||
)
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
checked = state.loudSoundReductionEnabled,
|
||||
onCheckedChange = {
|
||||
viewModel.setATTCharacteristicValue(
|
||||
ATTHandles.LOUD_SOUND_REDUCTION,
|
||||
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
|
||||
)
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
if (!hearingAidEnabled && state.vendorIdHook) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.customize_transparency_mode),
|
||||
onClick = navigateToTransparencyCustomization,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) }
|
||||
|
||||
LaunchedEffect(toneVolumeValue) {
|
||||
snapshotFlow {
|
||||
toneVolumeValue.floatValue
|
||||
}
|
||||
|
||||
if (!hearingAidEnabled && state.vendorIdHook) {
|
||||
NavigationButton(
|
||||
to = "transparency_customization",
|
||||
name = stringResource(R.string.customize_transparency_mode),
|
||||
navController = navController,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
val toneVolumeValue =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(
|
||||
0
|
||||
)?.toFloat() ?: 75f
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone_volume),
|
||||
description = stringResource(R.string.tone_volume_description),
|
||||
value = toneVolumeValue,
|
||||
onValueChange = {
|
||||
.debounce(100.milliseconds)
|
||||
.collect {
|
||||
viewModel.setControlCommandValue(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME,
|
||||
byteArrayOf(it.toInt().toByte(), 0x50)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone_volume),
|
||||
description = stringResource(R.string.tone_volume_description),
|
||||
value = toneVolumeValue.floatValue,
|
||||
onValueChange = {
|
||||
toneVolumeValue.floatValue = it
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
snapPoints = listOf(75f),
|
||||
startIcon = "\uDBC0\uDEA1",
|
||||
endIcon = "\uDBC0\uDEA9",
|
||||
independent = true,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
|
||||
val volumeSwipeEnabled =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(
|
||||
0
|
||||
)?.toInt() == 0x01
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.volume_control),
|
||||
description = stringResource(R.string.volume_control_description),
|
||||
checked = volumeSwipeEnabled,
|
||||
onCheckedChange = {
|
||||
viewModel.setControlCommandBoolean(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it
|
||||
)
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
snapPoints = listOf(75f),
|
||||
startIcon = "\uDBC0\uDEA1",
|
||||
endIcon = "\uDBC0\uDEA9",
|
||||
independent = true,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
|
||||
val volumeSwipeEnabled =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(
|
||||
0
|
||||
)?.toInt() == 0x01
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.volume_control),
|
||||
description = stringResource(R.string.volume_control_description),
|
||||
checked = volumeSwipeEnabled,
|
||||
onCheckedChange = {
|
||||
viewModel.setControlCommandBoolean(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it
|
||||
)
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
StyledList(
|
||||
title = stringResource(R.string.volume_swipe_speed),
|
||||
description = stringResource(R.string.volume_swipe_speed_description)
|
||||
) {
|
||||
volumeSwipeSpeedOptions.forEach { (value, label) ->
|
||||
StyledListItem(
|
||||
name = label,
|
||||
selected = selectedVolumeSwipeSpeed == label,
|
||||
onClick = {
|
||||
selectedVolumeSwipeSpeed = label
|
||||
|
||||
Box(
|
||||
modifier = Modifier.then(
|
||||
if (!state.isPremium) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else Modifier)) {
|
||||
DropdownMenuComponent(
|
||||
label = stringResource(R.string.volume_swipe_speed),
|
||||
description = stringResource(R.string.volume_swipe_speed_description),
|
||||
options = volumeSwipeSpeedOptions.values.toList(),
|
||||
selectedOption = selectedVolumeSwipeSpeed
|
||||
?: stringResource(R.string.default_option),
|
||||
onOptionSelected = { newValue ->
|
||||
selectedVolumeSwipeSpeed = newValue
|
||||
viewModel.setControlCommandByte(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
||||
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
|
||||
?: 1.toByte()
|
||||
value = value
|
||||
)
|
||||
},
|
||||
textColor = textColor,
|
||||
hazeState = hazeState,
|
||||
independent = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if (!hearingAidEnabled && XposedState.isAvailable) {
|
||||
// Text(
|
||||
@@ -635,210 +580,6 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
private fun DropdownMenuComponent(
|
||||
label: String,
|
||||
options: List<String>,
|
||||
selectedOption: String,
|
||||
onOptionSelected: (String) -> Unit,
|
||||
textColor: Color,
|
||||
hazeState: HazeState,
|
||||
description: String? = null,
|
||||
independent: Boolean = true
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val itemHeightPx = with(density) { 48.dp.toPx() }
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var touchOffset by remember { mutableStateOf<Offset?>(null) }
|
||||
var boxPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
||||
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var parentDragActive by remember { mutableStateOf(false) }
|
||||
var previousIdx by remember { mutableStateOf<Int?>(null) }
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (independent) {
|
||||
if (description != null) {
|
||||
Modifier.padding(top = 8.dp, bottom = 4.dp)
|
||||
} else {
|
||||
Modifier.padding(vertical = 8.dp)
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
.background(
|
||||
if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(
|
||||
0xFFFFFFFF
|
||||
)) else Color.Transparent,
|
||||
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
|
||||
) then (if (independent) Modifier.padding(horizontal = 4.dp) else Modifier).clip(
|
||||
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 12.dp)
|
||||
.height(58.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (expanded) {
|
||||
expanded = false
|
||||
lastDismissTime = now
|
||||
} else {
|
||||
if (now - lastDismissTime > 250L) {
|
||||
touchOffset = offset
|
||||
expanded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(onDragStart = { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
touchOffset = offset
|
||||
if (!expanded && now - lastDismissTime > 250L) {
|
||||
expanded = true
|
||||
}
|
||||
lastDismissTime = now
|
||||
parentDragActive = true
|
||||
parentHoveredIndex = 0
|
||||
}, onDrag = { change, _ ->
|
||||
val current = change.position
|
||||
val touch = touchOffset ?: current
|
||||
val posInPopupY = current.y - touch.y
|
||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||
if (idx != previousIdx) {
|
||||
scope.launch {
|
||||
haptics.performHapticFeedback(
|
||||
HapticFeedbackType.SegmentTick
|
||||
)
|
||||
}
|
||||
}
|
||||
parentHoveredIndex = idx
|
||||
previousIdx = idx
|
||||
}, onDragEnd = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex?.let { idx ->
|
||||
if (idx in options.indices) {
|
||||
onOptionSelected(options[idx])
|
||||
expanded = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
|
||||
scope.launch {
|
||||
haptics.performHapticFeedback(
|
||||
HapticFeedbackType.GestureEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
parentHoveredIndex = null
|
||||
}, onDragCancel = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex = null
|
||||
})
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
if (!independent && description != null) {
|
||||
Text(
|
||||
text = description, style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
), modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
boxPosition = coordinates.positionInParent()
|
||||
}) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = selectedOption, style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "", style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
), modifier = Modifier.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
StyledDropdown(
|
||||
expanded = expanded,
|
||||
onDismissRequest = {
|
||||
expanded = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
},
|
||||
options = options,
|
||||
selectedOption = selectedOption,
|
||||
touchOffset = touchOffset,
|
||||
boxPosition = boxPosition,
|
||||
externalHoveredIndex = parentHoveredIndex,
|
||||
externalDragActive = parentDragActive,
|
||||
onOptionSelected = { option ->
|
||||
onOptionSelected(option)
|
||||
expanded = false
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (independent && description != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.background(
|
||||
if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = description, style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||
alpha = 0.6f
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,19 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -36,95 +41,82 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
|
||||
@Composable
|
||||
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.customize_adaptive_audio)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
if (!state.isPremium) {
|
||||
StyledButton(
|
||||
onClick = {
|
||||
navController.navigate("purchase_screen")
|
||||
},
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val sliderValue = remember {
|
||||
mutableFloatStateOf(
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(
|
||||
0
|
||||
)?.toFloat() ?: 50f
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
if (!state.isPremium) {
|
||||
StyledButton(
|
||||
onClick = navigateToPurchase,
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
var job by remember { mutableStateOf<Job?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.customize_adaptive_audio),
|
||||
value = sliderValue.floatValue,
|
||||
onValueChange = {
|
||||
sliderValue.floatValue = it
|
||||
job?.cancel()
|
||||
job = scope.launch {
|
||||
delay(150)
|
||||
viewModel.setControlCommandValue(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
||||
byteArrayOf((100 - it).toInt().toByte())
|
||||
)
|
||||
}
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
snapPoints = listOf(0f, 50f, 100f),
|
||||
startIcon = "",
|
||||
endIcon = "",
|
||||
independent = true,
|
||||
description = stringResource(R.string.adaptive_audio_description),
|
||||
enabled = state.isPremium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
val sliderValue = remember {
|
||||
mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f))
|
||||
}
|
||||
var job by remember { mutableStateOf<Job?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.customize_adaptive_audio),
|
||||
value = sliderValue.floatValue,
|
||||
onValueChange = {
|
||||
sliderValue.floatValue = it
|
||||
job?.cancel()
|
||||
job = scope.launch {
|
||||
delay(150)
|
||||
viewModel.setControlCommandValue(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
||||
byteArrayOf((100 - it).toInt().toByte())
|
||||
)
|
||||
}
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
snapPoints = listOf(0f, 50f, 100f),
|
||||
startIcon = "",
|
||||
endIcon = "",
|
||||
independent = true,
|
||||
description = stringResource(R.string.adaptive_audio_description),
|
||||
enabled = state.isPremium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CallControlScreen(viewModel: AirPodsViewModel, action: String, onCallControlValueChanged: (Boolean) -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val bytes =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(
|
||||
2
|
||||
)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
|
||||
val flipped = try {
|
||||
bytes[1] == 0x02.toByte()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
val pressOnceText = stringResource(R.string.press_once)
|
||||
val pressTwiceText = stringResource(R.string.press_twice)
|
||||
val muteUnmuteText = stringResource(R.string.mute_unmute)
|
||||
|
||||
var singlePressAction by remember { mutableStateOf(if ((action == muteUnmuteText) == !flipped) pressOnceText else pressTwiceText) }
|
||||
|
||||
val pressOnceIsAction by remember { derivedStateOf { singlePressAction == pressOnceText } }
|
||||
val flippedValue = action != muteUnmuteText
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
StyledList {
|
||||
StyledListItem(
|
||||
name = pressOnceText,
|
||||
selected = pressOnceIsAction,
|
||||
onClick = {
|
||||
singlePressAction = pressOnceText
|
||||
onCallControlValueChanged(flippedValue)
|
||||
}
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = pressTwiceText,
|
||||
selected = !pressOnceIsAction,
|
||||
onClick = {
|
||||
singlePressAction = pressTwiceText
|
||||
onCallControlValueChanged(!flippedValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
@@ -18,94 +18,64 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import android.accessibilityservice.AccessibilityServiceInfo
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.SelectItem
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSelectList
|
||||
import me.kavishdevar.librepods.services.AppListenerService
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
|
||||
@Composable
|
||||
fun CameraControlScreen(viewModel: AirPodsViewModel) {
|
||||
val context = LocalContext.current
|
||||
val currentCameraAction by viewModel.cameraAction.collectAsState()
|
||||
|
||||
fun isAppListenerServiceEnabled(context: Context): Boolean {
|
||||
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
|
||||
val enabledServices =
|
||||
am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
|
||||
val serviceComponent = ComponentName(context, AppListenerService::class.java)
|
||||
return enabledServices.any {
|
||||
it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName &&
|
||||
it.resolveInfo.serviceInfo.name == serviceComponent.className
|
||||
}
|
||||
}
|
||||
|
||||
fun handleSelection(action: StemPressType?) {
|
||||
if (action != null && !isAppListenerServiceEnabled(context)) {
|
||||
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
} else {
|
||||
viewModel.setCameraAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
val cameraOptions = remember(currentCameraAction) {
|
||||
listOf(
|
||||
SelectItem(
|
||||
name = "Off",
|
||||
selected = currentCameraAction == null,
|
||||
onClick = { handleSelection(null) }
|
||||
),
|
||||
SelectItem(
|
||||
name = "Press once",
|
||||
selected = currentCameraAction == StemPressType.SINGLE_PRESS,
|
||||
onClick = { handleSelection(StemPressType.SINGLE_PRESS) }
|
||||
),
|
||||
SelectItem(
|
||||
name = "Press and hold AirPods",
|
||||
selected = currentCameraAction == StemPressType.LONG_PRESS,
|
||||
onClick = { handleSelection(StemPressType.LONG_PRESS) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.camera_control)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
StyledSelectList(items = cameraOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
//@Composable
|
||||
//fun CameraControlScreen(viewModel: AirPodsViewModel) {
|
||||
// val context = LocalContext.current
|
||||
// val currentCameraAction by viewModel.cameraAction.collectAsState()
|
||||
//
|
||||
// fun isAppListenerServiceEnabled(context: Context): Boolean {
|
||||
// val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
|
||||
// val enabledServices =
|
||||
// am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
|
||||
// val serviceComponent = ComponentName(context, AppListenerService::class.java)
|
||||
// return enabledServices.any {
|
||||
// it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName &&
|
||||
// it.resolveInfo.serviceInfo.name == serviceComponent.className
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// fun handleSelection(action: StemPressType?) {
|
||||
// if (action != null && !isAppListenerServiceEnabled(context)) {
|
||||
// context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
// } else {
|
||||
// viewModel.setCameraAction(action)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// val cameraOptions = remember(currentCameraAction) {
|
||||
// listOf(
|
||||
// SelectItem(
|
||||
// name = "Off",
|
||||
// selected = currentCameraAction == null,
|
||||
// onClick = { handleSelection(null) }
|
||||
// ),
|
||||
// SelectItem(
|
||||
// name = "Press once",
|
||||
// selected = currentCameraAction == StemPressType.SINGLE_PRESS,
|
||||
// onClick = { handleSelection(StemPressType.SINGLE_PRESS) }
|
||||
// ),
|
||||
// SelectItem(
|
||||
// name = "Press and hold AirPods",
|
||||
// selected = currentCameraAction == StemPressType.LONG_PRESS,
|
||||
// onClick = { handleSelection(StemPressType.LONG_PRESS) }
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// val backdrop = rememberLayerBackdrop()
|
||||
//
|
||||
// StyledScaffold(
|
||||
// titleRes = stringResource(R.string.camera_control)
|
||||
// ) { spacerHeight ->
|
||||
// Column(
|
||||
// modifier = Modifier
|
||||
// .fillMaxSize()
|
||||
// .layerBackdrop(backdrop)
|
||||
// .padding(horizontal = 16.dp),
|
||||
// verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
// ) {
|
||||
// Spacer(modifier = Modifier.height(spacerHeight))
|
||||
// StyledSelectList(items = cameraOptions)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
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
|
||||
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/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.data.BatteryStatus
|
||||
import me.kavishdevar.librepods.data.isHeadTrackingData
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
data class PacketInfo(
|
||||
val type: String,
|
||||
val description: String,
|
||||
val rawData: String,
|
||||
val parsedData: Map<String, String> = emptyMap(),
|
||||
val isUnknown: Boolean = false
|
||||
)
|
||||
|
||||
fun parsePacket(message: String): PacketInfo {
|
||||
val rawData = if (message.startsWith("Sent")) message.substring(5) else message.substring(9)
|
||||
val bytes = rawData.split(" ").mapNotNull {
|
||||
it.takeIf { it.isNotEmpty() }?.toIntOrNull(16)?.toByte()
|
||||
}.toByteArray()
|
||||
|
||||
val airPodsService = ServiceManager.getService()
|
||||
if (airPodsService != null) {
|
||||
return when {
|
||||
message.startsWith("Sent") -> parseOutgoingPacket(bytes, rawData)
|
||||
airPodsService.batteryNotification.isBatteryData(bytes) -> {
|
||||
val batteryInfo = mutableMapOf<String, String>()
|
||||
airPodsService.batteryNotification.setBattery(bytes)
|
||||
val batteries = airPodsService.batteryNotification.getBattery()
|
||||
val batteryInfoString = batteries.joinToString(", ") { battery ->
|
||||
"${battery.getComponentName() ?: "Unknown"}: ${battery.level}% ${if (battery.status == BatteryStatus.CHARGING) "(Charging)" else ""}"
|
||||
}
|
||||
batteries.forEach { battery ->
|
||||
if (battery.status != BatteryStatus.DISCONNECTED) {
|
||||
batteryInfo[battery.getComponentName() ?: "Unknown"] =
|
||||
"${battery.level}% ${if (battery.status == BatteryStatus.CHARGING) "(Charging)" else ""}"
|
||||
}
|
||||
}
|
||||
|
||||
PacketInfo(
|
||||
"Battery",
|
||||
batteryInfoString,
|
||||
rawData,
|
||||
batteryInfo
|
||||
)
|
||||
}
|
||||
airPodsService.ancNotification.isANCData(bytes) -> {
|
||||
airPodsService.ancNotification.setStatus(bytes)
|
||||
val mode = when (airPodsService.ancNotification.status) {
|
||||
1 -> "Off"
|
||||
2 -> "Noise Cancellation"
|
||||
3 -> "Transparency"
|
||||
4 -> "Adaptive"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
PacketInfo(
|
||||
"Noise Control",
|
||||
"Mode: $mode",
|
||||
rawData,
|
||||
mapOf("Mode" to mode)
|
||||
)
|
||||
}
|
||||
airPodsService.earDetectionNotification.isEarDetectionData(bytes) -> {
|
||||
airPodsService.earDetectionNotification.setStatus(bytes)
|
||||
val status = airPodsService.earDetectionNotification.status
|
||||
val primaryStatus = if (status[0] == 0.toByte()) "In ear" else "Out of ear"
|
||||
val secondaryStatus = if (status[1] == 0.toByte()) "In ear" else "Out of ear"
|
||||
|
||||
PacketInfo(
|
||||
"Ear Detection",
|
||||
"Primary: $primaryStatus, Secondary: $secondaryStatus",
|
||||
rawData,
|
||||
mapOf("Primary" to primaryStatus, "Secondary" to secondaryStatus)
|
||||
)
|
||||
}
|
||||
airPodsService.conversationAwarenessNotification.isConversationalAwarenessData(bytes) -> {
|
||||
airPodsService.conversationAwarenessNotification.setData(bytes)
|
||||
val statusMap = mapOf(
|
||||
1.toByte() to "Started speaking",
|
||||
2.toByte() to "Speaking",
|
||||
8.toByte() to "Stopped speaking",
|
||||
9.toByte() to "Not speaking"
|
||||
)
|
||||
val status = statusMap[airPodsService.conversationAwarenessNotification.status] ?:
|
||||
"Unknown (${airPodsService.conversationAwarenessNotification.status})"
|
||||
|
||||
PacketInfo(
|
||||
"Conversation Awareness",
|
||||
"Status: $status",
|
||||
rawData,
|
||||
mapOf("Status" to status)
|
||||
)
|
||||
}
|
||||
isHeadTrackingData(bytes) -> {
|
||||
val horizontal = if (bytes.size >= 53)
|
||||
"${bytes[51].toInt() and 0xFF or (bytes[52].toInt() shl 8)}" else "Unknown"
|
||||
val vertical = if (bytes.size >= 55)
|
||||
"${bytes[53].toInt() and 0xFF or (bytes[54].toInt() shl 8)}" else "Unknown"
|
||||
|
||||
PacketInfo(
|
||||
"Head Tracking",
|
||||
"Position data",
|
||||
rawData,
|
||||
mapOf("Horizontal" to horizontal, "Vertical" to vertical)
|
||||
)
|
||||
}
|
||||
else -> PacketInfo("Unknown", "Unknown packet format", rawData, emptyMap(), true)
|
||||
}
|
||||
} else {
|
||||
return if (message.startsWith("Sent")) {
|
||||
parseOutgoingPacket(bytes, rawData)
|
||||
} else {
|
||||
PacketInfo("Unknown", "Unknown packet format", rawData, emptyMap(), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseOutgoingPacket(bytes: ByteArray, rawData: String): PacketInfo {
|
||||
if (bytes.size < 7) {
|
||||
return PacketInfo("Unknown", "Unknown outgoing packet", rawData, emptyMap(), true)
|
||||
}
|
||||
|
||||
return when {
|
||||
bytes.size >= 16 &&
|
||||
bytes[0] == 0x00.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() -> {
|
||||
PacketInfo("Handshake", "Initial handshake with AirPods", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 11 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x09.toByte() &&
|
||||
bytes[5] == 0x00.toByte() &&
|
||||
bytes[6] == 0x0d.toByte() -> {
|
||||
val mode = when (bytes[7].toInt()) {
|
||||
1 -> "Off"
|
||||
2 -> "Noise Cancellation"
|
||||
3 -> "Transparency"
|
||||
4 -> "Adaptive"
|
||||
else -> "Unknown"
|
||||
}
|
||||
PacketInfo("Noise Control", "Set mode to $mode", rawData, mapOf("Mode" to mode))
|
||||
}
|
||||
|
||||
bytes.size >= 11 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x09.toByte() &&
|
||||
bytes[5] == 0x00.toByte() &&
|
||||
bytes[6] == 0x28.toByte() -> {
|
||||
val mode = if (bytes[7].toInt() == 1) "On" else "Off"
|
||||
PacketInfo("Conversation Awareness", "Set mode to $mode", rawData, mapOf("Mode" to mode))
|
||||
}
|
||||
|
||||
bytes.size > 10 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x17.toByte() -> {
|
||||
val action = if (bytes.joinToString(" ") { "%02X".format(it) }.contains("A1 02")) "Start" else "Stop"
|
||||
PacketInfo("Head Tracking", "$action head tracking", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 11 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x09.toByte() &&
|
||||
bytes[5] == 0x00.toByte() &&
|
||||
bytes[6] == 0x1A.toByte() -> {
|
||||
PacketInfo("Long Press Config", "Change long press modes", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 9 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x4d.toByte() -> {
|
||||
PacketInfo("Feature Request", "Set specific features", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 9 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x0f.toByte() -> {
|
||||
PacketInfo("Notifications", "Request notifications", rawData)
|
||||
}
|
||||
|
||||
else -> PacketInfo("Unknown", "Unknown outgoing packet", rawData, emptyMap(), true)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag")
|
||||
@Composable
|
||||
fun DebugScreen(navController: NavController) {
|
||||
val context = LocalContext.current
|
||||
val listState = rememberLazyListState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val airPodsService = remember { ServiceManager.getService() }
|
||||
val packetLogs = airPodsService?.packetLogsFlow?.collectAsState(emptySet())?.value ?: emptySet()
|
||||
|
||||
val refreshTrigger = remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(refreshTrigger.intValue) {
|
||||
while(true) {
|
||||
delay(1000)
|
||||
refreshTrigger.intValue += 1
|
||||
}
|
||||
}
|
||||
|
||||
val expandedItems = remember { mutableStateOf(setOf<Int>()) }
|
||||
|
||||
fun copyToClipboard(text: String) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Packet Data", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(context, "Packet copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
LaunchedEffect(packetLogs.size, refreshTrigger.intValue) {
|
||||
if (packetLogs.isNotEmpty()) {
|
||||
listState.animateScrollToItem(packetLogs.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = "Debug",
|
||||
actionButtons = listOf(
|
||||
{scaffoldBackdrop ->
|
||||
StyledIconButton(
|
||||
onClick = {
|
||||
airPodsService?.clearLogs()
|
||||
expandedItems.value = emptySet()
|
||||
},
|
||||
icon = "",
|
||||
backdrop = scaffoldBackdrop
|
||||
)
|
||||
}
|
||||
),
|
||||
) { topPadding, hazeState, bottomPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.hazeSource(hazeState)
|
||||
.navigationBarsPadding()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
content = {
|
||||
items(packetLogs.size) { index ->
|
||||
val message = packetLogs.elementAt(index)
|
||||
val isSent = message.startsWith("Sent")
|
||||
val isExpanded = expandedItems.value.contains(index)
|
||||
val packetInfo = parsePacket(message)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
expandedItems.value = if (isExpanded) {
|
||||
expandedItems.value - index
|
||||
} else {
|
||||
expandedItems.value + index
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
copyToClipboard(packetInfo.rawData)
|
||||
}
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
|
||||
)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (isSent) "" else "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isSent) Color(0xFF4CD964) else Color(0xFFFF3B30)
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = if (packetInfo.isUnknown) {
|
||||
val shortenedData = packetInfo.rawData.take(60) +
|
||||
(if (packetInfo.rawData.length > 60) "..." else "")
|
||||
shortenedData
|
||||
} else {
|
||||
"${packetInfo.type}: ${packetInfo.description}"
|
||||
},
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
)
|
||||
)
|
||||
if (isExpanded) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
if (packetInfo.parsedData.isNotEmpty()) {
|
||||
packetInfo.parsedData.forEach { (key, value) ->
|
||||
Row {
|
||||
Text(
|
||||
text = "$key: ",
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
),
|
||||
color = Color.Gray
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
),
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Raw: ${packetInfo.rawData}",
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
),
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val airPodsService = ServiceManager.getService()?.let { mutableStateOf(it) }
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val packet = remember { mutableStateOf(TextFieldValue("")) }
|
||||
TextField(
|
||||
value = packet.value,
|
||||
onValueChange = { packet.value = it },
|
||||
label = { Text("Packet") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(bottom = 5.dp),
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (packet.value.text.isNotBlank()) {
|
||||
airPodsService?.value?.aacpManager?.sendPacket(
|
||||
packet.value.text
|
||||
.split(" ")
|
||||
.map { it.toInt(16).toByte() }
|
||||
.toByteArray()
|
||||
)
|
||||
packet.value = TextFieldValue("")
|
||||
focusManager.clearFocus()
|
||||
|
||||
if (packetLogs.isNotEmpty()) {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
listState.animateScrollToItem(
|
||||
index = (packetLogs.size - 1).coerceAtLeast(0),
|
||||
scrollOffset = 0
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
listState.scrollToItem(
|
||||
index = (packetLogs.size - 1).coerceAtLeast(0)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
Icon(Icons.Filled.Send, contentDescription = "Send")
|
||||
}
|
||||
},
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
|
||||
unfocusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
unfocusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.6f),
|
||||
focusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black,
|
||||
unfocusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
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
|
||||
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 me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.visible
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.lens
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.demoState
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun EqualizerRoute(viewModel: AirPodsViewModel) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
EqualizerScreen(
|
||||
state = state,
|
||||
topPadding = topPadding,
|
||||
bottomPadding = bottomPadding,
|
||||
setCustomEqEnabled = viewModel::setCustomEqEnabled,
|
||||
setCustomEq = viewModel::setCustomEq
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
fun EqualizerScreen(
|
||||
state: AirPodsUiState,
|
||||
topPadding: Dp = 16.dp,
|
||||
bottomPadding: Dp = 16.dp,
|
||||
setCustomEqEnabled: (Boolean) -> Unit,
|
||||
setCustomEq: (Int, Int, Int) -> Unit
|
||||
) {
|
||||
val customEq = state.customEq
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
val height = 200.dp
|
||||
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
|
||||
|
||||
val offsets = remember(state.customEq) {
|
||||
listOf(
|
||||
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)),
|
||||
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)),
|
||||
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100))
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(offsets) {
|
||||
snapshotFlow {
|
||||
Triple(
|
||||
offsets[0].floatValue,
|
||||
offsets[1].floatValue,
|
||||
offsets[2].floatValue
|
||||
)
|
||||
}
|
||||
.debounce(100.milliseconds) // cool, should've been using this since the very beginning
|
||||
.collect { (lowF, midF, highF) ->
|
||||
val low =
|
||||
100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||
val mid =
|
||||
100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||
val high =
|
||||
100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||
|
||||
setCustomEq(low, mid, high)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
val enabled = customEq.isEnabled()
|
||||
|
||||
StyledList {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.recommended),
|
||||
selected = !enabled,
|
||||
onClick = { setCustomEqEnabled(false) }
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.custom),
|
||||
selected = enabled,
|
||||
onClick = { setCustomEqEnabled(true) }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Crossfade (
|
||||
customEq.isEnabled()
|
||||
) { visible ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.visible(visible),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
|
||||
EqualizerCard(
|
||||
lowOffset = offsets[0],
|
||||
midOffset = offsets[1],
|
||||
highOffset = offsets[2]
|
||||
)
|
||||
|
||||
val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } }
|
||||
|
||||
StyledButton(
|
||||
onClick = {
|
||||
offsets[0].floatValue = 0f
|
||||
offsets[1].floatValue = 0f
|
||||
offsets[2].floatValue = 0f
|
||||
},
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isInteractive = false,
|
||||
enabled = resetButtonEnabled.value
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.reset),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun EqualizerCard(
|
||||
lowOffset: MutableState<Float>,
|
||||
midOffset: MutableState<Float>,
|
||||
highOffset: MutableState<Float>
|
||||
) {
|
||||
val height = 200.dp
|
||||
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp))
|
||||
) {
|
||||
val dashColor = if (isSystemInDarkTheme()) Color(0x80AAAAAA) else Color(0x809D9D9D)
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp))
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(42.dp))
|
||||
// Row(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .padding(18.dp),
|
||||
// verticalAlignment = Alignment.CenterVertically,
|
||||
// horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
// ) {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .size(64.dp)
|
||||
// .background(if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, RoundedCornerShape(12.dp))
|
||||
// )
|
||||
// Column(
|
||||
// modifier = Modifier
|
||||
// .weight(1f),
|
||||
// verticalArrangement = Arrangement.Center
|
||||
// ) {
|
||||
// Text(
|
||||
// text = "Written into Changes",
|
||||
// style = TextStyle(
|
||||
// fontSize = 16.sp,
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// fontWeight = FontWeight.Bold,
|
||||
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
// )
|
||||
// )
|
||||
// Spacer(modifier = Modifier.height(4.dp))
|
||||
// Text(
|
||||
// text = "Avalon Emerson",
|
||||
// style = TextStyle(
|
||||
// fontSize = 14.sp,
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// fontWeight = FontWeight.Normal,
|
||||
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// val paused = remember { mutableStateOf(false) }
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .size(48.dp)
|
||||
// .background(Color(0x600091FF), CircleShape)
|
||||
// .clickable(
|
||||
// interactionSource = remember { MutableInteractionSource() },
|
||||
// indication = null,
|
||||
// ) {
|
||||
// paused.value = !paused.value
|
||||
// },
|
||||
// contentAlignment = Alignment.Center
|
||||
// ) {
|
||||
// Crossfade(
|
||||
// targetState = paused.value,
|
||||
// label = "media_icon"
|
||||
// ) { p ->
|
||||
// Text(
|
||||
// text = if (p) "" else "",
|
||||
// style = TextStyle(
|
||||
// fontSize = 24.sp,
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// fontWeight = FontWeight.Normal,
|
||||
// color = Color(0xFF0091FF),
|
||||
// textAlign = TextAlign.Center
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// HorizontalDivider(
|
||||
// thickness = 1.dp,
|
||||
// color = Color(0x40888888),
|
||||
// modifier = Modifier
|
||||
// .padding(horizontal = 20.dp)
|
||||
// .padding(bottom = 16.dp)
|
||||
// )
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
fun colorFromY(y: Float): Color {
|
||||
val f = ((y + maxOffset) / (2f * maxOffset)).coerceIn(0f, 1f)
|
||||
val stops = listOf(
|
||||
0.0f to Color(0xFFFFA300),
|
||||
0.25f to Color(0xFFFCE600),
|
||||
0.5f to Color(0xFF00FAAF),
|
||||
0.75f to Color(0xFF00FAFF),
|
||||
1.0f to Color(0xFF00B5FF)
|
||||
)
|
||||
val (start, end) = stops.zipWithNext()
|
||||
.first { f <= it.second.first }
|
||||
val c = (f - start.first) / (end.first - start.first)
|
||||
return lerp(start.second, end.second, c)
|
||||
}
|
||||
|
||||
fun pathBrush(
|
||||
startY: Float,
|
||||
endY: Float,
|
||||
): Brush {
|
||||
val stops = (0..20).map { i ->
|
||||
val t = i / 20f
|
||||
val y = lerp(startY, endY, t)
|
||||
t to colorFromY(y)
|
||||
}
|
||||
|
||||
return Brush.linearGradient(
|
||||
colorStops = stops.toTypedArray()
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.layerBackdrop(backdrop)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.padding(horizontal = 20.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
val dashCount = (height / 10.dp).toInt()
|
||||
repeat(3) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
for (i in 1..(dashCount)) {
|
||||
val t = i.toFloat() / dashCount
|
||||
val centerDistance = abs(0.5f - t)
|
||||
val alpha = 1f - (centerDistance * 2f)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(9.dp)
|
||||
.width(0.75.dp)
|
||||
.background(
|
||||
dashColor.copy(alpha),
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val backgroundColor = MaterialTheme.colorScheme.surface
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
val canvasWidth = size.width
|
||||
|
||||
drawLine(
|
||||
color = backgroundColor,
|
||||
start = Offset(
|
||||
x = 0f,
|
||||
y = lowOffset.value + maxOffset
|
||||
),
|
||||
end = Offset(
|
||||
x = 1 / 6f * canvasWidth,
|
||||
y = lowOffset.value + maxOffset
|
||||
),
|
||||
strokeWidth = 10f
|
||||
)
|
||||
drawLine(
|
||||
color = colorFromY(lowOffset.value),
|
||||
start = Offset(
|
||||
x = 0f,
|
||||
y = lowOffset.value + maxOffset
|
||||
),
|
||||
end = Offset(
|
||||
x = 1 / 6f * canvasWidth,
|
||||
y = lowOffset.value + maxOffset
|
||||
),
|
||||
strokeWidth = 8f
|
||||
)
|
||||
|
||||
val lowToMidPath = Path()
|
||||
lowToMidPath.moveTo(
|
||||
x = 1 / 6f * canvasWidth,
|
||||
y = lowOffset.value + maxOffset
|
||||
)
|
||||
lowToMidPath.cubicTo(
|
||||
x1 = canvasWidth * 1 / 6f + 108.dp.value,
|
||||
y1 = lowOffset.value + maxOffset,
|
||||
x2 = canvasWidth * 0.5f - 108.dp.value,
|
||||
y2 = midOffset.value + maxOffset,
|
||||
x3 = canvasWidth * 0.5f,
|
||||
y3 = midOffset.value + maxOffset
|
||||
)
|
||||
drawPath(
|
||||
color = backgroundColor,
|
||||
path = lowToMidPath,
|
||||
style = Stroke(width = 10f)
|
||||
)
|
||||
drawPath(
|
||||
brush = pathBrush(
|
||||
lowOffset.value,
|
||||
midOffset.value
|
||||
),
|
||||
path = lowToMidPath,
|
||||
style = Stroke(width = 8f)
|
||||
)
|
||||
|
||||
val midToHighPath = Path()
|
||||
midToHighPath.moveTo(
|
||||
x = 0.5f * canvasWidth,
|
||||
y = midOffset.value + maxOffset
|
||||
)
|
||||
midToHighPath.cubicTo(
|
||||
x1 = canvasWidth * 0.5f + 108.dp.value,
|
||||
y1 = midOffset.value + maxOffset,
|
||||
x2 = canvasWidth * 5 / 6f - 108.dp.value,
|
||||
y2 = highOffset.value + maxOffset,
|
||||
x3 = canvasWidth * 5 / 6f,
|
||||
y3 = highOffset.value + maxOffset
|
||||
)
|
||||
drawPath(
|
||||
color = backgroundColor,
|
||||
path = midToHighPath,
|
||||
style = Stroke(width = 10f)
|
||||
)
|
||||
drawPath(
|
||||
brush = pathBrush(
|
||||
midOffset.value,
|
||||
highOffset.value
|
||||
),
|
||||
path = midToHighPath,
|
||||
style = Stroke(width = 8f)
|
||||
)
|
||||
drawLine(
|
||||
color = backgroundColor,
|
||||
start = Offset(
|
||||
x = 5 / 6f * canvasWidth,
|
||||
y = highOffset.value + maxOffset
|
||||
),
|
||||
end = Offset(
|
||||
x = 1f * canvasWidth,
|
||||
y = highOffset.value + maxOffset
|
||||
),
|
||||
strokeWidth = 10f
|
||||
)
|
||||
drawLine(
|
||||
color = colorFromY(highOffset.value),
|
||||
start = Offset(
|
||||
x = 5 / 6f * canvasWidth,
|
||||
y = highOffset.value + maxOffset
|
||||
),
|
||||
end = Offset(
|
||||
x = 1f * canvasWidth,
|
||||
y = highOffset.value + maxOffset
|
||||
),
|
||||
strokeWidth = 8f
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp, horizontal = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "Low".uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||
0.2f
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "Mid".uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||
0.2f
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "High".uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||
0.2f
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.padding(horizontal = 20.dp),
|
||||
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
for (i in 0..2) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
val pressed = remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = 0,
|
||||
y = when (i) { 0 -> lowOffset.value; 1 -> midOffset.value; 2-> highOffset.value else -> 0f}.roundToInt()
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Crossfade(
|
||||
pressed.value
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.then(
|
||||
if (it) {
|
||||
Modifier.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { CircleShape },
|
||||
highlight = {
|
||||
Highlight.Ambient
|
||||
},
|
||||
onDrawSurface = {
|
||||
drawCircle(
|
||||
color = Color.White.copy(
|
||||
0.2f
|
||||
),
|
||||
radius = size.height
|
||||
)
|
||||
drawCircle(
|
||||
color = colorFromY(
|
||||
when (i) {
|
||||
0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value
|
||||
else -> 0f
|
||||
}
|
||||
),
|
||||
style = Stroke(2.dp.value),
|
||||
radius = size.height / 2
|
||||
)
|
||||
},
|
||||
effects = {
|
||||
lens(
|
||||
refractionHeight = 32f.dp.value,
|
||||
refractionAmount = size.height
|
||||
)
|
||||
}
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.background(
|
||||
colorFromY(
|
||||
when (i) {
|
||||
0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value
|
||||
else -> 0f
|
||||
}
|
||||
),
|
||||
CircleShape
|
||||
)
|
||||
.border(
|
||||
2.5.dp,
|
||||
MaterialTheme.colorScheme.surfaceContainer,
|
||||
CircleShape
|
||||
)
|
||||
.draggable(
|
||||
orientation = Orientation.Vertical,
|
||||
state = rememberDraggableState { delta ->
|
||||
when (i) {
|
||||
0 -> {
|
||||
lowOffset.value =
|
||||
(lowOffset.value + delta).coerceIn(
|
||||
-maxOffset,
|
||||
maxOffset
|
||||
)
|
||||
}
|
||||
|
||||
1 -> {
|
||||
midOffset.value =
|
||||
(midOffset.value + delta).coerceIn(
|
||||
-maxOffset,
|
||||
maxOffset
|
||||
)
|
||||
}
|
||||
|
||||
2 -> {
|
||||
highOffset.value =
|
||||
(highOffset.value + delta).coerceIn(
|
||||
-maxOffset,
|
||||
maxOffset
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragStarted = {
|
||||
pressed.value = true
|
||||
},
|
||||
onDragStopped = {
|
||||
pressed.value = false
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Apple")
|
||||
@Composable
|
||||
fun EqualizerScreenPreviewApple() {
|
||||
LibrePodsTheme(
|
||||
m3eEnabled = false
|
||||
) {
|
||||
Box (
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
EqualizerScreen(
|
||||
state = demoState,
|
||||
setCustomEqEnabled = { },
|
||||
setCustomEq = {_, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Material")
|
||||
@Composable
|
||||
fun EqualizerScreenPreviewMaterial() {
|
||||
LibrePodsTheme(
|
||||
m3eEnabled = true
|
||||
) {
|
||||
val state = remember { mutableStateOf(demoState) }
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.wrapContentHeight()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
EqualizerScreen(
|
||||
state = state.value,
|
||||
setCustomEqEnabled = { state.value = state.value.copy(customEq = state.value.customEq.copy(state = if (it) 2 else 1)) },
|
||||
setCustomEq = {low, mid, high -> state.value = state.value.copy(customEq = state.value.customEq.copy(low = low, mid = mid, high = high))}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,6 @@
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.Shader
|
||||
import android.graphics.Typeface
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
@@ -36,21 +32,26 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -66,52 +67,38 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.asAndroidPath
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.HeadTracking
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||
fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.startHeadTracking()
|
||||
@@ -123,508 +110,169 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
|
||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.head_tracking),
|
||||
actionButtons = listOf(
|
||||
{ scaffoldBackdrop ->
|
||||
StyledIconButton(
|
||||
onClick = {
|
||||
if (!state.headTrackingActive) {
|
||||
viewModel.startHeadTracking()
|
||||
Log.d("HeadTrackingScreen", "Head tracking started")
|
||||
} else {
|
||||
viewModel.stopHeadTracking()
|
||||
Log.d("HeadTrackingScreen", "Head tracking stopped")
|
||||
}
|
||||
},
|
||||
icon = if (state.headTrackingActive) "" else "",
|
||||
backdrop = scaffoldBackdrop
|
||||
)
|
||||
}
|
||||
),
|
||||
) { topPadding, hazeState, _ ->
|
||||
|
||||
var gestureText by remember { mutableStateOf("") }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
var lastClickTime by remember { mutableLongStateOf(0L) }
|
||||
var shouldExplode by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.hazeSource(state = hazeState)
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
var gestureText by remember { mutableStateOf("") }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (!state.isPremium) {
|
||||
StyledButton(
|
||||
onClick = {
|
||||
navController.navigate("purchase_screen")
|
||||
},
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
var lastClickTime by remember { mutableLongStateOf(0L) }
|
||||
var shouldExplode by remember { mutableStateOf(false) }
|
||||
|
||||
StyledToggle(
|
||||
label = "Head Gestures",
|
||||
checked = state.headGesturesEnabled,
|
||||
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
|
||||
enabled = state.isPremium,
|
||||
description = stringResource(R.string.head_gestures_details)
|
||||
)
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Head Orientation",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||
)
|
||||
HeadVisualization()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Velocity",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||
)
|
||||
AccelerationPlot()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
LaunchedEffect(gestureText) {
|
||||
if (gestureText.isNotEmpty()) {
|
||||
lastClickTime = System.currentTimeMillis()
|
||||
delay(3000)
|
||||
if (System.currentTimeMillis() - lastClickTime >= 3000) {
|
||||
shouldExplode = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
|
||||
StyledButton(
|
||||
onClick = {
|
||||
gestureText = gestureTextValue
|
||||
coroutineScope.launch {
|
||||
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
|
||||
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
|
||||
}
|
||||
},
|
||||
backdrop = backdrop,
|
||||
modifier = Modifier.fillMaxWidth(0.75f),
|
||||
maxScale = 0.05f
|
||||
) {
|
||||
Text(
|
||||
"Test Head Gestures",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = gestureText,
|
||||
transitionSpec = {
|
||||
(fadeIn(
|
||||
animationSpec = tween(300)
|
||||
) + slideInVertically(
|
||||
initialOffsetY = { 40 },
|
||||
animationSpec = tween(300)
|
||||
)).togetherWith(fadeOut(animationSpec = tween(150)))
|
||||
}
|
||||
) { text ->
|
||||
if (shouldExplode) {
|
||||
LaunchedEffect(Unit) {
|
||||
CoroutineScope(coroutineScope.coroutineContext).launch {
|
||||
delay(750)
|
||||
gestureText = ""
|
||||
}
|
||||
}
|
||||
ParticleText(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
onAnimationComplete = {
|
||||
shouldExplode = false
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private data class Particle(
|
||||
val initialPosition: Offset,
|
||||
val velocity: Offset,
|
||||
var alpha: Float = 1f
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ParticleText(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
onAnimationComplete: () -> Unit,
|
||||
) {
|
||||
val particles = remember { mutableStateListOf<Particle>() }
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
var isAnimating by remember { mutableStateOf(true) }
|
||||
var textVisible by remember { mutableStateOf(true) }
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxWidth()) {
|
||||
val textLayoutResult = textMeasurer.measure(text, style)
|
||||
val textBounds = textLayoutResult.size
|
||||
val centerX = (size.width - textBounds.width) / 2
|
||||
val centerY = size.height / 2
|
||||
|
||||
if (textVisible && particles.isEmpty()) {
|
||||
drawText(
|
||||
textMeasurer = textMeasurer,
|
||||
text = text,
|
||||
style = style,
|
||||
topLeft = Offset(centerX, centerY - textBounds.height / 2)
|
||||
)
|
||||
}
|
||||
|
||||
if (particles.isEmpty()) {
|
||||
val random = Random(System.currentTimeMillis())
|
||||
for (@Suppress("Unused")i in 0..100) {
|
||||
val x = centerX + random.nextFloat() * textBounds.width
|
||||
val y = centerY - textBounds.height / 2 + random.nextFloat() * textBounds.height
|
||||
val vx = (random.nextFloat() - 0.5f) * 20
|
||||
val vy = (random.nextFloat() - 0.5f) * 20
|
||||
particles.add(Particle(Offset(x, y), Offset(vx, vy)))
|
||||
}
|
||||
}
|
||||
|
||||
particles.forEach { particle ->
|
||||
drawCircle(
|
||||
color = style.color.copy(alpha = particle.alpha),
|
||||
radius = 0.5.dp.toPx(),
|
||||
center = particle.initialPosition
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(text) {
|
||||
while (isAnimating) {
|
||||
delay(16)
|
||||
particles.forEachIndexed { index, particle ->
|
||||
particles[index] = particle.copy(
|
||||
initialPosition = particle.initialPosition + particle.velocity,
|
||||
alpha = (particle.alpha - 0.02f).coerceAtLeast(0f)
|
||||
)
|
||||
}
|
||||
|
||||
if (particles.all { it.alpha <= 0f }) {
|
||||
isAnimating = false
|
||||
onAnimationComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeadVisualization() {
|
||||
val orientation by HeadTracking.orientation.collectAsState()
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
val strokeColor = if (darkTheme) Color.White else Color.Black
|
||||
|
||||
Card(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(2f),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = backgroundColor
|
||||
),
|
||||
shape = RoundedCornerShape(28.dp)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(scrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val center = Offset(width / 2, height / 2)
|
||||
val faceRadius = height * 0.35f
|
||||
|
||||
val pitch = Math.toRadians(orientation.pitch.toDouble())
|
||||
val yaw = Math.toRadians(orientation.yaw.toDouble())
|
||||
|
||||
val cosY = cos(yaw).toFloat()
|
||||
val sinY = sin(yaw).toFloat()
|
||||
val cosP = cos(pitch).toFloat()
|
||||
val sinP = sin(pitch).toFloat()
|
||||
|
||||
fun rotate3D(point: Triple<Float, Float, Float>): Triple<Float, Float, Float> {
|
||||
val (x, y, z) = point
|
||||
val x1 = x * cosY - z * sinY
|
||||
val z1 = x * sinY + z * cosY
|
||||
|
||||
val y2 = y * cosP - z1 * sinP
|
||||
val z2 = y * sinP + z1 * cosP
|
||||
|
||||
return Triple(x1, y2, z2)
|
||||
}
|
||||
|
||||
fun project(point: Triple<Float, Float, Float>): Pair<Float, Float> {
|
||||
val (x, y, z) = point
|
||||
val scale = 1f + (z / width)
|
||||
return Pair(center.x + x * scale, center.y + y * scale)
|
||||
}
|
||||
|
||||
val earWidth = height * 0.08f
|
||||
val earHeight = height * 0.2f
|
||||
val earOffsetX = height * 0.4f
|
||||
val earOffsetY = 0f
|
||||
val earZ = 0f
|
||||
|
||||
for (xSign in listOf(-1f, 1f)) {
|
||||
val rotated = rotate3D(Triple(earOffsetX * xSign, earOffsetY, earZ))
|
||||
val (earX, earY) = project(rotated)
|
||||
drawRoundRect(
|
||||
color = strokeColor,
|
||||
topLeft = Offset(earX - earWidth/2, earY - earHeight/2),
|
||||
size = Size(earWidth, earHeight),
|
||||
cornerRadius = CornerRadius(earWidth/2),
|
||||
style = Stroke(width = 4.dp.toPx())
|
||||
if (!state.isPremium) {
|
||||
StyledButton(
|
||||
onClick = navigateToPurchase,
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
val spherePath = Path()
|
||||
val firstPoint = project(rotate3D(Triple(faceRadius, 0f, 0f)))
|
||||
spherePath.moveTo(firstPoint.first, firstPoint.second)
|
||||
StyledToggle(
|
||||
label = "Head Gestures",
|
||||
checked = state.headGesturesEnabled,
|
||||
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
|
||||
enabled = state.isPremium || state.headGesturesEnabled,
|
||||
description = stringResource(R.string.head_gestures_details),
|
||||
header = true
|
||||
)
|
||||
|
||||
for (i in 1..32) {
|
||||
val angle = (i * 2 * Math.PI / 32).toFloat()
|
||||
val point = project(rotate3D(Triple(
|
||||
cos(angle) * faceRadius,
|
||||
sin(angle) * faceRadius,
|
||||
0f
|
||||
)))
|
||||
spherePath.lineTo(point.first, point.second)
|
||||
}
|
||||
spherePath.close()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = Paint().apply {
|
||||
style = Paint.Style.FILL
|
||||
shader = RadialGradient(
|
||||
center.x + sinY * faceRadius * 0.3f,
|
||||
center.y - sinP * faceRadius * 0.3f,
|
||||
faceRadius * 1.4f,
|
||||
intArrayOf(
|
||||
backgroundColor.copy(alpha = 1f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.95f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.9f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.8f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.7f).toArgb()
|
||||
),
|
||||
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), paint)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Velocity",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||
)
|
||||
Plot()
|
||||
|
||||
val highlightPaint = Paint().apply {
|
||||
style = Paint.Style.FILL
|
||||
shader = RadialGradient(
|
||||
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
|
||||
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
|
||||
faceRadius * 0.9f,
|
||||
intArrayOf(
|
||||
android.graphics.Color.WHITE,
|
||||
android.graphics.Color.argb(100, 255, 255, 255),
|
||||
android.graphics.Color.TRANSPARENT
|
||||
),
|
||||
floatArrayOf(0f, 0.3f, 1f),
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
alpha = if (darkTheme) 30 else 60
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), highlightPaint)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val secondaryHighlightPaint = Paint().apply {
|
||||
style = Paint.Style.FILL
|
||||
shader = RadialGradient(
|
||||
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
|
||||
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
|
||||
faceRadius * 0.7f,
|
||||
intArrayOf(
|
||||
android.graphics.Color.WHITE,
|
||||
android.graphics.Color.TRANSPARENT
|
||||
),
|
||||
floatArrayOf(0f, 1f),
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
alpha = if (darkTheme) 15 else 30
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
|
||||
|
||||
val shadowPaint = Paint().apply {
|
||||
style = Paint.Style.FILL
|
||||
shader = RadialGradient(
|
||||
center.x + sinY * faceRadius * 0.5f,
|
||||
center.y - sinP * faceRadius * 0.5f,
|
||||
faceRadius * 1.1f,
|
||||
intArrayOf(
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
android.graphics.Color.BLACK
|
||||
),
|
||||
floatArrayOf(0.7f, 1f),
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
alpha = if (darkTheme) 40 else 20
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), shadowPaint)
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = spherePath,
|
||||
color = strokeColor,
|
||||
style = Stroke(width = 4.dp.toPx())
|
||||
)
|
||||
|
||||
val smileRadius = faceRadius * 0.5f
|
||||
val smileStartAngle = -340f
|
||||
val smileSweepAngle = 140f
|
||||
val smileOffsetY = faceRadius * 0.1f
|
||||
|
||||
val smilePath = Path()
|
||||
for (i in 0..32) {
|
||||
val angle = Math.toRadians(smileStartAngle + (smileSweepAngle * i / 32.0))
|
||||
val x = cos(angle.toFloat()) * smileRadius
|
||||
val y = sin(angle.toFloat()) * smileRadius + smileOffsetY
|
||||
|
||||
val rotated = rotate3D(Triple(x, y, 0f))
|
||||
val projected = project(rotated)
|
||||
|
||||
if (i == 0) {
|
||||
smilePath.moveTo(projected.first, projected.second)
|
||||
} else {
|
||||
smilePath.lineTo(projected.first, projected.second)
|
||||
LaunchedEffect(gestureText) {
|
||||
if (gestureText.isNotEmpty()) {
|
||||
lastClickTime = System.currentTimeMillis()
|
||||
delay(3000)
|
||||
if (System.currentTimeMillis() - lastClickTime >= 3000) {
|
||||
shouldExplode = true
|
||||
}
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = smilePath,
|
||||
color = strokeColor,
|
||||
style = Stroke(
|
||||
width = 4.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
}
|
||||
}
|
||||
val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
|
||||
StyledButton(
|
||||
onClick = {
|
||||
gestureText = gestureTextValue
|
||||
coroutineScope.launch {
|
||||
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
|
||||
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
|
||||
}
|
||||
},
|
||||
backdrop = backdrop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
maxScale = 0.05f
|
||||
) {
|
||||
Text(
|
||||
"Test Head Gestures",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = gestureText,
|
||||
transitionSpec = {
|
||||
(fadeIn(
|
||||
animationSpec = tween(300)
|
||||
) + slideInVertically(
|
||||
initialOffsetY = { 40 },
|
||||
animationSpec = tween(300)
|
||||
)).togetherWith(fadeOut(animationSpec = tween(150)))
|
||||
}
|
||||
) { text ->
|
||||
if (shouldExplode) {
|
||||
LaunchedEffect(Unit) {
|
||||
CoroutineScope(coroutineScope.coroutineContext).launch {
|
||||
delay(750)
|
||||
gestureText = ""
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
)
|
||||
|
||||
val eyeOffsetX = height * 0.15f
|
||||
val eyeOffsetY = height * 0.1f
|
||||
val eyeLength = height * 0.08f
|
||||
|
||||
for (xSign in listOf(-1f, 1f)) {
|
||||
val rotated = rotate3D(Triple(eyeOffsetX * xSign, -eyeOffsetY, 0f))
|
||||
val (eyeX, eyeY) = project(rotated)
|
||||
drawLine(
|
||||
color = strokeColor,
|
||||
start = Offset(eyeX, eyeY - eyeLength/2),
|
||||
end = Offset(eyeX, eyeY + eyeLength/2),
|
||||
strokeWidth = 4.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
}
|
||||
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = Paint.Align.RIGHT
|
||||
typeface = Typeface.create(
|
||||
"SF Pro",
|
||||
Typeface.NORMAL
|
||||
)
|
||||
}
|
||||
|
||||
val pitch = orientation.pitch.toInt()
|
||||
val yaw = orientation.yaw.toInt()
|
||||
val text = "Pitch: ${pitch}° Yaw: ${yaw}°"
|
||||
|
||||
drawText(
|
||||
text,
|
||||
width - 8.dp.toPx(),
|
||||
height - 8.dp.toPx(),
|
||||
paint
|
||||
} else {
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccelerationPlot() {
|
||||
private fun Plot() {
|
||||
val acceleration by HeadTracking.acceleration.collectAsState()
|
||||
val maxPoints = 100
|
||||
val points = remember { mutableStateListOf<Pair<Float, Float>>() }
|
||||
@@ -646,11 +294,12 @@ private fun AccelerationPlot() {
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
shape = RoundedCornerShape(28.dp)
|
||||
) {
|
||||
val horizontalColor = MaterialTheme.colorScheme.primary
|
||||
val verticalColor = MaterialTheme.colorScheme.onPrimary
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -701,14 +350,14 @@ private fun AccelerationPlot() {
|
||||
val x2 = (i + 1) * xScale
|
||||
|
||||
drawLine(
|
||||
color = Color(0xFF007AFF),
|
||||
color = horizontalColor,
|
||||
start = Offset(x1, zeroY - points[i].first * yScale),
|
||||
end = Offset(x2, zeroY - points[i + 1].first * yScale),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
|
||||
drawLine(
|
||||
color = Color(0xFFFF3B30),
|
||||
color = verticalColor,
|
||||
start = Offset(x1, zeroY - points[i].second * yScale),
|
||||
end = Offset(x2, zeroY - points[i + 1].second * yScale),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
@@ -731,7 +380,7 @@ private fun AccelerationPlot() {
|
||||
val legendY = 15.dp.toPx()
|
||||
val textOffsetY = legendY + 5.dp.toPx() / 2
|
||||
|
||||
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
|
||||
drawCircle(horizontalColor, 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
@@ -741,7 +390,7 @@ private fun AccelerationPlot() {
|
||||
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
|
||||
}
|
||||
|
||||
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
|
||||
drawCircle(verticalColor, 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
|
||||
@@ -20,50 +20,46 @@ package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||
import me.kavishdevar.librepods.data.HearingAidSettings
|
||||
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import java.io.IOException
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
||||
private const val TAG = "HearingAidAdjustments"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@@ -71,222 +67,159 @@ private const val TAG = "HearingAidAdjustments"
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
||||
isSystemInDarkTheme()
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val hazeState = remember { HazeState() }
|
||||
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
|
||||
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.adjustments)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.hazeSource(hazeState)
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
val debounceJob = remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
|
||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
||||
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
|
||||
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
|
||||
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
|
||||
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||
val leftEQ = rememberSaveable { mutableStateOf(FloatArray(8)) }
|
||||
val rightEQ = rememberSaveable { mutableStateOf(FloatArray(8)) }
|
||||
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
|
||||
val initialLoadComplete = remember { mutableStateOf(false) }
|
||||
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
||||
val hearingAidSettings = remember { mutableStateOf(
|
||||
HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = 0f,
|
||||
rightAmplification = 0f,
|
||||
leftTone = 0f,
|
||||
rightTone = 0f,
|
||||
leftConversationBoost = false,
|
||||
rightConversationBoost = false,
|
||||
leftAmbientNoiseReduction = 0f,
|
||||
rightAmbientNoiseReduction = 0f,
|
||||
netAmplification = 0f,
|
||||
balance = 0f,
|
||||
ownVoiceAmplification = 0f
|
||||
)
|
||||
) }
|
||||
|
||||
val hearingAidSettings = remember {
|
||||
mutableStateOf(
|
||||
HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
|
||||
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
|
||||
leftTone = toneSliderValue.floatValue,
|
||||
rightTone = toneSliderValue.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
netAmplification = amplificationSliderValue.floatValue,
|
||||
balance = balanceSliderValue.floatValue,
|
||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val hearingAidATTListener = remember {
|
||||
object : (ByteArray) -> Unit {
|
||||
override fun invoke(value: ByteArray) {
|
||||
val parsed = parseHearingAidSettingsResponse(value)
|
||||
if (parsed != null) {
|
||||
amplificationSliderValue.floatValue = parsed.netAmplification
|
||||
balanceSliderValue.floatValue = parsed.balance
|
||||
toneSliderValue.floatValue = parsed.leftTone
|
||||
ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
|
||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||
leftEQ.value = parsed.leftEQ.copyOf()
|
||||
rightEQ.value = parsed.rightEQ.copyOf()
|
||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
|
||||
if (!initialLoadComplete.value) {
|
||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (!initialReadSucceeded.value) {
|
||||
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
hearingAidSettings.value = HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
|
||||
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
|
||||
leftTone = toneSliderValue.floatValue,
|
||||
rightTone = toneSliderValue.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
netAmplification = amplificationSliderValue.floatValue,
|
||||
balance = balanceSliderValue.floatValue,
|
||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||
)
|
||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Log.d(TAG, "Connecting to ATT...")
|
||||
try {
|
||||
attManager.enableNotifications(ATTHandles.HEARING_AID)
|
||||
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
||||
|
||||
var parsedSettings: HearingAidSettings? = null
|
||||
for (attempt in 1..3) {
|
||||
initialReadAttempts.intValue = attempt
|
||||
try {
|
||||
val data = attManager.read(ATTHandles.HEARING_AID)
|
||||
parsedSettings = parseHearingAidSettingsResponse(data = data)
|
||||
if (parsedSettings != null) {
|
||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
||||
break
|
||||
} else {
|
||||
Log.d(TAG, "Parsing returned null on attempt $attempt")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
||||
}
|
||||
delay(200)
|
||||
}
|
||||
|
||||
if (parsedSettings != null) {
|
||||
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
|
||||
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
||||
balanceSliderValue.floatValue = parsedSettings.balance
|
||||
toneSliderValue.floatValue = parsedSettings.leftTone
|
||||
ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
|
||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
||||
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
||||
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
|
||||
initialReadSucceeded.value = true
|
||||
} else {
|
||||
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
initialLoadComplete.value = true
|
||||
}
|
||||
}
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.amplification),
|
||||
valueRange = -1f..1f,
|
||||
value = amplificationSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
amplificationSliderValue.floatValue = it
|
||||
},
|
||||
startIcon = "",
|
||||
endIcon = "",
|
||||
independent = true,
|
||||
)
|
||||
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.swipe_to_control_amplification),
|
||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
||||
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
|
||||
description = stringResource(R.string.swipe_amplification_description)
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.balance),
|
||||
valueRange = -1f..1f,
|
||||
value = balanceSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
balanceSliderValue.floatValue = it
|
||||
},
|
||||
snapPoints = listOf(-1f, 0f, 1f),
|
||||
startLabel = stringResource(R.string.left),
|
||||
endLabel = stringResource(R.string.right),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone),
|
||||
valueRange = -1f..1f,
|
||||
value = toneSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
toneSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.darker),
|
||||
endLabel = stringResource(R.string.brighter),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.ambient_noise_reduction),
|
||||
valueRange = 0f..1f,
|
||||
value = ambientNoiseReductionSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
ambientNoiseReductionSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.less),
|
||||
endLabel = stringResource(R.string.more),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversation_boost),
|
||||
checked = conversationBoostEnabled.value,
|
||||
onCheckedChange = { conversationBoostEnabled.value = it },
|
||||
independent = true,
|
||||
description = stringResource(R.string.conversation_boost_description)
|
||||
)
|
||||
LaunchedEffect(state.hearingAidData) {
|
||||
parseHearingAidSettingsResponse(state.hearingAidData)?.let { parsed ->
|
||||
amplificationSliderValue.floatValue = parsed.netAmplification
|
||||
balanceSliderValue.floatValue = parsed.balance
|
||||
toneSliderValue.floatValue = parsed.leftTone
|
||||
ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
|
||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||
leftEQ.value = parsed.leftEQ.copyOf()
|
||||
rightEQ.value = parsed.rightEQ.copyOf()
|
||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||
initialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
amplificationSliderValue.floatValue,
|
||||
balanceSliderValue.floatValue,
|
||||
toneSliderValue.floatValue,
|
||||
conversationBoostEnabled.value,
|
||||
ambientNoiseReductionSliderValue.floatValue,
|
||||
ownVoiceAmplification.floatValue
|
||||
) {
|
||||
if (!initialized.value) return@LaunchedEffect
|
||||
hearingAidSettings.value = HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
|
||||
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
|
||||
leftTone = toneSliderValue.floatValue,
|
||||
rightTone = toneSliderValue.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
netAmplification = amplificationSliderValue.floatValue,
|
||||
balance = balanceSliderValue.floatValue,
|
||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||
)
|
||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
|
||||
}
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.amplification),
|
||||
valueRange = -1f..1f,
|
||||
value = amplificationSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
amplificationSliderValue.floatValue = it
|
||||
},
|
||||
startIcon = "",
|
||||
endIcon = "",
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.swipe_to_control_amplification),
|
||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
||||
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
|
||||
description = stringResource(R.string.swipe_amplification_description)
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.balance),
|
||||
valueRange = -1f..1f,
|
||||
value = balanceSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
balanceSliderValue.floatValue = it
|
||||
},
|
||||
snapPoints = listOf(-1f, 0f, 1f),
|
||||
startLabel = stringResource(R.string.left),
|
||||
endLabel = stringResource(R.string.right),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone),
|
||||
valueRange = -1f..1f,
|
||||
value = toneSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
toneSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.darker),
|
||||
endLabel = stringResource(R.string.brighter),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.ambient_noise_reduction),
|
||||
valueRange = 0f..1f,
|
||||
value = ambientNoiseReductionSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
ambientNoiseReductionSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.less),
|
||||
endLabel = stringResource(R.string.more),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversation_boost),
|
||||
checked = conversationBoostEnabled.value,
|
||||
onCheckedChange = { conversationBoostEnabled.value = it },
|
||||
description = stringResource(R.string.conversation_boost_description)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,16 +25,17 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -43,7 +44,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -52,11 +52,8 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -66,9 +63,11 @@ import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
||||
import me.kavishdevar.librepods.data.sendTransparencySettings
|
||||
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
|
||||
import me.kavishdevar.librepods.presentation.components.NavigationButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@@ -78,14 +77,11 @@ private const val TAG = "AccessibilitySettings"
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) {
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
val initialLoad = remember { mutableStateOf(true) }
|
||||
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
@@ -96,38 +92,36 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
|
||||
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
|
||||
}
|
||||
|
||||
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.hearing_aid),
|
||||
snackbarHostState = snackbarHostState,
|
||||
) { topPadding, hazeState, bottomPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.layerBackdrop(backdrop)
|
||||
.hazeSource(hazeState)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
hazeStateS.value = hazeState
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.layerBackdrop(backdrop)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
// val mediaAssistEnabled = remember { mutableStateOf(false) }
|
||||
// val adjustMediaEnabled = remember { mutableStateOf(false) }
|
||||
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(hearingAidEnabled.value) {
|
||||
if (hearingAidEnabled.value && !initialLoad.value) {
|
||||
showDialog.value = true
|
||||
} else if (!hearingAidEnabled.value && !initialLoad.value) {
|
||||
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
|
||||
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
|
||||
hearingAidEnabled.value = false
|
||||
}
|
||||
initialLoad.value = false
|
||||
LaunchedEffect(hearingAidEnabled.value) {
|
||||
if (hearingAidEnabled.value && !initialLoad.value) {
|
||||
showDialog.value = true
|
||||
} else if (!hearingAidEnabled.value && !initialLoad.value) {
|
||||
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
|
||||
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
|
||||
hearingAidEnabled.value = false
|
||||
}
|
||||
initialLoad.value = false
|
||||
}
|
||||
|
||||
// fun onAdjustPhoneChange(value: Boolean) {
|
||||
// // TODO
|
||||
@@ -137,105 +131,75 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
|
||||
// // TODO
|
||||
// }
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_aid),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, bottom = 2.dp)
|
||||
StyledList (title = stringResource(R.string.hearing_aid)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.hearing_aid),
|
||||
checked = hearingAidEnabled.value,
|
||||
onCheckedChange = { hearingAidEnabled.value = it },
|
||||
)
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.clip(
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.hearing_aid),
|
||||
checked = hearingAidEnabled.value,
|
||||
onCheckedChange = { hearingAidEnabled.value = it },
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
NavigationButton(
|
||||
to = "hearing_aid_adjustments",
|
||||
name = stringResource(R.string.adjustments),
|
||||
navController = navController,
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_aid_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.adjustments),
|
||||
onClick = onNavigateHearingAidAdjustments,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
NavigationButton(
|
||||
to = "update_hearing_test",
|
||||
name = stringResource(R.string.update_hearing_test),
|
||||
navController = navController,
|
||||
independent = true
|
||||
)
|
||||
|
||||
// not implemented yet
|
||||
|
||||
// StyledToggle(
|
||||
// title = stringResource(R.string.media_assist),
|
||||
// label = stringResource(R.string.media_assist),
|
||||
// checkedState = mediaAssistEnabled,
|
||||
// independent = true,
|
||||
// description = stringResource(R.string.media_assist_description)
|
||||
// )
|
||||
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Column (
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
// ) {
|
||||
// StyledToggle(
|
||||
// label = stringResource(R.string.adjust_media),
|
||||
// checkedState = adjustMediaEnabled,
|
||||
// onCheckedChange = { onAdjustMediaChange(it) },
|
||||
// independent = false
|
||||
// )
|
||||
// HorizontalDivider(
|
||||
// thickness = 1.dp,
|
||||
// color = Color(0x40888888),
|
||||
// modifier = Modifier
|
||||
// .padding(horizontal = 12.dp)
|
||||
// )
|
||||
|
||||
// StyledToggle(
|
||||
// label = stringResource(R.string.adjust_calls),
|
||||
// checkedState = adjustPhoneEnabled,
|
||||
// onCheckedChange = { onAdjustPhoneChange(it) },
|
||||
// independent = false
|
||||
// )
|
||||
// }
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_aid_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.update_hearing_test),
|
||||
onClick = onNavigateHearingTest,
|
||||
)
|
||||
|
||||
// not implemented yet
|
||||
|
||||
// StyledToggle(
|
||||
// titleRes = stringResource(R.string.media_assist),
|
||||
// label = stringResource(R.string.media_assist),
|
||||
// checkedState = mediaAssistEnabled,
|
||||
// independent = true,
|
||||
// descriptionRes = stringResource(R.string.media_assist_description)
|
||||
// )
|
||||
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Column (
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
// ) {
|
||||
// StyledToggle(
|
||||
// label = stringResource(R.string.adjust_media),
|
||||
// checkedState = adjustMediaEnabled,
|
||||
// onCheckedChange = { onAdjustMediaChange(it) },
|
||||
// independent = false
|
||||
// )
|
||||
// HorizontalDivider(
|
||||
// thickness = 1.dp,
|
||||
// color = Color(0x40888888),
|
||||
// modifier = Modifier
|
||||
// .padding(horizontal = 12.dp)
|
||||
// )
|
||||
|
||||
// StyledToggle(
|
||||
// label = stringResource(R.string.adjust_calls),
|
||||
// checkedState = adjustPhoneEnabled,
|
||||
// onCheckedChange = { onAdjustPhoneChange(it) },
|
||||
// independent = false
|
||||
// )
|
||||
// }
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
|
||||
ConfirmationDialog(
|
||||
showDialog = showDialog,
|
||||
title = "Enable Hearing Aid",
|
||||
@@ -259,6 +223,10 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
|
||||
return@launch
|
||||
}
|
||||
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
||||
if (parsed == null) {
|
||||
Log.w(TAG, "transparency parse failed")
|
||||
return@launch
|
||||
}
|
||||
val disabledSettings = parsed.copy(enabled = false)
|
||||
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
|
||||
} catch (e: Exception) {
|
||||
@@ -270,7 +238,6 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
|
||||
hearingAidEnabled.value = false
|
||||
showDialog.value = false
|
||||
},
|
||||
// hazeState = hazeStateS.value,
|
||||
backdrop = backdrop
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,103 +18,101 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
|
||||
@Composable
|
||||
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.hearing_protection),
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
if (!state.isPremium) {
|
||||
StyledButton(
|
||||
onClick = {
|
||||
navController.navigate("purchase_screen")
|
||||
},
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.vendorIdHook) {
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.environmental_noise),
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
checked = state.loudSoundReductionEnabled,
|
||||
onCheckedChange = {
|
||||
viewModel.setATTCharacteristicValue(
|
||||
ATTHandles.LOUD_SOUND_REDUCTION,
|
||||
byteArrayOf(if (it) 1.toByte() else 0.toByte())
|
||||
)
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
if (!state.isPremium) {
|
||||
StyledButton(
|
||||
onClick = navigateToPurchase,
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (state.vendorIdHook) {
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.workspace_use),
|
||||
label = stringResource(R.string.ppe),
|
||||
description = stringResource(R.string.workspace_use_description),
|
||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
|
||||
0
|
||||
)?.toInt() == 1,
|
||||
title = stringResource(R.string.environmental_noise),
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
checked = state.loudSoundReductionEnabled,
|
||||
onCheckedChange = {
|
||||
viewModel.setControlCommandBoolean(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
|
||||
viewModel.setATTCharacteristicValue(
|
||||
ATTHandles.LOUD_SOUND_REDUCTION,
|
||||
byteArrayOf(if (it) 1.toByte() else 0.toByte())
|
||||
)
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.workspace_use),
|
||||
label = stringResource(R.string.ppe),
|
||||
description = stringResource(R.string.workspace_use_description),
|
||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
|
||||
0
|
||||
)?.toInt() == 1,
|
||||
onCheckedChange = {
|
||||
viewModel.setControlCommandBoolean(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
|
||||
)
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
|
||||
@Composable
|
||||
fun LoadingScreen() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularWavyProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun LoadingScreenPreview() {
|
||||
LibrePodsTheme {
|
||||
LoadingScreen()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
|
||||
@Composable
|
||||
fun MicrophoneSettingsRoute(
|
||||
viewModel: AirPodsViewModel
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
||||
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
MicrophoneSettingsScreen(
|
||||
selectedMode = state.controlStates[id]?.getOrNull(0)?.toInt() ?: 0,
|
||||
topPadding = topPadding,
|
||||
bottomPadding = bottomPadding,
|
||||
onMicrophoneSettingsChanged = {
|
||||
viewModel.setControlCommandInt(id, it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MicrophoneSettingsScreen(
|
||||
selectedMode: Int,
|
||||
topPadding: Dp = 16.dp,
|
||||
bottomPadding: Dp = 16.dp,
|
||||
onMicrophoneSettingsChanged: (Int) -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
StyledList {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.microphone_automatic),
|
||||
selected = selectedMode == 0,
|
||||
onClick = { onMicrophoneSettingsChanged(0) }
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.microphone_always_right),
|
||||
selected = selectedMode == 1,
|
||||
onClick = { onMicrophoneSettingsChanged(1) }
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.microphone_always_left),
|
||||
selected = selectedMode == 2,
|
||||
onClick = { onMicrophoneSettingsChanged(2) }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
@@ -19,21 +19,24 @@
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||
@@ -41,7 +44,8 @@ import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Job
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: Job? = null
|
||||
@@ -50,33 +54,34 @@ private var debounceJob: Job? = null
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun OpenSourceLicensesScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
fun OpenSourceLicensesScreen() {
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.open_source_licenses)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
val context = LocalContext.current
|
||||
val libraries by produceLibraries {
|
||||
context.resources.openRawResource(R.raw.aboutlibraries)
|
||||
.bufferedReader()
|
||||
.use { it.readText() }
|
||||
}
|
||||
LibrariesContainer(
|
||||
libraries = libraries,
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxSize()
|
||||
)
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
val context = LocalContext.current
|
||||
val libraries by produceLibraries {
|
||||
context.resources.openRawResource(R.raw.aboutlibraries)
|
||||
.bufferedReader()
|
||||
.use { it.readText() }
|
||||
}
|
||||
LibrariesContainer(
|
||||
libraries = libraries,
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxSize()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,47 +20,43 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.SelectItem
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSelectList
|
||||
import me.kavishdevar.librepods.data.StemAction
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.data.StemAction
|
||||
import me.kavishdevar.librepods.presentation.components.ListItemOrientation
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import kotlin.experimental.and
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
@@ -68,10 +64,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0
|
||||
@@ -82,222 +75,159 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
|
||||
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
||||
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
|
||||
val longPressActionPref = sharedPreferences.getString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
Log.d("PressAndHoldSettingsScreen", "Long press action preference ($prefKey): $longPressActionPref")
|
||||
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = name
|
||||
) { spacerHeight ->
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.layerBackdrop(backdrop)
|
||||
.fillMaxSize()
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
val actionItems = listOf(
|
||||
SelectItem(
|
||||
name = stringResource(R.string.noise_control),
|
||||
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||
onClick = {
|
||||
longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES
|
||||
sharedPreferences.edit { putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name) }
|
||||
}
|
||||
),
|
||||
SelectItem(
|
||||
name = stringResource(R.string.digital_assistant),
|
||||
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
|
||||
onClick = {
|
||||
longPressAction = StemAction.DIGITAL_ASSISTANT
|
||||
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
val longPressAction = if (name.lowercase() == "left") state.leftAction else state.rightAction
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
StyledList {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.noise_control),
|
||||
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||
onClick = {
|
||||
viewModel.setLongPressAction(
|
||||
name,
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES
|
||||
)
|
||||
}
|
||||
)
|
||||
StyledSelectList(items = actionItems)
|
||||
|
||||
if (!state.isPremium) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
StyledButton(
|
||||
onClick = {
|
||||
navController.navigate("purchase_screen")
|
||||
},
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.digital_assistant),
|
||||
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
|
||||
onClick = {
|
||||
viewModel.setLongPressAction(
|
||||
name,
|
||||
StemAction.DIGITAL_ASSISTANT
|
||||
)
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
if (!state.isPremium) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
StyledButton(
|
||||
onClick = navigateToPurchase,
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
val currentByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
|
||||
|
||||
StyledList(
|
||||
title = stringResource(R.string.noise_control),
|
||||
description = stringResource(R.string.press_and_hold_noise_control_description)
|
||||
) {
|
||||
if (state.offListeningMode) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.off),
|
||||
description = stringResource(R.string.listening_mode_off_description),
|
||||
selected = (currentByte and 0x01) != 0,
|
||||
onClick = {
|
||||
viewModel.toggleListeningMode(0x01)
|
||||
},
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.noise_cancellation),
|
||||
contentDescription = "Icon",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.height(42.dp)
|
||||
.wrapContentWidth()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 18.dp)
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.transparency),
|
||||
description = stringResource(R.string.listening_mode_transparency_description),
|
||||
selected = (currentByte and 0x04) != 0,
|
||||
onClick = {
|
||||
viewModel.toggleListeningMode(0x04)
|
||||
},
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.transparency),
|
||||
contentDescription = "Icon",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.height(42.dp)
|
||||
.wrapContentWidth()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
Log.d("PressAndHoldSettingsScreen", "Allow Off state: $offListeningModeValue")
|
||||
val allowOff = offListeningModeValue == 1.toByte()
|
||||
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
|
||||
|
||||
val initialByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]
|
||||
?.get(0)?.toInt()
|
||||
?: sharedPreferences.getInt("long_press_byte", 0b0101)
|
||||
|
||||
var currentByte by remember { mutableIntStateOf(initialByte) }
|
||||
|
||||
val listeningModeItems = mutableListOf<SelectItem>()
|
||||
if (allowOff) {
|
||||
listeningModeItems.add(
|
||||
SelectItem(
|
||||
name = stringResource(R.string.off),
|
||||
description = stringResource(R.string.listening_mode_off_description),
|
||||
iconRes = R.drawable.noise_cancellation,
|
||||
selected = (currentByte and 0x01) != 0,
|
||||
onClick = {
|
||||
val bit = 0x01
|
||||
val newValue = if ((currentByte and bit) != 0) {
|
||||
val temp = currentByte and bit.inv()
|
||||
if (countEnabledModes(temp) >= 2) temp else currentByte
|
||||
} else {
|
||||
currentByte or bit
|
||||
}
|
||||
viewModel.setControlCommandByte(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||
newValue.toByte()
|
||||
)
|
||||
sharedPreferences.edit {
|
||||
putInt("long_press_byte", newValue)
|
||||
}
|
||||
currentByte = newValue
|
||||
}
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.adaptive),
|
||||
description = stringResource(R.string.listening_mode_adaptive_description),
|
||||
selected = (currentByte and 0x08) != 0,
|
||||
onClick = {
|
||||
viewModel.toggleListeningMode(0x08)
|
||||
},
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.adaptive),
|
||||
contentDescription = "Icon",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.height(42.dp)
|
||||
.wrapContentWidth()
|
||||
)
|
||||
)
|
||||
}
|
||||
listeningModeItems.addAll(listOf(
|
||||
SelectItem(
|
||||
name = stringResource(R.string.transparency),
|
||||
description = stringResource(R.string.listening_mode_transparency_description),
|
||||
iconRes = R.drawable.transparency,
|
||||
selected = (currentByte and 0x04) != 0,
|
||||
onClick = {
|
||||
val bit = 0x04
|
||||
val newValue = if ((currentByte and bit) != 0) {
|
||||
val temp = currentByte and bit.inv()
|
||||
if (countEnabledModes(temp) >= 2) temp else currentByte
|
||||
} else {
|
||||
currentByte or bit
|
||||
}
|
||||
viewModel.setControlCommandByte(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||
newValue.toByte()
|
||||
)
|
||||
sharedPreferences.edit {
|
||||
putInt("long_press_byte", newValue)
|
||||
}
|
||||
currentByte = newValue
|
||||
}
|
||||
),
|
||||
SelectItem(
|
||||
name = stringResource(R.string.adaptive),
|
||||
description = stringResource(R.string.listening_mode_adaptive_description),
|
||||
iconRes = R.drawable.adaptive,
|
||||
selected = (currentByte and 0x08) != 0,
|
||||
onClick = {
|
||||
val bit = 0x08
|
||||
val newValue = if ((currentByte and bit) != 0) {
|
||||
val temp = currentByte and bit.inv()
|
||||
if (countEnabledModes(temp) >= 2) temp else currentByte
|
||||
} else {
|
||||
currentByte or bit
|
||||
}
|
||||
viewModel.setControlCommandByte(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||
newValue.toByte()
|
||||
)
|
||||
sharedPreferences.edit {
|
||||
putInt("long_press_byte", newValue)
|
||||
}
|
||||
currentByte = newValue
|
||||
}
|
||||
),
|
||||
SelectItem(
|
||||
name = stringResource(R.string.noise_cancellation),
|
||||
description = stringResource(R.string.listening_mode_noise_cancellation_description),
|
||||
iconRes = R.drawable.noise_cancellation,
|
||||
selected = (currentByte and 0x02) != 0,
|
||||
onClick = {
|
||||
val bit = 0x02
|
||||
val newValue = if ((currentByte and bit) != 0) {
|
||||
val temp = currentByte and bit.inv()
|
||||
if (countEnabledModes(temp) >= 2) temp else currentByte
|
||||
} else {
|
||||
currentByte or bit
|
||||
}
|
||||
viewModel.setControlCommandByte(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||
newValue.toByte()
|
||||
)
|
||||
sharedPreferences.edit {
|
||||
putInt("long_press_byte", newValue)
|
||||
}
|
||||
currentByte = newValue
|
||||
}
|
||||
)
|
||||
))
|
||||
StyledSelectList(items = listeningModeItems)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.press_and_hold_noise_control_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 18.dp)
|
||||
}
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.noise_cancellation),
|
||||
description = stringResource(R.string.listening_mode_noise_cancellation_description),
|
||||
selected = (currentByte and 0x02) != 0,
|
||||
onClick = {
|
||||
viewModel.toggleListeningMode(0x02)
|
||||
},
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.noise_cancellation),
|
||||
contentDescription = "Icon",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.height(42.dp)
|
||||
.wrapContentWidth()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
Log.d("PressAndHoldSettingsScreen", "Current byte: ${modesByte.toString(2)}")
|
||||
}
|
||||
|
||||
fun countEnabledModes(byteValue: Int): Int {
|
||||
var count = 0
|
||||
if ((byteValue and 0x01) != 0) count++
|
||||
if ((byteValue and 0x02) != 0) count++
|
||||
if ((byteValue and 0x04) != 0) count++
|
||||
if ((byteValue and 0x08) != 0) count++
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -19,510 +19,199 @@
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.ListItemOrientation
|
||||
import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.navigation.Screen
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
||||
import me.kavishdevar.librepods.utils.XposedState
|
||||
|
||||
@Composable
|
||||
fun PurchaseScreen(
|
||||
viewModel: PurchaseViewModel = viewModel(),
|
||||
navController: NavController
|
||||
backStack: SnapshotStateList<Screen>
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.unlock_advanced_features)
|
||||
) { topPadding, hazeState, bottomPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.hazeSource(state = hazeState)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
|
||||
val cardBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
LaunchedEffect(state.isPremium) {
|
||||
if (state.isPremium) {
|
||||
navController.popBackStack()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.layerBackdrop(backdrop)
|
||||
.verticalScroll(scrollState)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
LaunchedEffect(state.isPremium) {
|
||||
if (state.isPremium) {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeAt(backStack.lastIndex)
|
||||
}
|
||||
}
|
||||
if (!state.isPremium) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(backgroundColor)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.free_features),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(cardBackgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ear_detection),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.ear_detection_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.battery),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.battery_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
)
|
||||
}
|
||||
if (BuildConfig.FLAVOR == "xposed") {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_aid) + " (" + stringResource(
|
||||
R.string.requires_xposed
|
||||
) + ")",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_aid_description).split("\n\n")[0],
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(backgroundColor)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.advanced_features),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(cardBackgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.conversational_awareness),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.conversational_awareness_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.digital_assistant_on_long_press),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.digital_assistant_on_long_press_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.head_gestures),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.head_gestures_details),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.advanced_device_settings),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.advanced_device_settings_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.automatic_connection),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.automatic_connection_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.customizations),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.customizations_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.support_the_development),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.support_development_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.feature_availability_disclaimer),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
}
|
||||
if (!state.isPremium) {
|
||||
StyledList(title = stringResource(R.string.free_features)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.ear_detection),
|
||||
description = stringResource(R.string.ear_detection_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.battery),
|
||||
description = stringResource(R.string.battery_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.noise_control),
|
||||
description = stringResource(R.string.noise_control_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledButton(
|
||||
onClick = {
|
||||
viewModel.purchase(context)
|
||||
},
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF)
|
||||
else Color(0xFF0088FF) // if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.buy_price, state.price),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
StyledButton(
|
||||
onClick = {
|
||||
viewModel.restorePurchases()
|
||||
},
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
isInteractive = false
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.restore_purchases),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
),
|
||||
if (XposedState.isAvailable) {
|
||||
StyledListItem(
|
||||
name = "${stringResource(R.string.hearing_aid)} (${stringResource(R.string.requires_xposed)})",
|
||||
description = stringResource(R.string.hearing_aid_description)
|
||||
.substringBefore("\n\n"),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
StyledList(title = stringResource(R.string.advanced_features), description = stringResource(R.string.feature_availability_disclaimer)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.conversational_awareness),
|
||||
description = stringResource(R.string.conversational_awareness_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.digital_assistant_on_long_press),
|
||||
description = stringResource(R.string.digital_assistant_on_long_press_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.head_gestures),
|
||||
description = stringResource(R.string.head_gestures_details),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.advanced_device_settings),
|
||||
description = stringResource(R.string.advanced_device_settings_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.automatic_connection),
|
||||
description = stringResource(R.string.automatic_connection_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.customizations),
|
||||
description = stringResource(R.string.customizations_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.support_the_development),
|
||||
description = stringResource(R.string.support_development_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
StyledButton(
|
||||
onClick = {
|
||||
viewModel.purchase(context)
|
||||
},
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary,
|
||||
materialButtonStyle = MaterialButtonStyle.Filled
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.buy_price, state.price),
|
||||
style = MaterialTheme.typography.bodyMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
StyledButton(
|
||||
onClick = {
|
||||
viewModel.restorePurchases()
|
||||
},
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
isInteractive = false,
|
||||
materialButtonStyle = MaterialButtonStyle.Outlined
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.restore_purchases),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.carousel.CarouselDefaults
|
||||
import androidx.compose.material3.carousel.HorizontalUncontainedCarousel
|
||||
import androidx.compose.material3.carousel.rememberCarouselState
|
||||
import androidx.compose.material3.toPath
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.center
|
||||
import androidx.compose.ui.graphics.Matrix
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_NO
|
||||
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
|
||||
import androidx.compose.ui.tooling.preview.Devices.PIXEL_9_PRO_XL
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
|
||||
import androidx.compose.ui.tooling.preview.Wallpapers.RED_DOMINATED_EXAMPLE
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.graphics.shapes.Morph
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.data.updates.UpdateItem
|
||||
import me.kavishdevar.librepods.data.updates.updates
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import kotlin.math.min
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReleaseNotesScreen(
|
||||
updates: List<UpdateItem>,
|
||||
releaseNotesShown: () -> Unit
|
||||
) {
|
||||
val state = rememberCarouselState(
|
||||
initialItem = 0,
|
||||
itemCount = { updates.size + 1 }
|
||||
)
|
||||
|
||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
|
||||
LibrePodsTheme(m3eEnabled = true) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background, RoundedCornerShape(52.dp)),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
Text(
|
||||
text = stringResource(R.string.what_s_new),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
val versionName = BuildConfig.VERSION_NAME.removeSuffix("-debug").removeSuffix("-play")
|
||||
val url = "https://github.com/kavishdevar/librepods/releases/v$versionName"
|
||||
val fullText = "${stringResource(R.string.version)} $versionName"
|
||||
val textColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
val annotatedString = buildAnnotatedString {
|
||||
append(fullText)
|
||||
addLink(
|
||||
url = LinkAnnotation.Url(
|
||||
url = url,
|
||||
styles = TextLinkStyles(
|
||||
style = SpanStyle(color = textColor)
|
||||
)
|
||||
),
|
||||
start = 0,
|
||||
end = fullText.length
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = annotatedString,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalUncontainedCarousel(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
state = state,
|
||||
itemSpacing = 16.dp,
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
itemWidth = LocalWindowInfo.current.containerDpSize.width - 64.dp,
|
||||
flingBehavior = CarouselDefaults.singleAdvanceFlingBehavior(state)
|
||||
) { index ->
|
||||
|
||||
val shape = rememberMaskShape(RoundedCornerShape(48.dp))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = shape,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
if (index != updates.size) {
|
||||
val updateItem = updates[index]
|
||||
|
||||
val deviceBorderColor = MaterialTheme.colorScheme.tertiary
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 16.dp)
|
||||
.heightIn(max = 700.dp)
|
||||
.align(Alignment.TopCenter)
|
||||
.drawWithCache {
|
||||
val path = createTopWavyRectPath(
|
||||
width = size.width,
|
||||
height = size.height,
|
||||
cornerRadius = 52.dp.toPx(),
|
||||
amplitude = 4.dp.toPx(),
|
||||
wavelength = 36.dp.toPx()
|
||||
)
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
|
||||
drawPath(
|
||||
path,
|
||||
color = deviceBorderColor,
|
||||
style = Stroke(
|
||||
width = 4.dp.toPx()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.clip(RoundedCornerShape(50.dp))
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalDensity provides Density(
|
||||
density = LocalDensity.current.density * 0.8f,
|
||||
fontScale = LocalDensity.current.fontScale
|
||||
),
|
||||
LocalDesignSystem provides if (m3eEnabled) DesignSystem.Material else DesignSystem.Apple
|
||||
) {
|
||||
updateItem.demoComposeable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.aspectRatio(1f)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.tertiary.copy(alpha = 0.9f),
|
||||
MaterialShapes.Arch.toShape()
|
||||
)
|
||||
.align(Alignment.BottomCenter)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(32.dp)
|
||||
.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(updateItem.titleRes),
|
||||
style = MaterialTheme.typography.displayMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onTertiary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = stringResource(updateItem.descriptionRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var pressed by remember { mutableStateOf(false) }
|
||||
|
||||
val morph = remember {
|
||||
Morph(
|
||||
MaterialShapes.Cookie7Sided.normalized(),
|
||||
MaterialShapes.SoftBurst.normalized()
|
||||
)
|
||||
}
|
||||
|
||||
val morphProgress by animateFloatAsState(
|
||||
targetValue = if (pressed) 1f else 0f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
),
|
||||
label = "morph",
|
||||
)
|
||||
|
||||
val rotationSpeed by animateFloatAsState(
|
||||
targetValue = if (pressed) 0f else 1f,
|
||||
animationSpec = tween(1200),
|
||||
label = "rotationSpeed"
|
||||
)
|
||||
|
||||
var rotation by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
var lastFrame = withFrameNanos { it }
|
||||
|
||||
while (true) {
|
||||
val frame = withFrameNanos { it }
|
||||
|
||||
val dt = (frame - lastFrame) / 1_000_000_000f
|
||||
lastFrame = frame
|
||||
|
||||
rotation += 60f * rotationSpeed * dt
|
||||
}
|
||||
}
|
||||
|
||||
val path = remember { Path() }
|
||||
val matrix = remember { Matrix() }
|
||||
|
||||
val tertiary = MaterialTheme.colorScheme.tertiary
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.Center)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
pressed = true
|
||||
tryAwaitRelease()
|
||||
pressed = false
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(260.dp)
|
||||
.align(Alignment.Center)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { releaseNotesShown() },
|
||||
onPress = {
|
||||
pressed = true
|
||||
tryAwaitRelease()
|
||||
pressed = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.drawBehind {
|
||||
val shapePath = morph.toPath(
|
||||
progress = morphProgress,
|
||||
path = path
|
||||
)
|
||||
|
||||
val bounds = shapePath.getBounds()
|
||||
|
||||
val scale = min(
|
||||
size.width / bounds.width,
|
||||
size.height / bounds.height
|
||||
) * 0.9f
|
||||
|
||||
matrix.reset()
|
||||
matrix.scale(scale, scale)
|
||||
|
||||
shapePath.transform(matrix)
|
||||
|
||||
shapePath.translate(
|
||||
size.center -
|
||||
shapePath.getBounds().center
|
||||
)
|
||||
|
||||
rotate(rotation) {
|
||||
drawPath(
|
||||
path = shapePath,
|
||||
color = tertiary
|
||||
)
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.ArrowForward,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(100.dp),
|
||||
tint = MaterialTheme.colorScheme.onTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = UI_MODE_NIGHT_YES, wallpaper = GREEN_DOMINATED_EXAMPLE, device = PIXEL_9_PRO_XL)
|
||||
@Preview(uiMode = UI_MODE_NIGHT_NO, wallpaper = RED_DOMINATED_EXAMPLE, device = PIXEL_9_PRO_XL)
|
||||
@Composable
|
||||
fun ReleaseNotesScreenPreview() {
|
||||
LibrePodsTheme(
|
||||
m3eEnabled = false
|
||||
) {
|
||||
ReleaseNotesScreen(
|
||||
updates = updates,
|
||||
releaseNotesShown = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ai gen'd helper
|
||||
fun createTopWavyRectPath(
|
||||
width: Float,
|
||||
height: Float,
|
||||
cornerRadius: Float,
|
||||
amplitude: Float,
|
||||
wavelength: Float
|
||||
): Path {
|
||||
return Path().apply {
|
||||
moveTo(cornerRadius, 0f)
|
||||
|
||||
var x = cornerRadius
|
||||
|
||||
while (x < width - cornerRadius - wavelength) {
|
||||
quadraticTo(
|
||||
x + wavelength / 4f,
|
||||
-amplitude,
|
||||
x + wavelength / 2f,
|
||||
0f
|
||||
)
|
||||
|
||||
quadraticTo(
|
||||
x + wavelength * 3f / 4f,
|
||||
amplitude,
|
||||
x + wavelength,
|
||||
0f
|
||||
)
|
||||
|
||||
x += wavelength
|
||||
}
|
||||
|
||||
arcTo(
|
||||
rect = Rect(
|
||||
left = width - 2 * cornerRadius,
|
||||
top = 0f,
|
||||
right = width,
|
||||
bottom = 2 * cornerRadius
|
||||
),
|
||||
startAngleDegrees = -90f,
|
||||
sweepAngleDegrees = 90f,
|
||||
forceMoveTo = false
|
||||
)
|
||||
|
||||
lineTo(width, height - cornerRadius)
|
||||
|
||||
arcTo(
|
||||
rect = Rect(
|
||||
left = width - 2 * cornerRadius,
|
||||
top = height - 2 * cornerRadius,
|
||||
right = width,
|
||||
bottom = height
|
||||
),
|
||||
startAngleDegrees = 0f,
|
||||
sweepAngleDegrees = 90f,
|
||||
forceMoveTo = false
|
||||
)
|
||||
|
||||
lineTo(cornerRadius, height)
|
||||
|
||||
arcTo(
|
||||
rect = Rect(
|
||||
left = 0f,
|
||||
top = height - 2 * cornerRadius,
|
||||
right = 2 * cornerRadius,
|
||||
bottom = height
|
||||
),
|
||||
startAngleDegrees = 90f,
|
||||
sweepAngleDegrees = 90f,
|
||||
forceMoveTo = false
|
||||
)
|
||||
|
||||
lineTo(0f, cornerRadius)
|
||||
|
||||
arcTo(
|
||||
rect = Rect(
|
||||
left = 0f,
|
||||
top = 0f,
|
||||
right = 2 * cornerRadius,
|
||||
bottom = 2 * cornerRadius
|
||||
),
|
||||
startAngleDegrees = 180f,
|
||||
sweepAngleDegrees = 90f,
|
||||
forceMoveTo = false
|
||||
)
|
||||
|
||||
close()
|
||||
}
|
||||
}
|
||||
@@ -21,13 +21,19 @@
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -35,13 +41,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.edit
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.StyledInputField
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@@ -58,27 +63,32 @@ fun RenameScreen(viewModel: AirPodsViewModel) {
|
||||
keyboardController?.show()
|
||||
}
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.name),
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
val textFieldState = rememberTextFieldState()
|
||||
textFieldState.edit { sharedPreferences.getString("name", "") ?: "" }
|
||||
LaunchedEffect(textFieldState.text) {
|
||||
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
|
||||
viewModel.setName(textFieldState.text.toString())
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
StyledInputField(
|
||||
textFieldState,
|
||||
focusRequester
|
||||
)
|
||||
val name = sharedPreferences.getString("name", "")?: ""
|
||||
val textFieldState = rememberTextFieldState(initialText = name)
|
||||
|
||||
LaunchedEffect(textFieldState.text) {
|
||||
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
|
||||
viewModel.setName(textFieldState.text.toString())
|
||||
}
|
||||
|
||||
StyledInputField(
|
||||
textFieldState,
|
||||
focusRequester
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,16 +28,21 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -46,9 +51,10 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
@@ -60,21 +66,16 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.delay
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.data.TransparencySettings
|
||||
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
||||
import me.kavishdevar.librepods.data.sendTransparencySettings
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import java.io.IOException
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private const val TAG = "TransparencySettings"
|
||||
@@ -92,90 +93,52 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
||||
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.customize_transparency_mode)
|
||||
){ topPadding, hazeState, bottomPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.hazeSource(hazeState)
|
||||
.layerBackdrop(backdrop)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
val enabled = remember { mutableStateOf(false) }
|
||||
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
|
||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
||||
val eq = remember { mutableStateOf(FloatArray(8)) }
|
||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
val initialLoadComplete = remember { mutableStateOf(false) }
|
||||
val enabled = rememberSaveable { mutableStateOf(false) }
|
||||
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||
val eq = rememberSaveable(
|
||||
saver = Saver(
|
||||
save = { it.value.toList() },
|
||||
restore = { mutableStateOf(it.toFloatArray()) }
|
||||
)
|
||||
) { mutableStateOf(FloatArray(8)) }
|
||||
val phoneMediaEQ = rememberSaveable(
|
||||
saver = Saver(
|
||||
save = { it.value.toList() },
|
||||
restore = { mutableStateOf(it.toFloatArray()) }
|
||||
)
|
||||
) { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||
|
||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
||||
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val transparencySettings = remember {
|
||||
mutableStateOf(
|
||||
TransparencySettings(
|
||||
enabled = enabled.value,
|
||||
leftEQ = eq.value,
|
||||
rightEQ = eq.value,
|
||||
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
|
||||
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
|
||||
leftTone = toneSliderValue.floatValue,
|
||||
rightTone = toneSliderValue.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
netAmplification = amplificationSliderValue.floatValue,
|
||||
balance = balanceSliderValue.floatValue
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
enabled.value,
|
||||
amplificationSliderValue.floatValue,
|
||||
balanceSliderValue.floatValue,
|
||||
toneSliderValue.floatValue,
|
||||
conversationBoostEnabled.value,
|
||||
ambientNoiseReductionSliderValue.floatValue,
|
||||
eq.value,
|
||||
initialLoadComplete.value,
|
||||
initialReadSucceeded.value
|
||||
) {
|
||||
if (!initialLoadComplete.value) {
|
||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (!initialReadSucceeded.value) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Initial device read not successful yet - skipping send until read succeeds"
|
||||
)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
transparencySettings.value = TransparencySettings(
|
||||
val transparencySettings = remember {
|
||||
mutableStateOf(
|
||||
TransparencySettings(
|
||||
enabled = enabled.value,
|
||||
leftEQ = eq.value,
|
||||
rightEQ = eq.value,
|
||||
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
|
||||
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
|
||||
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
|
||||
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
|
||||
leftTone = toneSliderValue.floatValue,
|
||||
rightTone = toneSliderValue.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
@@ -185,232 +148,218 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
||||
netAmplification = amplificationSliderValue.floatValue,
|
||||
balance = balanceSliderValue.floatValue
|
||||
)
|
||||
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
|
||||
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
enabled.value,
|
||||
amplificationSliderValue.floatValue,
|
||||
balanceSliderValue.floatValue,
|
||||
toneSliderValue.floatValue,
|
||||
conversationBoostEnabled.value,
|
||||
ambientNoiseReductionSliderValue.floatValue,
|
||||
eq.value
|
||||
) {
|
||||
if (!initialized.value) return@LaunchedEffect
|
||||
transparencySettings.value = TransparencySettings(
|
||||
enabled = enabled.value,
|
||||
leftEQ = eq.value,
|
||||
rightEQ = eq.value,
|
||||
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
|
||||
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
|
||||
leftTone = toneSliderValue.floatValue,
|
||||
rightTone = toneSliderValue.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
netAmplification = amplificationSliderValue.floatValue,
|
||||
balance = balanceSliderValue.floatValue
|
||||
)
|
||||
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
|
||||
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.transparencyData) {
|
||||
val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
|
||||
Log.d(TAG, "Initial transparency settings: $parsedSettings")
|
||||
enabled.value = parsedSettings.enabled
|
||||
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
||||
balanceSliderValue.floatValue = parsedSettings.balance
|
||||
toneSliderValue.floatValue = parsedSettings.leftTone
|
||||
ambientNoiseReductionSliderValue.floatValue =
|
||||
parsedSettings.leftAmbientNoiseReduction
|
||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
|
||||
eq.value = parsedSettings.leftEQ.copyOf()
|
||||
}
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Log.d(TAG, "Connecting to ATT...")
|
||||
try {
|
||||
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
|
||||
try {
|
||||
Log.d(TAG, "Found AACPManager, reading cached EQ data")
|
||||
val aacpEQ = state.eqData
|
||||
if (aacpEQ.isNotEmpty()) {
|
||||
eq.value = aacpEQ.copyOf()
|
||||
phoneMediaEQ.value = aacpEQ.copyOf()
|
||||
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
||||
} else {
|
||||
Log.d(TAG, "AACPManager EQ data empty")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
|
||||
}
|
||||
if (state.vendorIdHook) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.transparency_mode),
|
||||
checked = enabled.value,
|
||||
description = stringResource(R.string.customize_transparency_mode_description),
|
||||
onCheckedChange = { enabled.value = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.amplification),
|
||||
valueRange = -1f..1f,
|
||||
value = amplificationSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
amplificationSliderValue.floatValue = it
|
||||
},
|
||||
startIcon = "",
|
||||
endIcon = "",
|
||||
independent = true
|
||||
)
|
||||
|
||||
var parsedSettings: TransparencySettings? = null
|
||||
for (attempt in 1..3) {
|
||||
initialReadAttempts.intValue = attempt
|
||||
try {
|
||||
val data = state.transparencyData
|
||||
parsedSettings = parseTransparencySettingsResponse(data = data)
|
||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
||||
}
|
||||
delay(200)
|
||||
}
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.balance),
|
||||
valueRange = -1f..1f,
|
||||
value = balanceSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
balanceSliderValue.floatValue = it
|
||||
},
|
||||
snapPoints = listOf(-1f, 0f, 1f),
|
||||
startLabel = stringResource(R.string.left),
|
||||
endLabel = stringResource(R.string.right),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
if (parsedSettings != null) {
|
||||
Log.d(TAG, "Initial transparency settings: $parsedSettings")
|
||||
enabled.value = parsedSettings.enabled
|
||||
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
||||
balanceSliderValue.floatValue = parsedSettings.balance
|
||||
toneSliderValue.floatValue = parsedSettings.leftTone
|
||||
ambientNoiseReductionSliderValue.floatValue =
|
||||
parsedSettings.leftAmbientNoiseReduction
|
||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||
eq.value = parsedSettings.leftEQ.copyOf()
|
||||
initialReadSucceeded.value = true
|
||||
} else {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Failed to read/parse initial transparency settings after ${initialReadAttempts.intValue} attempts"
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone),
|
||||
valueRange = -1f..1f,
|
||||
value = toneSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
toneSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.darker),
|
||||
endLabel = stringResource(R.string.brighter),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.ambient_noise_reduction),
|
||||
valueRange = 0f..1f,
|
||||
value = ambientNoiseReductionSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
ambientNoiseReductionSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.less),
|
||||
endLabel = stringResource(R.string.more),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversation_boost),
|
||||
checked = conversationBoostEnabled.value,
|
||||
description = stringResource(R.string.conversation_boost_description),
|
||||
onCheckedChange = { conversationBoostEnabled.value = it }
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.equalizer),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
for (i in 0 until 8) {
|
||||
val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(38.dp)
|
||||
) {
|
||||
Text(
|
||||
text = String.format("%.2f", eqValue.floatValue),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
initialLoadComplete.value = true
|
||||
}
|
||||
}
|
||||
|
||||
if (state.vendorIdHook) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.transparency_mode),
|
||||
checked = enabled.value,
|
||||
independent = true,
|
||||
description = stringResource(R.string.customize_transparency_mode_description),
|
||||
onCheckedChange = { enabled.value = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.amplification),
|
||||
valueRange = -1f..1f,
|
||||
value = amplificationSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
amplificationSliderValue.floatValue = it
|
||||
},
|
||||
startIcon = "",
|
||||
endIcon = "",
|
||||
independent = true
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.balance),
|
||||
valueRange = -1f..1f,
|
||||
value = balanceSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
balanceSliderValue.floatValue = it
|
||||
},
|
||||
snapPoints = listOf(-1f, 0f, 1f),
|
||||
startLabel = stringResource(R.string.left),
|
||||
endLabel = stringResource(R.string.right),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone),
|
||||
valueRange = -1f..1f,
|
||||
value = toneSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
toneSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.darker),
|
||||
endLabel = stringResource(R.string.brighter),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.ambient_noise_reduction),
|
||||
valueRange = 0f..1f,
|
||||
value = ambientNoiseReductionSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
ambientNoiseReductionSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.less),
|
||||
endLabel = stringResource(R.string.more),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversation_boost),
|
||||
checked = conversationBoostEnabled.value,
|
||||
independent = true,
|
||||
description = stringResource(R.string.conversation_boost_description),
|
||||
onCheckedChange = { conversationBoostEnabled.value = it }
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.equalizer),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
for (i in 0 until 8) {
|
||||
val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
Slider(
|
||||
value = eqValue.floatValue,
|
||||
onValueChange = { newVal ->
|
||||
eqValue.floatValue = newVal
|
||||
val newEQ = eq.value.copyOf()
|
||||
newEQ[i] = eqValue.floatValue
|
||||
eq.value = newEQ
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(38.dp)
|
||||
) {
|
||||
Text(
|
||||
text = String.format("%.2f", eqValue.floatValue),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = eqValue.floatValue,
|
||||
onValueChange = { newVal ->
|
||||
eqValue.floatValue = newVal
|
||||
val newEQ = eq.value.copyOf()
|
||||
newEQ[i] = eqValue.floatValue
|
||||
eq.value = newEQ
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.height(36.dp),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = thumbColor,
|
||||
activeTrackColor = activeTrackColor,
|
||||
inactiveTrackColor = trackColor
|
||||
),
|
||||
thumb = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.shadow(4.dp, CircleShape)
|
||||
.background(thumbColor, CircleShape)
|
||||
)
|
||||
},
|
||||
track = {
|
||||
.fillMaxWidth(0.9f)
|
||||
.height(36.dp),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = thumbColor,
|
||||
activeTrackColor = activeTrackColor,
|
||||
inactiveTrackColor = trackColor
|
||||
),
|
||||
thumb = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.shadow(4.dp, CircleShape)
|
||||
.background(thumbColor, CircleShape)
|
||||
)
|
||||
},
|
||||
track = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(12.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
)
|
||||
{
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(12.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
.height(4.dp)
|
||||
.background(trackColor, RoundedCornerShape(4.dp))
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(eqValue.floatValue / 100f)
|
||||
.height(4.dp)
|
||||
.background(
|
||||
activeTrackColor,
|
||||
RoundedCornerShape(4.dp)
|
||||
)
|
||||
)
|
||||
{
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.background(trackColor, RoundedCornerShape(4.dp))
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(eqValue.floatValue / 100f)
|
||||
.height(4.dp)
|
||||
.background(
|
||||
activeTrackColor,
|
||||
RoundedCornerShape(4.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.band_label, i + 1),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.band_label, i + 1),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,186 +19,137 @@
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||
import me.kavishdevar.librepods.data.HearingAidSettings
|
||||
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
||||
import java.io.IOException
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.demoState
|
||||
|
||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
||||
private const val TAG = "HearingAidAdjustments"
|
||||
private const val TAG = "UpdateHearingTestScreen"
|
||||
|
||||
@Composable
|
||||
fun UpdateHearingTestScreen() {
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val attManager = ServiceManager.getService()?.attManager
|
||||
if (attManager == null) {
|
||||
Text(
|
||||
text = stringResource(R.string.att_manager_is_null_try_reconnecting),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
textAlign = TextAlign.Center
|
||||
fun UpdateHearingTestRoute(viewModel: AirPodsViewModel) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
UpdateHearingTestScreen(
|
||||
state = state,
|
||||
topPadding = topPadding,
|
||||
bottomPadding = bottomPadding,
|
||||
setATTCharacteristicValue = viewModel::setATTCharacteristicValue
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.hearing_test)
|
||||
) { topPadding, hazeState, bottomPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.hazeSource(hazeState)
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
@Composable
|
||||
fun UpdateHearingTestScreen(
|
||||
state: AirPodsUiState,
|
||||
topPadding: Dp = 16.dp,
|
||||
bottomPadding: Dp = 16.dp,
|
||||
setATTCharacteristicValue: (ATTHandles, ByteArray) -> Unit
|
||||
) {
|
||||
val verticalScrollState = rememberScrollState()
|
||||
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_test_value_instruction),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_test_value_instruction),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||
val leftEQ = rememberSaveable(
|
||||
saver = Saver(
|
||||
save = { it.value.toList() },
|
||||
restore = { mutableStateOf(it.toFloatArray()) }
|
||||
)
|
||||
val tone = remember { mutableFloatStateOf(0.5f) }
|
||||
val ambientNoiseReduction = remember { mutableFloatStateOf(0.0f) }
|
||||
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
|
||||
val leftAmplification = remember { mutableFloatStateOf(0.5f) }
|
||||
val rightAmplification = remember { mutableFloatStateOf(0.5f) }
|
||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
||||
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
|
||||
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
|
||||
) {
|
||||
mutableStateOf(FloatArray(8))
|
||||
}
|
||||
val rightEQ = rememberSaveable(
|
||||
saver = Saver(
|
||||
save = { it.value.toList() },
|
||||
restore = { mutableStateOf(it.toFloatArray()) }
|
||||
)
|
||||
) {
|
||||
mutableStateOf(FloatArray(8))
|
||||
}
|
||||
|
||||
val initialLoadComplete = remember { mutableStateOf(false) }
|
||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
||||
val debounceJob = remember { mutableStateOf<Job?>(null) }
|
||||
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val hearingAidSettings = remember {
|
||||
mutableStateOf(
|
||||
HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = leftAmplification.floatValue,
|
||||
rightAmplification = rightAmplification.floatValue,
|
||||
leftTone = tone.floatValue,
|
||||
rightTone = tone.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
|
||||
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val hearingAidATTListener = remember {
|
||||
object : (ByteArray) -> Unit {
|
||||
override fun invoke(value: ByteArray) {
|
||||
val parsed = parseHearingAidSettingsResponse(value)
|
||||
if (parsed != null) {
|
||||
leftEQ.value = parsed.leftEQ.copyOf()
|
||||
rightEQ.value = parsed.rightEQ.copyOf()
|
||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||
tone.floatValue = parsed.leftTone
|
||||
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
|
||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||
leftAmplification.floatValue = parsed.leftAmplification
|
||||
rightAmplification.floatValue = parsed.rightAmplification
|
||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
leftEQ.value,
|
||||
rightEQ.value,
|
||||
conversationBoostEnabled.value,
|
||||
initialLoadComplete.value,
|
||||
initialReadSucceeded.value,
|
||||
leftAmplification.floatValue,
|
||||
rightAmplification.floatValue,
|
||||
tone.floatValue,
|
||||
ambientNoiseReduction.floatValue,
|
||||
ownVoiceAmplification.floatValue
|
||||
) {
|
||||
if (!initialLoadComplete.value) {
|
||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (!initialReadSucceeded.value) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Initial device read not successful yet - skipping send until read succeeds"
|
||||
)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
hearingAidSettings.value = HearingAidSettings(
|
||||
val hearingAidSettings = remember {
|
||||
mutableStateOf(
|
||||
HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = leftAmplification.floatValue,
|
||||
@@ -213,146 +164,169 @@ fun UpdateHearingTestScreen() {
|
||||
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||
)
|
||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.hearingAidData) {
|
||||
val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
|
||||
if (parsed != null) {
|
||||
leftEQ.value = parsed.leftEQ.copyOf()
|
||||
rightEQ.value = parsed.rightEQ.copyOf()
|
||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||
tone.floatValue = parsed.leftTone
|
||||
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
|
||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||
leftAmplification.floatValue = parsed.leftAmplification
|
||||
rightAmplification.floatValue = parsed.rightAmplification
|
||||
initialized.value = true
|
||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Log.d(TAG, "Connecting to ATT...")
|
||||
try {
|
||||
attManager.enableNotifications(ATTHandles.HEARING_AID)
|
||||
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
||||
LaunchedEffect(
|
||||
leftEQ.value,
|
||||
rightEQ.value,
|
||||
conversationBoostEnabled.value,
|
||||
leftAmplification.floatValue,
|
||||
rightAmplification.floatValue,
|
||||
tone.floatValue,
|
||||
ambientNoiseReduction.floatValue,
|
||||
ownVoiceAmplification.floatValue
|
||||
) {
|
||||
if (!initialized.value) return@LaunchedEffect
|
||||
hearingAidSettings.value = HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = leftAmplification.floatValue,
|
||||
rightAmplification = rightAmplification.floatValue,
|
||||
leftTone = tone.floatValue,
|
||||
rightTone = tone.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
|
||||
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||
)
|
||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, setATTCharacteristicValue)
|
||||
}
|
||||
|
||||
var parsedSettings: HearingAidSettings? = null
|
||||
for (attempt in 1..3) {
|
||||
initialReadAttempts.intValue = attempt
|
||||
try {
|
||||
val data = attManager.read(ATTHandles.HEARING_AID)
|
||||
parsedSettings = parseHearingAidSettingsResponse(data = data)
|
||||
if (parsedSettings != null) {
|
||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
||||
break
|
||||
} else {
|
||||
Log.d(TAG, "Parsing returned null on attempt $attempt")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
||||
}
|
||||
delay(200)
|
||||
}
|
||||
val frequencies =
|
||||
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
|
||||
|
||||
if (parsedSettings != null) {
|
||||
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
|
||||
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
||||
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||
tone.floatValue = parsedSettings.leftTone
|
||||
ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction
|
||||
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
|
||||
leftAmplification.floatValue = parsedSettings.leftAmplification
|
||||
rightAmplification.floatValue = parsedSettings.rightAmplification
|
||||
initialReadSucceeded.value = true
|
||||
} else {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts"
|
||||
)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
initialLoadComplete.value = true
|
||||
}
|
||||
}
|
||||
|
||||
val frequencies =
|
||||
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(60.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.left),
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.right),
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
|
||||
frequencies.forEachIndexed { index, freq ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(60.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.left),
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
text = freq,
|
||||
modifier = Modifier
|
||||
.width(60.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
textAlign = TextAlign.End,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.right),
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
OutlinedTextField(
|
||||
value = leftEQ.value[index].toString(),
|
||||
onValueChange = { newValue ->
|
||||
val parsed = newValue.toFloatOrNull()
|
||||
if (parsed != null) {
|
||||
val newArray = leftEQ.value.copyOf()
|
||||
newArray[index] = parsed
|
||||
leftEQ.value = newArray
|
||||
Log.d(TAG, "Left EQ updated at index $index to $parsed")
|
||||
}
|
||||
},
|
||||
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = rightEQ.value[index].toString(),
|
||||
onValueChange = { newValue ->
|
||||
val parsed = newValue.toFloatOrNull()
|
||||
if (parsed != null) {
|
||||
val newArray = rightEQ.value.copyOf()
|
||||
newArray[index] = parsed
|
||||
rightEQ.value = newArray
|
||||
Log.d(TAG, "Right EQ updated at index $index to $parsed")
|
||||
}
|
||||
},
|
||||
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
frequencies.forEachIndexed { index, freq ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = freq,
|
||||
modifier = Modifier
|
||||
.width(60.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
textAlign = TextAlign.End,
|
||||
style = TextStyle(
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = leftEQ.value[index].toString(),
|
||||
onValueChange = { newValue ->
|
||||
val parsed = newValue.toFloatOrNull()
|
||||
if (parsed != null) {
|
||||
val newArray = leftEQ.value.copyOf()
|
||||
newArray[index] = parsed
|
||||
leftEQ.value = newArray
|
||||
Log.d(TAG, "Left EQ updated at index $index to $parsed")
|
||||
}
|
||||
},
|
||||
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = rightEQ.value[index].toString(),
|
||||
onValueChange = { newValue ->
|
||||
val parsed = newValue.toFloatOrNull()
|
||||
if (parsed != null) {
|
||||
val newArray = rightEQ.value.copyOf()
|
||||
newArray[index] = parsed
|
||||
rightEQ.value = newArray
|
||||
Log.d(TAG, "Right EQ updated at index $index to $parsed")
|
||||
}
|
||||
},
|
||||
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
@Preview(name = "Apple")
|
||||
@Composable
|
||||
fun UpdateHearingTestScreenPreviewApple() {
|
||||
LibrePodsTheme(
|
||||
m3eEnabled = false
|
||||
) {
|
||||
Box (
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
UpdateHearingTestScreen(
|
||||
state = demoState,
|
||||
setATTCharacteristicValue = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Material")
|
||||
@Composable
|
||||
fun UpdateHearingTestScreenPreviewMaterial() {
|
||||
LibrePodsTheme(
|
||||
m3eEnabled = true
|
||||
) {
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.wrapContentHeight()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
UpdateHearingTestScreen(
|
||||
state = demoState,
|
||||
setATTCharacteristicValue = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,162 +19,63 @@
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
|
||||
@Composable
|
||||
fun VersionScreen(viewModel: AirPodsViewModel) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.version)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.version),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
StyledList(title = stringResource(R.string.version)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.version) + " 1",
|
||||
description = state.version1,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.version) + " 1",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = state.version1,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.version) + " 2",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = state.version2,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.version) + " 3",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = state.version3,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.version) + " 2",
|
||||
description = state.version2,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.version) + " 3",
|
||||
description = state.version3,
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package me.kavishdevar.librepods.presentation.screens.onboarding
|
||||
|
||||
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.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.AppInfoCard
|
||||
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
|
||||
@Composable
|
||||
fun NotSupportedPage(
|
||||
bypassCompatibilityCheck: () -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Box(
|
||||
modifier = Modifier.background(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shape = RoundedCornerShape(42.dp)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.check_the_repository_for_more_info),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.enable_app_in_xposed_or_update_device),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
DeviceInfoCard()
|
||||
AppInfoCard()
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.bypass_compatibility_check),
|
||||
onClick = bypassCompatibilityCheck
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package me.kavishdevar.librepods.presentation.screens.onboarding
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.carousel.HorizontalUncontainedCarousel
|
||||
import androidx.compose.material3.carousel.rememberCarouselState
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.XposedState
|
||||
import me.kavishdevar.librepods.utils.bypassDeviceCheck
|
||||
import me.kavishdevar.librepods.utils.isSupported
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
onOnboardingComplete: () -> Unit,
|
||||
) {
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val isSupported = isSupported(sharedPreferences) || XposedState.bluetoothScopeEnabled
|
||||
|
||||
val state = rememberCarouselState(
|
||||
initialItem = 0,
|
||||
itemCount = { 4 }
|
||||
)
|
||||
|
||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
|
||||
val titles = listOf(
|
||||
null,
|
||||
stringResource(R.string.privacy_policy),
|
||||
stringResource(R.string.not_supported),
|
||||
stringResource(R.string.permissions),
|
||||
)
|
||||
|
||||
val animationScope = rememberCoroutineScope()
|
||||
|
||||
BackHandler {
|
||||
animationScope.launch {
|
||||
if (state.canScrollBackward) {
|
||||
val targetItem = if (isSupported && state.currentItem == 3) 1 else state.currentItem - 1
|
||||
state.animateScrollToItem(targetItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LibrePodsTheme(
|
||||
m3eEnabled = true
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
HorizontalUncontainedCarousel(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(12.dp),
|
||||
state = state,
|
||||
itemWidth = LocalWindowInfo.current.containerDpSize.width - 24.dp,
|
||||
userScrollEnabled = false
|
||||
) { index ->
|
||||
val shape = rememberMaskShape(RoundedCornerShape(52.dp))
|
||||
Surface(
|
||||
shape = shape,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(52.dp))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
titles[index]?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
when (index) {
|
||||
0 -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Welcome to",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.displayLargeEmphasized,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.app_description),
|
||||
style = MaterialTheme.typography.bodyMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
FilledTonalIconButton(
|
||||
onClick = {
|
||||
animationScope.launch {
|
||||
state.animateScrollToItem(1)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.minimumInteractiveComponentSize()
|
||||
.size(
|
||||
IconButtonDefaults.largeContainerSize(
|
||||
IconButtonDefaults.IconButtonWidthOption.Wide
|
||||
)
|
||||
),
|
||||
shape = IconButtonDefaults.largeRoundShape
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Default.ArrowForward,
|
||||
contentDescription = "forward",
|
||||
modifier = Modifier.size(IconButtonDefaults.largeIconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1 -> {
|
||||
PrivacyPolicyPage(
|
||||
onForward = {
|
||||
animationScope.launch {
|
||||
if (isSupported) state.animateScrollToItem(3) else state.animateScrollToItem(2)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
2 -> {
|
||||
NotSupportedPage(
|
||||
bypassCompatibilityCheck = {
|
||||
bypassDeviceCheck(sharedPreferences)
|
||||
}
|
||||
)
|
||||
}
|
||||
3 -> {
|
||||
PermissionsPage(
|
||||
onBackward = {
|
||||
animationScope.launch {
|
||||
if (state.canScrollBackward) state.animateScrollToItem(if (isSupported) 1 else 2)
|
||||
}
|
||||
},
|
||||
onForward = onOnboardingComplete
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Preview
|
||||
@Composable
|
||||
fun OnboardingScreenPreview(){
|
||||
OnboardingScreen {}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package me.kavishdevar.librepods.presentation.screens.onboarding
|
||||
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
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.Spacer
|
||||
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.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.ArrowForward
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import me.kavishdevar.librepods.presentation.MaterialIcons
|
||||
import me.kavishdevar.librepods.presentation.components.ListItemOrientation
|
||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun PermissionsPage(
|
||||
onBackward: () -> Unit,
|
||||
onForward: () -> Unit
|
||||
) {
|
||||
|
||||
var grantingAll = false
|
||||
|
||||
val context = LocalContext.current
|
||||
val canDrawOverlays = remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
||||
|
||||
val phonePermissionState = rememberMultiplePermissionsState(
|
||||
listOf(
|
||||
"android.permission.READ_PHONE_STATE",
|
||||
"android.permission.ANSWER_PHONE_CALLS"
|
||||
)
|
||||
) {
|
||||
if (grantingAll) {
|
||||
if (!canDrawOverlays.value) {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
"package:${context.packageName}".toUri()
|
||||
)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val notificationPermissionState = rememberPermissionState("android.permission.POST_NOTIFICATIONS") {
|
||||
if (grantingAll) {
|
||||
if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
|
||||
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val bluetoothPermissionsState = rememberMultiplePermissionsState(
|
||||
listOf(
|
||||
"android.permission.BLUETOOTH_CONNECT",
|
||||
"android.permission.BLUETOOTH_SCAN",
|
||||
"android.permission.BLUETOOTH",
|
||||
"android.permission.BLUETOOTH_ADMIN",
|
||||
"android.permission.BLUETOOTH_ADVERTISE"
|
||||
)
|
||||
) {
|
||||
if (grantingAll) {
|
||||
if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest()
|
||||
else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
|
||||
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
|
||||
}
|
||||
}
|
||||
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
canDrawOverlays.value = Settings.canDrawOverlays(context)
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Box(
|
||||
modifier = Modifier.background(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shape = RoundedCornerShape(42.dp)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
StyledList(title = "Required Permissions") {
|
||||
val animatedBluetoothIconColor by animateColorAsState(if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface)
|
||||
val animatedBluetoothContainerColor by animateColorAsState(
|
||||
if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = "Bluetooth",
|
||||
onClick = if (!bluetoothPermissionsState.allPermissionsGranted) {
|
||||
{
|
||||
grantingAll = false
|
||||
bluetoothPermissionsState.launchMultiplePermissionRequest()
|
||||
}
|
||||
} else null,
|
||||
description = "Required to communicate with AirPods",
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(
|
||||
animatedBluetoothContainerColor,
|
||||
MaterialShapes.SoftBurst.normalized()
|
||||
.toShape()
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MaterialIcons.bluetooth,
|
||||
contentDescription = "bluetooth",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = animatedBluetoothIconColor
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
StyledList(title = "Optional Permissions") {
|
||||
val animatedNotificationsIconColor by animateColorAsState(
|
||||
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
val animatedNotificationsContainerColor by animateColorAsState(
|
||||
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
)
|
||||
val animatedPhoneIconColor by animateColorAsState(if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface)
|
||||
val animatedPhoneContainerColor by animateColorAsState(
|
||||
if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = "Notifications",
|
||||
onClick = if (!notificationPermissionState.status.isGranted) {
|
||||
{
|
||||
grantingAll = false
|
||||
notificationPermissionState.launchPermissionRequest()
|
||||
}
|
||||
} else null,
|
||||
description = "Show battery status",
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(
|
||||
animatedNotificationsContainerColor,
|
||||
MaterialShapes.SoftBurst.normalized()
|
||||
.toShape()
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MaterialIcons.notifications,
|
||||
contentDescription = "notifications",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = animatedNotificationsIconColor
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
StyledListItem(
|
||||
name = "Phone",
|
||||
onClick = if (!phonePermissionState.allPermissionsGranted) {
|
||||
{
|
||||
grantingAll = false
|
||||
phonePermissionState.launchMultiplePermissionRequest()
|
||||
}
|
||||
} else null,
|
||||
description = "Respond to phone calls with head gestures",
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(
|
||||
animatedPhoneContainerColor,
|
||||
MaterialShapes.SoftBurst.normalized()
|
||||
.toShape()
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MaterialIcons.call,
|
||||
contentDescription = "bluetooth",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = animatedPhoneIconColor
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val animatedOverlayIconColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface)
|
||||
val animatedOverlayContainerColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
|
||||
StyledListItem(
|
||||
name = "Display over other apps",
|
||||
onClick = if (!canDrawOverlays.value) {
|
||||
{
|
||||
grantingAll = false
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
"package:${context.packageName}".toUri()
|
||||
)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
} else null,
|
||||
description = "Show popups when AirPods are nearby or audio switches to them.",
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(
|
||||
animatedOverlayContainerColor,
|
||||
MaterialShapes.SoftBurst.normalized()
|
||||
.toShape()
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MaterialIcons.stack,
|
||||
contentDescription = "bluetooth",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = animatedOverlayIconColor
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilledIconButton(
|
||||
onClick = onBackward,
|
||||
modifier = Modifier
|
||||
.minimumInteractiveComponentSize()
|
||||
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)),
|
||||
shape = IconButtonDefaults.mediumRoundShape
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Default.ArrowBack,
|
||||
contentDescription = "backward",
|
||||
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
grantingAll = true
|
||||
if (!bluetoothPermissionsState.allPermissionsGranted) bluetoothPermissionsState.launchMultiplePermissionRequest()
|
||||
else if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest()
|
||||
else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
|
||||
else if (!canDrawOverlays.value) canDrawOverlays.value =
|
||||
Settings.canDrawOverlays(context)
|
||||
},
|
||||
modifier = Modifier
|
||||
.height(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow).height)
|
||||
.weight(1f),
|
||||
enabled = !bluetoothPermissionsState.allPermissionsGranted || !notificationPermissionState.status.isGranted || !phonePermissionState.allPermissionsGranted || !canDrawOverlays.value
|
||||
) {
|
||||
Text(
|
||||
text = "Grant all",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
FilledIconButton(
|
||||
onClick = onForward,
|
||||
modifier = Modifier
|
||||
.minimumInteractiveComponentSize()
|
||||
.size(
|
||||
IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)
|
||||
),
|
||||
shape = IconButtonDefaults.mediumRoundShape,
|
||||
enabled = bluetoothPermissionsState.allPermissionsGranted
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Default.ArrowForward,
|
||||
contentDescription = "forward",
|
||||
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package me.kavishdevar.librepods.presentation.screens.onboarding
|
||||
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@Composable
|
||||
fun PrivacyPolicyPage(
|
||||
onForward: () -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Box(
|
||||
modifier = Modifier.background(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shape = RoundedCornerShape(42.dp)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Last updated: 20 June 2026",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Overview",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "LibrePods does not collect, store, sell, or share personal information for advertising, analytics, tracking, or profiling purposes. The app does not include analytics, crash reporting, telemetry, advertising SDKs, or tracking services.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "All information remains on your device unless you explicitly choose to contact me, create a GitHub issue from the app, or make a purchase or sponsorship through a third-party platform.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Third Party Services",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "LibrePods provides several ways to contact me, including email, Discord, and GitHub Issues.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Email",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "If you contact me by email, I receive your email address and any information you choose to include in your message. When using the contact form within LibrePods, your email client will open with a pre-filled email address, the subject line and body that you fill out. The body will also include LibrePods version information and device information to help with troubleshooting.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "You can edit or remove any of this information before sending the email.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Discord",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "The app provides a link to the LibrePods Discord server. If you choose to join the Discord server, you will be subject to Discord's privacy policy.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "I do not receive any information about you from Discord other than what is publicly visible in the Discord server, such as your username, joining date, common servers, and any messages or content you post in the server, unless you choose to share it with me in the Discord server.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "GitHub Issues",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "When creating a GitHub issue through LibrePods, the app will pre-fill the issue form with:",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
"• LibrePods version name and version code",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "• Device manufacturer and model",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "• Android build information",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "• Installation source (Google Play or GitHub)",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "This information helps diagnose bugs and provide support. No information is sent automatically. The information is only submitted if you choose to create the GitHub issue.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Payments", style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
if (BuildConfig.PLAY_BUILD) {
|
||||
Text(
|
||||
text = "Google Play", style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "When using the version available on Google Play, purchases are processed by Google Play.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "LibrePods verifies the purchase with Google Play on-device, not with a remote server that I control. I do not receive any information about you or your purchase from Google Play. Payment processing is handled entirely by Google Play, and I do not have access to any of your payment information.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "GitHub Sponsors", style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "When using the FOSS version available on GitHub, the upgrade button links to GitHub Sponsors. If you choose to sponsor LibrePods, your sponsorship is processed by GitHub.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Your username and country/region are shared with me when you sponsor LibrePods. Depending on your GitHub Sponsors privacy settings, I may also receive your email address.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Contact", style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "If you have questions about this privacy policy, please contact me via email at privacy@kavish.xyz.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onForward,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.i_agree),
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package me.kavishdevar.librepods.presentation.theme
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
|
||||
enum class DesignSystem {
|
||||
Apple,
|
||||
Material
|
||||
}
|
||||
|
||||
val LocalDesignSystem = compositionLocalOf {
|
||||
DesignSystem.Apple
|
||||
}
|
||||
@@ -18,47 +18,71 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.MotionScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
val ColorScheme.sectionHeader: Color
|
||||
get() = onBackground.copy(alpha = 0.6f)
|
||||
|
||||
private val AppleDarkColorScheme = darkColorScheme(
|
||||
surfaceContainer = Color(0xFF000000), // for some reason background is not used as the background in gmail and settings app, but surfacecontainer, so using that
|
||||
onBackground = Color(0xFFFFFFFF),
|
||||
surface = Color(0xFF1C1C1E),
|
||||
onSurface = Color(0xFFFFFFFF),
|
||||
surfaceDim = Color(0x40888888),
|
||||
primary = Color(0xFF0091FF),
|
||||
secondaryContainer = Color(0xFF366AA8),
|
||||
onSecondaryContainer = Color(0xFF0091FF),
|
||||
onPrimary = Color(0xFFFFFFFF)
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
private val AppleLightColorScheme = lightColorScheme(
|
||||
surfaceContainer = Color(0xFFF2F2F7),
|
||||
onBackground = Color(0xFF000000),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
onSurface = Color(0xFF000000),
|
||||
surfaceDim = Color(0x40D9D9D9),
|
||||
secondaryContainer = Color(0xFF6BC0FF),
|
||||
onSecondaryContainer = Color(0xFF0088FF),
|
||||
primary = Color(0xFF0088FF),
|
||||
onPrimary = Color(0xFFFFFFFF)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LibrePodsTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
m3eEnabled: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
m3eEnabled -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
darkTheme -> AppleDarkColorScheme
|
||||
else -> AppleLightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalDesignSystem provides
|
||||
if (m3eEnabled) DesignSystem.Material
|
||||
else DesignSystem.Apple
|
||||
) {
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
motionScheme = MotionScheme.expressive(),
|
||||
typography = if (m3eEnabled) MaterialTypography else AppleTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,35 +18,400 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.theme
|
||||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.googlefonts.GoogleFont
|
||||
import androidx.compose.ui.tooling.preview.Devices.PIXEL_9_PRO_XL
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
val sfProFamily = FontFamily(Font(R.font.sf_pro))
|
||||
|
||||
val AppleTypography = Typography().run {
|
||||
copy(
|
||||
displayLarge = displayLarge.copy(fontFamily = sfProFamily),
|
||||
displayMedium = displayMedium.copy(fontFamily = sfProFamily),
|
||||
displaySmall = displaySmall.copy(fontFamily = sfProFamily),
|
||||
|
||||
headlineLarge = headlineLarge.copy(fontFamily = sfProFamily),
|
||||
headlineMedium = headlineMedium.copy(fontFamily = sfProFamily),
|
||||
headlineSmall = headlineSmall.copy(fontFamily = sfProFamily),
|
||||
|
||||
titleLarge = titleLarge.copy(fontFamily = sfProFamily),
|
||||
titleMedium = titleMedium.copy(fontFamily = sfProFamily),
|
||||
titleSmall = titleSmall.copy(fontFamily = sfProFamily),
|
||||
|
||||
bodyLarge = bodyLarge.copy(fontFamily = sfProFamily),
|
||||
bodyMedium = bodyMedium.copy(
|
||||
fontFamily = sfProFamily,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
bodySmall = bodySmall.copy(
|
||||
fontFamily = sfProFamily,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp
|
||||
),
|
||||
|
||||
labelLarge = labelLarge.copy(fontFamily = sfProFamily),
|
||||
|
||||
labelMedium = labelMedium.copy(
|
||||
fontFamily = sfProFamily,
|
||||
fontSize = 16.sp,
|
||||
),
|
||||
labelMediumEmphasized = labelMediumEmphasized.copy(
|
||||
fontFamily = sfProFamily,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
labelSmallEmphasized = labelSmallEmphasized.copy(
|
||||
fontFamily = sfProFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
}
|
||||
|
||||
val provider = GoogleFont.Provider(
|
||||
providerAuthority = "com.google.android.gms.fonts",
|
||||
providerPackage = "com.google.android.gms",
|
||||
certificates = R.array.com_google_android_gms_fonts_certs
|
||||
)
|
||||
|
||||
private fun robotoFlex(
|
||||
wght: Float = 400f,
|
||||
slnt: Float = 0f,
|
||||
grad: Float = 0f,
|
||||
wdth: Float = 100f,
|
||||
xtra: Float = 468f,
|
||||
xopq: Float = 96f,
|
||||
yopq: Float = 79f,
|
||||
) = FontFamily(
|
||||
androidx.compose.ui.text.googlefonts.Font(
|
||||
// Font(
|
||||
// resId = R.font.roboto_flex,
|
||||
googleFont = GoogleFont("Roboto Flex"),
|
||||
fontProvider = provider,
|
||||
variationSettings = FontVariation.Settings(
|
||||
FontVariation.Setting("wght", wght),
|
||||
FontVariation.Setting("wdth", wdth),
|
||||
FontVariation.Setting("slnt", slnt),
|
||||
FontVariation.Setting("grad", grad),
|
||||
FontVariation.Setting("xtra", xtra),
|
||||
FontVariation.Setting("xopq", xopq),
|
||||
FontVariation.Setting("yopq", yopq),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val display = robotoFlex(
|
||||
wght = 800f,
|
||||
grad = 100f,
|
||||
wdth = 100f
|
||||
)
|
||||
|
||||
val displayEmphasized = robotoFlex(
|
||||
wght = 1000f,
|
||||
slnt = -2f,
|
||||
grad = 150f,
|
||||
wdth = 150f,
|
||||
)
|
||||
|
||||
val body = robotoFlex()
|
||||
|
||||
val bodyEmphasized = robotoFlex(
|
||||
wght = 600f,
|
||||
wdth = 130f,
|
||||
grad = 75f,
|
||||
)
|
||||
|
||||
val label = robotoFlex(
|
||||
wght = 450f,
|
||||
grad = 50f
|
||||
)
|
||||
|
||||
val labelEmphasized = robotoFlex(
|
||||
wght = 600f,
|
||||
wdth = 140f,
|
||||
grad = 75f
|
||||
)
|
||||
|
||||
|
||||
val MaterialTypography = Typography().run {
|
||||
copy(
|
||||
titleSmall = titleSmall.copy(
|
||||
fontFamily = display,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 30.sp,
|
||||
),
|
||||
|
||||
titleMedium = titleMedium.copy(
|
||||
fontFamily = display,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 32.sp,
|
||||
),
|
||||
|
||||
titleLarge = titleLarge.copy(
|
||||
fontFamily = display,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 36.sp,
|
||||
),
|
||||
|
||||
titleSmallEmphasized = titleSmallEmphasized.copy(
|
||||
fontFamily = display,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 30.sp,
|
||||
),
|
||||
|
||||
titleMediumEmphasized = titleMediumEmphasized.copy(
|
||||
fontFamily = displayEmphasized,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 32.sp,
|
||||
),
|
||||
|
||||
titleLargeEmphasized = titleLargeEmphasized.copy(
|
||||
fontFamily = displayEmphasized,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 36.sp,
|
||||
),
|
||||
|
||||
displaySmall = displaySmall.copy(
|
||||
fontFamily = display,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 36.sp,
|
||||
),
|
||||
|
||||
displayMedium = displayMedium.copy(
|
||||
fontFamily = display,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 40.sp,
|
||||
),
|
||||
|
||||
displayLarge = displayLarge.copy(
|
||||
fontFamily = display,
|
||||
fontSize = 40.sp,
|
||||
lineHeight = 44.sp,
|
||||
),
|
||||
|
||||
displaySmallEmphasized = displaySmallEmphasized.copy(
|
||||
fontFamily = displayEmphasized,
|
||||
fontSize = 38.sp,
|
||||
lineHeight = 42.sp,
|
||||
),
|
||||
|
||||
displayMediumEmphasized = displayMediumEmphasized.copy(
|
||||
fontFamily = displayEmphasized,
|
||||
fontSize = 42.sp,
|
||||
lineHeight = 48.sp,
|
||||
),
|
||||
|
||||
displayLargeEmphasized = displayLargeEmphasized.copy(
|
||||
fontFamily = displayEmphasized,
|
||||
fontSize = 48.sp,
|
||||
lineHeight = 52.sp,
|
||||
),
|
||||
|
||||
bodySmall = bodySmall.copy(
|
||||
fontFamily = body,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
|
||||
bodyMedium = bodyMedium.copy(
|
||||
fontFamily = body,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
),
|
||||
|
||||
bodyLarge = bodyLarge.copy(
|
||||
fontFamily = body,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 28.sp,
|
||||
),
|
||||
|
||||
bodySmallEmphasized = bodySmallEmphasized.copy(
|
||||
fontFamily = bodyEmphasized,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
|
||||
bodyMediumEmphasized = bodyMediumEmphasized.copy(
|
||||
fontFamily = bodyEmphasized,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
),
|
||||
|
||||
bodyLargeEmphasized = bodyLargeEmphasized.copy(
|
||||
fontFamily = bodyEmphasized,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 28.sp,
|
||||
),
|
||||
|
||||
labelSmall = labelSmall.copy(
|
||||
fontFamily = label,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
),
|
||||
|
||||
labelMedium = labelMedium.copy(
|
||||
fontFamily = label,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
|
||||
labelLarge = labelLarge.copy(
|
||||
fontFamily = label,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 22.sp,
|
||||
),
|
||||
|
||||
labelSmallEmphasized = labelSmallEmphasized.copy(
|
||||
fontFamily = labelEmphasized,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
),
|
||||
|
||||
labelMediumEmphasized = labelMediumEmphasized.copy(
|
||||
fontFamily = labelEmphasized,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
|
||||
labelLargeEmphasized = labelLargeEmphasized.copy(
|
||||
fontFamily = labelEmphasized,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 22.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(
|
||||
name = "Typography Showcase",
|
||||
showBackground = true,
|
||||
device = PIXEL_9_PRO_XL
|
||||
)
|
||||
@Composable
|
||||
private fun TypographyPreview() {
|
||||
LibrePodsTheme (m3eEnabled = true) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(28.dp))
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Display Large",
|
||||
style = MaterialTheme.typography.displayLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
"Display Large Emphasized",
|
||||
style = MaterialTheme.typography.displayLargeEmphasized
|
||||
)
|
||||
|
||||
Text(
|
||||
"Display Medium",
|
||||
style = MaterialTheme.typography.displayMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
"Display Medium Emphasized",
|
||||
style = MaterialTheme.typography.displayMediumEmphasized
|
||||
)
|
||||
|
||||
Text(
|
||||
"Display Small",
|
||||
style = MaterialTheme.typography.displaySmall
|
||||
)
|
||||
|
||||
Text(
|
||||
"Display Small Emphasized",
|
||||
style = MaterialTheme.typography.displaySmallEmphasized
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
"Body Large",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
"Body Large Emphasized",
|
||||
style = MaterialTheme.typography.bodyLargeEmphasized
|
||||
)
|
||||
|
||||
Text(
|
||||
"Body Medium",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
"Body Medium Emphasized",
|
||||
style = MaterialTheme.typography.bodyMediumEmphasized
|
||||
)
|
||||
|
||||
Text(
|
||||
"Body Small",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
|
||||
Text(
|
||||
"Body Small Emphasized",
|
||||
style = MaterialTheme.typography.bodySmallEmphasized
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
"Label Large",
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
"Label Large Emphasized",
|
||||
style = MaterialTheme.typography.labelLargeEmphasized
|
||||
)
|
||||
|
||||
Text(
|
||||
"Label Medium",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
"Label Medium Emphasized",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
|
||||
Text(
|
||||
"Label Small",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
|
||||
Text(
|
||||
"Label Small Emphasized",
|
||||
style = MaterialTheme.typography.labelSmallEmphasized
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,20 +23,26 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
import me.kavishdevar.librepods.billing.BillingManager
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
||||
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
|
||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
||||
import me.kavishdevar.librepods.data.AirPodsInstance
|
||||
import me.kavishdevar.librepods.data.AirPodsModels
|
||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||
@@ -45,13 +51,14 @@ import me.kavishdevar.librepods.data.BatteryComponent
|
||||
import me.kavishdevar.librepods.data.BatteryStatus
|
||||
import me.kavishdevar.librepods.data.Capability
|
||||
import me.kavishdevar.librepods.data.ControlCommandRepository
|
||||
import me.kavishdevar.librepods.data.CustomEq
|
||||
import me.kavishdevar.librepods.data.StemAction
|
||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
|
||||
@Suppress("ArrayInDataClass")
|
||||
data class AirPodsUiState(
|
||||
val deviceName: String,
|
||||
val deviceName: String = "AirPods",
|
||||
|
||||
val isLocallyConnected: Boolean = false,
|
||||
|
||||
@@ -87,27 +94,137 @@ data class AirPodsUiState(
|
||||
val hearingAidData: ByteArray = byteArrayOf(),
|
||||
|
||||
val isPremium: Boolean = false,
|
||||
val vendorIdHook: Boolean = false
|
||||
val vendorIdHook: Boolean = false,
|
||||
|
||||
val dynamicEndOfCharge: Boolean = false,
|
||||
|
||||
val connectionSuccessful: Boolean = false,
|
||||
val timeUntilFOSSPremiumExpiry: Long = 0L,
|
||||
|
||||
val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled
|
||||
)
|
||||
|
||||
val demoInstance = AirPodsInstance(
|
||||
name = "AirPods Pro",
|
||||
model = AirPodsModels.getModelByModelNumber("A3064")!!,
|
||||
actualModelNumber = "A3064",
|
||||
serialNumber = "JXF9Q94A40",
|
||||
leftSerialNumber = "L-DEMO",
|
||||
rightSerialNumber = "R-DEMO",
|
||||
version1 = "90.3388000000000000.1786",
|
||||
version2 = "90.3388000000000000.1786",
|
||||
version3 = "9441861",
|
||||
)
|
||||
|
||||
val demoState = AirPodsUiState(
|
||||
deviceName = demoInstance.name,
|
||||
|
||||
isLocallyConnected = true,
|
||||
|
||||
capabilities = demoInstance.model.capabilities,
|
||||
|
||||
battery = listOf(
|
||||
Battery(BatteryComponent.LEFT, 80, BatteryStatus.OPTIMIZED_CHARGING),
|
||||
Battery(BatteryComponent.RIGHT, 18, BatteryStatus.CHARGING),
|
||||
Battery(BatteryComponent.CASE, 76, BatteryStatus.NOT_CHARGING)
|
||||
),
|
||||
|
||||
ancMode = 3,
|
||||
offListeningMode = false,
|
||||
|
||||
modelName = demoInstance.model.displayName,
|
||||
actualModel = demoInstance.actualModelNumber,
|
||||
serialNumbers = listOf(
|
||||
demoInstance.serialNumber?: "",
|
||||
demoInstance.leftSerialNumber?: "",
|
||||
demoInstance.rightSerialNumber?: ""
|
||||
),
|
||||
|
||||
version1 = demoInstance.version1?: "",
|
||||
version2 = demoInstance.version2?: "",
|
||||
version3 = demoInstance.version3?: "",
|
||||
|
||||
headTrackingActive = true,
|
||||
headGesturesEnabled = true,
|
||||
|
||||
automaticEarDetectionEnabled = true,
|
||||
automaticConnectionEnabled = true,
|
||||
|
||||
leftAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||
rightAction = StemAction.DIGITAL_ASSISTANT,
|
||||
|
||||
loudSoundReductionEnabled = true,
|
||||
|
||||
isPremium = true,
|
||||
vendorIdHook = true,
|
||||
|
||||
dynamicEndOfCharge = true,
|
||||
|
||||
connectionSuccessful = true,
|
||||
|
||||
customEq = CustomEq(state = 2, low = 65, mid = 50, high = 70),
|
||||
|
||||
controlStates = mapOf(
|
||||
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG to byteArrayOf(0x01),
|
||||
ControlCommandIdentifiers.STEM_CONFIG to byteArrayOf(0x00),
|
||||
ControlCommandIdentifiers.CLICK_HOLD_INTERVAL to byteArrayOf(0x00),
|
||||
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL to byteArrayOf(0x00),
|
||||
ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL to byteArrayOf(0x00),
|
||||
ControlCommandIdentifiers.VOLUME_SWIPE_MODE to byteArrayOf(0x01),
|
||||
ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG to byteArrayOf(0x00, 0x03),
|
||||
ControlCommandIdentifiers.CHIME_VOLUME to byteArrayOf(0x46, 0x50),
|
||||
ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG to byteArrayOf(0x01),
|
||||
ControlCommandIdentifiers.HEARING_AID to byteArrayOf(0x01, 0x02),
|
||||
ControlCommandIdentifiers.HPS_GAIN_SWIPE to byteArrayOf(0x01),
|
||||
ControlCommandIdentifiers.HEARING_ASSIST_CONFIG to byteArrayOf(0x02),
|
||||
ControlCommandIdentifiers.HRM_STATE to byteArrayOf(0x01),
|
||||
ControlCommandIdentifiers.AUTO_ANC_STRENGTH to byteArrayOf(0x45),
|
||||
ControlCommandIdentifiers.ONE_BUD_ANC_MODE to byteArrayOf(0x01),
|
||||
ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG to byteArrayOf(0x01),
|
||||
ControlCommandIdentifiers.PPE_TOGGLE_CONFIG to byteArrayOf(0x01),
|
||||
ControlCommandIdentifiers.PPE_CAP_LEVEL_CONFIG to byteArrayOf(0x52),
|
||||
ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE to byteArrayOf(0x01),
|
||||
ControlCommandIdentifiers.LISTENING_MODE to byteArrayOf(0x04)
|
||||
)
|
||||
)
|
||||
|
||||
class AirPodsViewModel(
|
||||
private val service: AirPodsService,
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
private val controlRepo: ControlCommandRepository,
|
||||
private val appContext: Context
|
||||
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(
|
||||
AirPodsUiState(
|
||||
deviceName = sharedPreferences.getString(
|
||||
"name",
|
||||
"AirPods Pro"
|
||||
) ?: "AirPods Pro"
|
||||
)
|
||||
)
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
private lateinit var appContext: Context
|
||||
private lateinit var service: AirPodsService
|
||||
private lateinit var controlRepo: ControlCommandRepository
|
||||
|
||||
var isReady by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
fun init(service: AirPodsService, controlRepo: ControlCommandRepository, sharedPreferences: SharedPreferences, appContext: Context) {
|
||||
this.service = service
|
||||
this.controlRepo = controlRepo
|
||||
this.sharedPreferences = sharedPreferences
|
||||
this.appContext = appContext
|
||||
|
||||
observeBroadcasts()
|
||||
loadName()
|
||||
loadInstance()
|
||||
loadSharedPreferences()
|
||||
observeAACP()
|
||||
loadCurrentStatus()
|
||||
loadEq()
|
||||
loadATT()
|
||||
observeATT()
|
||||
observeSharedPreferences()
|
||||
observeBilling()
|
||||
if (isDemoMode) activateDemoMode()
|
||||
isReady = true
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(AirPodsUiState())
|
||||
|
||||
val uiState: StateFlow<AirPodsUiState> = _uiState
|
||||
|
||||
private var isDemoMode = false
|
||||
val demoActivated = MutableSharedFlow<Unit>()
|
||||
|
||||
private val listeners =
|
||||
mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>()
|
||||
@@ -116,40 +233,48 @@ class AirPodsViewModel(
|
||||
|
||||
private lateinit var broadcastReceiver: BroadcastReceiver
|
||||
|
||||
private val _cameraAction = MutableStateFlow(
|
||||
sharedPreferences.getString("camera_action", null)
|
||||
?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } })
|
||||
// private val _cameraAction = MutableStateFlow(
|
||||
// sharedPreferences.getString("camera_action", null)
|
||||
// ?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } })
|
||||
//
|
||||
// val cameraAction: StateFlow<AACPManager.Companion.StemPressType?> = _cameraAction
|
||||
//
|
||||
// fun setCameraAction(action: AACPManager.Companion.StemPressType?) {
|
||||
// sharedPreferences.edit {
|
||||
// if (action == null) remove("camera_action")
|
||||
// else putString("camera_action", action.name)
|
||||
// }
|
||||
// _cameraAction.value = action
|
||||
// }
|
||||
|
||||
val cameraAction: StateFlow<AACPManager.Companion.StemPressType?> = _cameraAction
|
||||
|
||||
fun setCameraAction(action: AACPManager.Companion.StemPressType?) {
|
||||
sharedPreferences.edit {
|
||||
if (action == null) remove("camera_action")
|
||||
else putString("camera_action", action.name)
|
||||
fun setCustomEq(low: Int, mid: Int, high: Int) {
|
||||
require(low in 0..100)
|
||||
require(mid in 0..100)
|
||||
require(high in 0..100)
|
||||
val updatedEq = _uiState.value.customEq.copy(low = low, mid = mid, high = high)
|
||||
service.aacpManager.sendCustomEqPacket(updatedEq)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
customEq = updatedEq
|
||||
)
|
||||
}
|
||||
_cameraAction.value = action
|
||||
}
|
||||
|
||||
init {
|
||||
observeBroadcasts()
|
||||
loadName()
|
||||
loadInstance()
|
||||
loadSharedPreferences()
|
||||
setupControlObservers()
|
||||
observeBilling()
|
||||
loadControlList()
|
||||
observeATT()
|
||||
if (isDemoMode) activateDemoMode()
|
||||
fun setCustomEqEnabled(enabled: Boolean) {
|
||||
service.aacpManager.sendCustomEqPacket(_uiState.value.customEq.copy(state = if (enabled) 2 else 1))
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
customEq = it.customEq.copy(state = if (enabled) 2 else 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
listeners.forEach { (id, listener) ->
|
||||
controlRepo.remove(id, listener)
|
||||
}
|
||||
|
||||
service.aacpManager.customEqCallback = null
|
||||
appContext.unregisterReceiver(broadcastReceiver)
|
||||
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun loadName() {
|
||||
@@ -160,24 +285,39 @@ class AirPodsViewModel(
|
||||
private fun observeBilling() {
|
||||
if (isDemoMode) return
|
||||
viewModelScope.launch {
|
||||
// if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events
|
||||
BillingManager.provider.isPremium.collect { premium ->
|
||||
// if (!billingFirstCollectDone) {
|
||||
// billingFirstCollectDone = true
|
||||
// return@collect
|
||||
// }
|
||||
if (!premium) {
|
||||
setControlCommandBoolean(
|
||||
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||
false
|
||||
)
|
||||
setHeadGesturesEnabled(false)
|
||||
if (premium) {
|
||||
sharedPreferences.edit {
|
||||
remove("premium_expiry_time")
|
||||
if (BuildConfig.PLAY_BUILD) remove("foss_upgraded")
|
||||
}
|
||||
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
|
||||
} else {
|
||||
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
|
||||
setControlCommandBoolean(
|
||||
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||
false
|
||||
)
|
||||
setHeadGesturesEnabled(false)
|
||||
_uiState.update { it.copy(isPremium = false) }
|
||||
}
|
||||
}
|
||||
_uiState.update { it.copy(isPremium = premium) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSharedPreferences() {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
when (key) {
|
||||
"name" -> loadName()
|
||||
"off_listening_mode", "automatic_ear_detection", "automatic_connection_ctrl_cmd",
|
||||
"head_gestures", "left_long_press_action", "right_long_press_action",
|
||||
"dynamic_end_of_charge", "foss_upgraded", "premium_expiry_time" -> loadSharedPreferences()
|
||||
}
|
||||
}
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
private fun observeBroadcasts() {
|
||||
broadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
@@ -266,9 +406,16 @@ class AirPodsViewModel(
|
||||
val current = state.controlStates[identifier]
|
||||
if (current?.contentEquals(value) == true) return@update state
|
||||
|
||||
state.copy(
|
||||
controlStates = state.controlStates + (identifier to value)
|
||||
)
|
||||
if (identifier == ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE) {
|
||||
state.copy(
|
||||
dynamicEndOfCharge = value[0] == 0x01.toByte(),
|
||||
controlStates = state.controlStates + (identifier to value)
|
||||
)
|
||||
} else {
|
||||
state.copy(
|
||||
controlStates = state.controlStates + (identifier to value)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +423,7 @@ class AirPodsViewModel(
|
||||
}
|
||||
|
||||
// I'm lazy, sorry.
|
||||
fun setupControlObservers() {
|
||||
fun observeAACP() {
|
||||
val identifiersList = listOf(
|
||||
ControlCommandIdentifiers.MIC_MODE,
|
||||
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||
@@ -303,18 +450,25 @@ class AirPodsViewModel(
|
||||
ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
|
||||
ControlCommandIdentifiers.OWNS_CONNECTION,
|
||||
ControlCommandIdentifiers.PPE_TOGGLE_CONFIG,
|
||||
ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE
|
||||
)
|
||||
for (identifier in identifiersList) {
|
||||
observeControl(identifier)
|
||||
}
|
||||
service.aacpManager.customEqCallback = { customEq ->
|
||||
_uiState.update { it.copy(customEq = customEq) }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshInitialData() {
|
||||
fun loadCurrentStatus() {
|
||||
if (isDemoMode) return
|
||||
service.let { service ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLocallyConnected = service.isConnected(), battery = service.getBattery()
|
||||
isLocallyConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true,
|
||||
battery = service.getBattery(),
|
||||
ancMode = controlRepo.getValue(ControlCommandIdentifiers.LISTENING_MODE)?.get(0)?.toInt() ?: 1,
|
||||
controlStates = controlRepo.getMap()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -340,6 +494,9 @@ class AirPodsViewModel(
|
||||
) ?: "CYCLE_NOISE_CONTROL_MODES"
|
||||
)
|
||||
val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false)
|
||||
val dynamicEndOfCharge = sharedPreferences.getBoolean("dynamic_end_of_charge", false)
|
||||
|
||||
val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -349,9 +506,60 @@ class AirPodsViewModel(
|
||||
headGesturesEnabled = headGesturesEnabled,
|
||||
leftAction = leftAction,
|
||||
rightAction = rightAction,
|
||||
vendorIdHook = vendorIdHook
|
||||
vendorIdHook = vendorIdHook,
|
||||
dynamicEndOfCharge = dynamicEndOfCharge,
|
||||
connectionSuccessful = connectionSuccessful,
|
||||
)
|
||||
}
|
||||
|
||||
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
|
||||
if (BuildConfig.PLAY_BUILD) {
|
||||
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
|
||||
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
when {
|
||||
// existing temporary premium
|
||||
expiryTime > 0L -> {
|
||||
if (expiryTime <= now) {
|
||||
sharedPreferences.edit {
|
||||
remove("premium_expiry_time")
|
||||
remove("foss_upgraded")
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
timeUntilFOSSPremiumExpiry = 0L,
|
||||
isPremium = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
timeUntilFOSSPremiumExpiry = expiryTime - now,
|
||||
isPremium = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First migration from accidental FOSS Play build
|
||||
fossUpgraded && !_uiState.value.isPremium -> {
|
||||
val newExpiry = now + 28L * 24 * 60 * 60 * 1000
|
||||
|
||||
sharedPreferences.edit {
|
||||
putLong("premium_expiry_time", newExpiry)
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
timeUntilFOSSPremiumExpiry = newExpiry - now,
|
||||
isPremium = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setOffListeningMode(enabled: Boolean) {
|
||||
@@ -369,10 +577,18 @@ class AirPodsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadControlList() {
|
||||
fun setDynamicEndOfCharge(enabled: Boolean) {
|
||||
service.aacpManager.sendControlCommand(ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE.value, enabled)
|
||||
sharedPreferences.edit { putBoolean("dynamic_end_of_charge", enabled) }
|
||||
_uiState.update {
|
||||
it.copy(dynamicEndOfCharge = enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadEq() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
controlStates = controlRepo.getMap()
|
||||
customEq = service.aacpManager.customEq
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -427,51 +643,68 @@ class AirPodsViewModel(
|
||||
}
|
||||
|
||||
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
|
||||
if (handle == ATTHandles.LOUD_SOUND_REDUCTION) {
|
||||
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
|
||||
when (handle) {
|
||||
// ideally should be using a different viewmodel for ATT based things because there are a lot of values, and I am not going to add all to this state, but there's loudsoundreduction.
|
||||
ATTHandles.LOUD_SOUND_REDUCTION -> {
|
||||
_uiState.value = _uiState.value.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01)
|
||||
}
|
||||
ATTHandles.HEARING_AID -> {
|
||||
_uiState.value = _uiState.value.copy(hearingAidData = value)
|
||||
}
|
||||
ATTHandles.TRANSPARENCY -> {
|
||||
_uiState.value = _uiState.value.copy(transparencyData = value)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
service.attManager?.connect()
|
||||
while (service.attManager?.socket?.isConnected != true) {
|
||||
delay(250)
|
||||
}
|
||||
service.attManager?.write(handle, value)
|
||||
service.attManager.writeCharacteristic(handle, value)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshATT() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val loudSoundReduction =
|
||||
runCatching { service.attManager?.read(ATTHandles.LOUD_SOUND_REDUCTION) }.getOrNull()
|
||||
val transparencyData =
|
||||
runCatching { service.attManager?.read(ATTHandles.TRANSPARENCY) }.getOrNull()?: byteArrayOf()
|
||||
val hearingAid =
|
||||
runCatching { service.attManager?.read(ATTHandles.HEARING_AID) }.getOrNull()?: byteArrayOf()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
loudSoundReductionEnabled = loudSoundReduction?.get(0)?.toInt() == 0x01,
|
||||
fun loadATT() {
|
||||
val loudSoundReduction = service.attManager.getCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf()
|
||||
val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) {
|
||||
loudSoundReduction[0].toInt() == 1
|
||||
} else false
|
||||
val hearingAidData = service.attManager.getCharacteristic(ATTHandles.HEARING_AID) ?: byteArrayOf()
|
||||
val transparencyData = service.attManager.getCharacteristic(ATTHandles.TRANSPARENCY) ?: byteArrayOf()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loudSoundReductionEnabled = loudSoundReductionEnabled,
|
||||
transparencyData = transparencyData,
|
||||
hearingAidData = hearingAid
|
||||
hearingAidData = hearingAidData
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun observeATT() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
service.attManager?.connect()
|
||||
while (service.attManager?.socket?.isConnected != true) {
|
||||
delay(1000)
|
||||
}
|
||||
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
|
||||
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
|
||||
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
|
||||
|
||||
while (true) {
|
||||
refreshATT()
|
||||
delay(15000)
|
||||
service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
|
||||
service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
|
||||
}
|
||||
service.attManager.setOnNotificationReceived { handle, value ->
|
||||
when (handle) {
|
||||
ATTHandles.LOUD_SOUND_REDUCTION.value.toByte() -> {
|
||||
val loudSoundReductionEnabled = if (value.isNotEmpty()) {
|
||||
value[0].toInt() == 1
|
||||
} else false
|
||||
_uiState.update {
|
||||
it.copy(loudSoundReductionEnabled = loudSoundReductionEnabled)
|
||||
}
|
||||
}
|
||||
ATTHandles.HEARING_AID.value.toByte() -> {
|
||||
_uiState.update {
|
||||
it.copy(hearingAidData = value)
|
||||
}
|
||||
}
|
||||
ATTHandles.TRANSPARENCY.value.toByte() -> {
|
||||
_uiState.update {
|
||||
it.copy(transparencyData = value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -498,47 +731,56 @@ class AirPodsViewModel(
|
||||
|
||||
fun activateDemoMode() {
|
||||
isDemoMode = true
|
||||
viewModelScope.launch {
|
||||
demoActivated.emit(Unit)
|
||||
}
|
||||
val fakeInstance = AirPodsInstance(
|
||||
name = "AirPods Pro (Demo)",
|
||||
model = AirPodsModels.getModelByModelNumber("A3049")!!,
|
||||
actualModelNumber = "A3049",
|
||||
serialNumber = "DEMO123",
|
||||
leftSerialNumber = "L-DEMO",
|
||||
rightSerialNumber = "R-DEMO",
|
||||
version1 = "1.0",
|
||||
version2 = "1.0",
|
||||
version3 = "1.0",
|
||||
)
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLocallyConnected = true,
|
||||
instance = fakeInstance,
|
||||
capabilities = fakeInstance.model.capabilities,
|
||||
|
||||
battery = listOf(
|
||||
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
|
||||
Battery(BatteryComponent.RIGHT, 25, BatteryStatus.NOT_CHARGING),
|
||||
Battery(BatteryComponent.CASE, 85, BatteryStatus.CHARGING),
|
||||
),
|
||||
|
||||
modelName = fakeInstance.model.displayName,
|
||||
actualModel = fakeInstance.actualModelNumber,
|
||||
serialNumbers = listOf("DEMO", "DEMO", "DEMO"),
|
||||
version3 = "Demo Firmware",
|
||||
// isPremium = true
|
||||
)
|
||||
}
|
||||
_uiState.update {demoState}
|
||||
}
|
||||
|
||||
fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) {
|
||||
service.aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
|
||||
}
|
||||
|
||||
fun setLongPressAction(side: String, action: StemAction) {
|
||||
val prefKey = if (side.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
|
||||
sharedPreferences.edit { putString(prefKey, action.name) }
|
||||
_uiState.update {
|
||||
if (side.lowercase() == "left") it.copy(leftAction = action) else it.copy(rightAction = action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun countEnabledModes(byteValue: Int): Int {
|
||||
var count = 0
|
||||
if ((byteValue and 0x01) != 0) count++
|
||||
if ((byteValue and 0x02) != 0) count++
|
||||
if ((byteValue and 0x04) != 0) count++
|
||||
if ((byteValue and 0x08) != 0) count++
|
||||
return count
|
||||
}
|
||||
|
||||
fun toggleListeningMode(modeBit: Int) {
|
||||
val currentByte = uiState.value.controlStates[ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
|
||||
val newValue = if ((currentByte and modeBit) != 0) {
|
||||
val temp = currentByte and modeBit.inv()
|
||||
if (countEnabledModes(temp) >= 2) temp else currentByte
|
||||
} else {
|
||||
currentByte or modeBit
|
||||
}
|
||||
setControlCommandByte(ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, newValue.toByte())
|
||||
sharedPreferences.edit { putInt("long_press_byte", newValue) }
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
service.disconnectAirPods()
|
||||
if (isDemoMode) {
|
||||
isDemoMode = false
|
||||
_uiState.update {
|
||||
it.copy(isLocallyConnected = false)
|
||||
}
|
||||
} else {
|
||||
service.disconnectAirPods()
|
||||
if (appContext.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) {
|
||||
Toast.makeText(
|
||||
appContext, "App has disconnected, disconnect from Android Settings.",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
import me.kavishdevar.librepods.billing.BillingManager
|
||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||
import me.kavishdevar.librepods.utils.NativeBridge
|
||||
import me.kavishdevar.librepods.utils.XposedState
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class AppSettingsUiState(
|
||||
@@ -34,7 +33,11 @@ data class AppSettingsUiState(
|
||||
val cameraPackageError: String? = null,
|
||||
val vendorIdHook: Boolean = false,
|
||||
val isPremium: Boolean = false,
|
||||
val connectionSuccessful: Boolean = false
|
||||
val connectionSuccessful: Boolean = false,
|
||||
val showBottomSheetPopup: Boolean = true,
|
||||
val showIslandPopup: Boolean = true,
|
||||
val timeUntilFOSSPremiumExpiry: Long = 0L,
|
||||
val m3eEnabled: Boolean = false
|
||||
)
|
||||
|
||||
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
@@ -60,18 +63,76 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
|
||||
|
||||
override fun onCleared() {
|
||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun observeBilling() {
|
||||
viewModelScope.launch {
|
||||
BillingManager.provider.isPremium.collect { premium ->
|
||||
_uiState.update { it.copy(isPremium = premium) }
|
||||
if (premium) {
|
||||
sharedPreferences.edit {
|
||||
remove("premium_expiry_time")
|
||||
if (BuildConfig.PLAY_BUILD) remove("foss_upgraded")
|
||||
}
|
||||
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
|
||||
} else {
|
||||
// No billing premium, only update if no temporary premium is active
|
||||
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
|
||||
_uiState.update { it.copy(isPremium = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
|
||||
|
||||
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
|
||||
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
when {
|
||||
// existing temporary premium
|
||||
expiryTime > 0L -> {
|
||||
if (expiryTime <= now) {
|
||||
sharedPreferences.edit {
|
||||
remove("premium_expiry_time")
|
||||
remove("foss_upgraded")
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
timeUntilFOSSPremiumExpiry = 0L,
|
||||
isPremium = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
timeUntilFOSSPremiumExpiry = expiryTime - now,
|
||||
isPremium = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First migration from accidental FOSS Play build
|
||||
fossUpgraded && !_uiState.value.isPremium && BuildConfig.PLAY_BUILD -> {
|
||||
val newExpiry = now + 28L * 24 * 60 * 60 * 1000
|
||||
|
||||
sharedPreferences.edit {
|
||||
putLong("premium_expiry_time", newExpiry)
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
timeUntilFOSSPremiumExpiry = newExpiry - now,
|
||||
isPremium = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_uiState.update { currentState ->
|
||||
currentState.copy(
|
||||
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
|
||||
@@ -88,12 +149,12 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
|
||||
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
|
||||
cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "",
|
||||
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false),
|
||||
connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
|
||||
connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false),
|
||||
showBottomSheetPopup = sharedPreferences.getBoolean("show_bottom_sheet_popup", true),
|
||||
showIslandPopup = sharedPreferences.getBoolean("show_island_popup", true),
|
||||
m3eEnabled = sharedPreferences.getBoolean("m3e_enabled", true)
|
||||
)
|
||||
}
|
||||
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
|
||||
NativeBridge.setSdpHook(_uiState.value.vendorIdHook)
|
||||
}
|
||||
}
|
||||
|
||||
fun setShowPhoneBatteryInWidget(enabled: Boolean) {
|
||||
@@ -178,8 +239,22 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
|
||||
}
|
||||
|
||||
fun setVendorIdHook(enabled: Boolean) {
|
||||
NativeBridge.setSdpHook(enabled)
|
||||
xposedRemotePref.putBoolean("vendor_id_hook", enabled)
|
||||
_uiState.update { it.copy(vendorIdHook = enabled) }
|
||||
}
|
||||
|
||||
fun setShowBottomSheetPopup(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("show_bottom_sheet_popup", enabled) }
|
||||
_uiState.update { it.copy(showBottomSheetPopup = enabled) }
|
||||
}
|
||||
|
||||
fun setShowIslandPopup(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("show_island_popup", enabled) }
|
||||
_uiState.update { it.copy(showIslandPopup = enabled) }
|
||||
}
|
||||
|
||||
fun setm3eEnabled(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("m3e_enabled", enabled) }
|
||||
_uiState.update { it.copy(m3eEnabled = enabled) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.widget.RemoteViews
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
class NoiseControlWidget : AppWidgetProvider() {
|
||||
@@ -82,8 +82,14 @@ class NoiseControlWidget : AppWidgetProvider() {
|
||||
if (intent.action == "ACTION_SET_ANC_MODE") {
|
||||
val mode = intent.getIntExtra("ANC_MODE", 1)
|
||||
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
|
||||
ServiceManager.getService()!!
|
||||
.aacpManager
|
||||
val service = ServiceManager.getService()
|
||||
|
||||
if (service == null) {
|
||||
Log.w("NoiseControlWidget", "Service unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
service.aacpManager
|
||||
.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||
mode.toByte()
|
||||
|
||||
@@ -35,9 +35,10 @@ import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@@ -98,7 +99,7 @@ class AirPodsQSService : TileService() {
|
||||
Log.d("AirPodsQSService", "onStartListening")
|
||||
|
||||
val service = ServiceManager.getService()
|
||||
isAirPodsConnected = service?.isConnected() == true
|
||||
isAirPodsConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true
|
||||
currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1)
|
||||
|
||||
if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) {
|
||||
|
||||
@@ -85,15 +85,19 @@ import me.kavishdevar.librepods.MainActivity
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
|
||||
import me.kavishdevar.librepods.bluetooth.ATTManager
|
||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||
import me.kavishdevar.librepods.bluetooth.ATTManagerv2
|
||||
import me.kavishdevar.librepods.bluetooth.BLEManager
|
||||
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
||||
import me.kavishdevar.librepods.bluetooth.createBluetoothSocket
|
||||
import me.kavishdevar.librepods.data.AirPodsInstance
|
||||
import me.kavishdevar.librepods.data.AirPodsModels
|
||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.data.Battery
|
||||
import me.kavishdevar.librepods.data.BatteryComponent
|
||||
import me.kavishdevar.librepods.data.BatteryStatus
|
||||
import me.kavishdevar.librepods.data.Capability
|
||||
import me.kavishdevar.librepods.data.CustomEq
|
||||
import me.kavishdevar.librepods.data.StemAction
|
||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||
import me.kavishdevar.librepods.data.isHeadTrackingData
|
||||
@@ -128,6 +132,7 @@ import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private const val TAG = "AirPodsService"
|
||||
|
||||
@@ -150,7 +155,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
var macAddress = ""
|
||||
var localMac = ""
|
||||
lateinit var aacpManager: AACPManager
|
||||
var attManager: ATTManager? = null
|
||||
lateinit var attManager: ATTManagerv2
|
||||
var airpodsInstance: AirPodsInstance? = null
|
||||
var cameraActive = false
|
||||
private var disconnectedBecauseReversed = false
|
||||
@@ -230,8 +235,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
lateinit var bleManager: BLEManager
|
||||
|
||||
private lateinit var socket: BluetoothSocket
|
||||
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("bluetooth_socket")
|
||||
@@ -243,7 +246,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
override fun onDeviceStatusChanged(
|
||||
device: BLEManager.AirPodsStatus, previousStatus: BLEManager.AirPodsStatus?
|
||||
) {
|
||||
if (device.connectionState == "Disconnected" && !isConnected()) { // should never happen unless android messes up and sends us a stale broadcast
|
||||
if (device.connectionState == "Disconnected" && BluetoothConnectionManager.aacpSocket?.isConnected != true) { // should never happen unless android messes up and sends us a stale broadcast
|
||||
Log.d(TAG, "Seems no device has taken over, we will.")
|
||||
val bluetoothManager = getSystemService(BluetoothManager::class.java)
|
||||
val bluetoothAdapter = bluetoothManager.adapter
|
||||
@@ -255,7 +258,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
connectToSocket(bluetoothAdapter, bluetoothDevice)
|
||||
}
|
||||
Log.d(TAG, "Device status changed")
|
||||
if (socket.isConnected) return
|
||||
if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return
|
||||
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
|
||||
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
|
||||
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
|
||||
@@ -288,7 +291,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro")
|
||||
?: "AirPods"
|
||||
)
|
||||
if (socket.isConnected) return
|
||||
if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return
|
||||
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
|
||||
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
|
||||
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
|
||||
@@ -322,7 +325,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
|
||||
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
|
||||
if (socket.isConnected) return
|
||||
if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return
|
||||
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
|
||||
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
|
||||
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
|
||||
@@ -379,6 +382,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
aacpManager = AACPManager()
|
||||
initializeAACPManagerCallback()
|
||||
|
||||
attManager = ATTManagerv2()
|
||||
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
||||
|
||||
localMac = config.selfMacAddress
|
||||
@@ -526,7 +531,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
initializeConfig()
|
||||
|
||||
ancModeReceiver = object : BroadcastReceiver() {
|
||||
externalBroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == "me.kavishdevar.librepods.SET_ANC_MODE") {
|
||||
if (intent.hasExtra("mode")) {
|
||||
@@ -539,28 +544,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
} else {
|
||||
val currentMode = ancNotification.status
|
||||
val configByte = sharedPreferences.getInt("long_press_byte", 0b0111)
|
||||
val allowOffModeValue =
|
||||
aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION }
|
||||
val allowOffMode = allowOffModeValue?.value?.takeIf { it.isNotEmpty() }
|
||||
?.get(0) == 0x01.toByte()
|
||||
|
||||
val nextMode = if (allowOffMode) {
|
||||
when (currentMode) {
|
||||
1 -> 2
|
||||
2 -> 3
|
||||
3 -> 4
|
||||
4 -> 1
|
||||
else -> 1
|
||||
}
|
||||
} else {
|
||||
when (currentMode) {
|
||||
1 -> 2
|
||||
2 -> 3
|
||||
3 -> 4
|
||||
4 -> 2
|
||||
else -> 2
|
||||
}
|
||||
}
|
||||
val allowOffMode =
|
||||
allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte() || sharedPreferences.getBoolean("off_listening_mode", true)
|
||||
val nextMode = getNextMode(currentMode = currentMode, configByte = configByte, allowOffMode)
|
||||
|
||||
aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||
@@ -568,7 +557,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
)
|
||||
Log.d(
|
||||
TAG,
|
||||
"Cycling ANC mode from $currentMode to $nextMode (offListeningMode: $allowOffMode)"
|
||||
"Cycling ANC mode from $currentMode to $nextMode"
|
||||
)
|
||||
}
|
||||
} else if (intent?.action == "me.kavishdevar.librepods.CONVO_DETECT") {
|
||||
if (intent.hasExtra("enabled")) {
|
||||
val enabled = intent.getBooleanExtra("enabled", false)
|
||||
aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
|
||||
enabled
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -576,10 +573,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(ancModeReceiver, ancModeFilter, RECEIVER_EXPORTED)
|
||||
registerReceiver(externalBroadcastReceiver, externalBroadcastFilter, RECEIVER_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver(
|
||||
ancModeReceiver, ancModeFilter
|
||||
externalBroadcastReceiver, externalBroadcastFilter
|
||||
)
|
||||
}
|
||||
val audioManager = this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
|
||||
@@ -661,6 +658,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT")
|
||||
addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
|
||||
addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED")
|
||||
addAction("android.bluetooth.device.action.UUID")
|
||||
}
|
||||
|
||||
connectionReceiver = object : BroadcastReceiver() {
|
||||
@@ -698,8 +696,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
// isConnectedLocally = false
|
||||
popupShown = false
|
||||
updateNotificationContent(false)
|
||||
attManager?.disconnect()
|
||||
attManager = null
|
||||
aacpManager.disconnected()
|
||||
BluetoothConnectionManager.aacpSocket = null
|
||||
BluetoothConnectionManager.attSocket = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1026,7 +1025,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
)
|
||||
// Store in SharedPreferences
|
||||
sharedPreferences.edit {
|
||||
putString("airpods_name", deviceInformation.name)
|
||||
putString("name", deviceInformation.name)
|
||||
putString("airpods_model_number", deviceInformation.modelNumber)
|
||||
putString("airpods_manufacturer", deviceInformation.manufacturer)
|
||||
putString("airpods_serial_number", deviceInformation.serialNumber)
|
||||
@@ -1101,9 +1100,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
"Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}"
|
||||
)
|
||||
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
|
||||
if (BuildConfig.FLAVOR == "xposed") {
|
||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
|
||||
}
|
||||
} else {
|
||||
val action = getActionFor(bud, stemPressType)
|
||||
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
|
||||
@@ -1116,7 +1113,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
"AirPodsParser",
|
||||
"Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}"
|
||||
)
|
||||
if (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && aacpManager.audioSource?.mac != localMac) {
|
||||
if (localMac!="" && (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && aacpManager.audioSource?.mac != localMac)) {
|
||||
Log.d(
|
||||
"AirPodsParser",
|
||||
"Audio source is another device, better to give up aacp control"
|
||||
@@ -1164,13 +1161,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEQPacketReceived(eqData: FloatArray) {
|
||||
override fun onHeadphoneAccommodationReceived(eqData: FloatArray) {
|
||||
sendBroadcast(
|
||||
Intent(AirPodsNotifications.EQ_DATA).putExtra("eqData", eqData).apply {
|
||||
setPackage(packageName)
|
||||
})
|
||||
}
|
||||
|
||||
override fun onCustomEqReceived(customEq: CustomEq) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override fun onCapabilitiesReceived(capabilities: List<Capability>) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override fun onUnknownPacketReceived(packet: ByteArray) {
|
||||
Log.d(
|
||||
"AACPManager",
|
||||
@@ -1272,6 +1277,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
disconnectAudio(this@AirPodsService, device)
|
||||
}
|
||||
}
|
||||
val wasNone = inEarData == listOf(false, false)
|
||||
val nowSingle = newInEarData.count { it } == 1
|
||||
|
||||
if (wasNone && nowSingle) {
|
||||
MediaController.sendPlay()
|
||||
MediaController.iPausedTheMedia = false
|
||||
return
|
||||
}
|
||||
|
||||
if (inEarData.contains(false) && newInEarData == listOf(true, true)) {
|
||||
Log.d("AirPodsParser", "User put in both AirPods from just one.")
|
||||
@@ -1644,6 +1657,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
var popupShown = false
|
||||
fun showPopup(service: Service, name: String) {
|
||||
if (!sharedPreferences.getBoolean("show_bottom_sheet_popup", true)) {
|
||||
return
|
||||
}
|
||||
if (!Settings.canDrawOverlays(service)) {
|
||||
Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW")
|
||||
return
|
||||
@@ -1668,6 +1684,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
otherDeviceName: String? = null
|
||||
) {
|
||||
Log.d(TAG, "Showing island window")
|
||||
if (!sharedPreferences.getBoolean("show_island_popup", true)) {
|
||||
return
|
||||
}
|
||||
if (!Settings.canDrawOverlays(service)) {
|
||||
Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW")
|
||||
return
|
||||
@@ -1728,7 +1747,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
val socketFailureChannel = NotificationChannel(
|
||||
"socket_connection_failure",
|
||||
"AirPods Socket Connection Issues",
|
||||
"AirPods BluetoothConnectionManager.aacpSocket? Connection Issues",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Notifications about problems connecting to AirPods protocol"
|
||||
@@ -1774,7 +1793,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
if (BuildConfig.FLAVOR != "xposed") {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Not showing socket error notification to user, the service shouldn't be running if it isn't supported."
|
||||
"Not showing BluetoothConnectionManager.aacpSocket? error notification to user, the service shouldn't be running if it isn't supported."
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -1903,7 +1922,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
)
|
||||
it.setViewVisibility(
|
||||
R.id.left_charging_icon,
|
||||
if (leftBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
|
||||
if (leftBattery?.status == BatteryStatus.CHARGING || leftBattery?.status == BatteryStatus.OPTIMIZED_CHARGING) View.VISIBLE else View.GONE
|
||||
)
|
||||
|
||||
it.setTextViewText(R.id.right_battery_widget, rightBattery?.let {
|
||||
@@ -1914,7 +1933,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
)
|
||||
it.setViewVisibility(
|
||||
R.id.right_charging_icon,
|
||||
if (rightBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
|
||||
if (rightBattery?.status == BatteryStatus.CHARGING || rightBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE
|
||||
)
|
||||
|
||||
it.setTextViewText(R.id.case_battery_widget, caseBattery?.let {
|
||||
@@ -1925,7 +1944,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
)
|
||||
it.setViewVisibility(
|
||||
R.id.case_charging_icon,
|
||||
if (caseBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
|
||||
if (caseBattery?.status == BatteryStatus.CHARGING || caseBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE
|
||||
)
|
||||
|
||||
it.setViewVisibility(
|
||||
@@ -1970,7 +1989,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
val allowOffModeValue =
|
||||
aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION }
|
||||
val allowOffMode =
|
||||
allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte()
|
||||
allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte() || sharedPreferences.getBoolean("off_listening_mode", true)
|
||||
it.setInt(
|
||||
R.id.widget_off_button,
|
||||
"setBackgroundResource",
|
||||
@@ -2029,10 +2048,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
if (!::socket.isInitialized) {
|
||||
if (BluetoothConnectionManager.aacpSocket == null) {
|
||||
return
|
||||
}
|
||||
if (connected && (config.bleOnlyMode || socket.isConnected)) {
|
||||
if (BluetoothConnectionManager.aacpSocket?.isConnected == true) {
|
||||
val updatedNotificationBuilder =
|
||||
NotificationCompat.Builder(this, "airpods_connection_status")
|
||||
.setSmallIcon(R.drawable.airpods)
|
||||
@@ -2080,8 +2099,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
notificationManager.cancel(1)
|
||||
} else if (!connected) {
|
||||
notificationManager.cancel(2)
|
||||
} else if (!config.bleOnlyMode && !socket.isConnected) {
|
||||
showSocketConnectionFailureNotification("Socket created, but not connected. Check logs")
|
||||
} else if (!config.bleOnlyMode && BluetoothConnectionManager.aacpSocket?.isConnected != true) {
|
||||
showSocketConnectionFailureNotification("BluetoothConnectionManager.aacpSocket? created, but not connected. Check logs")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2360,7 +2379,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
} else {
|
||||
Log.w(
|
||||
TAG,
|
||||
"AirPods instance is not of type AirPodsInstance, skipping metadata setting"
|
||||
"AirPods demoInstance is not of type AirPodsInstance, skipping metadata setting"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2383,24 +2402,38 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
?.getString("name", bluetoothDevice?.name)
|
||||
if (bluetoothDevice != null && !action.isNullOrEmpty()) {
|
||||
Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
|
||||
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
||||
|
||||
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
|
||||
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
||||
bluetoothDevice.fetchUuidsWithSdp()
|
||||
if (bluetoothDevice.uuids != null) {
|
||||
if (bluetoothDevice.uuids.contains(uuid)) {
|
||||
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
|
||||
intent.putExtra("name", name)
|
||||
intent.putExtra("device", bluetoothDevice)
|
||||
context?.sendBroadcast(intent)
|
||||
}
|
||||
if (bluetoothDevice.uuids?.contains(uuid) == true) {
|
||||
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
|
||||
intent.putExtra("name", name)
|
||||
intent.putExtra("device", bluetoothDevice)
|
||||
context?.sendBroadcast(intent)
|
||||
} else {
|
||||
bluetoothDevice.fetchUuidsWithSdp()
|
||||
}
|
||||
} else if ("android.bluetooth.device.action.UUID" == action) {
|
||||
val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
?.getString("mac_address", "") ?: ""
|
||||
val matchedByMac = savedMac.isNotEmpty() && bluetoothDevice.address == savedMac
|
||||
val matchedByUuid = bluetoothDevice.uuids?.contains(uuid) == true
|
||||
if (matchedByUuid || matchedByMac) {
|
||||
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
|
||||
intent.putExtra("name", name)
|
||||
intent.putExtra("device", bluetoothDevice)
|
||||
context?.sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ancModeFilter = IntentFilter("me.kavishdevar.librepods.SET_ANC_MODE")
|
||||
var ancModeReceiver: BroadcastReceiver? = null
|
||||
val externalBroadcastFilter = IntentFilter().apply {
|
||||
addAction("me.kavishdevar.librepods.SET_ANC_MODE")
|
||||
addAction("me.kavishdevar.librepods.CONVO_DETECT")
|
||||
}
|
||||
var externalBroadcastReceiver: BroadcastReceiver? = null
|
||||
|
||||
@SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -2442,8 +2475,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
Log.d(
|
||||
TAG, "owns connection: $ownsConnection"
|
||||
)
|
||||
if (!::socket.isInitialized) return
|
||||
if (socket.isConnected) {
|
||||
if (BluetoothConnectionManager.aacpSocket?.isConnected == true) {
|
||||
if (!XposedRemotePrefProvider.create().getBoolean("vendor_id_hook", false) || ownsConnection == 0) {
|
||||
Log.d(TAG, "not taking over, vendorid is probably not set to apple")
|
||||
return
|
||||
@@ -2601,62 +2633,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
// CrossDevice.isAvailable = false
|
||||
}
|
||||
|
||||
private fun createBluetoothSocket(
|
||||
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid
|
||||
): BluetoothSocket {
|
||||
val type = 3 // L2CAP
|
||||
val constructorSpecs = listOf(
|
||||
arrayOf(adapter, device, type, true, true, 0x1001, uuid), // A16QPR3
|
||||
arrayOf(device, type, true, true, 0x1001, uuid),
|
||||
arrayOf(device, type, 1, true, true, 0x1001, uuid),
|
||||
arrayOf(type, 1, true, true, device, 0x1001, uuid),
|
||||
arrayOf(type, true, true, device, 0x1001, uuid)
|
||||
)
|
||||
|
||||
val constructors = BluetoothSocket::class.java.declaredConstructors
|
||||
Log.d(TAG, "BluetoothSocket has ${constructors.size} constructors:")
|
||||
|
||||
constructors.forEachIndexed { index, constructor ->
|
||||
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
|
||||
Log.d(TAG, "Constructor $index: ($params)")
|
||||
}
|
||||
|
||||
var lastException: Exception? = null
|
||||
var attemptedConstructors = 0
|
||||
|
||||
for ((index, params) in constructorSpecs.withIndex()) {
|
||||
try {
|
||||
Log.d(TAG, "Trying constructor signature #${index + 1}")
|
||||
attemptedConstructors++
|
||||
|
||||
val paramTypes =
|
||||
params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
|
||||
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
|
||||
constructor.isAccessible = true
|
||||
return constructor.newInstance(*params) as BluetoothSocket
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Constructor signature #${index + 1} failed: ${e.message}")
|
||||
lastException = e
|
||||
}
|
||||
}
|
||||
|
||||
val errorMessage =
|
||||
"Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
|
||||
Log.e(TAG, errorMessage)
|
||||
showSocketConnectionFailureNotification(errorMessage)
|
||||
throw lastException ?: IllegalStateException(errorMessage)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||
fun connectToSocket(
|
||||
adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false
|
||||
) {
|
||||
if (BluetoothConnectionManager.aacpSocket != null && BluetoothConnectionManager.aacpSocket?.isConnected == true) return
|
||||
Log.d(TAG, "<LogCollector:Start> Connecting to socket")
|
||||
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
||||
// if (!isConnectedLocally) {
|
||||
socket = try {
|
||||
createBluetoothSocket(adapter, device, uuid)
|
||||
val socket = try {
|
||||
createBluetoothSocket(adapter, device, uuid, 4097)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
|
||||
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
|
||||
@@ -2665,19 +2651,31 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
withTimeout(5000L) {
|
||||
withTimeout(5000.milliseconds) {
|
||||
try {
|
||||
socket.connect()
|
||||
// isConnectedLocally = true
|
||||
this@AirPodsService.device = device
|
||||
|
||||
BluetoothConnectionManager.setCurrentConnection(socket, device)
|
||||
val xposedRemotePref = XposedRemotePrefProvider.create()
|
||||
if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
|
||||
attManager = ATTManager(adapter, device)
|
||||
attManager!!.connect()
|
||||
val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
|
||||
createBluetoothSocket(
|
||||
adapter,
|
||||
device,
|
||||
ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"),
|
||||
31
|
||||
)
|
||||
} else null
|
||||
attSocket?.connect()
|
||||
|
||||
if (attSocket != null) {
|
||||
attManager.startReader()
|
||||
attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION)
|
||||
attManager.readCharacteristic(ATTHandles.TRANSPARENCY)
|
||||
attManager.readCharacteristic(ATTHandles.HEARING_AID)
|
||||
}
|
||||
|
||||
BluetoothConnectionManager.aacpSocket = socket
|
||||
BluetoothConnectionManager.attSocket = attSocket
|
||||
|
||||
// Create AirPodsInstance from stored config if available
|
||||
if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) {
|
||||
val model =
|
||||
@@ -2703,6 +2701,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
)
|
||||
Log.d(TAG, "<LogCollector:Complete:Success> Socket connected")
|
||||
sharedPreferences.edit { putBoolean("connection_successful", true) }
|
||||
if (!sharedPreferences.contains("first_connection_successful_time")) {
|
||||
sharedPreferences.edit {
|
||||
putLong(
|
||||
"first_connection_successful_time",
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
}
|
||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED))
|
||||
} catch (e: Exception) {
|
||||
// sharedPreferences.edit { putBoolean("connection_successful", false) }
|
||||
@@ -2722,7 +2728,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
}
|
||||
if (!socket.isConnected) {
|
||||
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
|
||||
Log.d(TAG, "<LogCollector:Complete:Failed> socket not connected")
|
||||
if (manual) {
|
||||
sendToast(
|
||||
"Couldn't connect to socket: timeout."
|
||||
@@ -2733,13 +2739,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
return
|
||||
}
|
||||
this@AirPodsService.device = device
|
||||
socket.let {
|
||||
BluetoothConnectionManager.aacpSocket?.let {
|
||||
aacpManager.sendPacket(aacpManager.createHandshakePacket())
|
||||
aacpManager.sendSetFeatureFlagsPacket()
|
||||
aacpManager.sendNotificationRequest()
|
||||
Log.d(TAG, "Requesting proximity keys")
|
||||
aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte())
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(200)
|
||||
aacpManager.sendPacket(aacpManager.createHandshakePacket())
|
||||
delay(200)
|
||||
aacpManager.sendSetFeatureFlagsPacket()
|
||||
@@ -2767,7 +2774,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
setupStemActions()
|
||||
|
||||
while (socket.isConnected) {
|
||||
socket.let { it ->
|
||||
try {
|
||||
val buffer = ByteArray(1024)
|
||||
val bytesRead = it.inputStream.read(buffer)
|
||||
var data: ByteArray
|
||||
@@ -2794,18 +2801,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
|
||||
} else if (bytesRead == -1) {
|
||||
Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
|
||||
Log.d("AirPodsService", "socket closed (bytesRead = -1)")
|
||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||
setPackage(packageName)
|
||||
})
|
||||
aacpManager.disconnected()
|
||||
return@launch
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error reading data, we have probably disconnected.")
|
||||
e.printStackTrace()
|
||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||
setPackage(packageName)
|
||||
})
|
||||
aacpManager.disconnected()
|
||||
return@launch
|
||||
}
|
||||
|
||||
}
|
||||
Log.d("AirPods Service", "Socket closed")
|
||||
Log.d("AirPods Service", "socket closed")
|
||||
// isConnectedLocally = false
|
||||
socket.close()
|
||||
aacpManager.disconnected()
|
||||
updateNotificationContent(false)
|
||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||
@@ -2815,20 +2830,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.d(TAG, "Failed to connect to socket: ${e.message}")
|
||||
Log.d(TAG, "Failed to connect to BluetoothConnectionManager.aacpSocket?: ${e.message}")
|
||||
showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}")
|
||||
// isConnectedLocally = false
|
||||
this@AirPodsService.device = device
|
||||
updateNotificationContent(false)
|
||||
}
|
||||
// } else {
|
||||
// Log.d(TAG, "Already connected locally, skipping socket connection (isConnectedLocally = $isConnectedLocally, socket.isConnected = ${this::socket.isInitialized && socket.isConnected})")
|
||||
// Log.d(TAG, "Already connected locally, skipping BluetoothConnectionManager.aacpSocket? connection (isConnectedLocally = $isConnectedLocally, BluetoothConnectionManager.aacpSocket?.isConnected = ${this::BluetoothConnectionManager.aacpSocket?.isInitialized && BluetoothConnectionManager.aacpSocket?.isConnected})")
|
||||
// }
|
||||
}
|
||||
|
||||
fun disconnectForCD() {
|
||||
if (!this::socket.isInitialized) return
|
||||
socket.close()
|
||||
BluetoothConnectionManager.aacpSocket?.close()
|
||||
MediaController.pausedWhileTakingOver = false
|
||||
Log.d(TAG, "Disconnected from AirPods, showing island.")
|
||||
showIsland(
|
||||
@@ -2859,11 +2873,18 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
|
||||
fun disconnectAirPods() {
|
||||
if (!this::socket.isInitialized) return
|
||||
socket.close()
|
||||
if (BluetoothConnectionManager.aacpSocket == null) return
|
||||
try {
|
||||
BluetoothConnectionManager.aacpSocket?.close()
|
||||
} catch(e: Exception) {
|
||||
Log.e(TAG, "error closing aacp socket ${e.message}")
|
||||
}
|
||||
// isConnectedLocally = false
|
||||
aacpManager.disconnected()
|
||||
attManager?.disconnect()
|
||||
|
||||
BluetoothConnectionManager.aacpSocket = null
|
||||
BluetoothConnectionManager.attSocket = null
|
||||
|
||||
updateNotificationContent(false)
|
||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||
setPackage(packageName)
|
||||
@@ -2884,6 +2905,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
}, BluetoothProfile.A2DP)
|
||||
try {
|
||||
device?.disconnect()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "device.disconnect() failed, $e")
|
||||
}
|
||||
}
|
||||
if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED){
|
||||
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
|
||||
@@ -2901,7 +2927,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}, BluetoothProfile.HEADSET)
|
||||
}
|
||||
Log.d(TAG, "Disconnected AirPods upon user request")
|
||||
|
||||
}
|
||||
|
||||
val earDetectionNotification = AirPodsNotifications.EarDetection()
|
||||
@@ -2991,17 +3016,20 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
fun connectAudio(context: Context, device: BluetoothDevice?) {
|
||||
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
||||
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
|
||||
|
||||
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
if (profile == BluetoothProfile.A2DP) {
|
||||
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
if (profile == BluetoothProfile.A2DP) {
|
||||
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
|
||||
try {
|
||||
val policyMethod = proxy.javaClass.getMethod(
|
||||
"setConnectionPolicy", BluetoothDevice::class.java, Int::class.java
|
||||
"setConnectionPolicy",
|
||||
BluetoothDevice::class.java,
|
||||
Int::class.java
|
||||
)
|
||||
Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 100")
|
||||
policyMethod.invoke(proxy, device, 100)
|
||||
|
||||
val connectMethod =
|
||||
proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
|
||||
connectMethod.invoke(
|
||||
@@ -3016,24 +3044,34 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
val connectMethod =
|
||||
proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
|
||||
connectMethod.invoke(
|
||||
proxy, device
|
||||
)
|
||||
Log.d(TAG, "not setting connection policy for A2DP, no BLUETOOTH_PRIVILEGED permission. just called connect")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
}, BluetoothProfile.A2DP)
|
||||
} else {
|
||||
Log.d(TAG, "not connecting A2DP, no BLUETOOTH_PRIVILEGED permission")
|
||||
}
|
||||
if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
|
||||
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
if (profile == BluetoothProfile.HEADSET) {
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
}, BluetoothProfile.A2DP)
|
||||
|
||||
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
if (profile == BluetoothProfile.HEADSET) {
|
||||
if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
|
||||
try {
|
||||
val policyMethod = proxy.javaClass.getMethod(
|
||||
"setConnectionPolicy",
|
||||
BluetoothDevice::class.java,
|
||||
Int::class.java
|
||||
)
|
||||
Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 100")
|
||||
Log.d(
|
||||
TAG,
|
||||
"calling HEADSET.setConnectionPolicy for ${device?.address} to 100"
|
||||
)
|
||||
policyMethod.invoke(proxy, device, 100)
|
||||
val connectMethod =
|
||||
proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
|
||||
@@ -3043,14 +3081,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
} finally {
|
||||
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "not setting connection policy for HEADSET, no MODIFIY_PHONE_STATE permission")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
}, BluetoothProfile.HEADSET)
|
||||
} else {
|
||||
Log.d(TAG, "not connecting HEADSET, no MODIFIY_PHONE_STATE permission")
|
||||
}
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
}, BluetoothProfile.HEADSET)
|
||||
}
|
||||
|
||||
fun setName(name: String) {
|
||||
@@ -3079,7 +3117,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
e.printStackTrace()
|
||||
}
|
||||
try {
|
||||
unregisterReceiver(ancModeReceiver)
|
||||
unregisterReceiver(externalBroadcastReceiver)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
@@ -3138,6 +3176,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
aacpManager.sendStopHeadTracking()
|
||||
}
|
||||
isHeadTrackingActive = false
|
||||
gestureDetector?.stopDetection()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@@ -3150,16 +3189,30 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
Log.d(TAG, "connecting to $macAddress")
|
||||
connectToSocket(bluetoothAdapter, device!!, manual = true)
|
||||
connectAudio(this@AirPodsService, device!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isConnected(): Boolean {
|
||||
return if (::socket.isInitialized) socket.isConnected else false
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.dpToPx(): Int {
|
||||
val density = Resources.getSystem().displayMetrics.density
|
||||
return (this * density).toInt()
|
||||
}
|
||||
|
||||
fun getNextMode(currentMode: Int, configByte: Int, offmodeEnabled: Boolean): Int {
|
||||
val enabledModes = buildList {
|
||||
if ((configByte and 0x01) != 0 && offmodeEnabled) add(1)
|
||||
if ((configByte and 0x04) != 0) add(3)
|
||||
if ((configByte and 0x08) != 0) add(4)
|
||||
if ((configByte and 0x02) != 0) add(2)
|
||||
}
|
||||
Log.d(TAG, "currentMode: $currentMode, config: ${configByte.toString(2)}")
|
||||
|
||||
if (enabledModes.isEmpty()) return currentMode
|
||||
|
||||
val currentIndex = enabledModes.indexOf(currentMode)
|
||||
val nextIndex = if (currentIndex == -1) 0 else (currentIndex + 1) % enabledModes.size
|
||||
|
||||
return enabledModes[nextIndex]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user