mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-07-10 16:02:13 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04ef891c4b | |||
| 555a000def | |||
| 45f53d5cd4 | |||
| 1425a7d7cf | |||
| e8204a7750 | |||
| 5fbfda6115 | |||
| a6a284c2ec | |||
| 5c0d9b5096 | |||
| 752b53aecd | |||
| 3e4d401223 |
@@ -1,110 +0,0 @@
|
||||
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).
|
||||
@@ -1,83 +0,0 @@
|
||||
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
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
@@ -1,212 +1,100 @@
|
||||
name: Android CI
|
||||
name: Build APK and root module (and create nightly release)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
paths:
|
||||
- 'android/**'
|
||||
- 'root-module-manual/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'android/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: Branch to build
|
||||
release:
|
||||
description: 'Create a nightly release'
|
||||
required: true
|
||||
default: main
|
||||
type: boolean
|
||||
default: false
|
||||
custom_notes:
|
||||
description: 'Custom updates to add to What''s Changed section'
|
||||
required: false
|
||||
type: string
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-debug-apk:
|
||||
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:
|
||||
submodules: true
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }}
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: zulu
|
||||
distribution: 'zulu'
|
||||
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
|
||||
- name: Accept Licenses
|
||||
run: yes | sdkmanager --licenses
|
||||
- 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
|
||||
RELEASE_STORE_PASSWORD=${{ secrets.RELEASE_STORE_PASSWORD }}
|
||||
RELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }}
|
||||
RELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||
EOF
|
||||
- name: Build debug APK for PRs
|
||||
if: github.event_name == 'pull_request'
|
||||
run: ./gradlew assembleFossDebug
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
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'
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
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
|
||||
|
||||
release:
|
||||
if: github.event_name == 'push'
|
||||
name: Debug APK
|
||||
path: android/app/build/outputs/apk/**/*.apk
|
||||
nightly-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/release-nightly' || github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'true'
|
||||
needs: build-debug-apk
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: apk-release
|
||||
path: artifacts/apk-release
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: apk-debug
|
||||
path: artifacts/apk-debug
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: root-module-release
|
||||
path: artifacts/root-module-release
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: root-module-debug
|
||||
path: artifacts/root-module-debug
|
||||
- id: prev
|
||||
- name: Export APK_NAME for later use
|
||||
run: echo "APK_NAME=LibrePods-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV
|
||||
- name: Rename .apk file
|
||||
run: mv "./Debug APK/debug/"*.apk "./$APK_NAME"
|
||||
- name: Decode keystore file
|
||||
run: echo "${{ secrets.DEBUG_KEYSTORE_FILE }}" | base64 --decode > debug.keystore
|
||||
- name: Install apksigner
|
||||
run: sudo apt-get update && sudo apt-get install -y apksigner
|
||||
- name: Sign APK
|
||||
run: |
|
||||
apksigner sign --ks debug.keystore --ks-key-alias androiddebugkey --ks-pass pass:android --key-pass pass:android "./$APK_NAME"
|
||||
- name: Verify APK
|
||||
run: apksigner verify "./$APK_NAME"
|
||||
- name: Fetch the latest non-nightly release tag
|
||||
id: fetch-tag
|
||||
run: echo "::set-output name=tag::$(git describe --tags $(git rev-list --tags --max-count=1))"
|
||||
- name: Retrieve commits since the last release
|
||||
id: get-commits
|
||||
run: |
|
||||
COMMITS=$(git log ${{ steps.fetch-tag.outputs.tag }}..HEAD --pretty=format:"- %s (%h)" --abbrev-commit)
|
||||
echo "::set-output name=commits::${COMMITS}"
|
||||
- name: Prepare release notes
|
||||
id: release-notes
|
||||
run: |
|
||||
# Create a temporary file for release notes
|
||||
NOTES_FILE=$(mktemp)
|
||||
|
||||
# Process custom notes if they exist
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.custom_notes }}" ]; then
|
||||
CUSTOM_NOTES="${{ github.event.inputs.custom_notes }}"
|
||||
|
||||
# Check if custom notes already have bullet points or GitHub-style formatting
|
||||
if echo "$CUSTOM_NOTES" | grep -q "^\*\|^- \|http.*commit\|in #[0-9]\+"; then
|
||||
# Already formatted, use as is
|
||||
echo "$CUSTOM_NOTES" > "$NOTES_FILE"
|
||||
else
|
||||
# Add bullet point formatting
|
||||
echo "- $CUSTOM_NOTES" > "$NOTES_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT
|
||||
- name: Zip root-module directory
|
||||
run: sh ./build-magisk-module.sh
|
||||
- name: Delete release if exist then create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG=$(gh release list \
|
||||
--limit 1 \
|
||||
--json tagName \
|
||||
--jq '.[0].tagName')
|
||||
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
- id: changelog
|
||||
run: |
|
||||
if [ -z "${{ steps.prev.outputs.tag }}" ]; then
|
||||
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](https://github.com/kavishdevar/librepods/commit/%H))")
|
||||
fi
|
||||
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- id: tag
|
||||
run: echo "tag=nightly-${{ needs.build.outputs.short_sha }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "${{ steps.tag.outputs.tag }}" \
|
||||
artifacts/**/* \
|
||||
-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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}'
|
||||
gh release view "nightly" && gh release delete "nightly" -y --cleanup-tag
|
||||
gh release create "nightly" "./$APK_NAME" "./btl2capfix.zip" -p -t "Nightly Release" --notes-file "${{ steps.release-notes.outputs.notes_file }}" --generate-notes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Linux CI (Rust)
|
||||
name: Linux Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
+6
-1
@@ -1,5 +1,10 @@
|
||||
release
|
||||
root-module/radare2-5.9.9-android-aarch64.tar.gz
|
||||
wak.toml
|
||||
log.txt
|
||||
btl2capfix.zip
|
||||
root-module-manual
|
||||
.vscode
|
||||
testing.py
|
||||
.DS_Store
|
||||
CMakeLists.txt.user*
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# 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).
|
||||
@@ -1,228 +1,122 @@
|
||||
>[!IMPORTANT]
|
||||
Development paused due to lack of time until June 2026 (JEE Advanced). PRs and issues might not be responded to until then.
|
||||
Development paused due to lack of time until 17th May 2026 (JEE Advanced). PRs and issues might not be responded to until then.
|
||||
|
||||
---
|
||||

|
||||
|
||||
<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>
|
||||
## What is LibrePods?
|
||||
|
||||
<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>
|
||||
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.
|
||||
|
||||
# What is LibrePods?
|
||||
## Device Compatibility
|
||||
|
||||
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.
|
||||
| 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 availability
|
||||
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).
|
||||
|
||||
| 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) | 🔴 | 🔴 |
|
||||
## Key Features
|
||||
|
||||
| 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 |
|
||||
- **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!
|
||||
|
||||
## Find My
|
||||
* Features marked with an asterisk require the VendorID to be change to that of Apple.
|
||||
|
||||
The following features related to Find My are planned, but require further RE and might need root on Android:
|
||||
## Platform Support
|
||||
|
||||
- Add your AirPods to the Find My network
|
||||
- Play sound through charging case to find it
|
||||
- Notify when leaving behind
|
||||
- Toggle case charging sounds
|
||||
### 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)
|
||||
|
||||
## Spatial Audio
|
||||
new version in development ([#241](https://github.com/kavishdevar/librepods/pull/241))
|
||||
|
||||
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.
|
||||

|
||||
|
||||
Spatializing stereo sound is beyond this project's scope and will never be available. Many OEMs have an implementation of their own for this.
|
||||
### Android
|
||||
|
||||
## 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.
|
||||
#### Screenshots
|
||||
|
||||
## High quality two-way audio
|
||||
On iOS/iPadOS, you can continue using A2DP while AirPods send the audio stream from its microphone over AACP.
|
||||
| | | |
|
||||
| --------------------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|
||||
Since this needs deeper integration with audio on Android, it will most likely need root.
|
||||
|
||||
# Installation
|
||||
here's a very unprofessional demo video
|
||||
|
||||
- [**Android**](/android/README.md)
|
||||
- [**Linux**](/linux/README.md)
|
||||
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
|
||||
|
||||
# VendorID Spoofing
|
||||
#### Root Requirement
|
||||
|
||||
If you are using ColorOS/OxygenOS 16, you don't need root except for customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint. Changing ANC, conversational awareness, ear detection, and other customizations will work without root. For everyone else:
|
||||
|
||||
> [!CAUTION]
|
||||
> **You must have a rooted device with Xposed to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page. DO NOT leave a +1 comment - use the +1 button in the top right of the page next to the "Hotlists" field. Leaving +1 comment spam makes it impossible for developers to engage in the necessary technical discussion to implement this fix, and will disincentivize the responsible Google developers from engaging. I don't know a fix for Android versions <13 either. So, this needs a phone running A13+.
|
||||
>
|
||||
> There are **no exceptions** to the root requirement until Google/your OEM figures out a fix.
|
||||
|
||||
Until then, you must xposed. I used to provide a non-xposed method too, where the module used overlayfs to replace the bluetooth library with a locally patched one, but that was broken due to how various devices handled overlayfs and a patched library. With xposed, you can also enable the DID hook enabling a few extra features.
|
||||
|
||||
## Changing VendorID in the DID profile to that of Apple
|
||||
|
||||
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 (shown only when Xposed is available and LibrePods module is enabled).
|
||||
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.
|
||||
|
||||
## Multi-device Connectivity
|
||||
### Multi-device Connectivity
|
||||
|
||||
Upto two devices can be simultaneously connected to AirPods, for audio and control both. Seamless connection switching. The same notification shows up on Apple device when Android takes over the AirPods as if it were an Apple device ("Move to iPhone"). Android also shows a popup when the other device takes over.
|
||||
|
||||
## Accessibility Settings and Hearing Aid
|
||||
### Accessibility Settings and Hearing Aid
|
||||
|
||||
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured.
|
||||
|
||||
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
|
||||
#### A few notes
|
||||
|
||||
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)
|
||||
- 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.
|
||||
|
||||
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!
|
||||
- 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.
|
||||
|
||||
# Use of AI
|
||||
- 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.
|
||||
|
||||
## Android app
|
||||
- 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.
|
||||
|
||||
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
|
||||
## Supporters
|
||||
|
||||
A huge thank you to everyone supporting the project!
|
||||
- @davdroman
|
||||
- @tedsalmon
|
||||
- @wiless
|
||||
- @SmartMsg
|
||||
- @lunaroyster
|
||||
- @ressiwage
|
||||
|
||||
<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
|
||||
## 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/))
|
||||
|
||||
# Star History
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left">
|
||||
<picture>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
## 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.
|
||||
+24
-177
@@ -1,106 +1,42 @@
|
||||
import java.util.Properties
|
||||
|
||||
val appVersionName = "1.0.0-rc1"
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.aboutLibraries)
|
||||
// alias(libs.plugins.hilt)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
val localPropsFile = rootProject.file("local.properties")
|
||||
val props = Properties().apply {
|
||||
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 {
|
||||
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"
|
||||
compileSdk = 37
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "me.kavishdevar.librepods"
|
||||
targetSdk = 37
|
||||
versionCode = 61
|
||||
versionName = appVersionName
|
||||
minSdk = 33
|
||||
targetSdk = 36
|
||||
versionCode = 10
|
||||
versionName = "0.2.0-alpha.2"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += "-DCMAKE_BUILD_TYPE=Release"
|
||||
}
|
||||
}
|
||||
if (releaseSigningAvailable) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
defaultConfig {
|
||||
minSdk = 33
|
||||
}
|
||||
}
|
||||
debug {
|
||||
if (releaseSigningAvailable) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
versionNameSuffix = "-debug"
|
||||
defaultConfig {
|
||||
minSdk = 33
|
||||
}
|
||||
}
|
||||
}
|
||||
productFlavors {
|
||||
create("foss") {
|
||||
dimension = "env"
|
||||
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
||||
}
|
||||
create("play") {
|
||||
dimension = "env"
|
||||
buildConfigField("Boolean", "PLAY_BUILD", "true")
|
||||
versionNameSuffix = "-play"
|
||||
minSdk = 36
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
@@ -113,23 +49,18 @@ android {
|
||||
}
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
res.directories += "src/main/res-apple"
|
||||
res.srcDirs("src/main/res", "src/main/res-apple")
|
||||
}
|
||||
}
|
||||
|
||||
ndkVersion = "30.0.14904198"
|
||||
|
||||
flavorDimensions += "env"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.androidx.compose.ui.text.google.fonts)
|
||||
implementation(libs.hiddenapibypass)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
@@ -141,105 +72,21 @@ dependencies {
|
||||
implementation(libs.haze.materials)
|
||||
implementation(libs.androidx.dynamicanimation)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.material.icons.core)
|
||||
implementation(libs.billing)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
implementation(libs.androidx.compose.foundation.layout)
|
||||
implementation(libs.aboutlibraries)
|
||||
implementation(libs.aboutlibraries.compose.m3)
|
||||
implementation(libs.backdrop)
|
||||
// implementation(libs.hilt)
|
||||
// implementation(libs.hilt.compiler)
|
||||
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)
|
||||
// compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
||||
// implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar"))))
|
||||
compileOnly(files("libs/libxposed-api-100.aar"))
|
||||
debugImplementation(files("libs/backdrop-debug.aar"))
|
||||
releaseImplementation(files("libs/backdrop-release.aar"))
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
export {
|
||||
export{
|
||||
prettyPrint = true
|
||||
excludeFields = listOf("generated")
|
||||
outputFile = file("src/main/res/raw/aboutlibraries.json")
|
||||
}
|
||||
}
|
||||
|
||||
val rootModuleDir = rootProject.file("../root-module-manual")
|
||||
val releaseDir = rootProject.file("../release")
|
||||
|
||||
fun cap(s: String) = s.replaceFirstChar { it.uppercase() }
|
||||
|
||||
fun registerRootModuleZipTask(
|
||||
name: String,
|
||||
flavor: String,
|
||||
buildType: String
|
||||
) = tasks.register<Zip>(name) {
|
||||
|
||||
val variantTask = "assemble${cap(flavor)}${cap(buildType)}"
|
||||
dependsOn(variantTask)
|
||||
|
||||
val apkPath = "outputs/apk/$flavor/$buildType/app-$flavor-$buildType.apk"
|
||||
|
||||
from(rootModuleDir)
|
||||
|
||||
duplicatesStrategy = DuplicatesStrategy.WARN
|
||||
|
||||
from(layout.buildDirectory.file(apkPath)) {
|
||||
into("system/priv-app/LibrePods")
|
||||
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(
|
||||
"zipReleaseModule",
|
||||
"foss",
|
||||
"release"
|
||||
)
|
||||
|
||||
val zipDebug = registerRootModuleZipTask(
|
||||
"zipDebugModule",
|
||||
"foss",
|
||||
"debug"
|
||||
)
|
||||
|
||||
val collect = tasks.register<Copy>("collectReleaseArtifacts") {
|
||||
|
||||
dependsOn(
|
||||
zipRelease,
|
||||
zipDebug,
|
||||
"bundlePlayRelease"
|
||||
)
|
||||
|
||||
into(releaseDir)
|
||||
|
||||
from(layout.buildDirectory.dir("outputs/apk/foss/release")) {
|
||||
include("*.apk")
|
||||
rename(".*", "LibrePods-FOSS-v$appVersionName-release.apk")
|
||||
}
|
||||
|
||||
from(layout.buildDirectory.dir("outputs/apk/foss/debug")) {
|
||||
include("*.apk")
|
||||
rename(".*", "LibrePods-FOSS-v$appVersionName-debug.apk")
|
||||
}
|
||||
|
||||
from(layout.buildDirectory.dir("outputs/bundle/playRelease")) {
|
||||
include("*.aab")
|
||||
}
|
||||
|
||||
from(layout.buildDirectory.dir("outputs/rootModuleZips")) {
|
||||
include("*.zip")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("packageReleaseArtifacts") {
|
||||
dependsOn(collect)
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
-3
@@ -18,6 +18,4 @@
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-keep class me.kavishdevar.librepods.utils.KotlinModule { *; }
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -17,15 +17,6 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_PRIVILEGED"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.MODIFY_PHONE_STATE"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.LOCAL_MAC_ADDRESS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.INTERACT_ACROSS_USERS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_SCAN"
|
||||
@@ -35,15 +26,15 @@
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
|
||||
<!-- android:maxSdkVersion="30" />-->
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"-->
|
||||
<!-- android:maxSdkVersion="30" />-->
|
||||
<uses-permission android:name="com.android.vending.BILLING" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<application
|
||||
android:name=".LibrePodsApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
@@ -54,9 +45,10 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.LibrePods"
|
||||
android:description="@string/app_description"
|
||||
tools:ignore="UnusedAttribute" >
|
||||
tools:ignore="UnusedAttribute"
|
||||
tools:targetApi="31">
|
||||
<receiver
|
||||
android:name=".presentation.widgets.NoiseControlWidget"
|
||||
android:name=".widgets.NoiseControlWidget"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
@@ -68,7 +60,7 @@
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".presentation.widgets.BatteryWidget"
|
||||
android:name=".widgets.BatteryWidget"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
@@ -122,17 +114,17 @@
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<!-- <service-->
|
||||
<!-- android:name=".services.AppListenerService"-->
|
||||
<!-- android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"-->
|
||||
<!-- android:exported="true">-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="android.accessibilityservice.AccessibilityService" />-->
|
||||
<!-- </intent-filter>-->
|
||||
<!-- <meta-data-->
|
||||
<!-- android:name="android.accessibilityservice"-->
|
||||
<!-- android:resource="@xml/app_listener_service_config" />-->
|
||||
<!-- </service>-->
|
||||
<service
|
||||
android:name=".services.AppListenerService"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/app_listener_service_config" />
|
||||
</service>
|
||||
<receiver
|
||||
android:name=".receivers.BootReceiver"
|
||||
android:enabled="true"
|
||||
|
||||
@@ -3,28 +3,6 @@ cmake_minimum_required(VERSION 3.22.1)
|
||||
project("l2c_fcr_hook")
|
||||
set(CMAKE_CXX_STANDARD 23)
|
||||
|
||||
add_library(bluetooth_socket SHARED
|
||||
bluetooth_socket.cpp
|
||||
)
|
||||
|
||||
target_compile_options(bluetooth_socket PRIVATE
|
||||
-O2
|
||||
-fvisibility=hidden
|
||||
)
|
||||
|
||||
target_link_options(bluetooth_socket PRIVATE
|
||||
-Wl,--strip-all
|
||||
-Wl,--gc-sections
|
||||
)
|
||||
|
||||
target_link_libraries(bluetooth_socket
|
||||
android
|
||||
log
|
||||
)
|
||||
|
||||
|
||||
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
|
||||
|
||||
add_library(l2c_fcr_hook SHARED
|
||||
l2c_fcr_hook.cpp
|
||||
|
||||
@@ -53,5 +31,4 @@ target_compile_definitions(l2c_fcr_hook PRIVATE
|
||||
|
||||
target_link_libraries(l2c_fcr_hook
|
||||
android
|
||||
log
|
||||
)
|
||||
log)
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
#include <jni.h>
|
||||
#include <pthread.h>
|
||||
#include <array>
|
||||
#include <string>
|
||||
|
||||
static JavaVM* gVm = nullptr;
|
||||
|
||||
template<size_t N>
|
||||
constexpr auto encryptString(const char (&str)[N], char key) {
|
||||
std::array<char, N> encrypted{};
|
||||
for (size_t i = 0; i < N; i++) {
|
||||
encrypted[i] = str[i] ^ key;
|
||||
}
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
template<size_t N>
|
||||
static std::string decryptString(const std::array<char, N>& encrypted, char key) {
|
||||
std::string result(N - 1, '\0');
|
||||
for (size_t i = 0; i < N - 1; i++) {
|
||||
result[i] = encrypted[i] ^ key;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
#define ENC(str) encryptString(str, 0x47)
|
||||
#define DEC(arr) decryptString(arr, 0x47).c_str()
|
||||
|
||||
__attribute__((visibility("hidden")))
|
||||
static JavaVM* getVm() { return gVm; }
|
||||
|
||||
__attribute__((visibility("default")))
|
||||
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
|
||||
gVm = vm;
|
||||
|
||||
auto fn = [](void*) -> void* {
|
||||
constexpr auto c1 = ENC("dalvik/system/VMRuntime");
|
||||
constexpr auto c2 = ENC("getRuntime");
|
||||
constexpr auto c3 = ENC("()Ldalvik/system/VMRuntime;");
|
||||
constexpr auto c4 = ENC("setHiddenApiExemptions");
|
||||
constexpr auto c5 = ENC("([Ljava/lang/String;)V");
|
||||
constexpr auto c6 = ENC("java/lang/String");
|
||||
constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;");
|
||||
constexpr auto c8 = ENC("Landroid/bluetooth/BluetoothDevice;");
|
||||
|
||||
JNIEnv* env;
|
||||
getVm()->AttachCurrentThread(&env, nullptr);
|
||||
|
||||
jclass vmRuntime = env->FindClass(DEC(c1));
|
||||
jmethodID getRuntime = env->GetStaticMethodID(vmRuntime, DEC(c2), DEC(c3));
|
||||
jmethodID setExemptions = env->GetMethodID(vmRuntime, DEC(c4), DEC(c5));
|
||||
|
||||
jobject runtime = env->CallStaticObjectMethod(vmRuntime, getRuntime);
|
||||
jobjectArray prefixes = env->NewObjectArray(
|
||||
2, env->FindClass(DEC(c6)), nullptr);
|
||||
env->SetObjectArrayElement(prefixes, 0, env->NewStringUTF(DEC(c7)));
|
||||
env->SetObjectArrayElement(prefixes, 1, env->NewStringUTF(DEC(c8)));
|
||||
|
||||
env->CallVoidMethod(runtime, setExemptions, prefixes);
|
||||
getVm()->DetachCurrentThread();
|
||||
return nullptr;
|
||||
};
|
||||
|
||||
pthread_t t;
|
||||
pthread_create(&t, nullptr, fn, nullptr);
|
||||
pthread_join(t, nullptr);
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
@@ -24,75 +24,62 @@
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <elf.h>
|
||||
#include <atomic>
|
||||
#include <jni.h>
|
||||
|
||||
#include "l2c_fcr_hook.h"
|
||||
|
||||
extern "C" {
|
||||
#include "xz.h"
|
||||
#include "xz.h"
|
||||
}
|
||||
|
||||
#define LOG_TAG "LibrePodsHook"
|
||||
#define LOG_TAG "LibrePods"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
static HookFunType hook_func = nullptr;
|
||||
|
||||
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr;
|
||||
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void*) = nullptr;
|
||||
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(
|
||||
tSDP_DI_RECORD*, uint32_t*) = nullptr;
|
||||
|
||||
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD *, uint32_t *) = nullptr;
|
||||
|
||||
static std::atomic<bool> enableSdpHook(false);
|
||||
|
||||
uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) {
|
||||
LOGI("fake_l2c_fcr_chk_chan_modes called");
|
||||
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
|
||||
LOGI("l2c_fcr_chk_chan_modes hooked");
|
||||
uint8_t orig = 0;
|
||||
if (original_l2c_fcr_chk_chan_modes)
|
||||
orig = original_l2c_fcr_chk_chan_modes(p_ccb);
|
||||
|
||||
LOGI("fake_l2c_fcr_chk_chan_modes: orig = %d, returning 1", orig);
|
||||
LOGI("Original returned %d, forcing 1", orig);
|
||||
return 1;
|
||||
}
|
||||
|
||||
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) {
|
||||
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(
|
||||
tSDP_DI_RECORD* p_device_info,
|
||||
uint32_t* p_handle) {
|
||||
|
||||
LOGI("fake_BTA_DmSetLocalDiRecord called");
|
||||
|
||||
if (original_BTA_DmSetLocalDiRecord &&
|
||||
enableSdpHook.load(std::memory_order_relaxed))
|
||||
original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
|
||||
|
||||
LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001");
|
||||
LOGI("BTA_DmSetLocalDiRecord hooked");
|
||||
|
||||
if (p_device_info) {
|
||||
p_device_info->vendor = 0x004C;
|
||||
p_device_info->vendor_id_source = 0x0001;
|
||||
}
|
||||
|
||||
LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d",
|
||||
original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle)
|
||||
: BTA_FAILURE);
|
||||
return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info,
|
||||
p_handle)
|
||||
: BTA_FAILURE;
|
||||
if (original_BTA_DmSetLocalDiRecord)
|
||||
return original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
|
||||
|
||||
return BTA_FAILURE;
|
||||
}
|
||||
|
||||
static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<uint8_t> &output) {
|
||||
|
||||
LOGI("decompressXZ called with input_size: %zu", input_size);
|
||||
static bool decompressXZ(
|
||||
const uint8_t* input,
|
||||
size_t input_size,
|
||||
std::vector<uint8_t>& output) {
|
||||
|
||||
xz_crc32_init();
|
||||
#ifdef XZ_USE_CRC64
|
||||
xz_crc64_init();
|
||||
#endif
|
||||
|
||||
struct xz_dec *dec = xz_dec_init(XZ_DYNALLOC, 64U << 20);
|
||||
if (!dec) {
|
||||
LOGE("decompressXZ: xz_dec_init failed");
|
||||
return false;
|
||||
}
|
||||
LOGI("decompressXZ: xz_dec_init succeeded");
|
||||
struct xz_dec* dec = xz_dec_init(XZ_DYNALLOC, 64U << 20);
|
||||
if (!dec) return false;
|
||||
|
||||
struct xz_buf buf{};
|
||||
buf.in = input;
|
||||
@@ -105,26 +92,19 @@ static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<ui
|
||||
buf.out_pos = 0;
|
||||
buf.out_size = output.size();
|
||||
|
||||
LOGI("decompressXZ: entering decompression loop");
|
||||
while (true) {
|
||||
LOGI("decompressXZ: xz_dec_run iteration, buf.in_pos: %zu, buf.out_pos: %zu", buf.in_pos,
|
||||
buf.out_pos);
|
||||
enum xz_ret ret = xz_dec_run(dec, &buf);
|
||||
|
||||
LOGI("decompressXZ: xz_dec_run returned %d", ret);
|
||||
|
||||
if (ret == XZ_STREAM_END)
|
||||
break;
|
||||
|
||||
if (ret != XZ_OK) {
|
||||
LOGE("decompressXZ: xz_dec_run error");
|
||||
xz_dec_end(dec);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (buf.out_pos == buf.out_size) {
|
||||
size_t old = output.size();
|
||||
LOGI("decompressXZ: resizing output to %zu", old * 2);
|
||||
output.resize(old * 2);
|
||||
buf.out = output.data();
|
||||
buf.out_size = output.size();
|
||||
@@ -133,30 +113,21 @@ static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<ui
|
||||
|
||||
output.resize(buf.out_pos);
|
||||
xz_dec_end(dec);
|
||||
LOGI("decompressXZ: decompression successful, output size: %zu", output.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool getLibraryPath(const char *name, std::string &out) {
|
||||
LOGI("getLibraryPath called with name: %s", name);
|
||||
|
||||
FILE *fp = fopen("/proc/self/maps", "r");
|
||||
if (!fp) {
|
||||
LOGE("getLibraryPath: fopen failed");
|
||||
return false;
|
||||
}
|
||||
static bool getLibraryPath(const char* name, std::string& out) {
|
||||
FILE* fp = fopen("/proc/self/maps", "r");
|
||||
if (!fp) return false;
|
||||
|
||||
char line[1024];
|
||||
|
||||
LOGI("getLibraryPath: scanning /proc/self/maps");
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
if (strstr(line, name)) {
|
||||
LOGI("getLibraryPath: found line containing %s", name);
|
||||
char *path = strchr(line, '/');
|
||||
char* path = strchr(line, '/');
|
||||
if (path) {
|
||||
out = path;
|
||||
out.erase(out.find('\n'));
|
||||
LOGI("getLibraryPath: path found: %s", out.c_str());
|
||||
fclose(fp);
|
||||
return true;
|
||||
}
|
||||
@@ -164,147 +135,80 @@ static bool getLibraryPath(const char *name, std::string &out) {
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
LOGI("getLibraryPath: failed to find path for %s", name);
|
||||
return false;
|
||||
}
|
||||
|
||||
static uintptr_t getModuleBase(const char *name) {
|
||||
LOGI("getModuleBase called with name: %s", name);
|
||||
|
||||
FILE *fp = fopen("/proc/self/maps", "r");
|
||||
if (!fp) {
|
||||
LOGE("getModuleBase: fopen failed");
|
||||
return 0;
|
||||
}
|
||||
static uintptr_t getModuleBase(const char* name) {
|
||||
FILE* fp = fopen("/proc/self/maps", "r");
|
||||
if (!fp) return 0;
|
||||
|
||||
char line[1024];
|
||||
uintptr_t base = 0;
|
||||
|
||||
LOGI("getModuleBase: scanning /proc/self/maps");
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
if (strstr(line, name)) {
|
||||
base = strtoull(line, nullptr, 16);
|
||||
LOGI("getModuleBase: found base at 0x%lx", base);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
LOGI("getModuleBase: failed to find base for %s", name);
|
||||
return base;
|
||||
}
|
||||
|
||||
static uint64_t
|
||||
findSymbolOffsetDynsym(const std::vector<uint8_t> &elf, const char *symbol_substring) {
|
||||
static uint64_t findSymbolOffset(
|
||||
const std::vector<uint8_t>& elf,
|
||||
const char* symbol_substring) {
|
||||
|
||||
LOGI("findSymbolOffsetDynsym called with %s", symbol_substring);
|
||||
|
||||
auto *eh = reinterpret_cast<const Elf64_Ehdr *>(elf.data());
|
||||
auto *shdr = reinterpret_cast<const Elf64_Shdr *>(
|
||||
auto* eh = reinterpret_cast<const Elf64_Ehdr*>(elf.data());
|
||||
auto* shdr = reinterpret_cast<const Elf64_Shdr*>(
|
||||
elf.data() + eh->e_shoff);
|
||||
|
||||
const char *shstr = reinterpret_cast<const char *>(
|
||||
elf.data() + shdr[eh->e_shstrndx].sh_offset);
|
||||
const char* shstr =
|
||||
reinterpret_cast<const char*>(
|
||||
elf.data() + shdr[eh->e_shstrndx].sh_offset);
|
||||
|
||||
const Elf64_Shdr *dynsym = nullptr;
|
||||
const Elf64_Shdr *dynstr = nullptr;
|
||||
const Elf64_Shdr* symtab = nullptr;
|
||||
const Elf64_Shdr* strtab = nullptr;
|
||||
|
||||
for (int i = 0; i < eh->e_shnum; ++i) {
|
||||
const char *secname = shstr + shdr[i].sh_name;
|
||||
|
||||
if (!strcmp(secname, ".dynsym"))
|
||||
dynsym = &shdr[i];
|
||||
if (!strcmp(secname, ".dynstr"))
|
||||
dynstr = &shdr[i];
|
||||
}
|
||||
|
||||
if (!dynsym || !dynstr) {
|
||||
LOGE("findSymbolOffsetDynsym: dynsym or dynstr not found");
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto *symbols = reinterpret_cast<const Elf64_Sym *>(
|
||||
elf.data() + dynsym->sh_offset);
|
||||
|
||||
const char *strings = reinterpret_cast<const char *>(
|
||||
elf.data() + dynstr->sh_offset);
|
||||
|
||||
size_t count = dynsym->sh_size / sizeof(Elf64_Sym);
|
||||
|
||||
LOGI("findSymbolOffsetDynsym: scanning %zu symbols", count);
|
||||
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
const char *name = strings + symbols[i].st_name;
|
||||
|
||||
if (strstr(name, symbol_substring) && ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
|
||||
|
||||
LOGI("findSymbolOffsetDynsym: matched %s @ 0x%lx", name,
|
||||
(unsigned long) symbols[i].st_value);
|
||||
|
||||
return symbols[i].st_value;
|
||||
}
|
||||
}
|
||||
|
||||
LOGI("findSymbolOffsetDynsym: no match for %s", symbol_substring);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static uint64_t findSymbolOffset(const std::vector<uint8_t> &elf, const char *symbol_substring) {
|
||||
|
||||
LOGI("findSymbolOffset called with symbol_substring: %s", symbol_substring);
|
||||
|
||||
auto *eh = reinterpret_cast<const Elf64_Ehdr *>(elf.data());
|
||||
auto *shdr = reinterpret_cast<const Elf64_Shdr *>(
|
||||
elf.data() + eh->e_shoff);
|
||||
|
||||
const char *shstr = reinterpret_cast<const char *>(
|
||||
elf.data() + shdr[eh->e_shstrndx].sh_offset);
|
||||
|
||||
const Elf64_Shdr *symtab = nullptr;
|
||||
const Elf64_Shdr *strtab = nullptr;
|
||||
|
||||
LOGI("findSymbolOffset: parsing ELF sections");
|
||||
for (int i = 0; i < eh->e_shnum; ++i) {
|
||||
const char *secname = shstr + shdr[i].sh_name;
|
||||
const char* secname = shstr + shdr[i].sh_name;
|
||||
if (!strcmp(secname, ".symtab"))
|
||||
symtab = &shdr[i];
|
||||
if (!strcmp(secname, ".strtab"))
|
||||
strtab = &shdr[i];
|
||||
}
|
||||
|
||||
if (!symtab || !strtab) {
|
||||
LOGE("findSymbolOffset: symtab or strtab not found");
|
||||
if (!symtab || !strtab)
|
||||
return 0;
|
||||
}
|
||||
LOGI("findSymbolOffset: found symtab and strtab");
|
||||
|
||||
auto *symbols = reinterpret_cast<const Elf64_Sym *>(
|
||||
auto* symbols = reinterpret_cast<const Elf64_Sym*>(
|
||||
elf.data() + symtab->sh_offset);
|
||||
|
||||
const char *strings = reinterpret_cast<const char *>(
|
||||
elf.data() + strtab->sh_offset);
|
||||
const char* strings =
|
||||
reinterpret_cast<const char*>(
|
||||
elf.data() + strtab->sh_offset);
|
||||
|
||||
size_t count = symtab->sh_size / sizeof(Elf64_Sym);
|
||||
|
||||
LOGI("findSymbolOffset: scanning %zu symbols", count);
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
const char *name = strings + symbols[i].st_name;
|
||||
const char* name = strings + symbols[i].st_name;
|
||||
|
||||
if (strstr(name, symbol_substring) && ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
|
||||
if (strstr(name, symbol_substring) &&
|
||||
ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
|
||||
|
||||
LOGI("findSymbolOffset: matched symbol %s at 0x%lx", name,
|
||||
(unsigned long) symbols[i].st_value);
|
||||
LOGI("Resolved %s at 0x%lx",
|
||||
name,
|
||||
(unsigned long)symbols[i].st_value);
|
||||
|
||||
return symbols[i].st_value;
|
||||
}
|
||||
}
|
||||
|
||||
LOGI("findSymbolOffset: no match found for %s", symbol_substring);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool hookLibrary(const char *libname) {
|
||||
LOGI("hookLibrary called with libname: %s", libname);
|
||||
static bool hookLibrary(const char* libname) {
|
||||
|
||||
if (!hook_func) {
|
||||
LOGE("hook_func not initialized");
|
||||
@@ -316,93 +220,85 @@ static bool hookLibrary(const char *libname) {
|
||||
LOGE("Failed to locate %s", libname);
|
||||
return false;
|
||||
}
|
||||
LOGI("hookLibrary: located path: %s", path.c_str());
|
||||
|
||||
int fd = open(path.c_str(), O_RDONLY);
|
||||
if (fd < 0) {
|
||||
LOGE("hookLibrary: open failed");
|
||||
return false;
|
||||
}
|
||||
if (fd < 0) return false;
|
||||
|
||||
struct stat st{};
|
||||
if (fstat(fd, &st) != 0) {
|
||||
LOGE("hookLibrary: fstat failed");
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
LOGI("hookLibrary: opened file, size: %lld", (long long) st.st_size);
|
||||
|
||||
std::vector<uint8_t> file(st.st_size);
|
||||
read(fd, file.data(), st.st_size);
|
||||
close(fd);
|
||||
|
||||
auto *eh = reinterpret_cast<Elf64_Ehdr *>(file.data());
|
||||
auto *shdr = reinterpret_cast<Elf64_Shdr *>(
|
||||
auto* eh = reinterpret_cast<Elf64_Ehdr*>(file.data());
|
||||
auto* shdr = reinterpret_cast<Elf64_Shdr*>(
|
||||
file.data() + eh->e_shoff);
|
||||
|
||||
const char *shstr = reinterpret_cast<const char *>(
|
||||
file.data() + shdr[eh->e_shstrndx].sh_offset);
|
||||
|
||||
uint64_t chk_offset = 0;
|
||||
uint64_t sdp_offset = 0;
|
||||
const char* shstr =
|
||||
reinterpret_cast<const char*>(
|
||||
file.data() + shdr[eh->e_shstrndx].sh_offset);
|
||||
|
||||
for (int i = 0; i < eh->e_shnum; ++i) {
|
||||
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
|
||||
LOGI("hookLibrary: found .gnu_debugdata section");
|
||||
|
||||
std::vector<uint8_t> compressed(file.begin() + shdr[i].sh_offset,
|
||||
file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
|
||||
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
|
||||
|
||||
std::vector<uint8_t> compressed(
|
||||
file.begin() + shdr[i].sh_offset,
|
||||
file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
|
||||
|
||||
std::vector<uint8_t> decompressed;
|
||||
|
||||
if (decompressXZ(compressed.data(), compressed.size(), decompressed)) {
|
||||
if (!decompressXZ(
|
||||
compressed.data(),
|
||||
compressed.size(),
|
||||
decompressed))
|
||||
return false;
|
||||
|
||||
chk_offset = findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes");
|
||||
uintptr_t base = getModuleBase(libname);
|
||||
if (!base) return false;
|
||||
|
||||
sdp_offset = findSymbolOffset(decompressed, "BTA_DmSetLocalDiRecord");
|
||||
} else {
|
||||
LOGE("debugdata decompress failed");
|
||||
uint64_t chk_offset =
|
||||
findSymbolOffset(decompressed,
|
||||
"l2c_fcr_chk_chan_modes");
|
||||
|
||||
// uint64_t sdp_offset =
|
||||
// findSymbolOffset(decompressed,
|
||||
// "BTA_DmSetLocalDiRecord");
|
||||
|
||||
if (chk_offset) {
|
||||
void* target =
|
||||
reinterpret_cast<void*>(base + chk_offset);
|
||||
|
||||
hook_func(target,
|
||||
(void*)fake_l2c_fcr_chk_chan_modes,
|
||||
(void**)&original_l2c_fcr_chk_chan_modes);
|
||||
|
||||
LOGI("Hooked l2c_fcr_chk_chan_modes");
|
||||
}
|
||||
|
||||
break;
|
||||
// if (sdp_offset) {
|
||||
// void* target =
|
||||
// reinterpret_cast<void*>(base + sdp_offset);
|
||||
//
|
||||
// hook_func(target,
|
||||
// (void*)fake_BTA_DmSetLocalDiRecord,
|
||||
// (void**)&original_BTA_DmSetLocalDiRecord);
|
||||
//
|
||||
// LOGI("Hooked BTA_DmSetLocalDiRecord");
|
||||
// }
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!chk_offset) {
|
||||
LOGI("fallback dynsym chk");
|
||||
chk_offset = findSymbolOffsetDynsym(file, "l2c_fcr_chk_chan_modes");
|
||||
}
|
||||
|
||||
if (!sdp_offset) {
|
||||
LOGI("fallback dynsym sdp");
|
||||
sdp_offset = findSymbolOffsetDynsym(file, "BTA_DmSetLocalDiRecord");
|
||||
}
|
||||
|
||||
uintptr_t base = getModuleBase(libname);
|
||||
if (!base) {
|
||||
LOGE("hookLibrary: getModuleBase failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (chk_offset) {
|
||||
void *target = reinterpret_cast<void *>(base + chk_offset);
|
||||
hook_func(target, (void *) fake_l2c_fcr_chk_chan_modes,
|
||||
(void **) &original_l2c_fcr_chk_chan_modes);
|
||||
LOGI("hooked chk");
|
||||
}
|
||||
|
||||
if (sdp_offset) {
|
||||
void *target = reinterpret_cast<void *>(base + sdp_offset);
|
||||
hook_func(target, (void *) fake_BTA_DmSetLocalDiRecord,
|
||||
(void **) &original_BTA_DmSetLocalDiRecord);
|
||||
LOGI("hooked sdp");
|
||||
}
|
||||
|
||||
return chk_offset || sdp_offset;
|
||||
return false;
|
||||
}
|
||||
|
||||
static void on_library_loaded(const char *name, void *) {
|
||||
LOGI("on_library_loaded called with name: %s", name);
|
||||
static void on_library_loaded(const char* name, void*) {
|
||||
|
||||
if (strstr(name, "libbluetooth_jni.so")) {
|
||||
LOGI("Bluetooth JNI loaded");
|
||||
@@ -415,21 +311,14 @@ static void on_library_loaded(const char *name, void *) {
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" [[gnu::visibility("default")]]
|
||||
extern "C"
|
||||
[[gnu::visibility("default")]]
|
||||
[[gnu::used]]
|
||||
NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) {
|
||||
LOGI("native_init called with entries: %p", entries);
|
||||
hook_func = (HookFunType) entries->hook_func;
|
||||
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d",
|
||||
enableSdpHook.load(std::memory_order_relaxed));
|
||||
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
|
||||
|
||||
LOGI("LibrePods initialized");
|
||||
|
||||
hook_func = (HookFunType)entries->hook_func;
|
||||
|
||||
return on_library_loaded;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(JNIEnv *, jobject thiz,
|
||||
jboolean enable) {
|
||||
LOGI("setSdpHook called with enable: %d", enable);
|
||||
enableSdpHook.store(enable, std::memory_order_relaxed);
|
||||
|
||||
LOGI("sdp hook enabled: %d", enable);
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package me.kavishdevar.librepods
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import io.github.libxposed.service.XposedService
|
||||
import io.github.libxposed.service.XposedServiceHelper
|
||||
import me.kavishdevar.librepods.billing.BillingManager
|
||||
import me.kavishdevar.librepods.billing.BillingProviderFactory
|
||||
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)
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
|
||||
super<Application>.onCreate()
|
||||
|
||||
}
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
BillingManager.provider.queryPurchases()
|
||||
XposedState.isAvailable = XposedServiceHolder.service != null
|
||||
XposedState.bluetoothScopeEnabled = XposedServiceHolder.service?.scope?.contains("com.google.android.bluetooth") == true || XposedServiceHolder.service?.scope?.contains("com.android.bluetooth") == true
|
||||
}
|
||||
|
||||
override fun onServiceBind(service: XposedService) {
|
||||
XposedServiceHolder.service = service
|
||||
XposedState.isAvailable = true
|
||||
XposedState.bluetoothScopeEnabled = XposedServiceHolder.service?.scope?.contains("com.google.android.bluetooth") == true || XposedServiceHolder.service?.scope?.contains("com.android.bluetooth") == true
|
||||
}
|
||||
|
||||
override fun onServiceDied(p0: XposedService) {
|
||||
XposedServiceHolder.service = null
|
||||
XposedState.isAvailable = false
|
||||
}
|
||||
}
|
||||
@@ -20,92 +20,147 @@
|
||||
|
||||
package me.kavishdevar.librepods
|
||||
|
||||
// import me.kavishdevar.librepods.screens.Onboarding
|
||||
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
//import dagger.hilt.android.AndroidEntryPoint
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
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.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
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.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.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
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.core.content.edit
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.android.play.core.review.ReviewManagerFactory
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.data.ControlCommandRepository
|
||||
import me.kavishdevar.librepods.presentation.navigation.NavigationRoot
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
|
||||
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
|
||||
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
|
||||
import me.kavishdevar.librepods.screens.AppSettingsScreen
|
||||
import me.kavishdevar.librepods.screens.CameraControlScreen
|
||||
import me.kavishdevar.librepods.screens.DebugScreen
|
||||
import me.kavishdevar.librepods.screens.HeadTrackingScreen
|
||||
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
|
||||
import me.kavishdevar.librepods.screens.HearingAidScreen
|
||||
import me.kavishdevar.librepods.screens.HearingProtectionScreen
|
||||
import me.kavishdevar.librepods.screens.LongPress
|
||||
// import me.kavishdevar.librepods.screens.Onboarding
|
||||
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen
|
||||
import me.kavishdevar.librepods.screens.RenameScreen
|
||||
import me.kavishdevar.librepods.screens.TransparencySettingsScreen
|
||||
import me.kavishdevar.librepods.screens.TroubleshootingScreen
|
||||
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen
|
||||
import me.kavishdevar.librepods.screens.VersionScreen
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.utils.XposedState
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
lateinit var serviceConnection: ServiceConnection
|
||||
lateinit var connectionStatusReceiver: BroadcastReceiver
|
||||
lateinit var testReviewReceiver: BroadcastReceiver
|
||||
|
||||
//@AndroidEntryPoint
|
||||
@ExperimentalMaterial3Api
|
||||
class MainActivity : ComponentActivity() {
|
||||
companion object {
|
||||
init {
|
||||
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
|
||||
System.loadLibrary("l2c_fcr_hook")
|
||||
}
|
||||
System.loadLibrary("l2c_fcr_hook")
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
val m3eEnabled = remember { mutableStateOf(sharedPreferences.getBoolean("m3e_enabled", true)) }
|
||||
|
||||
val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
|
||||
when (key) {
|
||||
"m3e_enabled" -> m3eEnabled.value = sharedPreferences.getBoolean(key, true)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
|
||||
onDispose {
|
||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
|
||||
}
|
||||
}
|
||||
LibrePodsTheme(
|
||||
m3eEnabled = m3eEnabled.value
|
||||
) {
|
||||
// For demo screenshots
|
||||
// val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||
// windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
// windowInsetsController.hide(WindowInsetsCompat.Type.statusBars())
|
||||
|
||||
LibrePodsTheme {
|
||||
Main()
|
||||
}
|
||||
}
|
||||
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -140,106 +195,610 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIncomingIntent(intent: Intent) {
|
||||
val data: Uri? = intent.data
|
||||
|
||||
if (data != null && data.scheme == "librepods") {
|
||||
when (data.host) {
|
||||
"add-magic-keys" -> {
|
||||
val queryParams = data.queryParameterNames
|
||||
queryParams.forEach { param ->
|
||||
val value = data.getQueryParameter(param)
|
||||
Log.d("LibrePods", "Parameter: $param = $value")
|
||||
}
|
||||
|
||||
handleAddMagicKeys(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAddMagicKeys(uri: Uri) {
|
||||
val sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
|
||||
|
||||
val irkHex = uri.getQueryParameter("irk")
|
||||
val encKeyHex = uri.getQueryParameter("enc_key")
|
||||
|
||||
try {
|
||||
if (irkHex != null && validateHexInput(irkHex)) {
|
||||
val irkBytes = hexStringToByteArray(irkHex)
|
||||
val irkBase64 = Base64.encode(irkBytes)
|
||||
sharedPreferences.edit {putString("IRK", irkBase64)}
|
||||
}
|
||||
|
||||
if (encKeyHex != null && validateHexInput(encKeyHex)) {
|
||||
val encKeyBytes = hexStringToByteArray(encKeyHex)
|
||||
val encKeyBase64 = Base64.encode(encKeyBytes)
|
||||
sharedPreferences.edit { putString("ENC_KEY", encKeyBase64)}
|
||||
}
|
||||
|
||||
Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateHexInput(input: String): Boolean {
|
||||
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
|
||||
return hexPattern.matches(input)
|
||||
}
|
||||
|
||||
private fun hexStringToByteArray(hex: String): ByteArray {
|
||||
val result = ByteArray(16)
|
||||
for (i in 0 until 16) {
|
||||
val hexByte = hex.substring(i * 2, i * 2 + 2)
|
||||
result[i] = hexByte.toInt(16).toByte()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
||||
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun Main() {
|
||||
val isConnected = remember { mutableStateOf(false) }
|
||||
val isRemotelyConnected = remember { mutableStateOf(false) }
|
||||
// val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
||||
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
|
||||
|
||||
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
listOf(
|
||||
"android.permission.BLUETOOTH_CONNECT",
|
||||
"android.permission.BLUETOOTH_SCAN",
|
||||
"android.permission.BLUETOOTH",
|
||||
"android.permission.BLUETOOTH_ADMIN",
|
||||
"android.permission.BLUETOOTH_ADVERTISE"
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
"android.permission.BLUETOOTH",
|
||||
"android.permission.BLUETOOTH_ADMIN",
|
||||
"android.permission.ACCESS_FINE_LOCATION"
|
||||
)
|
||||
}
|
||||
val otherPermissions = listOf(
|
||||
"android.permission.POST_NOTIFICATIONS",
|
||||
"android.permission.READ_PHONE_STATE",
|
||||
"android.permission.ANSWER_PHONE_CALLS"
|
||||
)
|
||||
val allPermissions = bluetoothPermissions + otherPermissions
|
||||
|
||||
val permissionState = rememberMultiplePermissionsState(
|
||||
permissions = allPermissions
|
||||
)
|
||||
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
||||
|
||||
val airPodsViewModel: AirPodsViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (BuildConfig.PLAY_BUILD) {
|
||||
val now = System.currentTimeMillis()
|
||||
val firstConn =
|
||||
sharedPreferences.getLong("first_connection_successful_time", 0L)
|
||||
|
||||
val alreadyPrompted =
|
||||
sharedPreferences.getBoolean("review_prompted", false)
|
||||
|
||||
val oneDay = 24 * 60 * 60 * 1000L
|
||||
|
||||
if (
|
||||
firstConn != 0L &&
|
||||
!alreadyPrompted &&
|
||||
(now - firstConn) > oneDay
|
||||
) {
|
||||
triggerReviewFlow(context as? Activity ?: return@LaunchedEffect)
|
||||
|
||||
sharedPreferences.edit {
|
||||
putBoolean("review_prompted", true)
|
||||
}
|
||||
}
|
||||
}
|
||||
canDrawOverlays = Settings.canDrawOverlays(context)
|
||||
}
|
||||
|
||||
val onboardingComplete = sharedPreferences.getBoolean("onboarding_complete", false)
|
||||
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val releaseNotesShownPrefKey = "release_notes_shown_${BuildConfig.VERSION_NAME.removeSuffix("-debug")}"
|
||||
val releaseNotesShown = sharedPreferences.getBoolean(releaseNotesShownPrefKey, false)
|
||||
val navController = rememberNavController()
|
||||
|
||||
fun bindService() {
|
||||
context.startForegroundService(Intent(context, AirPodsService::class.java))
|
||||
serviceConnection = object: ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val binder = service as AirPodsService.LocalBinder
|
||||
val service = binder.getService()
|
||||
airPodsService.value = service
|
||||
airPodsViewModel.init(
|
||||
service = service,
|
||||
controlRepo = ControlCommandRepository(service.aacpManager),
|
||||
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
|
||||
appContext = context.applicationContext
|
||||
)
|
||||
|
||||
if (!sharedPreferences.contains("first_connection_successful_time")) {
|
||||
sharedPreferences.edit {
|
||||
putLong("first_connection_successful_time", System.currentTimeMillis())
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
){
|
||||
val backButtonBackdrop = rememberLayerBackdrop()
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
|
||||
.layerBackdrop(backButtonBackdrop)
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "settings", // if (hookAvailable) "settings" else "onboarding",
|
||||
enterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) // + fadeIn(animationSpec = tween(durationMillis = 300))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { -it/4 },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) // + fadeOut(animationSpec = tween(durationMillis = 150))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { -it/4 },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) // + fadeIn(animationSpec = tween(durationMillis = 300))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) // + fadeOut(animationSpec = tween(durationMillis = 150))
|
||||
}
|
||||
) {
|
||||
composable("settings") {
|
||||
if (airPodsService.value != null) {
|
||||
AirPodsSettingsScreen(
|
||||
dev = airPodsService.value?.device,
|
||||
service = airPodsService.value!!,
|
||||
navController = navController,
|
||||
isConnected = isConnected.value,
|
||||
isRemotelyConnected = isRemotelyConnected.value
|
||||
)
|
||||
}
|
||||
}
|
||||
composable("debug") {
|
||||
DebugScreen(navController = navController)
|
||||
}
|
||||
composable("long_press/{bud}") { navBackStackEntry ->
|
||||
LongPress(
|
||||
navController = navController,
|
||||
name = navBackStackEntry.arguments?.getString("bud")!!
|
||||
)
|
||||
}
|
||||
composable("rename") {
|
||||
RenameScreen(navController)
|
||||
}
|
||||
composable("app_settings") {
|
||||
AppSettingsScreen(navController)
|
||||
}
|
||||
composable("troubleshooting") {
|
||||
TroubleshootingScreen(navController)
|
||||
}
|
||||
composable("head_tracking") {
|
||||
HeadTrackingScreen()
|
||||
}
|
||||
/*composable("onboarding") {
|
||||
Onboarding(navController, context)
|
||||
}*/
|
||||
composable("accessibility") {
|
||||
AccessibilitySettingsScreen(navController)
|
||||
}
|
||||
composable("transparency_customization") {
|
||||
TransparencySettingsScreen(navController)
|
||||
}
|
||||
composable("hearing_aid") {
|
||||
HearingAidScreen(navController)
|
||||
}
|
||||
composable("hearing_aid_adjustments") {
|
||||
HearingAidAdjustmentsScreen(navController)
|
||||
}
|
||||
composable("adaptive_strength") {
|
||||
AdaptiveStrengthScreen(navController)
|
||||
}
|
||||
composable("camera_control") {
|
||||
CameraControlScreen(navController)
|
||||
}
|
||||
composable("open_source_licenses") {
|
||||
OpenSourceLicensesScreen(navController)
|
||||
}
|
||||
composable("update_hearing_test") {
|
||||
UpdateHearingTestScreen(navController)
|
||||
}
|
||||
composable("version_info") {
|
||||
VersionScreen(navController)
|
||||
}
|
||||
composable("hearing_protection") {
|
||||
HearingProtectionScreen(navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
airPodsService.value = null
|
||||
val showBackButton = remember{ mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(navController) {
|
||||
navController.addOnDestinationChangedListener { _, destination, _ ->
|
||||
showBackButton.value = destination.route != "settings" // && destination.route != "onboarding"
|
||||
Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}")
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showBackButton.value,
|
||||
enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()),
|
||||
exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(
|
||||
start = 8.dp,
|
||||
top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
|
||||
)
|
||||
) {
|
||||
StyledIconButton(
|
||||
onClick = { navController.popBackStack() },
|
||||
icon = "",
|
||||
darkMode = isSystemInDarkTheme(),
|
||||
backdrop = backButtonBackdrop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
context.bindService(
|
||||
Intent(context, AirPodsService::class.java),
|
||||
serviceConnection,
|
||||
Context.BIND_AUTO_CREATE
|
||||
serviceConnection = remember {
|
||||
object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val binder = service as AirPodsService.LocalBinder
|
||||
airPodsService.value = binder.getService()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
airPodsService.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
|
||||
|
||||
if (airPodsService.value?.isConnectedLocally == true) {
|
||||
isConnected.value = true
|
||||
}
|
||||
} else {
|
||||
PermissionsScreen(
|
||||
permissionState = permissionState,
|
||||
canDrawOverlays = canDrawOverlays,
|
||||
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) }
|
||||
)
|
||||
}
|
||||
|
||||
if (onboardingComplete) {
|
||||
bindService()
|
||||
}
|
||||
|
||||
NavigationRoot(
|
||||
showReleaseNotes = !releaseNotesShown,
|
||||
updatesShown = { sharedPreferences.edit { putBoolean(releaseNotesShownPrefKey, true) } },
|
||||
showOnboarding = !onboardingComplete,
|
||||
onboardingComplete = {
|
||||
sharedPreferences.edit { putBoolean("onboarding_complete", true) }
|
||||
bindService()
|
||||
},
|
||||
airPodsViewModel = airPodsViewModel
|
||||
)
|
||||
}
|
||||
|
||||
private fun triggerReviewFlow(activity: Activity) {
|
||||
val manager = ReviewManagerFactory.create(activity)
|
||||
val request = manager.requestReviewFlow()
|
||||
request.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
val reviewInfo = task.result
|
||||
manager.launchReviewFlow(activity, reviewInfo)
|
||||
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PermissionsScreen(
|
||||
permissionState: MultiplePermissionsState,
|
||||
canDrawOverlays: Boolean,
|
||||
onOverlaySettingsReturn: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val basicPermissionsGranted = permissionState.permissions.all { it.status.isGranted }
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
val pulseScale by infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1.05f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "pulse scale"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
||||
.padding(16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "\uDBC2\uDEB7",
|
||||
style = TextStyle(
|
||||
fontSize = 48.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
)
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.scale(pulseScale)
|
||||
) {
|
||||
val radius = size.minDimension / 2.2f
|
||||
val centerX = size.width / 2
|
||||
val centerY = size.height / 2
|
||||
|
||||
rotate(degrees = 45f) {
|
||||
drawCircle(
|
||||
color = accentColor.copy(alpha = 0.1f),
|
||||
radius = radius * 1.3f,
|
||||
center = Offset(centerX, centerY)
|
||||
)
|
||||
|
||||
drawCircle(
|
||||
color = accentColor.copy(alpha = 0.2f),
|
||||
radius = radius * 1.1f,
|
||||
center = Offset(centerX, centerY)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Permission Required",
|
||||
style = TextStyle(
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.permissions_required),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
PermissionCard(
|
||||
title = "Bluetooth Permissions",
|
||||
description = "Required to communicate with your AirPods",
|
||||
icon = ImageVector.vectorResource(id = R.drawable.ic_bluetooth),
|
||||
isGranted = permissionState.permissions.filter {
|
||||
it.permission.contains("BLUETOOTH")
|
||||
}.all { it.status.isGranted },
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
PermissionCard(
|
||||
title = "Notification Permission",
|
||||
description = "To show battery status",
|
||||
icon = Icons.Default.Notifications,
|
||||
isGranted = permissionState.permissions.find {
|
||||
it.permission == "android.permission.POST_NOTIFICATIONS"
|
||||
}?.status?.isGranted == true,
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
PermissionCard(
|
||||
title = "Phone Permissions",
|
||||
description = "For answering calls with Head Gestures",
|
||||
icon = Icons.Default.Phone,
|
||||
isGranted = permissionState.permissions.filter {
|
||||
it.permission.contains("PHONE") || it.permission.contains("CALLS")
|
||||
}.all { it.status.isGranted },
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
PermissionCard(
|
||||
title = "Display Over Other Apps",
|
||||
description = "For popup animations when AirPods connect",
|
||||
icon = ImageVector.vectorResource(id = R.drawable.ic_layers),
|
||||
isGranted = canDrawOverlays,
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = { permissionState.launchMultiplePermissionRequest() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Ask for regular permissions",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
"package:${context.packageName}".toUri()
|
||||
)
|
||||
context.startActivity(intent)
|
||||
onOverlaySettingsReturn()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (canDrawOverlays) Color.Gray else accentColor
|
||||
),
|
||||
enabled = !canDrawOverlays,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
if (canDrawOverlays) "Overlay Permission Granted" else "Grant Overlay Permission",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (!canDrawOverlays && basicPermissionsGranted) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
context.getSharedPreferences("settings", MODE_PRIVATE).edit {
|
||||
putBoolean("overlay_permission_skipped", true)
|
||||
}
|
||||
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF757575)
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Continue without overlay",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PermissionCard(
|
||||
title: String,
|
||||
description: String,
|
||||
icon: ImageVector,
|
||||
isGranted: Boolean,
|
||||
backgroundColor: Color,
|
||||
textColor: Color,
|
||||
accentColor: Color
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = backgroundColor
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
|
||||
alpha = 0.15f
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
tint = if (isGranted) accentColor else Color.Gray,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (isGranted) Color(0xFF4CAF50) else Color.Gray),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (isGranted) "✓" else "!",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,15 +85,15 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush
|
||||
import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton
|
||||
import me.kavishdevar.librepods.presentation.components.IconAreaSize
|
||||
import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider
|
||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
|
||||
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
|
||||
import me.kavishdevar.librepods.composables.IconAreaSize
|
||||
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package me.kavishdevar.librepods.billing
|
||||
|
||||
object BillingManager {
|
||||
lateinit var provider: BillingProvider
|
||||
}
|
||||
@@ -1,30 +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.billing
|
||||
|
||||
import android.app.Activity
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface BillingProvider {
|
||||
val isPremium: StateFlow<Boolean>
|
||||
val price: StateFlow<String>
|
||||
fun purchase(activity: Activity)
|
||||
fun queryPurchases()
|
||||
fun restorePurchases()
|
||||
}
|
||||
@@ -1,77 +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.billing
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
class FOSSBillingProvider(context: Context): BillingProvider {
|
||||
private val _isPremium = MutableStateFlow(false)
|
||||
override val isPremium: StateFlow<Boolean> = _isPremium
|
||||
|
||||
private val _price = MutableStateFlow(context.getString(R.string.name_your_own_price))
|
||||
override val price: StateFlow<String> = _price
|
||||
|
||||
private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var purchaseJob: Job? = null
|
||||
|
||||
init {
|
||||
queryPurchases()
|
||||
}
|
||||
|
||||
override fun purchase(activity: Activity) {
|
||||
activity.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, "https://github.com/sponsors/kavishdevar".toUri())
|
||||
)
|
||||
|
||||
purchaseJob?.cancel()
|
||||
|
||||
purchaseJob = scope.launch {
|
||||
delay(5_000)
|
||||
_isPremium.value = true
|
||||
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun queryPurchases() {
|
||||
val stored = sharedPreferences.getBoolean("foss_upgraded", false)
|
||||
if (stored != _isPremium.value) {
|
||||
_isPremium.value = stored
|
||||
}
|
||||
}
|
||||
|
||||
override fun restorePurchases() {
|
||||
_isPremium.value = true
|
||||
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
|
||||
}
|
||||
}
|
||||
@@ -1,206 +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.billing
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.android.billingclient.api.AcknowledgePurchaseParams
|
||||
import com.android.billingclient.api.BillingClient
|
||||
import com.android.billingclient.api.BillingClientStateListener
|
||||
import com.android.billingclient.api.BillingFlowParams
|
||||
import com.android.billingclient.api.BillingResult
|
||||
import com.android.billingclient.api.PendingPurchasesParams
|
||||
import com.android.billingclient.api.ProductDetails
|
||||
import com.android.billingclient.api.Purchase
|
||||
import com.android.billingclient.api.PurchasesUpdatedListener
|
||||
import com.android.billingclient.api.QueryProductDetailsParams
|
||||
import com.android.billingclient.api.QueryPurchasesParams
|
||||
import com.android.billingclient.api.acknowledgePurchase
|
||||
import com.android.billingclient.api.queryProductDetails
|
||||
import com.android.billingclient.api.queryPurchasesAsync
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
const val TAG = "PlayBillingProvider"
|
||||
|
||||
private const val PREMIUM_PRODUCT_ID = "librepods.advanced_features.v2"
|
||||
|
||||
class PlayBillingProvider(
|
||||
context: Context
|
||||
) : BillingProvider, PurchasesUpdatedListener {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val _isPremium = MutableStateFlow(false)
|
||||
override val isPremium: StateFlow<Boolean> = _isPremium
|
||||
|
||||
private val _price = MutableStateFlow("unknown")
|
||||
override val price: StateFlow<String> = _price
|
||||
|
||||
|
||||
private var productDetails: ProductDetails? = null
|
||||
|
||||
private val billingClient = BillingClient.newBuilder(context)
|
||||
.setListener(this)
|
||||
.enablePendingPurchases(
|
||||
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
|
||||
)
|
||||
.build()
|
||||
|
||||
init {
|
||||
connect()
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
billingClient.startConnection(object : BillingClientStateListener {
|
||||
override fun onBillingSetupFinished(result: BillingResult) {
|
||||
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
scope.launch {
|
||||
queryProductDetails()
|
||||
queryExistingPurchases()
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Billing setup failed: ${result.debugMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBillingServiceDisconnected() {
|
||||
connect()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private suspend fun queryProductDetails() {
|
||||
val params = QueryProductDetailsParams.newBuilder()
|
||||
.setProductList(
|
||||
listOf(
|
||||
QueryProductDetailsParams.Product.newBuilder()
|
||||
.setProductId(PREMIUM_PRODUCT_ID)
|
||||
.setProductType(BillingClient.ProductType.INAPP)
|
||||
.build()
|
||||
)
|
||||
).build()
|
||||
|
||||
val result = billingClient.queryProductDetails(params)
|
||||
if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
productDetails = result.productDetailsList?.firstOrNull()
|
||||
Log.d(TAG, "Product loaded: ${productDetails?.name}")
|
||||
val priceString = productDetails
|
||||
?.oneTimePurchaseOfferDetails
|
||||
?.formattedPrice
|
||||
|
||||
if (priceString != null) {
|
||||
_price.value = priceString
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "queryProductDetails failed: ${result.billingResult.debugMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryExistingPurchases() {
|
||||
val result = billingClient.queryPurchasesAsync(
|
||||
QueryPurchasesParams.newBuilder()
|
||||
.setProductType(BillingClient.ProductType.INAPP)
|
||||
.build()
|
||||
)
|
||||
processPurchases(result.purchasesList)
|
||||
}
|
||||
|
||||
override fun purchase(activity: Activity) {
|
||||
val details = productDetails ?: run {
|
||||
Log.e(TAG, "Product details not loaded yet")
|
||||
return
|
||||
}
|
||||
|
||||
val billingFlowParams = BillingFlowParams.newBuilder()
|
||||
.setProductDetailsParamsList(
|
||||
listOf(
|
||||
BillingFlowParams.ProductDetailsParams.newBuilder()
|
||||
.setProductDetails(details)
|
||||
.build()
|
||||
)
|
||||
).build()
|
||||
|
||||
val result = billingClient.launchBillingFlow(activity, billingFlowParams)
|
||||
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
Log.e(TAG, "launchBillingFlow failed: ${result.debugMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
|
||||
when (result.responseCode) {
|
||||
BillingClient.BillingResponseCode.OK -> purchases?.let { processPurchases(it) }
|
||||
BillingClient.BillingResponseCode.USER_CANCELED -> Log.d(TAG, "User cancelled")
|
||||
else -> Log.w(TAG, "Purchase error ${result.responseCode}: ${result.debugMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun processPurchases(purchases: List<Purchase>) {
|
||||
val hasPremium = purchases.any {
|
||||
it.products.contains(PREMIUM_PRODUCT_ID) &&
|
||||
it.purchaseState == Purchase.PurchaseState.PURCHASED
|
||||
}
|
||||
|
||||
// val purchase = purchases.find {
|
||||
// it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED
|
||||
// }
|
||||
//
|
||||
// if (purchase != null) {
|
||||
// val consumeParams = ConsumeParams.newBuilder()
|
||||
// .setPurchaseToken(purchase.purchaseToken)
|
||||
// .build()
|
||||
// scope.launch {
|
||||
// billingClient.consumeAsync(consumeParams) { _, _ ->}
|
||||
// }
|
||||
// }
|
||||
|
||||
_isPremium.value = hasPremium
|
||||
|
||||
scope.launch {
|
||||
purchases
|
||||
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
|
||||
.forEach { acknowledge(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun acknowledge(purchase: Purchase) {
|
||||
val params = AcknowledgePurchaseParams.newBuilder()
|
||||
.setPurchaseToken(purchase.purchaseToken)
|
||||
.build()
|
||||
val result = billingClient.acknowledgePurchase(params)
|
||||
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
Log.e(TAG, "Acknowledgement failed: ${result.debugMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun queryPurchases() {
|
||||
scope.launch {
|
||||
queryExistingPurchases()
|
||||
}
|
||||
}
|
||||
|
||||
override fun restorePurchases() {
|
||||
queryPurchases()
|
||||
}
|
||||
}
|
||||
@@ -1,211 +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.bluetooth
|
||||
|
||||
import android.util.Log
|
||||
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)
|
||||
}
|
||||
|
||||
enum class ATTCCCDHandles(val value: Int) {
|
||||
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
|
||||
// LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work
|
||||
HEARING_AID(ATTHandles.HEARING_AID.value + 1)
|
||||
}
|
||||
|
||||
class ATTManagerv2 {
|
||||
val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
fun stopReader() {
|
||||
readerRunning.set(false)
|
||||
readerThread?.interrupt()
|
||||
readerThread = null
|
||||
}
|
||||
|
||||
fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) {
|
||||
onNotificationReceived = listener
|
||||
}
|
||||
|
||||
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 {
|
||||
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.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 {
|
||||
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.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
|
||||
}
|
||||
|
||||
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 {
|
||||
Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "error in reader loop: ${e.message}", e)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
-76
@@ -1,76 +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.bluetooth
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
|
||||
object BluetoothConnectionManager {
|
||||
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,205 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
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.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun AboutCard(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val service = ServiceManager.getService()
|
||||
if (service == null) return
|
||||
val airpodsInstance = service.airpodsInstance
|
||||
if (airpodsInstance == null) return
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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 = airpodsInstance.model.displayName,
|
||||
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 = airpodsInstance.actualModelNumber,
|
||||
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(
|
||||
airpodsInstance.serialNumber?: "",
|
||||
" ${airpodsInstance.leftSerialNumber}",
|
||||
" ${airpodsInstance.rightSerialNumber}"
|
||||
)
|
||||
val serialNumber = remember { mutableStateOf(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.value],
|
||||
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.value = (serialNumber.value + 1) % serialNumbers.size
|
||||
}
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
NavigationButton(
|
||||
to = "version_info",
|
||||
navController = navController,
|
||||
name = stringResource(R.string.version),
|
||||
currentState = airpodsInstance.version3,
|
||||
independent = false,
|
||||
height = rowHeight.value + 32.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
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.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 me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import me.kavishdevar.librepods.utils.Capability
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun AudioSettings(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val service = ServiceManager.getService()
|
||||
if (service == null) return
|
||||
val airpodsInstance = service.airpodsInstance
|
||||
if (airpodsInstance == null) return
|
||||
if (!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME) &&
|
||||
!airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS) &&
|
||||
!airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) &&
|
||||
!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)
|
||||
) {
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.personalized_volume),
|
||||
description = stringResource(R.string.personalized_volume_description),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
|
||||
independent = false
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversational_awareness),
|
||||
description = stringResource(R.string.conversational_awareness_description),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
attHandle = ATTHandles.LOUD_SOUND_REDUCTION,
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) {
|
||||
NavigationButton(
|
||||
to = "adaptive_strength",
|
||||
name = stringResource(R.string.adaptive_audio),
|
||||
navController = navController,
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AudioSettingsPreview() {
|
||||
AudioSettings(rememberNavController())
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
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.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@Composable
|
||||
fun BatteryIndicator(
|
||||
batteryPercentage: Int,
|
||||
charging: Boolean = false,
|
||||
prefix: String = "",
|
||||
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)
|
||||
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
|
||||
|
||||
val initialScale = if (previousCharging) 1f else 0f
|
||||
val scaleAnim = remember { Animatable(initialScale) }
|
||||
val targetScale = if (charging) 1f else 0f
|
||||
|
||||
LaunchedEffect(previousCharging, charging) {
|
||||
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(backgroundColor), // just for haze to work
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
progress = { batteryPercentage / 100f },
|
||||
modifier = Modifier.size(40.dp),
|
||||
color = batteryFillColor,
|
||||
gapSize = 0.dp,
|
||||
strokeCap = StrokeCap.Round,
|
||||
strokeWidth = 4.dp,
|
||||
trackColor = if (isDarkTheme) Color(0xFF0E0E0F) else Color(0xFFE3E3E8)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "\uDBC0\uDEE6",
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = batteryFillColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.scale(scaleAnim.value)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = "$prefix $batteryPercentage%",
|
||||
color = batteryTextColor,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun BatteryIndicatorPreview() {
|
||||
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
||||
Box(
|
||||
modifier = Modifier.background(bg)
|
||||
) {
|
||||
BatteryIndicator(batteryPercentage = 24, charging = true, prefix = "\uDBC6\uDCE5", previousCharging = false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.Image
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.Battery
|
||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
||||
val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
|
||||
|
||||
val previousBatteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
|
||||
|
||||
@Suppress("DEPRECATION") val batteryReceiver = remember {
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AirPodsNotifications.BATTERY_DATA) {
|
||||
batteryStatus.value =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra("data", Battery::class.java)
|
||||
} else {
|
||||
intent.getParcelableArrayListExtra("data")
|
||||
}?.toList() ?: listOf()
|
||||
}
|
||||
else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
||||
try {
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
catch (_: IllegalArgumentException) {
|
||||
Log.wtf("BatteryReceiver", "Receiver already unregistered")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(context) {
|
||||
val batteryIntentFilter = IntentFilter()
|
||||
.apply {
|
||||
addAction(AirPodsNotifications.BATTERY_DATA)
|
||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(
|
||||
batteryReceiver,
|
||||
batteryIntentFilter,
|
||||
Context.RECEIVER_EXPORTED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
previousBatteryStatus.value = batteryStatus.value
|
||||
batteryStatus.value = service.getBattery()
|
||||
|
||||
if (preview) {
|
||||
batteryStatus.value = listOf(
|
||||
Battery(BatteryComponent.LEFT, 100, BatteryStatus.NOT_CHARGING),
|
||||
Battery(BatteryComponent.RIGHT, 94, BatteryStatus.CHARGING),
|
||||
Battery(BatteryComponent.CASE, 40, BatteryStatus.CHARGING)
|
||||
)
|
||||
previousBatteryStatus.value = batteryStatus.value
|
||||
}
|
||||
|
||||
val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
|
||||
val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
|
||||
val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
|
||||
val leftLevel = left?.level ?: 0
|
||||
val rightLevel = right?.level ?: 0
|
||||
val caseLevel = case?.level ?: 0
|
||||
val leftCharging = left?.status == BatteryStatus.CHARGING
|
||||
val rightCharging = right?.status == BatteryStatus.CHARGING
|
||||
val caseCharging = case?.status == BatteryStatus.CHARGING
|
||||
|
||||
val prevLeft = previousBatteryStatus.value.find { it.component == BatteryComponent.LEFT }
|
||||
val prevRight = previousBatteryStatus.value.find { it.component == BatteryComponent.RIGHT }
|
||||
val prevCase = previousBatteryStatus.value.find { it.component == BatteryComponent.CASE }
|
||||
val prevLeftCharging = prevLeft?.status == BatteryStatus.CHARGING
|
||||
val prevRightCharging = prevRight?.status == BatteryStatus.CHARGING
|
||||
val prevCaseCharging = prevCase?.status == BatteryStatus.CHARGING
|
||||
|
||||
val singleDisplayed = remember { mutableStateOf(false) }
|
||||
|
||||
val airpodsInstance = service.airpodsInstance
|
||||
if (airpodsInstance == null) {
|
||||
return
|
||||
}
|
||||
val budsRes = airpodsInstance.model.budsRes
|
||||
val caseRes = airpodsInstance.model.caseRes
|
||||
|
||||
Row {
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.5f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image (
|
||||
bitmap = ImageBitmap.imageResource(budsRes),
|
||||
contentDescription = stringResource(R.string.buds),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
if (
|
||||
leftCharging == rightCharging &&
|
||||
(leftLevel - rightLevel) in -3..3
|
||||
)
|
||||
{
|
||||
BatteryIndicator(
|
||||
leftLevel.coerceAtMost(rightLevel),
|
||||
leftCharging,
|
||||
previousCharging = (prevLeftCharging && prevRightCharging)
|
||||
)
|
||||
singleDisplayed.value = true
|
||||
}
|
||||
else {
|
||||
singleDisplayed.value = false
|
||||
Row (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
|
||||
BatteryIndicator(
|
||||
leftLevel,
|
||||
leftCharging,
|
||||
"\uDBC6\uDCE5",
|
||||
previousCharging = prevLeftCharging
|
||||
)
|
||||
}
|
||||
if (leftLevel > 0 && rightLevel > 0)
|
||||
{
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED)
|
||||
{
|
||||
BatteryIndicator(
|
||||
rightLevel,
|
||||
rightCharging,
|
||||
"\uDBC6\uDCE8",
|
||||
previousCharging = prevRightCharging
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
bitmap = ImageBitmap.imageResource(caseRes),
|
||||
contentDescription = stringResource(R.string.case_alt),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
|
||||
BatteryIndicator(
|
||||
caseLevel,
|
||||
caseCharging,
|
||||
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "",
|
||||
previousCharging = prevCaseCharging
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun BatteryViewPreview() {
|
||||
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
||||
Box(
|
||||
modifier = Modifier.background(bg)
|
||||
) {
|
||||
BatteryView(AirPodsService(), preview = true)
|
||||
}
|
||||
}
|
||||
+470
@@ -0,0 +1,470 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
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.DisposableEffect
|
||||
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.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.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.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 dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun CallControlSettings(hazeState: HazeState) {
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
val service = ServiceManager.getService()!!
|
||||
val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
|
||||
}?.value ?: byteArrayOf(0x00, 0x03)
|
||||
|
||||
val pressOnceText = stringResource(R.string.press_once)
|
||||
val pressTwiceText = stringResource(R.string.press_twice)
|
||||
|
||||
var flipped by remember {
|
||||
mutableStateOf(
|
||||
callControlEnabledValue.contentEquals(
|
||||
byteArrayOf(
|
||||
0x00,
|
||||
0x02
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
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 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) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val listener = object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
|
||||
) {
|
||||
val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02))
|
||||
flipped = newFlipped
|
||||
singlePressAction = if (newFlipped) pressTwiceText else pressOnceText
|
||||
doublePressAction = if (newFlipped) pressOnceText else pressTwiceText
|
||||
Log.d(
|
||||
"CallControlSettings",
|
||||
"Control command received, flipped: $newFlipped"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
service.aacpManager.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
|
||||
listener
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear()
|
||||
}
|
||||
}
|
||||
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()
|
||||
parentHoveredIndexSingle = 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()
|
||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
||||
0x00,
|
||||
0x03
|
||||
) else byteArrayOf(0x00, 0x02)
|
||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
||||
}
|
||||
}
|
||||
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 bytes = if (option == pressOnceText) byteArrayOf(
|
||||
0x00,
|
||||
0x03
|
||||
) else byteArrayOf(0x00, 0x02)
|
||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
||||
},
|
||||
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()
|
||||
parentHoveredIndexDouble = 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 bytes = if (option == pressOnceText) byteArrayOf(
|
||||
0x00,
|
||||
0x02
|
||||
) else byteArrayOf(0x00, 0x03)
|
||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
||||
}
|
||||
}
|
||||
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 bytes = if (option == pressOnceText) byteArrayOf(
|
||||
0x00,
|
||||
0x02
|
||||
) else byteArrayOf(0x00, 0x03)
|
||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Preview
|
||||
@Composable
|
||||
fun CallControlSettingsPreview() {
|
||||
CallControlSettings(HazeState())
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredWidthIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.MutableState
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
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.compose.ui.window.Dialog
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun ConfirmationDialog(
|
||||
showDialog: MutableState<Boolean>,
|
||||
title: String,
|
||||
message: String,
|
||||
confirmText: String = "Enable",
|
||||
dismissText: String = "Cancel",
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit = { showDialog.value = false },
|
||||
hazeState: HazeState,
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
if (showDialog.value) {
|
||||
Dialog(onDismissRequest = { showDialog.value = false }) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
// .fillMaxWidth(0.75f)
|
||||
.requiredWidthIn(min = 200.dp, max = 360.dp)
|
||||
.background(Color.Transparent, RoundedCornerShape(14.dp))
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.hazeEffect(
|
||||
hazeState,
|
||||
style = CupertinoMaterials.regular(
|
||||
containerColor = if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.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)
|
||||
)
|
||||
androidx.compose.foundation.layout.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)
|
||||
)
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
var leftPressed by remember { mutableStateOf(false) }
|
||||
var rightPressed by remember { mutableStateOf(false) }
|
||||
val pressedColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val position = event.changes.first().position
|
||||
val width = size.width.toFloat()
|
||||
val height = size.height.toFloat()
|
||||
val isWithinBounds = position.y >= 0 && position.y <= height
|
||||
val isLeft = position.x < width / 2
|
||||
event.changes.first().consume()
|
||||
when (event.type) {
|
||||
PointerEventType.Press -> {
|
||||
if (isWithinBounds) {
|
||||
leftPressed = isLeft
|
||||
rightPressed = !isLeft
|
||||
} else {
|
||||
leftPressed = false
|
||||
rightPressed = false
|
||||
}
|
||||
}
|
||||
PointerEventType.Move -> {
|
||||
if (isWithinBounds) {
|
||||
leftPressed = isLeft
|
||||
rightPressed = !isLeft
|
||||
} else {
|
||||
leftPressed = false
|
||||
rightPressed = false
|
||||
}
|
||||
}
|
||||
PointerEventType.Release -> {
|
||||
if (isWithinBounds) {
|
||||
if (leftPressed) {
|
||||
onDismiss()
|
||||
} else if (rightPressed) {
|
||||
onConfirm()
|
||||
}
|
||||
}
|
||||
leftPressed = false
|
||||
rightPressed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(if (leftPressed) pressedColor else Color.Transparent),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = dismissText,
|
||||
style = TextStyle(
|
||||
color = accentColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.fillMaxHeight()
|
||||
.background(Color(0x40888888))
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(if (rightPressed) pressedColor else Color.Transparent),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = confirmText,
|
||||
style = TextStyle(
|
||||
color = accentColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun ConnectionSettings() {
|
||||
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)
|
||||
) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.ear_detection),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
|
||||
sharedPreferenceKey = "automatic_ear_detection",
|
||||
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
|
||||
independent = false
|
||||
)
|
||||
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),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
|
||||
sharedPreferenceKey = "automatic_connection_ctrl_cmd",
|
||||
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ConnectionSettingsPreview() {
|
||||
ConnectionSettings()
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
|
||||
@file:Suppress("unused")
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
+2
-2
@@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
@@ -56,7 +56,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
|
||||
private val ContainerColor = Color(0x593C3C3E)
|
||||
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
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.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.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.Capability
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun HearingHealthSettings(navController: NavController) {
|
||||
val service = ServiceManager.getService()
|
||||
if (service == null) return
|
||||
val airpodsInstance = service.airpodsInstance
|
||||
if (airpodsInstance == null) return
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.PPE)) {
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
NavigationButton(
|
||||
to = "hearing_protection",
|
||||
name = stringResource(R.string.hearing_protection),
|
||||
navController = navController,
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
NavigationButton(
|
||||
to = "hearing_aid",
|
||||
name = stringResource(R.string.hearing_aid),
|
||||
navController = navController,
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
NavigationButton(
|
||||
to = "hearing_aid",
|
||||
name = stringResource(R.string.hearing_aid),
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
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.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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.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.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.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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun MicrophoneSettings(hazeState: HazeState) {
|
||||
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)
|
||||
) {
|
||||
val service = ServiceManager.getService()!!
|
||||
val micModeValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
||||
}?.value?.get(0) ?: 0x00.toByte()
|
||||
|
||||
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 listener = object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
|
||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
||||
) {
|
||||
selectedMode = when (controlCommand.value[0]) {
|
||||
0x00.toByte() -> "Automatic"
|
||||
0x01.toByte() -> "Always Right"
|
||||
0x02.toByte() -> "Always Left"
|
||||
else -> "Automatic"
|
||||
}
|
||||
Log.d("MicrophoneSettings", "Microphone mode received: $selectedMode")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
service.aacpManager.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
|
||||
listener
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
service.aacpManager.unregisterControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
|
||||
listener
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val itemHeightPx = with(density) { 48.dp.toPx() }
|
||||
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var parentDragActive by remember { mutableStateOf(false) }
|
||||
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()
|
||||
parentHoveredIndex = 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())
|
||||
)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
service.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
||||
byteArrayOf(byteValue.toByte())
|
||||
)
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Preview
|
||||
@Composable
|
||||
fun MicrophoneSettingsPreview() {
|
||||
MicrophoneSettings(HazeState())
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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 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,
|
||||
) {
|
||||
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))
|
||||
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),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp))
|
||||
.height(height)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = {
|
||||
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))
|
||||
}
|
||||
+2
-2
@@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -74,4 +74,4 @@ fun NoiseControlButtonPreview() {
|
||||
onClick = {},
|
||||
textColor = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.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.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
||||
@Composable
|
||||
fun NoiseControlSettings(
|
||||
service: AirPodsService,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
||||
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
|
||||
|
||||
val offListeningModeListener = object: AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
offListeningMode.value = controlCommand.value[0] == 1.toByte()
|
||||
}
|
||||
}
|
||||
|
||||
service.aacpManager.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
||||
offListeningModeListener
|
||||
)
|
||||
|
||||
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) }
|
||||
|
||||
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
||||
val previousMode = noiseControlMode.value
|
||||
|
||||
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
|
||||
NoiseControlMode.TRANSPARENCY
|
||||
} else {
|
||||
mode
|
||||
}
|
||||
|
||||
noiseControlMode.value = targetMode
|
||||
|
||||
if (!received && targetMode != previousMode) {
|
||||
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = 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 noiseControlReceiver = remember {
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AirPodsNotifications.ANC_DATA) {
|
||||
noiseControlMode.value = NoiseControlMode.entries.toTypedArray()[intent.getIntExtra("data", 3) - 1]
|
||||
onModeSelected(noiseControlMode.value, true)
|
||||
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
||||
try {
|
||||
context.unregisterReceiver(this)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val noiseControlIntentFilter = IntentFilter().apply {
|
||||
addAction(AirPodsNotifications.ANC_DATA)
|
||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
|
||||
}
|
||||
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),
|
||||
)
|
||||
)
|
||||
}
|
||||
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val buttonCount = if (offListeningMode.value) 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 (offListeningMode.value) 0f else buttonWidth.toPx()
|
||||
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) buttonWidth.toPx() else 0f
|
||||
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) (buttonWidth * 2).toPx() else buttonWidth.toPx()
|
||||
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) (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 (offListeningMode.value) 0 else 1
|
||||
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) 1 else 0
|
||||
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) 2 else 1
|
||||
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) 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 (offListeningMode.value) {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
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() }
|
||||
)
|
||||
},
|
||||
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 (offListeningMode.value) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
|
||||
1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
|
||||
2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
|
||||
3 -> NoiseControlMode.NOISE_CANCELLATION
|
||||
else -> noiseControlMode.value // Keep current if index is invalid
|
||||
}
|
||||
// Call onModeSelected which now handles service call but not callback
|
||||
onModeSelected(newMode)
|
||||
}
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(3.dp)
|
||||
.background(selectedBackground, RoundedCornerShape(26.dp))
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.zIndex(1f)
|
||||
) {
|
||||
if (offListeningMode.value) {
|
||||
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 (offListeningMode.value) {
|
||||
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() {
|
||||
NoiseControlSettings(AirPodsService())
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
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.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.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.tooling.preview.Preview
|
||||
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.constants.StemAction
|
||||
|
||||
@Composable
|
||||
fun PressAndHoldSettings(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val dividerColor = Color(0x40888888)
|
||||
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
|
||||
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||
else -> "INVALID!!"
|
||||
}
|
||||
|
||||
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||
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))
|
||||
) {
|
||||
NavigationButton(
|
||||
to = "long_press/Left",
|
||||
name = stringResource(R.string.left),
|
||||
navController = navController,
|
||||
independent = false,
|
||||
currentState = leftActionText,
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = dividerColor,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
NavigationButton(
|
||||
to = "long_press/Right",
|
||||
name = stringResource(R.string.right),
|
||||
navController = navController,
|
||||
independent = false,
|
||||
currentState = rightActionText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun PressAndHoldSettingsPreview() {
|
||||
PressAndHoldSettings(navController = NavController(LocalContext.current))
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
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.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.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.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastCoerceAtMost
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.kyant.backdrop.Backdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.blur
|
||||
import com.kyant.backdrop.effects.refraction
|
||||
import com.kyant.backdrop.effects.vibrancy
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.utils.inspectDragGestures
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.tanh
|
||||
|
||||
@Composable
|
||||
fun StyledButton(
|
||||
onClick: () -> Unit,
|
||||
backdrop: Backdrop,
|
||||
modifier: Modifier = Modifier,
|
||||
isInteractive: Boolean = true,
|
||||
tint: Color = Color.Unspecified,
|
||||
surfaceColor: Color = Color.Unspecified,
|
||||
maxScale: Float = 0.1f,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val animationScope = rememberCoroutineScope()
|
||||
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;
|
||||
uniform float2 offset;
|
||||
|
||||
half4 main(float2 coord) {
|
||||
float2 center = offset;
|
||||
float dist = distance(coord, center);
|
||||
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) {
|
||||
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 = null,
|
||||
highlight = { Highlight.Ambient.copy(alpha = 0f) }
|
||||
)
|
||||
} else {
|
||||
Modifier.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { RoundedCornerShape(28f.dp) },
|
||||
effects = {
|
||||
vibrancy()
|
||||
blur(2f.dp.toPx())
|
||||
refraction(12f.dp.toPx(), 24f.dp.toPx())
|
||||
},
|
||||
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 = onClick
|
||||
)
|
||||
.then(
|
||||
if (isInteractive) {
|
||||
Modifier.pointerInput(animationScope) {
|
||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||
val onDragStop: () -> Unit = {
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
||||
}
|
||||
}
|
||||
inspectDragGestures(
|
||||
onDragStart = { down ->
|
||||
pressStartPosition = down.position
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||
}
|
||||
},
|
||||
onDragEnd = { onDragStop() },
|
||||
onDragCancel = onDragStop
|
||||
) { _, dragAmount ->
|
||||
animationScope.launch {
|
||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed = true
|
||||
tryAwaitRelease()
|
||||
isPressed = false
|
||||
},
|
||||
onTap = {
|
||||
onClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
.height(48f.dp)
|
||||
.padding(horizontal = 16f.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8f.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
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.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.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
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 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
|
||||
|
||||
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
|
||||
hoveredIndex = (y / itemHeight.toPx()).toInt()
|
||||
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) {
|
||||
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
|
||||
) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
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.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.BlurEffect
|
||||
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.translate
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
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.compose.ui.util.fastCoerceAtMost
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
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.blur
|
||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.kyant.backdrop.shadow.Shadow
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.inspectDragGestures
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.tanh
|
||||
|
||||
@Composable
|
||||
fun StyledIconButton(
|
||||
onClick: () -> Unit,
|
||||
icon: String,
|
||||
darkMode: Boolean,
|
||||
tint: Color = Color.Unspecified,
|
||||
backdrop: LayerBackdrop = rememberLayerBackdrop(),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val animationScope = 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 interactiveHighlightShader = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
RuntimeShader(
|
||||
"""
|
||||
uniform float2 size;
|
||||
layout(color) uniform half4 color;
|
||||
uniform float radius;
|
||||
uniform float2 offset;
|
||||
|
||||
half4 main(float2 coord) {
|
||||
float2 center = offset;
|
||||
float dist = distance(coord, center);
|
||||
float intensity = smoothstep(radius, radius * 0.5, dist);
|
||||
return color * intensity;
|
||||
}"""
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
TextButton(
|
||||
onClick = 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 = {
|
||||
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 = {
|
||||
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)
|
||||
|
||||
drawRect(
|
||||
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(progress.coerceIn(0.15f, 0.35f))
|
||||
)
|
||||
},
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
effects = {
|
||||
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
|
||||
// blur(24f, TileMode.Decal)
|
||||
},
|
||||
)
|
||||
.pointerInput(animationScope) {
|
||||
val onDragStop: () -> Unit = {
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
||||
}
|
||||
}
|
||||
inspectDragGestures(
|
||||
onDragStart = { down ->
|
||||
pressStartPosition = down.position
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||
}
|
||||
},
|
||||
onDragEnd = { onDragStop() },
|
||||
onDragCancel = onDragStop
|
||||
) { _, dragAmount ->
|
||||
animationScope.launch {
|
||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
.size(48.dp),
|
||||
) {
|
||||
Text(
|
||||
text = icon,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = if (tint.isSpecified) tint else if (darkMode) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
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.fillMaxSize
|
||||
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.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
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.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.navigation.NavController
|
||||
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.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun StyledScaffold(
|
||||
title: String,
|
||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
content: @Composable (spacerValue: Dp, hazeState: HazeState) -> 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, bottom = bottomPadding)
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun StyledScaffold(
|
||||
title: String,
|
||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
StyledScaffold(
|
||||
title = title,
|
||||
actionButtons = actionButtons,
|
||||
snackbarHostState = snackbarHostState,
|
||||
) { _, _ ->
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
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.height
|
||||
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.input.pointer.pointerInput
|
||||
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.text.font.FontWeight
|
||||
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 enabled: Boolean = true
|
||||
)
|
||||
|
||||
data class SelectItem2(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val iconRes: Int? = null,
|
||||
val selected: () -> Boolean,
|
||||
val onClick: () -> Unit,
|
||||
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
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp)),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val visibleItems = items.filter { it.enabled }
|
||||
visibleItems.forEachIndexed { index, item ->
|
||||
val isFirst = index == 0
|
||||
val isLast = index == visibleItems.size - 1
|
||||
val hasIcon = item.iconRes != null
|
||||
|
||||
val shape = when {
|
||||
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(backgroundColor) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(if (hasIcon) 72.dp else 55.dp)
|
||||
.background(animatedBackgroundColor, shape)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
itemBackgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
itemBackgroundColor = backgroundColor
|
||||
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
|
||||
.height(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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
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.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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableFloatState
|
||||
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.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.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
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.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
import androidx.compose.ui.util.fastRoundToInt
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.kyant.backdrop.Backdrop
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.blur
|
||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.kyant.backdrop.shadow.InnerShadow
|
||||
import com.kyant.backdrop.shadow.Shadow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.inspectDragGestures
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun rememberMomentumAnimation(
|
||||
maxScale: Float,
|
||||
progressAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(1f, 1000f, 0.01f),
|
||||
velocityAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(0.5f, 250f, 5f),
|
||||
scaleXAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(0.4f, 400f, 0.01f),
|
||||
scaleYAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(0.6f, 400f, 0.01f)
|
||||
): MomentumAnimation {
|
||||
val animationScope = rememberCoroutineScope()
|
||||
return remember(
|
||||
maxScale,
|
||||
animationScope,
|
||||
progressAnimationSpec,
|
||||
velocityAnimationSpec,
|
||||
scaleXAnimationSpec,
|
||||
scaleYAnimationSpec
|
||||
) {
|
||||
MomentumAnimation(
|
||||
maxScale = maxScale,
|
||||
animationScope = animationScope,
|
||||
progressAnimationSpec = progressAnimationSpec,
|
||||
velocityAnimationSpec = velocityAnimationSpec,
|
||||
scaleXAnimationSpec = scaleXAnimationSpec,
|
||||
scaleYAnimationSpec = scaleYAnimationSpec
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class MomentumAnimation(
|
||||
val maxScale: Float,
|
||||
private val animationScope: CoroutineScope,
|
||||
private val progressAnimationSpec: FiniteAnimationSpec<Float>,
|
||||
private val velocityAnimationSpec: FiniteAnimationSpec<Float>,
|
||||
private val scaleXAnimationSpec: FiniteAnimationSpec<Float>,
|
||||
private val scaleYAnimationSpec: FiniteAnimationSpec<Float>
|
||||
) {
|
||||
|
||||
private val velocityTracker = VelocityTracker()
|
||||
|
||||
private val progressAnimation = Animatable(0f)
|
||||
private val velocityAnimation = Animatable(0f)
|
||||
private val scaleXAnimation = Animatable(1f)
|
||||
private val scaleYAnimation = Animatable(1f)
|
||||
|
||||
val progress: Float get() = progressAnimation.value
|
||||
val velocity: Float get() = velocityAnimation.value
|
||||
val scaleX: Float get() = scaleXAnimation.value
|
||||
val scaleY: Float get() = scaleYAnimation.value
|
||||
|
||||
var isDragging: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val modifier: Modifier = Modifier.pointerInput(Unit) {
|
||||
inspectDragGestures(
|
||||
onDragStart = {
|
||||
isDragging = true
|
||||
velocityTracker.resetTracking()
|
||||
startPressingAnimation()
|
||||
},
|
||||
onDragEnd = { change ->
|
||||
isDragging = false
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
updateVelocity(velocity)
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
velocityTracker.resetTracking()
|
||||
endPressingAnimation()
|
||||
settleVelocity()
|
||||
},
|
||||
onDragCancel = {
|
||||
isDragging = false
|
||||
velocityTracker.resetTracking()
|
||||
endPressingAnimation()
|
||||
settleVelocity()
|
||||
}
|
||||
) { change, _ ->
|
||||
isDragging = true
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
updateVelocity(velocity)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateVelocity(velocity: Velocity) {
|
||||
animationScope.launch { velocityAnimation.animateTo(velocity.x, velocityAnimationSpec) }
|
||||
}
|
||||
|
||||
private fun settleVelocity() {
|
||||
animationScope.launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) }
|
||||
}
|
||||
|
||||
fun startPressingAnimation() {
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
||||
launch { scaleXAnimation.animateTo(maxScale, scaleXAnimationSpec) }
|
||||
launch { scaleYAnimation.animateTo(maxScale, scaleYAnimationSpec) }
|
||||
}
|
||||
}
|
||||
|
||||
fun endPressingAnimation() {
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch { scaleXAnimation.animateTo(1f, scaleXAnimationSpec) }
|
||||
launch { scaleYAnimation.animateTo(1f, scaleYAnimationSpec) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StyledSlider(
|
||||
label: String? = null,
|
||||
mutableFloatState: MutableFloatState,
|
||||
onValueChange: (Float) -> Unit,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
backdrop: Backdrop = rememberLayerBackdrop(),
|
||||
snapPoints: List<Float> = emptyList(),
|
||||
snapThreshold: Float = 0.05f,
|
||||
startIcon: String? = null,
|
||||
endIcon: String? = null,
|
||||
startLabel: String? = null,
|
||||
endLabel: String? = null,
|
||||
independent: Boolean = false,
|
||||
description: String? = null
|
||||
) {
|
||||
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val isLightTheme = !isSystemInDarkTheme()
|
||||
val accentColor =
|
||||
if (isLightTheme) Color(0xFF0088FF)
|
||||
else Color(0xFF0091FF)
|
||||
val trackColor =
|
||||
if (isLightTheme) Color(0xFF787878).copy(0.2f)
|
||||
else Color(0xFF787880).copy(0.36f)
|
||||
val labelTextColor = if (isLightTheme) Color.Black else Color.White
|
||||
|
||||
val fraction by remember {
|
||||
derivedStateOf {
|
||||
((mutableFloatState.floatValue - 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 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) { 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() }
|
||||
}
|
||||
.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)
|
||||
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
|
||||
targetValue,
|
||||
snapPoints,
|
||||
snapThreshold
|
||||
) else targetValue
|
||||
onValueChange(snappedValue)
|
||||
}
|
||||
},
|
||||
Orientation.Horizontal,
|
||||
startDragImmediately = true,
|
||||
onDragStarted = {
|
||||
// Remove this block as momentumAnimation handles pressing
|
||||
},
|
||||
onDragStopped = {
|
||||
// Remove this block as momentumAnimation handles pressing
|
||||
onValueChange((mutableFloatState.floatValue * 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))
|
||||
refractionWithDispersion(
|
||||
height = 6f.dp.toPx() * progress,
|
||||
amount = size.height / 2f * progress
|
||||
)
|
||||
}
|
||||
)
|
||||
.size(40f.dp, 24f.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.05f): Float {
|
||||
val nearest = points.minByOrNull { abs(it - value) } ?: value
|
||||
return if (abs(nearest - value) <= threshold) nearest else value
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun StyledSliderPreview() {
|
||||
val a = remember { mutableFloatStateOf(0.5f) }
|
||||
Box(
|
||||
Modifier
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF0F0F0))
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Box (
|
||||
Modifier.align(Alignment.Center)
|
||||
)
|
||||
{
|
||||
StyledSlider(
|
||||
mutableFloatState = a,
|
||||
onValueChange = {
|
||||
a.floatValue = it
|
||||
},
|
||||
valueRange = 0f..2f,
|
||||
snapPoints = listOf(1f),
|
||||
snapThreshold = 0.1f,
|
||||
independent = true,
|
||||
startIcon = "A",
|
||||
endIcon = "B",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-33
@@ -16,10 +16,10 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.Animatable
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.spring
|
||||
@@ -58,10 +58,8 @@ import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
@@ -70,12 +68,11 @@ import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.lens
|
||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.kyant.backdrop.shadow.Shadow
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
@Composable
|
||||
fun StyledSwitch(
|
||||
@@ -84,12 +81,9 @@ fun StyledSwitch(
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
|
||||
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
||||
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(
|
||||
0x805B5B5E
|
||||
) else Color(0xFFD1D1D6)
|
||||
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
||||
|
||||
val trackWidth = 64.dp
|
||||
val trackHeight = 28.dp
|
||||
@@ -104,25 +98,24 @@ fun StyledSwitch(
|
||||
val animatedFraction = remember { Animatable(fraction) }
|
||||
val trackWidthPx = remember { mutableFloatStateOf(0f) }
|
||||
val density = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val animationScope = rememberCoroutineScope()
|
||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||
val colorAnimationSpec = tween<Color>(200, easing = FastOutSlowInEasing)
|
||||
val progressAnimation = remember { Animatable(0f) }
|
||||
val innerShadowLayer = rememberGraphicsLayer().apply {
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
val targetColor = if (checked) onColor else offColor
|
||||
val animatedTrackColor by animateColorAsState(targetColor)
|
||||
val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) }
|
||||
val totalDrag = remember { mutableFloatStateOf(0f) }
|
||||
val tapThreshold = 10f
|
||||
val isFirstComposition = remember { mutableStateOf(true) }
|
||||
LaunchedEffect(checked) {
|
||||
if (!isFirstComposition.value) {
|
||||
if (checked) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.ToggleOn)
|
||||
} else {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.ToggleOff)
|
||||
}
|
||||
coroutineScope {
|
||||
launch {
|
||||
val targetColor = if (checked) onColor else offColor
|
||||
animatedTrackColor.animateTo(targetColor, colorAnimationSpec)
|
||||
}
|
||||
launch {
|
||||
val targetFrac = if (checked) 1f else 0f
|
||||
animatedFraction.animateTo(targetFrac, progressAnimationSpec)
|
||||
@@ -147,7 +140,7 @@ fun StyledSwitch(
|
||||
modifier = Modifier
|
||||
.layerBackdrop(switchBackdrop)
|
||||
.clip(RoundedCornerShape(trackHeight / 2))
|
||||
.background(animatedTrackColor)
|
||||
.background(animatedTrackColor.value)
|
||||
.width(trackWidth)
|
||||
.height(trackHeight)
|
||||
.onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
|
||||
@@ -161,31 +154,27 @@ fun StyledSwitch(
|
||||
.then(if (enabled) Modifier.draggable(
|
||||
rememberDraggableState { delta ->
|
||||
if (trackWidthPx.floatValue > 0f) {
|
||||
val oldFraction = animatedFraction.value
|
||||
val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f)
|
||||
scope.launch {
|
||||
animationScope.launch {
|
||||
animatedFraction.snapTo(newFraction)
|
||||
}
|
||||
totalDrag.floatValue += abs(delta)
|
||||
totalDrag.floatValue += kotlin.math.abs(delta)
|
||||
val newChecked = newFraction >= 0.5f
|
||||
if (newChecked != checked) {
|
||||
onCheckedChange(newChecked)
|
||||
}
|
||||
if ((oldFraction < 0.5f && newFraction >= 0.5f) || (oldFraction >= 0.5f && newFraction < 0.5f)) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
}
|
||||
}
|
||||
},
|
||||
Orientation.Horizontal,
|
||||
startDragImmediately = true,
|
||||
onDragStarted = {
|
||||
totalDrag.floatValue = 0f
|
||||
scope.launch {
|
||||
animationScope.launch {
|
||||
progressAnimation.animateTo(1f, progressAnimationSpec)
|
||||
}
|
||||
},
|
||||
onDragStopped = {
|
||||
scope.launch {
|
||||
animationScope.launch {
|
||||
if (totalDrag.floatValue < tapThreshold) {
|
||||
val newChecked = !checked
|
||||
onCheckedChange(newChecked)
|
||||
@@ -273,12 +262,7 @@ fun StyledSwitch(
|
||||
drawRect(Color.White.copy(1f - progress))
|
||||
},
|
||||
effects = {
|
||||
lens(
|
||||
refractionHeight = 6f.dp.toPx(),
|
||||
refractionAmount = size.height / 2f,
|
||||
depthEffect = true,
|
||||
chromaticAberration = true
|
||||
)
|
||||
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
|
||||
}
|
||||
)
|
||||
.width(thumbWidth)
|
||||
@@ -0,0 +1,687 @@
|
||||
/*
|
||||
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.composables
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
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
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.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.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
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.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.core.content.edit
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun StyledToggle(
|
||||
title: String? = null,
|
||||
label: String,
|
||||
description: String? = null,
|
||||
checkedState: MutableState<Boolean> = remember { mutableStateOf(false) } ,
|
||||
sharedPreferenceKey: String? = null,
|
||||
sharedPreferences: SharedPreferences? = null,
|
||||
independent: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
var checked by checkedState
|
||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
||||
}
|
||||
fun cb() {
|
||||
if (sharedPreferences != null) {
|
||||
if (sharedPreferenceKey == null) {
|
||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
||||
return
|
||||
}
|
||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
||||
}
|
||||
onCheckedChange?.invoke(checked)
|
||||
}
|
||||
|
||||
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)
|
||||
),
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(4.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = {
|
||||
if (enabled) {
|
||||
checked = !checked
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
checked = it
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
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))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
checked = !checked
|
||||
cb()
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
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
|
||||
)
|
||||
)
|
||||
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)),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
checked = it
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StyledToggle(
|
||||
title: String? = null,
|
||||
label: String,
|
||||
description: String? = null,
|
||||
controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers,
|
||||
independent: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
sharedPreferenceKey: String? = null,
|
||||
sharedPreferences: SharedPreferences? = null,
|
||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
val service = ServiceManager.getService() ?: return
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val checkedValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == controlCommandIdentifier
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
var checked by remember { mutableStateOf(checkedValue == 1.toByte()) }
|
||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
||||
}
|
||||
fun cb() {
|
||||
service.aacpManager.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
|
||||
if (sharedPreferences != null) {
|
||||
if (sharedPreferenceKey == null) {
|
||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
||||
return
|
||||
}
|
||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
||||
}
|
||||
onCheckedChange?.invoke(checked)
|
||||
}
|
||||
|
||||
val listener = remember {
|
||||
object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == controlCommandIdentifier.value) {
|
||||
Log.d("StyledToggle", "Received control command for $label: ${controlCommand.value}")
|
||||
checked = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
service.aacpManager.registerControlCommandListener(controlCommandIdentifier, listener)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
service.aacpManager.unregisterControlCommandListener(controlCommandIdentifier, listener)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
),
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(4.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = {
|
||||
if (enabled) {
|
||||
checked = !checked
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
checked = it
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
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))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
checked = !checked
|
||||
cb()
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
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
|
||||
)
|
||||
)
|
||||
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)),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
checked = it
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StyledToggle(
|
||||
title: String? = null,
|
||||
label: String,
|
||||
description: String? = null,
|
||||
attHandle: ATTHandles,
|
||||
independent: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
sharedPreferenceKey: String? = null,
|
||||
sharedPreferences: SharedPreferences? = null,
|
||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val checkedValue = try {
|
||||
attManager.read(attHandle).getOrNull(0)?.toInt()
|
||||
} catch (e: Exception) {
|
||||
Log.w("StyledToggle", "Error reading initial value for $label: ${e.message}")
|
||||
null
|
||||
} ?: 0
|
||||
var checked by remember { mutableStateOf(checkedValue !=0) }
|
||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
|
||||
attManager.enableNotifications(attHandle)
|
||||
|
||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
||||
}
|
||||
|
||||
fun cb() {
|
||||
if (sharedPreferences != null) {
|
||||
if (sharedPreferenceKey == null) {
|
||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
||||
return
|
||||
}
|
||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
||||
}
|
||||
onCheckedChange?.invoke(checked)
|
||||
}
|
||||
|
||||
LaunchedEffect(checked) {
|
||||
if (attManager.socket?.isConnected != true) return@LaunchedEffect
|
||||
attManager.write(attHandle, if (checked) byteArrayOf(1) else byteArrayOf(0))
|
||||
}
|
||||
|
||||
val listener = remember {
|
||||
object : (ByteArray) -> Unit {
|
||||
override fun invoke(value: ByteArray) {
|
||||
if (value.isNotEmpty()) {
|
||||
checked = value[0].toInt() != 0
|
||||
Log.d("StyledToggle", "Updated from notification for $label: enabled=$checked")
|
||||
} else {
|
||||
Log.w("StyledToggle", "Empty value in notification for $label")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
attManager.registerListener(attHandle, listener)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
attManager.unregisterListener(attHandle, listener)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
),
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(4.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = {
|
||||
if (enabled) {
|
||||
checked = !checked
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
checked = it
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
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))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
checked = !checked
|
||||
cb()
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
checked = it
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun StyledTogglePreview() {
|
||||
val context = LocalContext.current
|
||||
val sharedPrefs = context.getSharedPreferences("preview", 0)
|
||||
StyledToggle(
|
||||
label = "Example Toggle",
|
||||
description = "This is an example description for the styled toggle.",
|
||||
sharedPreferences = sharedPrefs
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
+10
-10
@@ -16,16 +16,14 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.data
|
||||
package me.kavishdevar.librepods.constants
|
||||
|
||||
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(byteArrayOf(0x0d)),
|
||||
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
|
||||
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
||||
SETTINGS(byteArrayOf(0x09, 0x00)),
|
||||
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
|
||||
@@ -42,7 +40,6 @@ object BatteryStatus {
|
||||
const val CHARGING = 1
|
||||
const val NOT_CHARGING = 2
|
||||
const val DISCONNECTED = 4
|
||||
const val OPTIMIZED_CHARGING = 5
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@@ -61,7 +58,6 @@ data class Battery(val component: Int, val level: Int, val status: Int) : Parcel
|
||||
BatteryStatus.CHARGING -> "CHARGING"
|
||||
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
|
||||
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
|
||||
BatteryStatus.OPTIMIZED_CHARGING -> "OPTIMIZED_CHARGING"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -74,7 +70,6 @@ enum class NoiseControlMode {
|
||||
class AirPodsNotifications {
|
||||
companion object {
|
||||
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
|
||||
const val AIRPODS_L2CAP_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
|
||||
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
|
||||
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
|
||||
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
|
||||
@@ -83,12 +78,10 @@ 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.HEADPHONE_ACCOMMODATION"
|
||||
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
|
||||
}
|
||||
|
||||
class EarDetection {
|
||||
private val notificationBit = 6.toByte()
|
||||
private val notificationBit = Capabilities.EAR_DETECTION
|
||||
private val notificationPrefix = Enums.PREFIX.value + notificationBit
|
||||
|
||||
var status: List<Byte> = listOf(0x01, 0x01)
|
||||
@@ -245,6 +238,13 @@ 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
|
||||
|
||||
+2
-2
@@ -16,9 +16,9 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.data
|
||||
package me.kavishdevar.librepods.constants
|
||||
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
|
||||
enum class StemAction {
|
||||
PLAY_PAUSE,
|
||||
@@ -1,70 +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.data
|
||||
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
||||
|
||||
class ControlCommandRepository(
|
||||
private val aacpManager: AACPManager
|
||||
) {
|
||||
fun getValue(
|
||||
identifier: ControlCommandIdentifiers
|
||||
): ByteArray? {
|
||||
return aacpManager.controlCommandStatusList
|
||||
.find { it.identifier == identifier }
|
||||
?.value
|
||||
}
|
||||
|
||||
fun setValue(
|
||||
id: ControlCommandIdentifiers,
|
||||
value: ByteArray
|
||||
) {
|
||||
aacpManager.sendControlCommand(id.value, value)
|
||||
}
|
||||
|
||||
|
||||
fun observe(
|
||||
identifier: ControlCommandIdentifiers,
|
||||
onChange: (ByteArray) -> Unit
|
||||
): AACPManager.ControlCommandListener {
|
||||
|
||||
val listener = object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
onChange(controlCommand.value)
|
||||
}
|
||||
}
|
||||
|
||||
aacpManager.registerControlCommandListener(identifier, listener)
|
||||
return listener
|
||||
}
|
||||
|
||||
fun remove(
|
||||
identifier: ControlCommandIdentifiers,
|
||||
listener: AACPManager.ControlCommandListener
|
||||
) {
|
||||
aacpManager.unregisterControlCommandListener(identifier, listener)
|
||||
}
|
||||
|
||||
fun getMap(): Map<ControlCommandIdentifiers, ByteArray> {
|
||||
return aacpManager.controlCommandStatusList.associate {
|
||||
it.identifier to it.value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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" }
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package me.kavishdevar.librepods.data
|
||||
|
||||
interface XposedRemotePref {
|
||||
fun isAvailable(): Boolean
|
||||
|
||||
fun getBoolean(key: String, def: Boolean): Boolean
|
||||
fun putBoolean(key: String, value: Boolean)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package me.kavishdevar.librepods.data
|
||||
|
||||
import androidx.core.content.edit
|
||||
import me.kavishdevar.librepods.utils.XposedServiceHolder
|
||||
|
||||
class XposedRemotePrefImpl: XposedRemotePref {
|
||||
override fun isAvailable(): Boolean {
|
||||
return XposedServiceHolder.service != null
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String, def: Boolean): Boolean {
|
||||
val s = XposedServiceHolder.service ?: return def
|
||||
return s.getRemotePreferences("me.kavishdevar.librepods").getBoolean(key, def)
|
||||
}
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean) {
|
||||
val s = XposedServiceHolder.service ?: return
|
||||
s.getRemotePreferences("me.kavishdevar.librepods")
|
||||
.edit { putBoolean(key, value) }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package me.kavishdevar.librepods.data
|
||||
|
||||
object XposedRemotePrefProvider {
|
||||
fun create(): XposedRemotePref = XposedRemotePrefImpl()
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package me.kavishdevar.librepods.data.updates
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
data class UpdateItem(
|
||||
val titleRes: Int,
|
||||
val descriptionRes: Int,
|
||||
val demoComposeable: @Composable () -> Unit
|
||||
)
|
||||
@@ -1,35 +0,0 @@
|
||||
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 update0_3_1 = 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 = update0_3_1
|
||||
@@ -1,528 +0,0 @@
|
||||
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
|
||||
}
|
||||
-79
@@ -1,79 +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.runtime.Composable
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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(
|
||||
modelName: String,
|
||||
actualModel: String,
|
||||
serialNumbers: List<String>,
|
||||
version: String?,
|
||||
navigateToVersion: () -> Unit
|
||||
) {
|
||||
val serialNumbers = when (LocalDesignSystem.current) {
|
||||
DesignSystem.Apple -> listOf(
|
||||
serialNumbers[0],
|
||||
" ${serialNumbers[1]}",
|
||||
" ${serialNumbers[2]}"
|
||||
)
|
||||
|
||||
DesignSystem.Material -> listOf(
|
||||
serialNumbers[0],
|
||||
stringResource(R.string.left) + " " + {serialNumbers[1]},
|
||||
stringResource(R.string.right) + " " + {serialNumbers[2]},
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
description = version,
|
||||
onClick = navigateToVersion,
|
||||
)
|
||||
}
|
||||
}
|
||||
-52
@@ -1,52 +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.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@Composable
|
||||
fun AppInfoCard(
|
||||
navigateToReleaseNotesScreen: (() -> Unit)? = null,
|
||||
) {
|
||||
StyledList(title = stringResource(R.string.about)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.version),
|
||||
description = BuildConfig.VERSION_NAME,
|
||||
onClick = navigateToReleaseNotesScreen
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.version_code),
|
||||
description = BuildConfig.VERSION_CODE.toString(),
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.flavor),
|
||||
description = BuildConfig.FLAVOR,
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.build_type),
|
||||
description = BuildConfig.BUILD_TYPE,
|
||||
)
|
||||
}
|
||||
}
|
||||
-98
@@ -1,98 +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.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import me.kavishdevar.librepods.R
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun AudioSettings(
|
||||
adaptiveVolumeCapability: Boolean,
|
||||
conversationalAwarenessCapability: Boolean,
|
||||
loudSoundReductionCapability: Boolean,
|
||||
adaptiveAudioCapability: Boolean,
|
||||
customEqCapability: Boolean,
|
||||
|
||||
adaptiveVolumeChecked: Boolean,
|
||||
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
|
||||
|
||||
conversationalAwarenessChecked: Boolean,
|
||||
onConversationalAwarenessCheckedChange: (Boolean) -> Unit,
|
||||
|
||||
loudSoundReductionChecked: Boolean,
|
||||
onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
|
||||
|
||||
navigateToAdaptiveStrength: () -> Unit,
|
||||
navigateToEqualizer: () -> Unit,
|
||||
|
||||
vendorIdHook: Boolean,
|
||||
isPremium: Boolean
|
||||
) {
|
||||
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 (conversationalAwarenessCapability) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversational_awareness),
|
||||
description = stringResource(R.string.conversational_awareness_description),
|
||||
checked = conversationalAwarenessChecked,
|
||||
onCheckedChange = onConversationalAwarenessCheckedChange,
|
||||
enabled = isPremium,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
if (adaptiveAudioCapability) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.adaptive_audio),
|
||||
onClick = navigateToAdaptiveStrength,
|
||||
)
|
||||
}
|
||||
|
||||
if (customEqCapability) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.equalizer),
|
||||
onClick = navigateToEqualizer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-212
@@ -1,212 +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.content.res.Configuration
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
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.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
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
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.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@Composable
|
||||
fun BatteryIndicator(
|
||||
batteryPercentage: Int,
|
||||
status: Int,
|
||||
prefix: String = "",
|
||||
previousCharging: Boolean = false,
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val batteryFillColor =
|
||||
if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
|
||||
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
|
||||
|
||||
val initialScale = if (previousCharging) 1f else 0f
|
||||
val scaleAnim = remember { Animatable(initialScale) }
|
||||
val charging = status == BatteryStatus.CHARGING || status == BatteryStatus.OPTIMIZED_CHARGING
|
||||
val targetScale = if (charging) 1f else 0f
|
||||
|
||||
LaunchedEffect(previousCharging, charging) {
|
||||
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer).padding(4.dp), // just for haze to work
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(bottom = 4.dp), contentAlignment = Alignment.Center
|
||||
) {
|
||||
val strokeWidthPx = with(LocalDensity.current) { 4.dp.toPx() }
|
||||
val gapFromCenterPx = with(LocalDensity.current) { 8.sp.toPx() }
|
||||
|
||||
val trackColor = if (isDarkTheme) Color(0xFF272728) else Color(0xFFE3E3E8)
|
||||
val optimizedLimit = 0.8f
|
||||
val progress = batteryPercentage / 100f
|
||||
|
||||
Canvas(modifier = Modifier.size(34.dp)) {
|
||||
val startAngle = -90f
|
||||
val stroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Round)
|
||||
val inset = strokeWidthPx / 2
|
||||
Rect(
|
||||
left = inset,
|
||||
top = inset,
|
||||
right = size.width - inset,
|
||||
bottom = size.height - inset
|
||||
)
|
||||
val radius = size.minDimension / 2
|
||||
|
||||
if (status == BatteryStatus.OPTIMIZED_CHARGING) {
|
||||
drawArc(
|
||||
color = trackColor,
|
||||
startAngle = startAngle,
|
||||
sweepAngle = 360f * optimizedLimit,
|
||||
useCenter = false,
|
||||
style = stroke
|
||||
)
|
||||
|
||||
val sweep = 360f * min(progress, optimizedLimit)
|
||||
drawArc(
|
||||
color = batteryFillColor,
|
||||
startAngle = startAngle,
|
||||
sweepAngle = sweep,
|
||||
useCenter = false,
|
||||
style = stroke
|
||||
)
|
||||
|
||||
// ---- PILL MARKER AT 80% ----
|
||||
val angleDeg = startAngle + 360f * optimizedLimit
|
||||
val angleRad = Math.toRadians(angleDeg.toDouble())
|
||||
|
||||
val arcRadius = radius - strokeWidthPx
|
||||
|
||||
val outerX = center.x + arcRadius * cos(angleRad).toFloat()
|
||||
val outerY = center.y + arcRadius * sin(angleRad).toFloat()
|
||||
|
||||
val dirX = center.x - outerX
|
||||
val dirY = center.y - outerY
|
||||
val length = sqrt(dirX * dirX + dirY * dirY)
|
||||
|
||||
val normX = dirX / length
|
||||
val normY = dirY / length
|
||||
|
||||
val startX = outerX - normX * strokeWidthPx / 2
|
||||
val startY = outerY - normY * strokeWidthPx / 2
|
||||
|
||||
val endX = center.x - normX * gapFromCenterPx
|
||||
val endY = center.y - normY * gapFromCenterPx
|
||||
|
||||
drawLine(
|
||||
color = if (batteryPercentage >= 80) batteryFillColor else trackColor,
|
||||
start = Offset(startX, startY),
|
||||
end = Offset(endX, endY),
|
||||
strokeWidth = strokeWidthPx,
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
} else {
|
||||
drawArc(
|
||||
color = trackColor,
|
||||
startAngle = 0f,
|
||||
sweepAngle = 360f,
|
||||
useCenter = false,
|
||||
style = stroke
|
||||
)
|
||||
|
||||
drawArc(
|
||||
color = batteryFillColor,
|
||||
startAngle = startAngle,
|
||||
sweepAngle = 360f * progress,
|
||||
useCenter = false,
|
||||
style = stroke
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "\uDBC0\uDEE6", style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = batteryFillColor,
|
||||
textAlign = TextAlign.Center
|
||||
), modifier = Modifier.scale(scaleAnim.value)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = "$prefix $batteryPercentage%",
|
||||
color = batteryTextColor,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun BatteryIndicatorPreview() {
|
||||
LibrePodsTheme(m3eEnabled = false) {
|
||||
BatteryIndicator(
|
||||
batteryPercentage = 50,
|
||||
status = BatteryStatus.OPTIMIZED_CHARGING,
|
||||
prefix = "\uDBC6\uDCE5",
|
||||
previousCharging = false
|
||||
)
|
||||
}
|
||||
}
|
||||
-174
@@ -1,174 +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 android.content.res.Configuration
|
||||
import androidx.compose.foundation.Image
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.data.Battery
|
||||
import me.kavishdevar.librepods.data.BatteryComponent
|
||||
import me.kavishdevar.librepods.data.BatteryStatus
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun BatteryView(
|
||||
batteryList: List<Battery>,
|
||||
budsRes: Int,
|
||||
caseRes: Int
|
||||
) {
|
||||
val left = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||
val right = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||
val case = batteryList.find { it.component == BatteryComponent.CASE }
|
||||
|
||||
val leftLevel = left?.level ?: 0
|
||||
val rightLevel = right?.level ?: 0
|
||||
val caseLevel = case?.level ?: 0
|
||||
|
||||
val singleDisplayed = remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.widthIn(max = 500.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
bitmap = ImageBitmap.imageResource(budsRes),
|
||||
contentDescription = stringResource(R.string.buds),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
|
||||
if (
|
||||
left?.status == right?.status &&
|
||||
(leftLevel - rightLevel) in -3..3
|
||||
) {
|
||||
BatteryIndicator(
|
||||
leftLevel.coerceAtMost(rightLevel),
|
||||
left?.status ?: BatteryStatus.NOT_CHARGING
|
||||
)
|
||||
singleDisplayed.value = true
|
||||
} else {
|
||||
singleDisplayed.value = false
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
|
||||
BatteryIndicator(
|
||||
leftLevel,
|
||||
left?.status ?: BatteryStatus.NOT_CHARGING,
|
||||
"\uDBC6\uDCE5"
|
||||
)
|
||||
}
|
||||
|
||||
if (leftLevel > 0 && rightLevel > 0) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
|
||||
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) {
|
||||
BatteryIndicator(
|
||||
rightLevel,
|
||||
right?.status ?: BatteryStatus.NOT_CHARGING,
|
||||
"\uDBC6\uDCE8"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
bitmap = ImageBitmap.imageResource(caseRes),
|
||||
contentDescription = stringResource(R.string.case_alt),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
|
||||
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
|
||||
BatteryIndicator(
|
||||
caseLevel,
|
||||
case?.status ?: BatteryStatus.NOT_CHARGING,
|
||||
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun BatteryViewPreview() {
|
||||
val fakeBattery = listOf(
|
||||
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
|
||||
Battery(BatteryComponent.RIGHT, 40, BatteryStatus.OPTIMIZED_CHARGING),
|
||||
Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
|
||||
)
|
||||
|
||||
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(bg)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
BatteryView(
|
||||
batteryList = fakeBattery,
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
caseRes = R.drawable.airpods_pro_2_case
|
||||
)
|
||||
}
|
||||
}
|
||||
-89
@@ -1,89 +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.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.res.stringResource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun CallControlSettings(
|
||||
flipped: Boolean,
|
||||
navigateToCallControlScreen: (action: String) -> Unit,
|
||||
) {
|
||||
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
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = muteUnmuteText,
|
||||
description = singlePressAction,
|
||||
onClick = { navigateToCallControlScreen(muteUnmuteText) } ,
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = hangUpText,
|
||||
description = doublePressAction,
|
||||
onClick = { navigateToCallControlScreen(hangUpText) }
|
||||
)
|
||||
|
||||
// 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)
|
||||
// }
|
||||
// )
|
||||
}
|
||||
}
|
||||
-218
@@ -1,218 +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.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.compose.ui.window.DialogProperties
|
||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.blur
|
||||
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(
|
||||
showDialog: MutableState<Boolean>,
|
||||
title: String,
|
||||
message: String,
|
||||
confirmText: String = "Enable",
|
||||
dismissText: String = "Cancel",
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit = { showDialog.value = false },
|
||||
backdrop: LayerBackdrop,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = showDialog.value,
|
||||
enter = scaleIn(initialScale = 1.05f) + fadeIn(),
|
||||
exit = scaleOut(targetScale = 1.05f) + fadeOut()
|
||||
) {
|
||||
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(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Row(modifier = Modifier.align(Alignment.End)) {
|
||||
TextButton(
|
||||
onClick = onDismiss
|
||||
) {
|
||||
Text(
|
||||
text = dismissText,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onConfirm
|
||||
) {
|
||||
Text(
|
||||
text = confirmText,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-49
@@ -1,49 +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.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import me.kavishdevar.librepods.R
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun ConnectionSettings(
|
||||
automaticEarDetectionEnabled: Boolean,
|
||||
onAutomaticEarDetectionChanged: (Boolean) -> Unit,
|
||||
automaticConnectionEnabled: Boolean,
|
||||
onAutomaticConnectionChanged: (Boolean) -> Unit,
|
||||
) {
|
||||
StyledList {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.ear_detection),
|
||||
checked = automaticEarDetectionEnabled,
|
||||
onCheckedChange = onAutomaticEarDetectionChanged
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.automatically_connect),
|
||||
description = stringResource(R.string.automatically_connect_description),
|
||||
checked = automaticConnectionEnabled,
|
||||
onCheckedChange = onAutomaticConnectionChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.XposedState
|
||||
|
||||
@Composable
|
||||
fun DeviceInfoCard() {
|
||||
StyledList(title = stringResource(R.string.device_info)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.manufacturer),
|
||||
description = Build.MANUFACTURER,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.model_number),
|
||||
description = Build.MODEL,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.build_id),
|
||||
description = Build.DISPLAY,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +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.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import me.kavishdevar.librepods.R
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun HearingHealthSettings(
|
||||
hasPPECapability: Boolean,
|
||||
hasHearingAidCapability: Boolean,
|
||||
vendorIdHook: Boolean,
|
||||
navigateToHearingProtection: () -> Unit,
|
||||
navigateToHearingAid: () -> Unit
|
||||
) {
|
||||
val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook
|
||||
|
||||
if (hasPPECapability && shouldShowHearingAid) {
|
||||
StyledList(title = stringResource(R.string.hearing_health)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.hearing_protection),
|
||||
onClick = navigateToHearingProtection
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.hearing_aid),
|
||||
onClick = navigateToHearingAid
|
||||
)
|
||||
}
|
||||
} else if (shouldShowHearingAid) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.hearing_aid),
|
||||
onClick = navigateToHearingAid
|
||||
)
|
||||
} else if (hasPPECapability) {
|
||||
StyledListItem(
|
||||
title = stringResource(R.string.hearing_health),
|
||||
name = stringResource(R.string.hearing_protection),
|
||||
onClick = navigateToHearingProtection
|
||||
)
|
||||
}
|
||||
}
|
||||
-522
@@ -1,522 +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 android.annotation.SuppressLint
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
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
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.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
|
||||
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
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
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.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(
|
||||
showOffListeningMode: Boolean,
|
||||
noiseControlModeValue: Int,
|
||||
onNoiseControlModeChanged: (Int) -> Unit
|
||||
) {
|
||||
when (LocalDesignSystem.current) {
|
||||
DesignSystem.Material -> {
|
||||
val options = buildList {
|
||||
if (showOffListeningMode) {
|
||||
add(
|
||||
Triple(
|
||||
NoiseControlMode.OFF,
|
||||
R.string.off,
|
||||
R.drawable.noise_cancellation
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
.background(Color.Transparent)
|
||||
.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)
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
.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
|
||||
)
|
||||
}
|
||||
|
||||
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() }
|
||||
)
|
||||
},
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NoiseControlSettingsPreview() {
|
||||
LibrePodsTheme(
|
||||
m3eEnabled = true
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
NoiseControlSettings(
|
||||
showOffListeningMode = false,
|
||||
noiseControlModeValue = 2,
|
||||
onNoiseControlModeChanged = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-59
@@ -1,59 +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.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.data.StemAction
|
||||
|
||||
@Composable
|
||||
fun PressAndHoldSettings(
|
||||
leftAction: StemAction,
|
||||
rightAction: StemAction,
|
||||
navigateToLeftLongPress: () -> Unit,
|
||||
navigateToRightLongPress: () -> Unit
|
||||
) {
|
||||
val leftActionText = when (leftAction) {
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||
else -> "INVALID!!"
|
||||
}
|
||||
|
||||
val rightActionText = when (rightAction) {
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||
else -> "INVALID!!"
|
||||
}
|
||||
|
||||
StyledList(
|
||||
title = stringResource(R.string.press_and_hold_airpods)
|
||||
) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.left),
|
||||
description = leftActionText,
|
||||
onClick = navigateToLeftLongPress
|
||||
)
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.right),
|
||||
description = rightActionText,
|
||||
onClick = navigateToRightLongPress,
|
||||
)
|
||||
}
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.blur
|
||||
import com.kyant.backdrop.effects.lens
|
||||
import com.kyant.backdrop.effects.vibrancy
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StyledBottomSheet(
|
||||
visible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
backdrop: LayerBackdrop,
|
||||
content: @Composable (innerBackdrop: LayerBackdrop, progress: Float) -> Unit
|
||||
) {
|
||||
if (!visible) return
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val sheetState = rememberModalBottomSheetState(false) // move this to parent composable
|
||||
|
||||
val isExpanded = sheetState.targetValue == SheetValue.Expanded
|
||||
|
||||
val progress by animateFloatAsState(
|
||||
targetValue = if (isExpanded) 1f else 0f,
|
||||
label = "sheetProgress"
|
||||
)
|
||||
|
||||
val animatedCorner = lerp(48.dp, 42.dp, progress)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = Color.Transparent,
|
||||
dragHandle = { },
|
||||
shape = RoundedCornerShape(animatedCorner),
|
||||
scrimColor = Color.Transparent,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
val innerBackdrop = rememberLayerBackdrop()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(animatedCorner))
|
||||
.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
exportedBackdrop = innerBackdrop,
|
||||
shape = { RoundedCornerShape(animatedCorner) },
|
||||
effects = {
|
||||
vibrancy()
|
||||
blur(4f.dp.toPx())
|
||||
lens(12f.dp.toPx(), 48f.dp.toPx(), true)
|
||||
},
|
||||
onDrawSurface = {
|
||||
drawRect(
|
||||
if (isDarkTheme) Color.DarkGray.copy(alpha = 0.3f) else Color(
|
||||
0xFFE0E0E0
|
||||
).copy(alpha = 0.45f)
|
||||
)
|
||||
}
|
||||
)
|
||||
.padding(top = 24.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
content(innerBackdrop, progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
-404
@@ -1,404 +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.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.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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
|
||||
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
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastCoerceAtMost
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.kyant.backdrop.Backdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.blur
|
||||
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
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.tanh
|
||||
|
||||
enum class MaterialButtonStyle {
|
||||
Tonal,
|
||||
Normal,
|
||||
Outlined,
|
||||
Filled
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StyledButton(
|
||||
onClick: () -> Unit,
|
||||
backdrop: Backdrop,
|
||||
modifier: Modifier = Modifier,
|
||||
isInteractive: Boolean = true,
|
||||
tint: Color = Color.Unspecified,
|
||||
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,
|
||||
) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
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;
|
||||
uniform float2 offset;
|
||||
|
||||
half4 main(float2 coord) {
|
||||
float2 center = offset;
|
||||
float dist = distance(coord, center);
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-437
@@ -1,437 +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.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
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.BlurEffect
|
||||
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
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
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.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.fastCoerceAtMost
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
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 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
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.tanh
|
||||
|
||||
@Composable
|
||||
fun StyledIconButton(
|
||||
modifier: Modifier = Modifier,
|
||||
icon: String,
|
||||
iconTint: Color = Color.Unspecified,
|
||||
surfaceColor: Color = Color.Unspecified,
|
||||
backdrop: LayerBackdrop = rememberLayerBackdrop(),
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Normal
|
||||
) {
|
||||
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(
|
||||
"""
|
||||
uniform float2 size;
|
||||
layout(color) uniform half4 color;
|
||||
uniform float radius;
|
||||
uniform float2 offset;
|
||||
|
||||
half4 main(float2 coord) {
|
||||
float2 center = offset;
|
||||
float dist = distance(coord, center);
|
||||
float intensity = smoothstep(radius, radius * 0.5, dist);
|
||||
return color * intensity;
|
||||
}"""
|
||||
)
|
||||
} 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))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
-178
@@ -1,178 +0,0 @@
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.animation.core.animateDp
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
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.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.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
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
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
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.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
|
||||
|
||||
@Composable
|
||||
fun StyledInputField(
|
||||
inputState: TextFieldState,
|
||||
focusRequester: FocusRequester,
|
||||
placeholder: String = "",
|
||||
singleLine: Boolean = true,
|
||||
forceApple: Boolean = false
|
||||
) {
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material && !forceApple
|
||||
|
||||
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()
|
||||
.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"
|
||||
)
|
||||
|
||||
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()
|
||||
.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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-409
@@ -1,409 +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.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-295
@@ -1,295 +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.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
|
||||
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.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
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
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.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.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,
|
||||
showBackButton: Boolean = false,
|
||||
onNavigateBack: () -> Unit = {},
|
||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val hazeState = rememberHazeState(blurEnabled = true)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-808
@@ -1,808 +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 android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
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.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
|
||||
import androidx.compose.runtime.getValue
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
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.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
import androidx.compose.ui.util.fastRoundToInt
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.kyant.backdrop.Backdrop
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.blur
|
||||
import com.kyant.backdrop.effects.lens
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.kyant.backdrop.shadow.InnerShadow
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun rememberMomentumAnimation(
|
||||
maxScale: Float,
|
||||
progressAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(1f, 1000f, 0.01f),
|
||||
velocityAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(0.5f, 250f, 5f),
|
||||
scaleXAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(0.4f, 400f, 0.01f),
|
||||
scaleYAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(0.6f, 400f, 0.01f)
|
||||
): MomentumAnimation {
|
||||
val animationScope = rememberCoroutineScope()
|
||||
return remember(
|
||||
maxScale,
|
||||
animationScope,
|
||||
progressAnimationSpec,
|
||||
velocityAnimationSpec,
|
||||
scaleXAnimationSpec,
|
||||
scaleYAnimationSpec
|
||||
) {
|
||||
MomentumAnimation(
|
||||
maxScale = maxScale,
|
||||
animationScope = animationScope,
|
||||
progressAnimationSpec = progressAnimationSpec,
|
||||
velocityAnimationSpec = velocityAnimationSpec,
|
||||
scaleXAnimationSpec = scaleXAnimationSpec,
|
||||
scaleYAnimationSpec = scaleYAnimationSpec
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class MomentumAnimation(
|
||||
val maxScale: Float,
|
||||
private val animationScope: CoroutineScope,
|
||||
private val progressAnimationSpec: FiniteAnimationSpec<Float>,
|
||||
private val velocityAnimationSpec: FiniteAnimationSpec<Float>,
|
||||
private val scaleXAnimationSpec: FiniteAnimationSpec<Float>,
|
||||
private val scaleYAnimationSpec: FiniteAnimationSpec<Float>
|
||||
) {
|
||||
|
||||
private val velocityTracker = VelocityTracker()
|
||||
|
||||
private val progressAnimation = Animatable(0f)
|
||||
private val velocityAnimation = Animatable(0f)
|
||||
private val scaleXAnimation = Animatable(1f)
|
||||
private val scaleYAnimation = Animatable(1f)
|
||||
|
||||
val progress: Float get() = progressAnimation.value
|
||||
val velocity: Float get() = velocityAnimation.value
|
||||
val scaleX: Float get() = scaleXAnimation.value
|
||||
val scaleY: Float get() = scaleYAnimation.value
|
||||
|
||||
var isDragging: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val modifier: Modifier = Modifier.pointerInput(Unit) {
|
||||
inspectDragGestures(
|
||||
onDragStart = {
|
||||
isDragging = true
|
||||
velocityTracker.resetTracking()
|
||||
startPressingAnimation()
|
||||
},
|
||||
onDragEnd = { change ->
|
||||
isDragging = false
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
updateVelocity(velocity)
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
velocityTracker.resetTracking()
|
||||
endPressingAnimation()
|
||||
settleVelocity()
|
||||
},
|
||||
onDragCancel = {
|
||||
isDragging = false
|
||||
velocityTracker.resetTracking()
|
||||
endPressingAnimation()
|
||||
settleVelocity()
|
||||
}
|
||||
) { change, _ ->
|
||||
isDragging = true
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
updateVelocity(velocity)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateVelocity(velocity: Velocity) {
|
||||
animationScope.launch { velocityAnimation.animateTo(velocity.x, velocityAnimationSpec) }
|
||||
}
|
||||
|
||||
private fun settleVelocity() {
|
||||
animationScope.launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) }
|
||||
}
|
||||
|
||||
fun startPressingAnimation() {
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
||||
launch { scaleXAnimation.animateTo(maxScale, scaleXAnimationSpec) }
|
||||
launch { scaleYAnimation.animateTo(maxScale, scaleYAnimationSpec) }
|
||||
}
|
||||
}
|
||||
|
||||
fun endPressingAnimation() {
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch { scaleXAnimation.animateTo(1f, scaleXAnimationSpec) }
|
||||
launch { scaleYAnimation.animateTo(1f, scaleYAnimationSpec) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnrememberedMutableState")
|
||||
@Composable
|
||||
fun StyledSlider(
|
||||
label: String? = null,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
backdrop: Backdrop = rememberLayerBackdrop(),
|
||||
snapPoints: List<Float> = emptyList(),
|
||||
snapThreshold: Float = 0.05f,
|
||||
startIcon: String? = null,
|
||||
endIcon: String? = null,
|
||||
startLabel: String? = null,
|
||||
endLabel: String? = null,
|
||||
independent: Boolean = false,
|
||||
description: String? = null,
|
||||
enabled: Boolean = true,
|
||||
index: Int = 0,
|
||||
count: Int = 1
|
||||
) {
|
||||
when (LocalDesignSystem.current) {
|
||||
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 {
|
||||
label?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.labelSmallEmphasized,
|
||||
modifier = Modifier
|
||||
.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()
|
||||
) {
|
||||
description?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
if (startLabel != null || endLabel != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.05f): Float {
|
||||
val nearest = points.minByOrNull { abs(it - value) } ?: value
|
||||
return if (abs(nearest - value) <= threshold) nearest else value
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, wallpaper = GREEN_DOMINATED_EXAMPLE)
|
||||
@Composable
|
||||
fun StyledSliderPreview() {
|
||||
val a = remember { mutableFloatStateOf(0.5f) }
|
||||
LibrePodsTheme(
|
||||
m3eEnabled = true
|
||||
) {
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-387
@@ -1,387 +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.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.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
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
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
|
||||
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.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
|
||||
fun StyledToggle(
|
||||
title: String? = null,
|
||||
label: String,
|
||||
description: String? = null,
|
||||
checked: Boolean = false,
|
||||
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)
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
|
||||
if (m3eEnabled) {
|
||||
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(
|
||||
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
|
||||
)
|
||||
},
|
||||
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
|
||||
)
|
||||
}
|
||||
},
|
||||
content = {
|
||||
Text(
|
||||
text = label,
|
||||
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
|
||||
)
|
||||
},
|
||||
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) }
|
||||
Column {
|
||||
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(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = textColor,
|
||||
)
|
||||
|
||||
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(name = "Normal", group = "Apple")
|
||||
@Composable
|
||||
fun StyledToggleApplePreview() {
|
||||
val checked = remember { mutableStateOf(false) }
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
-324
@@ -1,324 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
-158
@@ -1,158 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
-585
@@ -1,585 +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.screens
|
||||
|
||||
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
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.foundation.rememberScrollState
|
||||
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.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
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.StyledButton
|
||||
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
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class, FlowPreview::class)
|
||||
@Composable
|
||||
fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit, navigateToTransparencyCustomization: () -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val hearingAidEnabled =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
|
||||
1
|
||||
)
|
||||
?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
|
||||
0
|
||||
)?.toInt() == 1
|
||||
|
||||
|
||||
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(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 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)
|
||||
)
|
||||
|
||||
val selectedPressAndHoldDurationValue =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(
|
||||
0
|
||||
)
|
||||
var selectedPressAndHoldDuration by remember {
|
||||
mutableStateOf(
|
||||
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
|
||||
?: pressAndHoldDurationOptions[0]
|
||||
)
|
||||
}
|
||||
|
||||
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]
|
||||
)
|
||||
}
|
||||
|
||||
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}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = value
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
viewModel.setControlCommandByte(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
|
||||
value = value
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
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
|
||||
}
|
||||
.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
|
||||
)
|
||||
},
|
||||
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
|
||||
|
||||
viewModel.setControlCommandByte(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
||||
value = value
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if (!hearingAidEnabled && XposedState.isAvailable) {
|
||||
// Text(
|
||||
// text = stringResource(R.string.apply_eq_to), style = TextStyle(
|
||||
// fontSize = 14.sp,
|
||||
// fontWeight = FontWeight.Bold,
|
||||
// color = textColor.copy(alpha = 0.6f),
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
// ), modifier = Modifier.padding(8.dp, bottom = 0.dp)
|
||||
// )
|
||||
// Column(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(
|
||||
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
|
||||
// RoundedCornerShape(28.dp)
|
||||
// )
|
||||
// .padding(vertical = 0.dp)
|
||||
// ) {
|
||||
// val darkModeLocal = isSystemInDarkTheme()
|
||||
//
|
||||
// val phoneShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
||||
// var phoneBackgroundColor by remember {
|
||||
// mutableStateOf(
|
||||
// if (darkModeLocal) Color(
|
||||
// 0xFF1C1C1E
|
||||
// ) else Color(0xFFFFFFFF)
|
||||
// )
|
||||
// }
|
||||
// val phoneAnimatedBackgroundColor by animateColorAsState(
|
||||
// targetValue = phoneBackgroundColor,
|
||||
// animationSpec = tween(durationMillis = 500)
|
||||
// )
|
||||
//
|
||||
// Row(
|
||||
// modifier = Modifier
|
||||
// .height(48.dp)
|
||||
// .fillMaxWidth()
|
||||
// .background(phoneAnimatedBackgroundColor, phoneShape)
|
||||
// .pointerInput(Unit) {
|
||||
// detectTapGestures(
|
||||
// onPress = {
|
||||
// phoneBackgroundColor =
|
||||
// if (darkModeLocal) Color(0x40888888) else Color(
|
||||
// 0x40D9D9D9
|
||||
// )
|
||||
// tryAwaitRelease()
|
||||
// phoneBackgroundColor =
|
||||
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(
|
||||
// 0xFFFFFFFF
|
||||
// )
|
||||
// phoneEQEnabled.value = !phoneEQEnabled.value
|
||||
// })
|
||||
// }
|
||||
// .padding(horizontal = 16.dp),
|
||||
// verticalAlignment = Alignment.CenterVertically) {
|
||||
// Text(
|
||||
// stringResource(R.string.phone),
|
||||
// fontSize = 16.sp,
|
||||
// color = textColor,
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// modifier = Modifier.weight(1f)
|
||||
// )
|
||||
// Checkbox(
|
||||
// checked = phoneEQEnabled.value,
|
||||
// onCheckedChange = { phoneEQEnabled.value = it },
|
||||
// colors = CheckboxDefaults.colors().copy(
|
||||
// checkedCheckmarkColor = Color(0xFF007AFF),
|
||||
// uncheckedCheckmarkColor = Color.Transparent,
|
||||
// checkedBoxColor = Color.Transparent,
|
||||
// uncheckedBoxColor = Color.Transparent,
|
||||
// checkedBorderColor = Color.Transparent,
|
||||
// uncheckedBorderColor = Color.Transparent
|
||||
// ),
|
||||
// modifier = Modifier
|
||||
// .height(24.dp)
|
||||
// .scale(1.5f)
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// HorizontalDivider(
|
||||
// thickness = 1.dp, color = Color(0x40888888)
|
||||
// )
|
||||
//
|
||||
// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
||||
// var mediaBackgroundColor by remember {
|
||||
// mutableStateOf(
|
||||
// if (darkModeLocal) Color(
|
||||
// 0xFF1C1C1E
|
||||
// ) else Color(0xFFFFFFFF)
|
||||
// )
|
||||
// }
|
||||
// val mediaAnimatedBackgroundColor by animateColorAsState(
|
||||
// targetValue = mediaBackgroundColor,
|
||||
// animationSpec = tween(durationMillis = 500)
|
||||
// )
|
||||
//
|
||||
// Row(
|
||||
// modifier = Modifier
|
||||
// .height(48.dp)
|
||||
// .fillMaxWidth()
|
||||
// .background(mediaAnimatedBackgroundColor, mediaShape)
|
||||
// .pointerInput(Unit) {
|
||||
// detectTapGestures(
|
||||
// onPress = {
|
||||
// mediaBackgroundColor =
|
||||
// if (darkModeLocal) Color(0x40888888) else Color(
|
||||
// 0x40D9D9D9
|
||||
// )
|
||||
// tryAwaitRelease()
|
||||
// mediaBackgroundColor =
|
||||
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(
|
||||
// 0xFFFFFFFF
|
||||
// )
|
||||
// mediaEQEnabled.value = !mediaEQEnabled.value
|
||||
// })
|
||||
// }
|
||||
// .padding(horizontal = 16.dp),
|
||||
// verticalAlignment = Alignment.CenterVertically) {
|
||||
// Text(
|
||||
// stringResource(R.string.media),
|
||||
// fontSize = 16.sp,
|
||||
// color = textColor,
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// modifier = Modifier.weight(1f)
|
||||
// )
|
||||
// Checkbox(
|
||||
// checked = mediaEQEnabled.value,
|
||||
// onCheckedChange = { mediaEQEnabled.value = it },
|
||||
// colors = CheckboxDefaults.colors().copy(
|
||||
// checkedCheckmarkColor = Color(0xFF007AFF),
|
||||
// uncheckedCheckmarkColor = Color.Transparent,
|
||||
// checkedBoxColor = Color.Transparent,
|
||||
// uncheckedBoxColor = Color.Transparent,
|
||||
// checkedBorderColor = Color.Transparent,
|
||||
// uncheckedBorderColor = Color.Transparent
|
||||
// ),
|
||||
// modifier = Modifier
|
||||
// .height(24.dp)
|
||||
// .scale(1.5f)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//// EQ Settings. Don't seem to have an effect?
|
||||
// Column(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(
|
||||
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
|
||||
// RoundedCornerShape(28.dp)
|
||||
// )
|
||||
// .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally
|
||||
// ) {
|
||||
// val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
||||
// val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
// val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
||||
//
|
||||
// for (i in 0 until 8) {
|
||||
// val eqPhoneValue =
|
||||
// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
|
||||
// Row(
|
||||
// horizontalArrangement = Arrangement.SpaceBetween,
|
||||
// verticalAlignment = Alignment.CenterVertically,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(38.dp)
|
||||
// ) {
|
||||
// Text(
|
||||
// text = String.format("%.2f", eqPhoneValue.floatValue),
|
||||
// fontSize = 12.sp,
|
||||
// color = textColor,
|
||||
// modifier = Modifier.padding(bottom = 4.dp)
|
||||
// )
|
||||
//
|
||||
// Slider(
|
||||
// value = eqPhoneValue.floatValue,
|
||||
// onValueChange = { newVal ->
|
||||
// eqPhoneValue.floatValue = newVal
|
||||
// val newEQ = phoneMediaEQ.value.copyOf()
|
||||
// newEQ[i] = eqPhoneValue.floatValue
|
||||
// phoneMediaEQ.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 = {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(12.dp),
|
||||
// contentAlignment = Alignment.CenterStart
|
||||
// ) {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(4.dp)
|
||||
// .background(trackColor, RoundedCornerShape(4.dp))
|
||||
// )
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth(eqPhoneValue.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)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
-122
@@ -1,122 +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.screens
|
||||
|
||||
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
|
||||
import androidx.compose.runtime.getValue
|
||||
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.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 kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
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, navigateToPurchase: () -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
-1022
File diff suppressed because it is too large
Load Diff
-613
@@ -1,613 +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.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
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.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
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.StyledBottomSheet
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledInputField
|
||||
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.theme.MaterialTypography
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||
import me.kavishdevar.librepods.utils.XposedState
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppSettingsScreen(
|
||||
viewModel: AppSettingsViewModel = viewModel(),
|
||||
navigateToPurchase: () -> Unit,
|
||||
navigateToTroubleshooting: () -> Unit,
|
||||
navigateToOpenSourceLicenses: () -> Unit,
|
||||
navigateToReleaseNotesScreen: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
val contactBottomSheet = remember { mutableStateOf(false) }
|
||||
val subjectState = remember { TextFieldState() }
|
||||
val descriptionState = remember { TextFieldState() }
|
||||
val subjectFocusRequester = remember { FocusRequester() }
|
||||
val descriptionFocusRequester = remember { FocusRequester() }
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 16.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)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
if (!state.isPremium && state.connectionSuccessful) {
|
||||
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.timeUntilFOSSPremiumExpiry > 0L) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(Color(0xFF32829B), RoundedCornerShape(28.dp))
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.clickable {
|
||||
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
|
||||
data = "mailto:".toUri()
|
||||
putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
|
||||
putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
"Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
|
||||
)
|
||||
}
|
||||
context.startActivity(emailIntent)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt())
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(16.dp),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.appearance),
|
||||
label = stringResource(R.string.use_material3e),
|
||||
checked = state.m3eEnabled,
|
||||
onCheckedChange = viewModel::setm3eEnabled,
|
||||
// enabled = state.isPremium
|
||||
)
|
||||
|
||||
if (state.connectionSuccessful) {
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.widget),
|
||||
label = stringResource(R.string.show_phone_battery_in_widget),
|
||||
description = stringResource(R.string.show_phone_battery_in_widget_description),
|
||||
checked = state.showPhoneBatteryInWidget,
|
||||
onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
StyledList(title = stringResource(R.string.popup_animations)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.show_bottom_sheet_popup),
|
||||
description = stringResource(R.string.show_bottom_sheet_popup_description),
|
||||
checked = state.showBottomSheetPopup,
|
||||
onCheckedChange = viewModel::setShowBottomSheetPopup,
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.show_island_popup),
|
||||
description = stringResource(R.string.show_island_popup_description),
|
||||
checked = state.showIslandPopup,
|
||||
onCheckedChange = viewModel::setShowIslandPopup,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
StyledList (title = stringResource(R.string.conversational_awareness)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversational_awareness_pause_music),
|
||||
description = stringResource(R.string.conversational_awareness_pause_music_description),
|
||||
checked = state.conversationalAwarenessPauseMusicEnabled,
|
||||
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.relative_conversational_awareness_volume),
|
||||
description = stringResource(R.string.relative_conversational_awareness_volume_description),
|
||||
checked = state.relativeConversationalAwarenessVolumeEnabled,
|
||||
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
|
||||
enabled = state.isPremium,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val conversationalAwarenessVolume = state.conversationalAwarenessVolume
|
||||
LaunchedEffect(conversationalAwarenessVolume) {
|
||||
viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
|
||||
}
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.conversational_awareness_volume),
|
||||
value = conversationalAwarenessVolume,
|
||||
valueRange = 10f..85f,
|
||||
snapPoints = listOf(44f),
|
||||
startLabel = "10%",
|
||||
endLabel = "85%",
|
||||
onValueChange = { newValue ->
|
||||
viewModel.setConversationalAwarenessVolume(
|
||||
newValue
|
||||
)
|
||||
},
|
||||
independent = true,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
// if (!BuildConfig.PLAY_BUILD) {
|
||||
// Spacer(modifier = Modifier.height(16.dp))
|
||||
//
|
||||
// StyledListItem(
|
||||
// to = "",
|
||||
// titleRes = stringResource(R.string.camera_control),
|
||||
// name = stringResource(R.string.set_custom_camera_package),
|
||||
// navController = navController,
|
||||
// onClick = {
|
||||
// if (state.isPremium) viewModel.setShowCameraDialog(true)
|
||||
// },
|
||||
// independent = true,
|
||||
// descriptionRes = stringResource(R.string.camera_control_app_description)
|
||||
// )
|
||||
// }
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.ear_detection),
|
||||
label = stringResource(R.string.disconnect_when_not_wearing),
|
||||
description = stringResource(R.string.disconnect_when_not_wearing_description),
|
||||
checked = state.disconnectWhenNotWearing,
|
||||
onCheckedChange = viewModel::setDisconnectWhenNotWearing,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
StyledList(title = stringResource(R.string.takeover_airpods_state)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_disconnected),
|
||||
description = stringResource(R.string.takeover_disconnected_desc),
|
||||
checked = state.takeoverWhenDisconnected,
|
||||
onCheckedChange = viewModel::setTakeoverWhenDisconnected,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_idle),
|
||||
description = stringResource(R.string.takeover_idle_desc),
|
||||
checked = state.takeoverWhenIdle,
|
||||
onCheckedChange = viewModel::setTakeoverWhenIdle,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_music),
|
||||
description = stringResource(R.string.takeover_music_desc),
|
||||
checked = state.takeoverWhenMusic,
|
||||
onCheckedChange = viewModel::setTakeoverWhenMusic,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_call),
|
||||
description = stringResource(R.string.takeover_call_desc),
|
||||
checked = state.takeoverWhenCall,
|
||||
onCheckedChange = viewModel::setTakeoverWhenCall,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
StyledList(title = stringResource(R.string.takeover_phone_state)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_ringing_call),
|
||||
description = stringResource(R.string.takeover_ringing_call_desc),
|
||||
checked = state.takeoverWhenRingingCall,
|
||||
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_media_start),
|
||||
description = stringResource(R.string.takeover_media_start_desc),
|
||||
checked = state.takeoverWhenMediaStart,
|
||||
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.advanced_options), // shouldn't be here, but okay
|
||||
label = stringResource(R.string.use_alternate_head_tracking_packets),
|
||||
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
|
||||
checked = state.useAlternateHeadTrackingPackets,
|
||||
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 16.dp, bottom = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.customizations_unavailable),
|
||||
style = MaterialTypography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
|
||||
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.act_as_an_apple_device) + " (${
|
||||
stringResource(
|
||||
R.string.requires_xposed
|
||||
)
|
||||
})",
|
||||
description = stringResource(R.string.act_as_an_apple_device_description),
|
||||
checked = state.vendorIdHook,
|
||||
onCheckedChange = { enabled ->
|
||||
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
|
||||
viewModel.setVendorIdHook(enabled)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!BuildConfig.PLAY_BUILD) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
StyledList {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.troubleshooting),
|
||||
onClick = navigateToTroubleshooting,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
StyledList(title = stringResource(R.string.contact)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.email),
|
||||
onClick = { contactBottomSheet.value = true },
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.discord),
|
||||
onClick = {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
|
||||
context.startActivity(intent)
|
||||
},
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.github_issues),
|
||||
onClick = {
|
||||
val appVersion =
|
||||
Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||
val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})")
|
||||
val appSource = Uri.encode(
|
||||
when {
|
||||
BuildConfig.PLAY_BUILD -> "Play"
|
||||
else -> "GitHub"
|
||||
}
|
||||
)
|
||||
val url = "https://github.com/kavishdevar/librepods/issues/new" +
|
||||
"?template=01-bug-report-android.yml" +
|
||||
"&app-source=$appSource" +
|
||||
"&app-version=$appVersion" +
|
||||
"&device=$device" +
|
||||
"&android-version=$androidVersion"
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
context.startActivity(intent)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
DeviceInfoCard()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AppInfoCard(navigateToReleaseNotesScreen)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.open_source_licenses),
|
||||
onClick = navigateToOpenSourceLicenses,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
|
||||
if (state.showCameraDialog) {
|
||||
AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
|
||||
Text(
|
||||
stringResource(R.string.set_custom_camera_package),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}, text = {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.enter_custom_camera_package),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.cameraPackageValue,
|
||||
onValueChange = {
|
||||
viewModel.setCameraPackageValue(it)
|
||||
viewModel.setCameraPackageError(null)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = state.cameraPackageError != null,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
capitalization = KeyboardCapitalization.None
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(
|
||||
0xFF3C6DF5
|
||||
),
|
||||
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
||||
),
|
||||
supportingText = {
|
||||
if (state.cameraPackageError != null) {
|
||||
Text(
|
||||
state.cameraPackageError ?: "",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.custom_camera_package)) })
|
||||
}
|
||||
}, confirmButton = {
|
||||
val successText = stringResource(R.string.custom_camera_package_set_success)
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.saveCameraPackage()
|
||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
||||
}) {
|
||||
Text(
|
||||
"Save",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}, dismissButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.setShowCameraDialog(false) }) {
|
||||
Text(
|
||||
"Cancel",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
StyledBottomSheet(
|
||||
visible = contactBottomSheet.value,
|
||||
onDismiss = { contactBottomSheet.value = false },
|
||||
backdrop = backdrop
|
||||
) { innerBackdrop, progress ->
|
||||
val animatedPadding = lerp(16.dp, 2.dp, progress)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = animatedPadding)
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
StyledIconButton(
|
||||
icon = "\uDBC0\uDD84",
|
||||
backdrop = innerBackdrop,
|
||||
onClick = { contactBottomSheet.value = false }
|
||||
)
|
||||
Text (
|
||||
text = stringResource(R.string.describe_your_issue),
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
)
|
||||
)
|
||||
StyledIconButton(
|
||||
icon = "\uDBC0\uDE1F",
|
||||
backdrop = innerBackdrop,
|
||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF),
|
||||
iconTint = if (subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty()) Color.White else Color.Gray,
|
||||
enabled = subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty(),
|
||||
onClick = {
|
||||
contactBottomSheet.value = false
|
||||
val intent = Intent(Intent.ACTION_SENDTO).apply {
|
||||
data = "mailto:".toUri()
|
||||
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
|
||||
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ${subjectState.text}")
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
"${descriptionState.text}" +
|
||||
"\n\n----------" +
|
||||
"\nPhone details:" +
|
||||
"\nMANUFACTURER: ${Build.MANUFACTURER}" +
|
||||
"\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
|
||||
"\nDISPLAY_VERSION: ${Build.DISPLAY}" +
|
||||
"\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" +
|
||||
"\nXposed enabled/active: ${XposedState.isAvailable}/${XposedState.bluetoothScopeEnabled}" +
|
||||
"\n\nApp details:" +
|
||||
"\nVERSION: ${BuildConfig.VERSION_NAME}" +
|
||||
"\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
|
||||
"\nFLAVOR: ${BuildConfig.FLAVOR}" +
|
||||
"\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}"
|
||||
)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
subjectState.clearText()
|
||||
descriptionState.clearText()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
StyledInputField(
|
||||
inputState = subjectState,
|
||||
focusRequester = subjectFocusRequester,
|
||||
placeholder = stringResource(R.string.subject),
|
||||
forceApple = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
StyledInputField(
|
||||
inputState = descriptionState,
|
||||
focusRequester = descriptionFocusRequester,
|
||||
placeholder = stringResource(R.string.describe_your_issue),
|
||||
singleLine = false,
|
||||
forceApple = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
-81
@@ -1,81 +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.screens
|
||||
|
||||
//@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)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
-762
@@ -1,762 +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.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))}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-405
@@ -1,405 +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/>.
|
||||
*/
|
||||
|
||||
|
||||
// this is absolutely unnecessary, why did I make this. a simple toggle would've sufficed
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import android.graphics.Paint
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
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.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
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
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.graphics.nativeCanvas
|
||||
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 com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
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.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 kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.startHeadTracking()
|
||||
onDispose {
|
||||
viewModel.stopHeadTracking()
|
||||
}
|
||||
}
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
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
|
||||
|
||||
var gestureText by remember { mutableStateOf("") }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var lastClickTime by remember { mutableLongStateOf(0L) }
|
||||
var shouldExplode by remember { mutableStateOf(false) }
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(scrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
.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
|
||||
)
|
||||
} 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 Plot() {
|
||||
val acceleration by HeadTracking.acceleration.collectAsState()
|
||||
val maxPoints = 100
|
||||
val points = remember { mutableStateListOf<Pair<Float, Float>>() }
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
|
||||
var maxAbs by remember { mutableFloatStateOf(1000f) }
|
||||
|
||||
LaunchedEffect(acceleration) {
|
||||
points.add(Pair(acceleration.horizontal, acceleration.vertical))
|
||||
if (points.size > maxPoints) {
|
||||
points.removeAt(0)
|
||||
}
|
||||
|
||||
val currentMax = points.maxOf { maxOf(abs(it.first), abs(it.second)) }
|
||||
maxAbs = maxOf(currentMax * 1.2f, 1000f)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
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()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val xScale = width / maxPoints
|
||||
val yScale = (height - 40.dp.toPx()) / (maxAbs * 2)
|
||||
val zeroY = height / 2
|
||||
|
||||
val gridColor = if (darkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f)
|
||||
|
||||
for (i in 0..maxPoints step 10) {
|
||||
val x = i * xScale
|
||||
drawLine(
|
||||
color = gridColor,
|
||||
start = Offset(x, 0f),
|
||||
end = Offset(x, height),
|
||||
strokeWidth = 1.dp.toPx()
|
||||
)
|
||||
}
|
||||
|
||||
val gridStep = maxAbs / 4
|
||||
for (value in (-maxAbs.toInt()..maxAbs.toInt()) step gridStep.toInt()) {
|
||||
val y = zeroY - value * yScale
|
||||
drawLine(
|
||||
color = gridColor,
|
||||
start = Offset(0f, y),
|
||||
end = Offset(width, y),
|
||||
strokeWidth = 1.dp.toPx()
|
||||
)
|
||||
}
|
||||
|
||||
drawLine(
|
||||
color = if (darkTheme) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.3f),
|
||||
start = Offset(0f, zeroY),
|
||||
end = Offset(width, zeroY),
|
||||
strokeWidth = 1.5f.dp.toPx()
|
||||
)
|
||||
|
||||
if (points.size > 1) {
|
||||
for (i in 0 until points.size - 1) {
|
||||
val x1 = i * xScale
|
||||
val x2 = (i + 1) * xScale
|
||||
|
||||
drawLine(
|
||||
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 = verticalColor,
|
||||
start = Offset(x1, zeroY - points[i].second * yScale),
|
||||
end = Offset(x2, zeroY - points[i + 1].second * yScale),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
|
||||
drawText("0", 30.dp.toPx(), height/2, paint)
|
||||
drawText("-${maxAbs.toInt()}", 30.dp.toPx(), height - 10.dp.toPx(), paint)
|
||||
}
|
||||
|
||||
val legendY = 15.dp.toPx()
|
||||
val textOffsetY = legendY + 5.dp.toPx() / 2
|
||||
|
||||
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
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = Paint.Align.LEFT
|
||||
}
|
||||
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
|
||||
}
|
||||
|
||||
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
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = Paint.Align.LEFT
|
||||
}
|
||||
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-225
@@ -1,225 +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.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
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.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
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 dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Job
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
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 kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private const val TAG = "HearingAidAdjustments"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val debounceJob = remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
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 initialized = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
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
|
||||
)
|
||||
) }
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
-243
@@ -1,243 +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.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
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.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.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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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 com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
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.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
|
||||
|
||||
private const val TAG = "AccessibilitySettings"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) {
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
val initialLoad = remember { mutableStateOf(true) }
|
||||
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val hearingAidEnabled = remember {
|
||||
val aidStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]
|
||||
val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG]
|
||||
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// fun onAdjustPhoneChange(value: Boolean) {
|
||||
// // TODO
|
||||
// }
|
||||
|
||||
// fun onAdjustMediaChange(value: Boolean) {
|
||||
// // TODO
|
||||
// }
|
||||
|
||||
StyledList (title = stringResource(R.string.hearing_aid)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.hearing_aid),
|
||||
checked = hearingAidEnabled.value,
|
||||
onCheckedChange = { hearingAidEnabled.value = it },
|
||||
)
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.adjustments),
|
||||
onClick = onNavigateHearingAidAdjustments,
|
||||
)
|
||||
}
|
||||
|
||||
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",
|
||||
message = "Enabling Hearing Aid will disable Headphone Accommodation and Customized Transparency Mode.",
|
||||
confirmText = "Enable",
|
||||
dismissText = "Cancel",
|
||||
onConfirm = {
|
||||
showDialog.value = false
|
||||
val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte()
|
||||
if (!enrolled) {
|
||||
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
|
||||
} else {
|
||||
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
|
||||
}
|
||||
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte())
|
||||
hearingAidEnabled.value = true
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
if (state.hearingAidData.isEmpty()) {
|
||||
Log.w(TAG, "read failed")
|
||||
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) {
|
||||
Log.e(TAG, "Error disabling transparency: ${e.message}")
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
hearingAidEnabled.value = false
|
||||
showDialog.value = false
|
||||
},
|
||||
backdrop = backdrop
|
||||
)
|
||||
}
|
||||
-118
@@ -1,118 +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.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.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.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.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
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, navigateToPurchase: () -> Unit) {
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
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
|
||||
|
||||
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.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))
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user