Compare commits

...

48 commits

Author SHA1 Message Date
Dr.Blank
07aea41c6e
fix: update build_runner command to use dart directly instead of fvm
Some checks failed
Flutter CI & Release / Test (push) Has been cancelled
Flutter CI & Release / Build Android APKs (push) Has been cancelled
Flutter CI & Release / build_linux (push) Has been cancelled
Flutter CI & Release / Create GitHub Release (push) Has been cancelled
2025-05-28 12:25:22 +05:30
Dr.Blank
8485a26f1a
chore: update Flutter to version 3.32.0 (#85) 2025-05-28 11:32:07 +05:30
Dr.Blank
19046d92d9
feat: add documentation for testing deeplinks on Linux
Some checks are pending
Flutter CI & Release / Test (push) Waiting to run
Flutter CI & Release / Build Android APKs (push) Blocked by required conditions
Flutter CI & Release / build_linux (push) Blocked by required conditions
Flutter CI & Release / Create GitHub Release (push) Blocked by required conditions
2025-05-27 23:15:52 +05:30
Dr.Blank
4619657f00
feat: deeplinks for linux (#84)
Some checks failed
Flutter CI & Release / Test (push) Has been cancelled
Flutter CI & Release / Build Android APKs (push) Has been cancelled
Flutter CI & Release / build_linux (push) Has been cancelled
Flutter CI & Release / Create GitHub Release (push) Has been cancelled
2025-05-23 21:03:02 +05:30
Dr.Blank
db20682004
feat: Add AppImage build and generic naming for Linux artifacts (#83)
Some checks are pending
Flutter CI & Release / Test (push) Waiting to run
Flutter CI & Release / Build Android APKs (push) Blocked by required conditions
Flutter CI & Release / build_linux (push) Blocked by required conditions
Flutter CI & Release / Create GitHub Release (push) Blocked by required conditions
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-23 10:53:24 +05:30
Dr.Blank
5c7be5cbe4
feat: Add settings to control play button visibility on home shelves (#81)
Some checks failed
Flutter CI & Release / Test (push) Has been cancelled
Flutter CI & Release / Build Android APKs (push) Has been cancelled
Flutter CI & Release / build_linux (push) Has been cancelled
Flutter CI & Release / Create GitHub Release (push) Has been cancelled
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-22 08:24:59 +05:30
Dr.Blank
25c3346941
feat: Replace theme dialog with segmented buttons (#82)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-22 07:26:25 +05:30
github-actions[bot]
23e5d73bea chore(release): bump version to v0.0.18
Some checks failed
Flutter CI & Release / Test (push) Has been cancelled
Flutter CI & Release / Build Android APKs (push) Has been cancelled
Flutter CI & Release / build_linux (push) Has been cancelled
Flutter CI & Release / Create GitHub Release (push) Has been cancelled
2025-04-23 11:01:25 +00:00
Dr.Blank
bae99292a2
feat: add PlayingIndicatorIcon widget for animated playback indication (#80) 2025-04-23 16:23:57 +05:30
Dr.Blank
25be7fda03
fix: keyboard not showing when adding new user (#79)
* feat: add fadeSlideTransitionBuilder for smoother transitions in user login

* fix: reuse onboarding components on server manager page

* fix: gaining focus rebuilt the widget

using memoized fixes this issue
2025-04-23 15:00:01 +05:30
Dr.Blank
c8767b4e1e
feat: enhance UserBar with user API details and improved text styling 2025-04-23 14:51:39 +05:30
Dr.Blank
ad0cd6e2ad
fix: run dart fix
Some checks are pending
Flutter CI & Release / Test (push) Waiting to run
Flutter CI & Release / Build Android APKs (push) Blocked by required conditions
Flutter CI & Release / build_linux (push) Blocked by required conditions
Flutter CI & Release / Create GitHub Release (push) Blocked by required conditions
2025-04-23 00:29:02 +05:30
Dr.Blank
2cb00c451e
feat: implement scroll-to-top FAB and enhance library item app bar with scroll listener 2025-04-23 00:22:32 +05:30
Dr.Blank
c3d3a3900d
refactor: remove transparent background color from app bars
Some checks failed
Flutter CI & Release / Test (push) Has been cancelled
Flutter CI & Release / Build Android APKs (push) Has been cancelled
Flutter CI & Release / build_linux (push) Has been cancelled
Flutter CI & Release / Create GitHub Release (push) Has been cancelled
2025-04-19 19:47:25 +05:30
Dr.Blank
5f85df4d19
add SVG conversion instructions and vaani_logo.svg file 2025-04-19 19:27:15 +05:30
Dr.Blank
5986482baf
feat: ability to change library (#77)
* feat: add AbsIcons font and update pubspec.yaml for font integration

* feat: implement library selection in YouPage

* fix: optimize authenticatedApi provider to not rebuild unnecessarily

* feat: add LibrarySwitchChip widget and integrate it into YouPage and ScaffoldWithNavBar

* feat: enhance library selection UI with refresh functionality and error handling

* fix: change library switcher activation from long press to double tap

* feat: show current library on nav bar

* feat: refactor LibraryBrowserPage to use CustomScrollView and enhance app bar with dynamic library icon and title
2025-04-19 19:17:31 +05:30
Dr.Blank
37c44f1c6b
add more ways to get app
Some checks failed
Flutter CI & Release / Test (push) Has been cancelled
Flutter CI & Release / Build Android APKs (push) Has been cancelled
Flutter CI & Release / build_linux (push) Has been cancelled
Flutter CI & Release / Create GitHub Release (push) Has been cancelled
2025-04-11 21:00:50 +05:30
github-actions[bot]
b0ea9e14d2 chore(release): bump version to v0.0.17
Some checks are pending
Flutter CI & Release / Test (push) Waiting to run
Flutter CI & Release / Build Android APKs (push) Blocked by required conditions
Flutter CI & Release / build_linux (push) Blocked by required conditions
Flutter CI & Release / Create GitHub Release (push) Blocked by required conditions
2025-04-11 06:49:50 +00:00
Dr.Blank
de7c3359f7
ci: use PAT instead of github token 2025-04-11 12:13:08 +05:30
Dr.Blank
39d051746b
update label regex 2025-04-10 19:46:57 +05:30
Dr.Blank
4ebf46d2fd
chore: remove deprecated apis (#74)
* dart format + remove withopacity

* remove unused imports
2025-04-10 19:42:08 +05:30
Dr.Blank
4af16ac5b4
ci: add format check for lib/ 2025-04-10 19:38:18 +05:30
Dr.Blank
28ceca5408
fix: bug login not shown for some languages (#73)
* fix language preventing logging in

* make eye blink once
2025-04-10 19:12:20 +05:30
Dr.Blank
4663ff9094
feat: add Linux packaging support (#70)
Some checks failed
Flutter CI & Release / Test (push) Has been cancelled
Flutter CI & Release / Build Android APKs (push) Has been cancelled
Flutter CI & Release / build_linux (push) Has been cancelled
Flutter CI & Release / Create GitHub Release (push) Has been cancelled
* Refactor CI workflow and add Linux packaging support; update app title to "Vaani"

* Refactor CI workflow to separate setup, testing, and building steps; add Linux AppImage packaging support

* use reusable workflow

* Make Flutter version input optional in setup-env action and rename step in workflow

* Replace setup-env action with reusable flutter-setup workflow; streamline CI configuration and enhance dependency management

* Add Flutter setup composite action for streamlined environment configuration

* Move repository checkout step to the main workflow for better control and clarity in the CI process

* Remove unnecessary shell specification for Flutter dependency setup to simplify action configuration

* Add shell specification for Flutter dependency command to enhance cross-platform compatibility

* Comment out static analysis step in Flutter test workflow to streamline CI process

* Add repository checkout and Flutter environment setup steps to CI workflow

* Add installation of Linux dependencies for Flutter test workflow

* Remove obsolete Flutter setup and release workflows to streamline CI configuration

* Fix formatting in make_config.yaml by ensuring newline at end of file
2025-04-05 10:31:07 +05:30
Dr.Blank
412c212118
bump version
Some checks failed
Flutter Test / test (push) Has been cancelled
2025-04-04 14:28:39 +05:30
Dr.Blank
edf7b2790f
upgrade to flutter 3.29.2
Some checks are pending
Flutter Test / test (push) Waiting to run
2025-04-03 23:07:00 +05:30
Dr.Blank
2fd4650bb8
upgrade to flutter 3.27.4 2025-03-25 22:01:16 +05:30
Dr.Blank
e7946feca1
Update full_description.txt
Co-Authored-By: Izzy <6781438+izzysoft@users.noreply.github.com>
2024-12-21 07:53:59 +05:30
Dr.Blank
997d3eb5e4
fix flutter version for reproducible builds 2024-12-08 03:58:03 +05:30
Dr.Blank
77f7a7e3b5
add instructions to add keystore 2024-12-08 03:43:46 +05:30
Dr.Blank
6c50821682
Make shelfsdk a submodule (#66)
* make shelfsdk a submodule

* add contributing.md

* add submodule info

* update ci

* add submodule
2024-12-08 01:52:30 +05:30
Dr.Blank
247413def0
fastlane init (#65) 2024-12-08 01:50:00 +05:30
Dr.Blank
2a715f6fa8
remove manage external storage permission 2024-12-07 21:07:48 +05:30
Dr.Blank
c2cf999398
dart fix 2024-12-07 12:26:59 +05:30
Dr-Blank
3488ae97fb
fix overflow error for onboarding 2024-10-28 05:45:28 +05:30
Dr-Blank
11b768d41c
chore: bump version to 0.0.15+6 in pubspec.yaml 2024-10-11 12:01:14 -04:00
Dr.Blank
630219dfbe
fix: disable dependency metadata in APKs and App Bundles (#57) 2024-10-11 11:25:41 -04:00
Dr-Blank
781e266a5f
docs: update README for clearer instructions on joining Playstore testing 2024-10-07 14:28:51 -04:00
Dr-Blank
5b896e8b09
refactor: fade in images on home screen 2024-10-06 05:52:29 -04:00
Dr.Blank
09eafb2c28
fix: content behind miniplayer not visible (#53) 2024-10-06 05:45:14 -04:00
Dr-Blank
e8903081b7
chore: bump version to 0.0.14+5 in pubspec.yaml 2024-10-06 03:02:44 -04:00
Dr-Blank
747dbdb46f
fix: bad state when reading player for theme and new login 2024-10-05 21:02:14 -04:00
Dr-Blank
e7205ed874
chore: bump version to 0.0.13+4 in pubspec.yaml 2024-10-05 10:05:26 -04:00
Dr.Blank
ff83c2cc63
feat: multiple theming options (#50)
* refactor: consolidate theme definitions by removing separate dark and light theme files

* feat: integrate dynamic color support and enhance theme settings management

* feat: add theme settings route and update theme management in app settings

* feat: enhance theme management by integrating high contrast support in various components

* feat: implement mode selection dialog for theme settings and enhance button functionality

* refactor: update theme import paths and consolidate theme provider files

* feat: enhance theme management by integrating theme selection based on audiobook playback

* refactor: update default value for useMaterialThemeFromSystem to false in theme settings

* refactor: adjust high contrast condition order in theme settings for consistency

* refactor: rename useMaterialThemeOfPlayingItem to useCurrentPlayerThemeThroughoutApp for clarity

* refactor: correct spelling in system theme provider and replace with updated implementation

* refactor: extract restore backup dialog into a separate widget for improved readability

* refactor: reorganize settings sections for clarity and improve restore dialog functionality
2024-10-05 10:01:08 -04:00
Dr-Blank
758e4cdc83
refactor: update personalized view handling, improve GitHub issue link, and adjust VaaniLogo size 2024-10-05 04:48:52 -04:00
Dr-Blank
fa815ae206
refactor: update AuthenticatedUser model to require id and remove password, enhance server URI handling in AddNewServer widget 2024-10-04 02:31:51 -04:00
Dr-Blank
eda45efbce
chore: bump version to 0.0.12+3 in pubspec.yaml 2024-10-03 19:05:29 -04:00
Dr-Blank
33c57da78f
hotfix: add permission handling for external storage and update log file naming 2024-10-03 19:04:36 -04:00
154 changed files with 5367 additions and 1710 deletions

3
.fvmrc Normal file
View file

@ -0,0 +1,3 @@
{
"flutter": "3.32.0"
}

View file

@ -0,0 +1,46 @@
# .github/actions/flutter-setup/action.yml
name: "Flutter Setup Composite Action"
description: "Checks out code, sets up Java/Flutter, caches, and runs pub get"
# Define inputs for customization (optional, but good practice)
inputs:
flutter-channel:
description: "Flutter channel to use (stable, beta, dev, master)"
required: false
default: "stable"
java-version:
description: "Java version to set up"
required: false
default: "17"
runs:
using: "composite" # Specify this is a composite action
steps:
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: ${{ inputs.java-version }}
- name: Set up Flutter SDK
uses: subosito/flutter-action@v2
with:
channel: ${{ inputs.flutter-channel }}
flutter-version-file: pubspec.yaml
cache: true # Cache Flutter SDK itself
- name: Cache Flutter dependencies
id: cache-pub
uses: actions/cache@v4
with:
path: ${{ env.FLUTTER_HOME }}/.pub-cache
key: ${{ runner.os }}-flutter-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-flutter-pub-
- name: Get Flutter dependencies
run: flutter pub get
# Use shell: bash for potential cross-platform compatibility in complex commands
shell: bash
# Add other common setup steps if needed

View file

@ -40,7 +40,7 @@ template: |
**Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION
exclude-labels:
- "skip-changelog"
- "skip changelog"
exclude-contributors:
- "Dr-Blank"
@ -55,15 +55,15 @@ autolabeler:
branch:
- '/feature\/.+/'
title:
- "/feat(ure)?/i"
- "/^feat(ure)?/i"
body:
- "/JIRA-[0-9]{1,4}/"
- label: "chore"
title:
- "/chore/i"
- "/^chore\b/i"
- label: "ui"
title:
- "/^ui\b/i"
- label: "refactor"
title:
- "/refactor/i"
- "/^refactor/i"

218
.github/workflows/flutter-ci.yaml vendored Normal file
View file

@ -0,0 +1,218 @@
name: Flutter CI & Release
on:
push:
branches: [main]
tags: ["v*.*.*"]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Flutter Environment
uses: ./.github/actions/flutter-setup # Path to the composite action directory
# Pass inputs if needed (optional, using defaults here)
# with:
# flutter-channel: 'stable'
# java-version: '17'
# Debug: Echo current directory contents
- name: List root directory contents
run: |
pwd
ls -la
# Debug: Recursive directory structure
- name: Show full directory structure
run: |
echo "Full directory structure:"
tree -L 3
# Debug: Submodule status and details
- name: Check submodule status
run: |
echo "Submodule status:"
git submodule status
echo "\nSubmodule details:"
git submodule foreach 'echo $path: && pwd && ls -la'
# - name: Run static analysis
# run: flutter analyze
- name: Check formatting
run: |
dart format -o none --set-exit-if-changed lib/
- name: Run tests
run: flutter test
build_android:
name: Build Android APKs
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Flutter Environment
uses: ./.github/actions/flutter-setup # Path to the composite action directory
with:
flutter-channel: stable
java-version: 17
- name: Accept Android SDK Licenses
run: |
yes | sudo $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses
- name: Decode android/upload.jks
run: echo "${{ secrets.UPLOAD_KEYSTORE_JKS }}" | base64 --decode > android/upload.jks
- name: Decode android/key.properties
run: echo "${{ secrets.KEY_PROPERTIES }}" | base64 --decode > android/key.properties
- name: Build APKs
run: flutter build apk --release --split-per-abi
- name: Build Universal APK
run: flutter build apk --release
- name: Rename Universal APK
run: mv build/app/outputs/flutter-apk/{app-release,app-universal-release}.apk
- name: Build App Bundle
run: flutter build appbundle --release
- name: Upload Android APK Artifact
uses: actions/upload-artifact@v4
with:
name: android-release-artifacts
path: |
build/app/outputs/flutter-apk/*-release*.apk
build/app/outputs/bundle/release/*.aab
build_linux:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Flutter Environment
uses: ./.github/actions/flutter-setup # Path to the composite action directory
- name: Install Linux dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev locate libfuse2
# Download and install appimagetool
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
sudo mv appimagetool-x86_64.AppImage /usr/local/bin/appimagetool
shell: bash
- name: setup fastforge
run: |
dart pub global activate fastforge
- name: Build Linux AppImage and deb
run: fastforge package --platform linux --targets deb,appimage
- name: Rename Linux Artifacts
run: |
# Find and rename .deb file
DEB_FILE=$(find dist/ -name "*.deb" -type f)
if [ -n "$DEB_FILE" ]; then
mv "$DEB_FILE" dist/vaani-linux-amd64.deb
echo "Renamed DEB: $DEB_FILE to dist/vaani-linux-amd64.deb"
else
echo "Error: .deb file not found in dist/"
exit 1
fi
# Find and rename .AppImage file
APPIMAGE_FILE=$(find dist/ -name "*.AppImage" -type f)
if [ -n "$APPIMAGE_FILE" ]; then
mv "$APPIMAGE_FILE" dist/vaani-linux-amd64.AppImage
echo "Renamed AppImage: $APPIMAGE_FILE to dist/vaani-linux-amd64.AppImage"
else
echo "Error: .AppImage file not found in dist/"
exit 1
fi
shell: bash
- name: Upload Linux Artifacts
uses: actions/upload-artifact@v4
with:
name: linux-release-artifacts
path: |
dist/vaani-linux-amd64.deb
dist/vaani-linux-amd64.AppImage
# Job 4: Create GitHub Release (NEW - runs only on tag pushes)
create_release:
name: Create GitHub Release
needs: [build_android, build_linux] # Depends on successful builds
runs-on: ubuntu-latest
permissions:
contents: write # Need write access to create release
# <<< CONDITION: Only run this job if the trigger was a tag starting with 'v'
if: startsWith(github.ref, 'refs/tags/v')
steps:
# No checkout needed if only downloading artifacts and using context variables
# - name: Checkout repository
# uses: actions/checkout@v4
# Download artifacts created earlier IN THIS SAME WORKFLOW RUN
- name: Download Android Artifacts
uses: actions/download-artifact@v4
with:
name: android-release-artifacts
path: ./release-artifacts/android
- name: Download Linux Artifacts
uses: actions/download-artifact@v4
with:
name: linux-release-artifacts
path: ./release-artifacts/linux
- name: List downloaded files (for debugging)
run: ls -R ./release-artifacts
shell: bash
# Extract version info from the tag
- name: Extract Version from Tag
id: version
run: |
TAG_NAME=${GITHUB_REF#refs/tags/}
VERSION=${TAG_NAME#v}
echo "tag=${TAG_NAME}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT
shell: bash
# Generate release notes (optional, consider its configuration for tags)
- name: Generate Release Notes
id: generate_release_notes
uses: release-drafter/release-drafter@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Create the GitHub Release using downloaded artifacts
- name: Create GitHub Release
uses: ncipollo/release-action@v1
with:
artifacts: "./release-artifacts/**/*" # Use downloaded artifacts
name: Release v${{ steps.version.outputs.version }}
tag: ${{ github.ref }}
body: ${{ steps.generate_release_notes.outputs.body }}
# token: ${{ secrets.GITHUB_TOKEN }} # Usually inferred

View file

@ -1,84 +0,0 @@
name: Flutter Release Workflow
on:
push:
tags:
- "v**"
# manually trigger a release if needed
workflow_dispatch:
jobs:
build:
permissions:
# write permission is required to create a github release
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Checkout shelfsdk
uses: actions/checkout@v3
with:
repository: Dr-Blank/shelfsdk
path: ./shelfsdk
- name: Set Up Java
uses: actions/setup-java@v3.12.0
with:
distribution: "oracle"
java-version: "17"
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
- name: Install dependencies
run: flutter pub get
# - name: Run tests
# run: flutter test
- name: Decode android/upload.jks
run: echo "${{ secrets.UPLOAD_KEYSTORE_JKS }}" | base64 --decode > android/upload.jks
- name: Decode android/key.properties
run: echo "${{ secrets.KEY_PROPERTIES }}" | base64 --decode > android/key.properties
- name: Build APKs
run: flutter build apk --release --split-per-abi
- name: Build Universal APK
run: flutter build apk --release
- name: Rename Universal APK
run: mv build/app/outputs/flutter-apk/{app-release,app-release-universal}.apk
- name: Build App Bundle
run: flutter build appbundle --release
- name: version
id: version
run: |
tag=${GITHUB_REF/refs\/tags\//}
version=${tag#v}
major=${version%%.*}
echo "tag=${tag}" >> $GITHUB_OUTPUT
echo "version=${version}" >> $GITHUB_OUTPUT
echo "major=${major}" >> $GITHUB_OUTPUT
- name: Generate Release Notes
id: generate_release_notes
uses: release-drafter/release-drafter@v6
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Create GitHub Release
uses: ncipollo/release-action@v1
with:
artifacts: "build/app/outputs/flutter-apk/*-release*.apk,build/app/outputs/bundle/release/*.aab"
name: v${{ steps.version.outputs.version }}
tag: ${{ github.ref }}
body: ${{ steps.generate_release_notes.outputs.body }}

View file

@ -1,53 +0,0 @@
name: Flutter Test
on:
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Decode android/upload.jks
run: echo "${{ secrets.UPLOAD_KEYSTORE_JKS }}" | base64 --decode > android/upload.jks
- name: Decode android/key.properties
run: echo "${{ secrets.KEY_PROPERTIES }}" | base64 --decode > android/key.properties
- name: Checkout shelfsdk
uses: actions/checkout@v3
with:
repository: Dr-Blank/shelfsdk
path: ./shelfsdk
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
- name: Install dependencies
run: flutter pub get
- name: Run tests
run: flutter test
- name: Set Up Java
uses: actions/setup-java@v3.12.0
with:
distribution: "oracle"
java-version: "17"
- name: Build APK
run: flutter build apk --release
- name: Upload APKs
uses: actions/upload-artifact@v4
with:
name: app-release
path: build/app/outputs/flutter-apk/*.apk

130
.github/workflows/prepare-release.yaml vendored Normal file
View file

@ -0,0 +1,130 @@
# .github/workflows/prepare-release.yml
name: Prepare Release (using Cider)
on:
workflow_dispatch:
inputs:
bump_type:
description: "Type of version bump (patch, minor, major)"
required: true
type: choice
options:
- patch
- minor
- major
default: "patch"
permissions:
contents: write # NEEDED to commit, push, and tag
jobs:
bump_version_and_tag:
name: Bump Version and Tag using Cider
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
# Use a PAT if pushing to protected branches is restricted for GITHUB_TOKEN
token: ${{ secrets.PAT_TOKEN }} # Create PAT with repo scope
# token: ${{ secrets.GITHUB_TOKEN }} # this does not trigger other workflows
# Setup Flutter/Dart environment needed to run dart pub global activate
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable" # Or match your project's channel
flutter-version-file: pubspec.yaml
- name: Install Cider
run: dart pub global activate cider
shell: bash
# Add pub global bin to PATH for this job
- name: Add pub global bin to PATH
run: echo "$HOME/.pub-cache/bin" >> $GITHUB_PATH
shell: bash
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
shell: bash
- name: Bump version using Cider
id: bump
run: |
echo "Current version:"
grep '^version:' pubspec.yaml
# Run cider to bump version and build number
# Cider modifies pubspec.yaml in place
cider bump ${{ github.event.inputs.bump_type }} --bump-build
echo "New version (after cider bump):"
# Read the *new* version directly from the modified file
new_version_line=$(grep '^version:' pubspec.yaml)
# Extract just the version string (e.g., 1.2.3+4)
new_version=$(echo "$new_version_line" | sed 's/version: *//')
echo "$new_version_line"
echo "Extracted new version: $new_version"
if [[ -z "$new_version" ]]; then
echo "Error: Could not extract new version after cider bump."
exit 1
fi
# Create tag name (e.g., v1.2.3 - usually tags don't include build number)
# Extract version part before '+' for the tag
version_for_tag=$(echo "$new_version" | cut -d'+' -f1)
new_tag="v$version_for_tag"
echo "New tag: $new_tag"
# Set outputs for later steps
echo "new_version=$new_version" >> $GITHUB_OUTPUT
echo "new_tag=$new_tag" >> $GITHUB_OUTPUT
shell: bash
- name: Commit version bump
run: |
# Add pubspec.yaml. Add CHANGELOG.md if cider modifies it and you want to commit it.
git add pubspec.yaml
# git add CHANGELOG.md # Uncomment if needed
# Check if there are changes to commit
if git diff --staged --quiet; then
echo "No changes detected in pubspec.yaml (or CHANGELOG.md) to commit."
else
# Use the version *without* build number for the commit message usually
git commit -m "chore(release): bump version to ${{ steps.bump.outputs.new_tag }}"
fi
shell: bash
- name: Create Git tag
# Only run if the commit step actually committed something (check git status)
# or simply run always, it won't hurt if the commit was skipped
run: |
git tag ${{ steps.bump.outputs.new_tag }}
shell: bash
- name: Push changes and tag
run: |
# Push the commit first (e.g., to main branch - adjust if needed)
# Handle potential conflicts if main changed since checkout? (More advanced setup)
# Check if there are commits to push before pushing branch
if ! git diff --quiet HEAD^ HEAD; then
echo "Pushing commit to main..."
git push origin HEAD:main
else
echo "No new commits to push to main."
fi
# Always push the tag
echo "Pushing tag ${{ steps.bump.outputs.new_tag }}..."
git push origin ${{ steps.bump.outputs.new_tag }}
shell: bash
- name: Output New Tag
run: echo "Successfully tagged release ${{ steps.bump.outputs.new_tag }}"

9
.gitignore vendored
View file

@ -30,6 +30,7 @@ migrate_working_dir/
.pub-cache/
.pub/
/build/
dist/
# Symbolication related
app.*.symbols
@ -41,6 +42,10 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
/android/app/.cxx/
# separate git repo for api sdk
/shelfsdk
# secret keys
/secrets
# FVM Version Cache
.fvm/

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "shelfsdk"]
path = shelfsdk
url = https://github.com/Dr-Blank/shelfsdk

1
.vscode/launch.json vendored
View file

@ -7,6 +7,7 @@
{
"name": "vaani",
"request": "launch",
"program": "lib/main.dart",
"type": "dart"
},
{

28
.vscode/settings.json vendored
View file

@ -1,27 +1,35 @@
{
"workbench.colorCustomizations": {
"activityBar.background": "#5A1021",
"titleBar.activeBackground": "#7E162E",
"titleBar.activeForeground": "#FEFBFC"
},
"files.exclude": {
"**/*.freezed.dart": true,
"**/*.g.dart": true
},
"cmake.configureOnOpen": false,
"cSpell.words": [
"audioplayers",
"autolabeler",
"Autovalidate",
"Checkmark",
"Debounceable",
"deeplinking",
"fullscreen",
"Lerp",
"miniplayer",
"mocktail",
"nodename",
"numberpicker",
"riverpod",
"Schyler",
"shelfsdk",
"sysname",
"tapable",
"unfocus",
"utsname",
"Vaani"
],
"cmake.configureOnOpen": false
"dart.flutterSdkPath": ".fvm/versions/3.32.0",
"files.exclude": {
"**/*.freezed.dart": true,
"**/*.g.dart": true
},
"workbench.colorCustomizations": {
"activityBar.background": "#5A1021",
"titleBar.activeBackground": "#7E162E",
"titleBar.activeForeground": "#FEFBFC"
}
}

181
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,181 @@
# Contributing to Vaani
## Welcome Contributors! 🚀
We appreciate your interest in contributing to Vaani. This guide will help you navigate the contribution process effectively.
## How to Contribute
### Reporting Bugs 🐞
1. **Check Existing Issues**:
- Search through the [GitHub Issues](https://github.com/Dr-Blank/Vaani/issues)
2. **Create a Detailed Bug Report**:
- Provide:
* Exact steps to reproduce
* Relevant error logs or screenshots
### Submodule Contribution Workflow 🧩
#### Understanding Vaani's Submodule Structure
Vaani uses Git submodules to manage interconnected components. This means each submodule is a separate Git repository nested within the main project.
#### Working with Submodules
1. **Identifying Submodules**:
- List all submodules in the project
```bash
git submodule status
```
2. **Initializing Submodules**:
```bash
# Ensure all submodules are initialized
git submodule update --init --recursive
```
3. **Contributing to a Specific Submodule**:
a. **Navigate to Submodule Directory**:
```bash
cd path/to/submodule
```
b. **Create a Separate Branch**:
```bash
git checkout -b feature/your-submodule-feature
```
c. **Make and Commit Changes**:
```bash
# Stage changes
git add .
# Commit with descriptive message
git commit -m "feat(submodule): describe specific change"
```
d. **Push Submodule Changes**:
```bash
git push origin feature/your-submodule-feature
```
4. **Updating Submodule References**:
After making changes to a submodule:
```bash
# From the main repository root
git add path/to/submodule
git commit -m "Update submodule reference to latest changes"
```
5. **Pulling Latest Submodule Changes**:
```bash
# Update all submodules
git submodule update --recursive --remote
# Or update a specific submodule
git submodule update --remote path/to/specific/submodule
```
#### Submodule Contribution Best Practices
- Always work in a feature branch within the submodule
- Ensure submodule changes do not break the main application
- Write tests for submodule-specific changes
- Update documentation if the submodule's interface changes
- Create a pull request for the submodule first, then update the main project's submodule reference
### Development Workflow
#### Setting Up the Development Environment
1. **Prerequisites**:
- [Git](https://git-scm.com/)
- [Flutter SDK](https://flutter.dev/)
- Recommended IDE: [VS Code](https://code.visualstudio.com/)
2. **Repository Setup**:
1. [Fork the repo](https://github.com/Dr-Blank/Vaani/fork)
1. Clone the forked repository to your local machine
```bash
# Fork the main repository on GitHub
git clone --recursive https://github.com/[YOUR_USERNAME]/Vaani.git
cd Vaani
# Initialize and update submodules
git submodule update --init --recursive
# Install dependencies for the main app and submodules
flutter pub get
```
#### Coding Standards
1. **Code Style**:
- Follow [Flutter's style guide](https://dart.dev/guides/language/effective-dart/style)
- Use `dart format` and `flutter analyze`
```bash
dart format .
flutter analyze
```
2. **Testing**:
- Write unit and widget tests
- Ensure tests pass for both the main app and submodules
```bash
flutter test
```
### Pull Request Process
1. **Branch Naming**:
- Use descriptive branch names
- Prefix with feature/, bugfix/, or docs/
```bash
git checkout -b feature/add-accessibility-support
```
2. **Commit Messages**:
- Use clear, concise descriptions
- Reference issue numbers when applicable
- Follow conventional commits format:
`<type>(scope): <description>`
3. **Pull Request Guidelines**:
- Clearly describe the purpose of your changes
- Include screenshots for visual changes
- Specify if changes affect specific submodules
- Ensure all CI checks pass
### Signing the app
once the keystore is created, you can sign the app with the keystore.
but for github action you need to make a base64 encoded string of the keystore.
```bash
# convert keystore to base64
cat android/key.properties | base64 > key.base64
# convert keystore to base64
cat android/upload.jks | base64 > keystore.base64
```
## Communication
* [Open an Issue](https://github.com/Dr-Blank/Vaani/issues)
* [Discussion Forum](https://github.com/Dr-Blank/Vaani/discussions)
## Code of Conduct
* Be respectful and inclusive
* Constructive feedback is welcome
* Collaborate and support fellow contributors
Happy Contributing! 🌟

3
Gemfile Normal file
View file

@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"

221
Gemfile.lock Normal file
View file

@ -0,0 +1,221 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.7)
base64
nkf
rexml
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.1018.0)
aws-sdk-core (3.214.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.176.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.3.1)
fastlane (2.225.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.7.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.4.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.9.0)
jwt (2.9.3)
base64
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.4.1)
nanaimo (0.4.0)
naturally (2.2.1)
nkf (0.2.0)
optparse (0.6.0)
os (1.1.4)
plist (3.7.1)
public_suffix (6.0.1)
rake (13.2.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.3.9)
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.5)
signet (0.19.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
x64-mingw-ucrt
DEPENDENCIES
fastlane
BUNDLED WITH
2.5.23

View file

@ -18,19 +18,25 @@ Client for [Audiobookshelf](https://github.com/advplyr/audiobookshelf) server ma
### Android
<!-- a github image with link to releases for download -->
[<img src="https://github.com/NeoApplications/Neo-Backup/raw/main/badge_github.png" alt="Get it on GitHub" height="80">](https://github.com/Dr-Blank/Vaani/releases/latest/download/app-release-universal.apk) [<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="80">](http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/Dr-Blank/Vaani)
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="80">](http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/Dr-Blank/Vaani)
[<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=dr.blank.vaani)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dr.blank.vaani)
[<img src="https://github.com/NeoApplications/Neo-Backup/raw/main/badge_github.png" alt="Get it on GitHub" height="80">](https://github.com/Dr-Blank/Vaani/releases/latest/download/app-universal-release.apk)
Playstore App is in closed testing. To join testing
1. [Join the Google Group](https://groups.google.com/g/vaani-app)
2. [Join on Android](https://play.google.com/store/apps/details?id=dr.blank.vaani) Or [Join on Web](https://play.google.com/apps/testing/dr.blank.vaani)
*<small>Play Store version is paid if you want to support the development.</small>*
### Linux
[<img src="https://img.shields.io/badge/.deb-Download-blue" alt="Download Linux (.deb)" height="30">](https://github.com/Dr-Blank/Vaani/releases/latest/download/vaani-linux-amd64.deb)
[<img src="https://img.shields.io/badge/AppImage-Download-blue" alt="Download Linux (AppImage)" height="30">](https://github.com/Dr-Blank/Vaani/releases/latest/download/vaani-linux-amd64.AppImage)
## Screencaps
https://github.com/user-attachments/assets/2ac9ace2-4a3c-40fc-adde-55914e4cf62d
|<img src="images/screenshots/android/home.jpg" width="200" />|<img src="images/screenshots/android/bookview.jpg" width="200" />|<img src="images/screenshots/android/player.jpg" width="200" />|
|:---:|:---:|:---:|
|Home|Book View|Player|
| <img src="images/screenshots/android/home.jpg" width="200" /> | <img src="images/screenshots/android/bookview.jpg" width="200" /> | <img src="images/screenshots/android/player.jpg" width="200" /> |
| :-----------------------------------------------------------: | :---------------------------------------------------------------: | :-------------------------------------------------------------: |
| Home | Book View | Player |
Currently, the app is in development and is not ready for production use.

View file

@ -25,6 +25,10 @@ linter:
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
require_trailing_commas: true
analyzer:
exclude:
- '**.freezed.dart'
- '**.g.dart'
- '**.gr.dart'
errors:
invalid_annotation_target: ignore
plugins:

View file

@ -31,7 +31,11 @@ if (keystorePropertiesFile.exists()) {
android {
namespace "dr.blank.vaani"
compileSdk flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
// ndkVersion flutter.ndkVersion
// The NDK version is set to a specific version since it was not building
// TODO remove when https://github.com/flutter/flutter/issues/139427 is closed
ndkVersion = "29.0.13113456"
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
@ -46,6 +50,15 @@ android {
main.java.srcDirs += 'src/main/kotlin'
}
// see: https://gitlab.com/IzzyOnDroid/repo/-/issues/623#note_2149548690
// https://android.izzysoft.de/articles/named/iod-scan-apkchecks#blobs
dependenciesInfo {
// Disables dependency metadata when building APKs.
includeInApk = false
// Disables dependency metadata when building Android App Bundles.
includeInBundle = false
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "dr.blank.vaani"
@ -80,3 +93,11 @@ flutter {
}
dependencies {}
// https://stackoverflow.com/questions/78626580/how-to-resolve-app-execution-failure-due-to-androidx-corecore1-15-0-alpha
configurations.all {
resolutionStrategy {
force "androidx.core:core:1.13.1"
force "androidx.core:core-ktx:1.13.1"
}
}

View file

@ -7,10 +7,13 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:label="Vaani"
android:name="${applicationName}"
android:usesCleartextTraffic="true"
android:requestLegacyExternalStorage="true"
android:icon="@mipmap/ic_launcher">
<!-- android:name=".MainActivity" -->
<activity

View file

@ -1,5 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip

View file

@ -19,7 +19,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.0" apply false
id "com.android.application" version '8.10.0' apply false
id "org.jetbrains.kotlin.android" version "2.0.20" apply false
}

BIN
assets/fonts/AbsIcons.ttf Normal file

Binary file not shown.

8
distribute_options.yaml Normal file
View file

@ -0,0 +1,8 @@
output: dist/
releases:
- name: dev
jobs:
- name: release-dev-linux-deb
package:
platform: linux
target: deb

8
docs/images_and_logos.md Normal file
View file

@ -0,0 +1,8 @@
this is how i converted my png to svg
`convert -background White vaani_logo.png vaani_logo.pbm`
`potrace -b svg -i vaani_logo.pbm -o vaani_logo.svg`
`-i` flag was needed so that it took white as the svgs and black as background

45
docs/linux_build_guide.md Normal file
View file

@ -0,0 +1,45 @@
# Linux Build Guide
## Determining Package Size
To determine the installed size for your Linux package configuration, you can use the following script:
```bash
#!/bin/bash
# Build the Linux app
flutter build linux
# Get size in KB and add 17% buffer for runtime dependencies
SIZE_KB=$(du -sk build/linux/x64/release/bundle | cut -f1)
BUFFER_SIZE_KB=$(($SIZE_KB + ($SIZE_KB * 17 / 100)))
echo "Actual bundle size: $SIZE_KB KB"
echo "Recommended installed_size (with 17% buffer): $BUFFER_SIZE_KB KB"
```
Save this as `get_package_size.sh` in your project root and make it executable:
```bash
chmod +x get_package_size.sh
```
### Usage
1. Run the script:
```bash
./get_package_size.sh
```
2. Use the output value for `installed_size` in your `linux/packaging/deb/make_config.yaml` file:
```yaml
installed_size: 75700 # Replace with the value from the script
```
### Why add a buffer?
The 17% buffer is added to account for:
- Runtime dependencies
- Future updates
- Potential additional assets
- Prevent installation issues on systems with limited space
### Notes
- The installed size should be specified in kilobytes (KB)
- Always round up the buffer size to be safe
- Re-run this script after significant changes to your app (new assets, dependencies, etc.)

2
docs/linux_deeplink.md Normal file
View file

@ -0,0 +1,2 @@
to test deeplink
`xdg-open vaani://test?code=123&state=abc`

2
fastlane/Appfile Normal file
View file

@ -0,0 +1,2 @@
json_key_file("./secrets/play-store-credentials.json") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one
package_name("dr.blank.vaani") # e.g. com.krausefx.app

38
fastlane/Fastfile Normal file
View file

@ -0,0 +1,38 @@
# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
#
# For a list of all available actions, check out
#
# https://docs.fastlane.tools/actions
#
# For a list of all available plugins, check out
#
# https://docs.fastlane.tools/plugins/available-plugins
#
# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane
default_platform(:android)
platform :android do
desc "Runs all the tests"
lane :test do
gradle(task: "test", project_dir: 'android/')
end
desc "Submit a new Beta Build to Crashlytics Beta"
lane :beta do
gradle(task: "clean assembleRelease", project_dir: 'android/')
crashlytics
# sh "your_script.sh"
# You can also use other beta testing services here
end
desc "Deploy a new version to the Google Play"
lane :deploy do
gradle(task: "clean assembleRelease", project_dir: 'android/')
upload_to_play_store
end
end

48
fastlane/README.md Normal file
View file

@ -0,0 +1,48 @@
fastlane documentation
----
# Installation
Make sure you have the latest version of the Xcode command line tools installed:
```sh
xcode-select --install
```
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
# Available Actions
## Android
### android test
```sh
[bundle exec] fastlane android test
```
Runs all the tests
### android beta
```sh
[bundle exec] fastlane android beta
```
Submit a new Beta Build to Crashlytics Beta
### android deploy
```sh
[bundle exec] fastlane android deploy
```
Deploy a new version to the Google Play
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).

View file

@ -0,0 +1,10 @@
<i>Vaani</i> is a client for your (self-hosted) <a href='https://github.com/advplyr/audiobookshelf'>Audiobookshelf</a> server.
<b>Features:</b>
- Functional Player: Speed Control, Sleep Timer, Shake to Control Player
- Save data with Offline listening and caching
- Material Design
- Extensive Settings to customize the every tiny detail
Note: you need an Audiobookshelf server setup for this app to work. Please see https://www.audiobookshelf.org/ on how to setup one if not already.

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

View file

@ -0,0 +1 @@
Beautiful, Fast and Functional Audiobook Player for your Audiobookshelf server.

View file

@ -0,0 +1 @@
Vaani

18
fastlane/report.xml Normal file
View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="fastlane.lanes">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.0039338">
</testcase>
<testcase classname="fastlane.lanes" name="1: test" time="10.3552991">
</testcase>
</testsuite>
</testsuites>

36
images/vaani_logo.svg Normal file
View file

@ -0,0 +1,36 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="192.000000pt" height="192.000000pt"
viewBox="0 0 192.000000 192.000000" preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.16, written by Peter Selinger 2001-2019
</metadata>
<g transform="translate(0.000000,192.000000) scale(0.100000,-0.100000)" fill="#ffffff" stroke="none">
<path d="M602 1678 c-18 -18 -17 -689 1 -741 17 -48 30 -61 55 -53 25 8 38 57
23 85 -6 11 -11 75 -11 144 0 284 -13 548 -27 563 -18 17 -25 18 -41 2z" />
<path d="M897 1683 c-4 -3 -7 -176 -7 -383 l0 -377 23 -22 c30 -28 53 -19 67
25 10 29 9 39 -4 59 -14 21 -16 69 -16 353 0 242 -3 331 -12 340 -13 13 -41
16 -51 5z" />
<path d="M1329 1668 c-8 -15 -10 -104 -7 -311 4 -288 4 -289 27 -308 16 -13
28 -16 39 -10 13 8 15 46 13 312 -1 169 -7 311 -12 321 -13 25 -46 23 -60 -4z" />
<path d="M1035 1568 c-3 -7 -6 -123 -8 -258 l-2 -245 27 -3 c36 -4 36 -5 43
266 7 234 4 252 -37 252 -10 0 -21 -6 -23 -12z" />
<path d="M1474 1467 c-2 -7 -3 -60 -2 -118 3 -100 4 -104 26 -107 42 -6 49 10
45 105 -5 123 -8 133 -39 133 -13 0 -27 -6 -30 -13z" />
<path d="M443 1408 c-13 -23 -3 -251 13 -275 28 -45 88 -19 64 27 -6 10 -10
64 -10 120 0 56 -5 110 -10 121 -12 21 -45 25 -57 7z" />
<path d="M777 1407 c-12 -9 -16 -39 -19 -132 l-3 -120 30 0 c37 0 43 18 44
147 1 85 -1 99 -18 107 -13 7 -23 7 -34 -2z" />
<path d="M1182 1398 c-8 -8 -12 -48 -12 -115 0 -93 2 -103 21 -113 39 -21 49
2 49 111 0 75 -4 101 -16 113 -19 19 -26 20 -42 4z" />
<path d="M1505 1048 c-11 -6 -25 -14 -31 -17 -56 -30 -192 -131 -223 -164 -57
-62 -134 -166 -158 -214 -12 -23 -26 -50 -32 -60 -9 -17 -30 -66 -60 -138
l-12 -29 -21 34 c-114 189 -151 238 -243 316 -44 38 -107 95 -141 127 -33 31
-66 57 -73 57 -8 0 -30 8 -50 19 -20 10 -55 27 -79 37 -39 16 -44 16 -58 2
-29 -29 -8 -52 84 -93 51 -23 106 -57 132 -81 25 -23 65 -58 90 -78 59 -49
152 -147 186 -199 15 -23 35 -53 44 -67 10 -14 35 -63 56 -108 28 -61 45 -84
61 -88 28 -7 49 8 57 41 8 33 106 246 128 279 7 11 27 40 43 66 51 79 135 167
192 202 30 18 67 42 81 53 15 11 44 29 65 39 31 16 37 24 35 45 -3 29 -38 38
-73 19z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -2,12 +2,15 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/db/cache_manager.dart';
import 'package:vaani/models/error_response.dart';
import 'package:vaani/settings/api_settings_provider.dart';
import 'package:vaani/settings/models/authenticated_user.dart';
import 'package:vaani/shared/extensions/obfuscation.dart';
part 'api_provider.g.dart';
@ -32,7 +35,7 @@ Uri makeBaseUrl(String address) {
/// get the api instance for the given base url
@riverpod
AudiobookshelfApi audiobookshelfApi(AudiobookshelfApiRef ref, Uri? baseUrl) {
AudiobookshelfApi audiobookshelfApi(Ref ref, Uri? baseUrl) {
// try to get the base url from app settings
final apiSettings = ref.watch(apiSettingsProvider);
baseUrl ??= apiSettings.activeServer?.serverUrl;
@ -45,10 +48,10 @@ AudiobookshelfApi audiobookshelfApi(AudiobookshelfApiRef ref, Uri? baseUrl) {
///
/// if the user is not authenticated throw an error
@Riverpod(keepAlive: true)
AudiobookshelfApi authenticatedApi(AuthenticatedApiRef ref) {
final apiSettings = ref.watch(apiSettingsProvider);
final user = apiSettings.activeUser;
AudiobookshelfApi authenticatedApi(Ref ref) {
final user = ref.watch(apiSettingsProvider.select((s) => s.activeUser));
if (user == null) {
_logger.severe('No active user can not provide authenticated api');
throw StateError('No active user');
}
return AudiobookshelfApi(
@ -59,7 +62,7 @@ AudiobookshelfApi authenticatedApi(AuthenticatedApiRef ref) {
/// ping the server to check if it is reachable
@riverpod
FutureOr<bool> isServerAlive(IsServerAliveRef ref, String address) async {
FutureOr<bool> isServerAlive(Ref ref, String address) async {
if (address.isEmpty) {
return false;
}
@ -77,7 +80,7 @@ FutureOr<bool> isServerAlive(IsServerAliveRef ref, String address) async {
/// fetch status of server
@riverpod
FutureOr<ServerStatusResponse?> serverStatus(
ServerStatusRef ref,
Ref ref,
Uri baseUrl, [
ResponseErrorHandler? responseErrorHandler,
]) async {
@ -97,17 +100,28 @@ class PersonalizedView extends _$PersonalizedView {
final api = ref.watch(authenticatedApiProvider);
final apiSettings = ref.watch(apiSettingsProvider);
final user = apiSettings.activeUser;
if (user == null) {
_logger.warning('no active user');
yield [];
return;
}
if (apiSettings.activeLibraryId == null) {
// set it to default user library by logging in and getting the library id
final login =
await api.login(username: user!.username!, password: user.password!);
final login = await ref.read(loginProvider().future);
if (login == null) {
_logger.shout('failed to login, not building personalized view');
yield [];
return;
}
ref.read(apiSettingsProvider.notifier).updateState(
apiSettings.copyWith(activeLibraryId: login!.userDefaultLibraryId),
apiSettings.copyWith(activeLibraryId: login.userDefaultLibraryId),
);
yield [];
return;
}
// try to find in cache
// final cacheKey = 'personalizedView:${apiSettings.activeLibraryId}';
var key = 'personalizedView:${apiSettings.activeLibraryId! + user!.id!}';
final key = 'personalizedView:${apiSettings.activeLibraryId! + user.id}';
final cachedRes = await apiResponseCacheManager.getFileFromMemory(
key,
) ??
@ -127,7 +141,7 @@ class PersonalizedView extends _$PersonalizedView {
}
}
// ! exagerated delay
// ! exaggerated delay
// await Future.delayed(const Duration(seconds: 2));
final res = await api.libraries
.getPersonalized(libraryId: apiSettings.activeLibraryId!);
@ -151,6 +165,7 @@ class PersonalizedView extends _$PersonalizedView {
// method to force refresh the view and ignore the cache
Future<void> forceRefresh() async {
// clear the cache
// TODO: find a better way to clear the cache for only personalized view key
return apiResponseCacheManager.emptyCache();
}
}
@ -158,7 +173,7 @@ class PersonalizedView extends _$PersonalizedView {
/// fetch continue listening audiobooks
@riverpod
FutureOr<GetUserSessionsResponse> fetchContinueListening(
FetchContinueListeningRef ref,
Ref ref,
) async {
final api = ref.watch(authenticatedApiProvider);
final res = await api.me.getSessions();
@ -170,9 +185,50 @@ FutureOr<GetUserSessionsResponse> fetchContinueListening(
@riverpod
FutureOr<User> me(
MeRef ref,
Ref ref,
) async {
final api = ref.watch(authenticatedApiProvider);
final res = await api.me.getUser();
return res!;
final errorResponseHandler = ErrorResponseHandler();
final res = await api.me.getUser(
responseErrorHandler: errorResponseHandler.storeError,
);
if (res == null) {
_logger.severe(
'me failed, got response: ${errorResponseHandler.response.obfuscate()}',
);
throw StateError('me failed');
}
return res;
}
@riverpod
FutureOr<LoginResponse?> login(
Ref ref, {
AuthenticatedUser? user,
}) async {
if (user == null) {
// try to get the user from settings
final apiSettings = ref.watch(apiSettingsProvider);
user = apiSettings.activeUser;
if (user == null) {
_logger.severe('no active user to login');
return null;
}
_logger.fine('no user provided, using active user: ${user.obfuscate()}');
}
final api = ref.watch(audiobookshelfApiProvider(user.server.serverUrl));
api.token = user.authToken;
var errorResponseHandler = ErrorResponseHandler();
_logger.fine('logging in with authenticated api');
final res = await api.misc.authorize(
responseErrorHandler: errorResponseHandler.storeError,
);
if (res == null) {
_logger.severe(
'login failed, got response: ${errorResponseHandler.response.obfuscate()}',
);
return null;
}
_logger.fine('login response: ${res.obfuscate()}');
return res;
}

View file

@ -6,7 +6,7 @@ part of 'api_provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$audiobookshelfApiHash() => r'2c310ea77fea9918ccf96180a92075acd037bd95';
String _$audiobookshelfApiHash() => r'f23a06c404e11867a7f796877eaca99b8ff25458';
/// Copied from Dart SDK
class _SystemHash {
@ -154,6 +154,8 @@ class AudiobookshelfApiProvider extends AutoDisposeProvider<AudiobookshelfApi> {
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin AudiobookshelfApiRef on AutoDisposeProviderRef<AudiobookshelfApi> {
/// The parameter `baseUrl` of this provider.
Uri? get baseUrl;
@ -168,7 +170,7 @@ class _AudiobookshelfApiProviderElement
Uri? get baseUrl => (origin as AudiobookshelfApiProvider).baseUrl;
}
String _$authenticatedApiHash() => r'f555efb6eede590b5a8d60cad2e6bfc2847e2d14';
String _$authenticatedApiHash() => r'284be2c39823c20fb70035a136c430862c28fa27';
/// get the api instance for the authenticated user
///
@ -186,8 +188,10 @@ final authenticatedApiProvider = Provider<AudiobookshelfApi>.internal(
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AuthenticatedApiRef = ProviderRef<AudiobookshelfApi>;
String _$isServerAliveHash() => r'6ff90b6e0febd2cd4a4d3a5209a59afc778cd3b6';
String _$isServerAliveHash() => r'bb3a53cae1eb64b8760a56864feed47b7a3f1c29';
/// ping the server to check if it is reachable
///
@ -314,6 +318,8 @@ class IsServerAliveProvider extends AutoDisposeFutureProvider<bool> {
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin IsServerAliveRef on AutoDisposeFutureProviderRef<bool> {
/// The parameter `address` of this provider.
String get address;
@ -327,7 +333,7 @@ class _IsServerAliveProviderElement
String get address => (origin as IsServerAliveProvider).address;
}
String _$serverStatusHash() => r'd7079e19e68f5f61b0afa0f73a2af8807c4b3cf6';
String _$serverStatusHash() => r'2d9c5d6f970caec555e5322d43a388ea8572619f';
/// fetch status of server
///
@ -467,6 +473,8 @@ class ServerStatusProvider
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin ServerStatusRef on AutoDisposeFutureProviderRef<ServerStatusResponse?> {
/// The parameter `baseUrl` of this provider.
Uri get baseUrl;
@ -488,7 +496,7 @@ class _ServerStatusProviderElement
}
String _$fetchContinueListeningHash() =>
r'f65fe3ac3a31b8ac074330525c5d2cc4b526802d';
r'50aeb77369eda38d496b2f56f3df2aea135dab45';
/// fetch continue listening audiobooks
///
@ -505,9 +513,11 @@ final fetchContinueListeningProvider =
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef FetchContinueListeningRef
= AutoDisposeFutureProviderRef<GetUserSessionsResponse>;
String _$meHash() => r'bdc664c4fd867ad13018fa769ce7a6913248c44f';
String _$meHash() => r'b3b6d6d940b465c60d0c29cd6e81ba2fcccab186';
/// See also [me].
@ProviderFor(me)
@ -520,8 +530,139 @@ final meProvider = AutoDisposeFutureProvider<User>.internal(
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef MeRef = AutoDisposeFutureProviderRef<User>;
String _$personalizedViewHash() => r'4c392ece4650bdc36d7195a0ddb8810e8fe4caa9';
String _$loginHash() => r'99410c2bed9c8f412c7b47c4e655db64e0054be2';
/// See also [login].
@ProviderFor(login)
const loginProvider = LoginFamily();
/// See also [login].
class LoginFamily extends Family<AsyncValue<LoginResponse?>> {
/// See also [login].
const LoginFamily();
/// See also [login].
LoginProvider call({
AuthenticatedUser? user,
}) {
return LoginProvider(
user: user,
);
}
@override
LoginProvider getProviderOverride(
covariant LoginProvider provider,
) {
return call(
user: provider.user,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'loginProvider';
}
/// See also [login].
class LoginProvider extends AutoDisposeFutureProvider<LoginResponse?> {
/// See also [login].
LoginProvider({
AuthenticatedUser? user,
}) : this._internal(
(ref) => login(
ref as LoginRef,
user: user,
),
from: loginProvider,
name: r'loginProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$loginHash,
dependencies: LoginFamily._dependencies,
allTransitiveDependencies: LoginFamily._allTransitiveDependencies,
user: user,
);
LoginProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.user,
}) : super.internal();
final AuthenticatedUser? user;
@override
Override overrideWith(
FutureOr<LoginResponse?> Function(LoginRef provider) create,
) {
return ProviderOverride(
origin: this,
override: LoginProvider._internal(
(ref) => create(ref as LoginRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
user: user,
),
);
}
@override
AutoDisposeFutureProviderElement<LoginResponse?> createElement() {
return _LoginProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is LoginProvider && other.user == user;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, user.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin LoginRef on AutoDisposeFutureProviderRef<LoginResponse?> {
/// The parameter `user` of this provider.
AuthenticatedUser? get user;
}
class _LoginProviderElement
extends AutoDisposeFutureProviderElement<LoginResponse?> with LoginRef {
_LoginProviderElement(super.provider);
@override
AuthenticatedUser? get user => (origin as LoginProvider).user;
}
String _$personalizedViewHash() => r'425e89d99d7e4712b4d6a688f3a12442bd66584f';
/// fetch the personalized view
///
@ -540,4 +681,4 @@ final personalizedViewProvider =
typedef _$PersonalizedView = AutoDisposeStreamNotifier<List<Shelf>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -1,28 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'authenticated_user_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$authenticatedUserHash() => r'1983527595207c63a12bc84cf0bf1a3c1d729506';
/// provides with a set of authenticated users
///
/// Copied from [AuthenticatedUser].
@ProviderFor(AuthenticatedUser)
final authenticatedUserProvider = AutoDisposeNotifierProvider<AuthenticatedUser,
Set<model.AuthenticatedUser>>.internal(
AuthenticatedUser.new,
name: r'authenticatedUserProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$authenticatedUserHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AuthenticatedUser = AutoDisposeNotifier<Set<model.AuthenticatedUser>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -8,15 +8,15 @@ import 'package:vaani/settings/models/audiobookshelf_server.dart';
import 'package:vaani/settings/models/authenticated_user.dart' as model;
import 'package:vaani/shared/extensions/obfuscation.dart';
part 'authenticated_user_provider.g.dart';
part 'authenticated_users_provider.g.dart';
final _box = AvailableHiveBoxes.authenticatedUserBox;
final _logger = Logger('authenticated_user_provider');
final _logger = Logger('authenticated_users_provider');
/// provides with a set of authenticated users
@riverpod
class AuthenticatedUser extends _$AuthenticatedUser {
class AuthenticatedUsers extends _$AuthenticatedUsers {
@override
Set<model.AuthenticatedUser> build() {
ref.listenSelf((_, __) {
@ -56,6 +56,7 @@ class AuthenticatedUser extends _$AuthenticatedUser {
void addUser(model.AuthenticatedUser user, {bool setActive = false}) {
state = state..add(user);
ref.invalidateSelf();
if (setActive) {
final apiSettings = ref.read(apiSettingsProvider);
ref.read(apiSettingsProvider.notifier).updateState(
@ -82,9 +83,12 @@ class AuthenticatedUser extends _$AuthenticatedUser {
// also remove the user from the active user
final apiSettings = ref.read(apiSettingsProvider);
if (apiSettings.activeUser == user) {
// replace the active user with the first user in the list
// or null if there are no users left
final newActiveUser = state.isNotEmpty ? state.first : null;
ref.read(apiSettingsProvider.notifier).updateState(
apiSettings.copyWith(
activeUser: null,
activeUser: newActiveUser,
),
);
}

View file

@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'authenticated_users_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$authenticatedUsersHash() =>
r'5fdd472f62fc3b73ff8417cdce9f02e86c33d00f';
/// provides with a set of authenticated users
///
/// Copied from [AuthenticatedUsers].
@ProviderFor(AuthenticatedUsers)
final authenticatedUsersProvider = AutoDisposeNotifierProvider<
AuthenticatedUsers, Set<model.AuthenticatedUser>>.internal(
AuthenticatedUsers.new,
name: r'authenticatedUsersProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$authenticatedUsersHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AuthenticatedUsers
= AutoDisposeNotifier<Set<model.AuthenticatedUser>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -155,6 +155,8 @@ class CoverImageProvider
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin CoverImageRef on StreamNotifierProviderRef<Uint8List> {
/// The parameter `itemId` of this provider.
String get itemId;
@ -169,4 +171,4 @@ class _CoverImageProviderElement
String get itemId => (origin as CoverImageProvider).itemId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -170,6 +170,8 @@ class LibraryItemProvider extends StreamNotifierProviderImpl<LibraryItem,
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin LibraryItemRef
on StreamNotifierProviderRef<shelfsdk.LibraryItemExpanded> {
/// The parameter `id` of this provider.
@ -184,4 +186,4 @@ class _LibraryItemProviderElement extends StreamNotifierProviderElement<
String get id => (origin as LibraryItemProvider).id;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -0,0 +1,58 @@
import 'package:hooks_riverpod/hooks_riverpod.dart' show Ref;
import 'package:logging/logging.dart' show Logger;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shelfsdk/audiobookshelf_api.dart' show Library;
import 'package:vaani/api/api_provider.dart' show authenticatedApiProvider;
import 'package:vaani/settings/api_settings_provider.dart'
show apiSettingsProvider;
part 'library_provider.g.dart';
final _logger = Logger('LibraryProvider');
@riverpod
Future<Library?> library(Ref ref, String id) async {
final api = ref.watch(authenticatedApiProvider);
final library = await api.libraries.get(libraryId: id);
if (library == null) {
_logger.warning('No library found through id: $id');
// try to get the library from the list of libraries
final libraries = await ref.watch(librariesProvider.future);
for (final lib in libraries) {
if (lib.id == id) {
return lib;
}
}
_logger.warning('No library found in the list of libraries');
return null;
}
_logger.fine('Fetched library: $library');
return library.library;
}
@riverpod
Future<Library?> currentLibrary(Ref ref) async {
final libraryId =
ref.watch(apiSettingsProvider.select((s) => s.activeLibraryId));
if (libraryId == null) {
_logger.warning('No active library id found');
return null;
}
return await ref.watch(libraryProvider(libraryId).future);
}
@riverpod
class Libraries extends _$Libraries {
@override
FutureOr<List<Library>> build() async {
final api = ref.watch(authenticatedApiProvider);
final libraries = await api.libraries.getAll();
if (libraries == null) {
_logger.warning('Failed to fetch libraries');
return [];
}
_logger.fine('Fetched ${libraries.length} libraries');
ref.keepAlive();
return libraries;
}
}

View file

@ -0,0 +1,192 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'library_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$libraryHash() => r'f8a34100acb58f02fa958c71a629577bf815710e';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [library].
@ProviderFor(library)
const libraryProvider = LibraryFamily();
/// See also [library].
class LibraryFamily extends Family<AsyncValue<Library?>> {
/// See also [library].
const LibraryFamily();
/// See also [library].
LibraryProvider call(
String id,
) {
return LibraryProvider(
id,
);
}
@override
LibraryProvider getProviderOverride(
covariant LibraryProvider provider,
) {
return call(
provider.id,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'libraryProvider';
}
/// See also [library].
class LibraryProvider extends AutoDisposeFutureProvider<Library?> {
/// See also [library].
LibraryProvider(
String id,
) : this._internal(
(ref) => library(
ref as LibraryRef,
id,
),
from: libraryProvider,
name: r'libraryProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$libraryHash,
dependencies: LibraryFamily._dependencies,
allTransitiveDependencies: LibraryFamily._allTransitiveDependencies,
id: id,
);
LibraryProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.id,
}) : super.internal();
final String id;
@override
Override overrideWith(
FutureOr<Library?> Function(LibraryRef provider) create,
) {
return ProviderOverride(
origin: this,
override: LibraryProvider._internal(
(ref) => create(ref as LibraryRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
id: id,
),
);
}
@override
AutoDisposeFutureProviderElement<Library?> createElement() {
return _LibraryProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is LibraryProvider && other.id == id;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, id.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin LibraryRef on AutoDisposeFutureProviderRef<Library?> {
/// The parameter `id` of this provider.
String get id;
}
class _LibraryProviderElement extends AutoDisposeFutureProviderElement<Library?>
with LibraryRef {
_LibraryProviderElement(super.provider);
@override
String get id => (origin as LibraryProvider).id;
}
String _$currentLibraryHash() => r'658498a531e04a01e2b3915a3319101285601118';
/// See also [currentLibrary].
@ProviderFor(currentLibrary)
final currentLibraryProvider = AutoDisposeFutureProvider<Library?>.internal(
currentLibrary,
name: r'currentLibraryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$currentLibraryHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef CurrentLibraryRef = AutoDisposeFutureProviderRef<Library?>;
String _$librariesHash() => r'95ebd4d1ac0cc2acf7617dc22895eff0ca30600f';
/// See also [Libraries].
@ProviderFor(Libraries)
final librariesProvider =
AutoDisposeAsyncNotifierProvider<Libraries, List<Library>>.internal(
Libraries.new,
name: r'librariesProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$librariesHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$Libraries = AutoDisposeAsyncNotifier<List<Library>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -1,7 +1,6 @@
import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:vaani/api/authenticated_user_provider.dart';
import 'package:vaani/api/authenticated_users_provider.dart';
import 'package:vaani/db/storage.dart';
import 'package:vaani/settings/api_settings_provider.dart';
import 'package:vaani/settings/models/audiobookshelf_server.dart' as model;
@ -50,7 +49,7 @@ class AudiobookShelfServer extends _$AudiobookShelfServer {
if (_box.isNotEmpty) {
final foundServers = _box.getRange(0, _box.length);
_logger.info('found servers in box: ${foundServers.obfuscate()}');
return foundServers.whereNotNull().toSet();
return foundServers.nonNulls.toSet();
} else {
_logger.info('no settings found in box');
return {};
@ -89,7 +88,7 @@ class AudiobookShelfServer extends _$AudiobookShelfServer {
}
// remove the users of this server
if (removeUsers) {
ref.read(authenticatedUserProvider.notifier).removeUsersOfServer(server);
ref.read(authenticatedUsersProvider.notifier).removeUsersOfServer(server);
}
}

View file

@ -7,7 +7,7 @@ part of 'server_provider.dart';
// **************************************************************************
String _$audiobookShelfServerHash() =>
r'0084fb72c4c54323207928b95716cfd9ca496c11';
r'31a96b431221965cd586aad670a32ca901539e41';
/// provides with a set of servers added by the user
///
@ -27,4 +27,4 @@ final audiobookShelfServerProvider = AutoDisposeNotifierProvider<
typedef _$AudiobookShelfServer
= AutoDisposeNotifier<Set<model.AudiobookShelfServer>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -10,5 +10,4 @@ class HeroTagPrefixes {
static const String bookTitle = 'book_title_';
static const String narratorName = 'narrator_name_';
static const String libraryItemPlayButton = 'library_item_play_button_';
}

View file

@ -1,4 +1,5 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
@ -122,7 +123,7 @@ class ItemDownloadProgress extends _$ItemDownloadProgress {
@riverpod
FutureOr<List<TaskRecord>> downloadHistory(
DownloadHistoryRef ref, {
Ref ref, {
String? group,
}) async {
return await FileDownloader().database.allRecords(group: group);

View file

@ -6,7 +6,7 @@ part of 'download_manager.dart';
// RiverpodGenerator
// **************************************************************************
String _$downloadHistoryHash() => r'76c449e8abfa61d57566991686f534a06dc7fef7';
String _$downloadHistoryHash() => r'4d8b84e30f7ff5ae69d23c8e03ff24af1234a1ad';
/// Copied from Dart SDK
class _SystemHash {
@ -143,6 +143,8 @@ class DownloadHistoryProvider
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin DownloadHistoryRef on AutoDisposeFutureProviderRef<List<TaskRecord>> {
/// The parameter `group` of this provider.
String? get group;
@ -318,6 +320,8 @@ class IsItemDownloadingProvider
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin IsItemDownloadingRef on AutoDisposeNotifierProviderRef<bool> {
/// The parameter `id` of this provider.
String get id;
@ -463,6 +467,8 @@ class ItemDownloadProgressProvider extends AutoDisposeAsyncNotifierProviderImpl<
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin ItemDownloadProgressRef on AutoDisposeAsyncNotifierProviderRef<double?> {
/// The parameter `id` of this provider.
String get id;
@ -607,6 +613,8 @@ class IsItemDownloadedProvider
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin IsItemDownloadedRef on AutoDisposeAsyncNotifierProviderRef<bool> {
/// The parameter `item` of this provider.
LibraryItemExpanded get item;
@ -621,4 +629,4 @@ class _IsItemDownloadedProviderElement
LibraryItemExpanded get item => (origin as IsItemDownloadedProvider).item;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -13,7 +13,6 @@ class DownloadsPage extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(
title: const Text('Downloads'),
backgroundColor: Colors.transparent,
),
body: Center(
// history of downloads

View file

@ -26,4 +26,4 @@ final globalSearchControllerProvider =
typedef _$GlobalSearchController = Notifier<Raw<SearchController>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -1,3 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/api/api_provider.dart';
@ -8,7 +9,7 @@ part 'search_result_provider.g.dart';
/// The provider for the search result.
@riverpod
FutureOr<LibrarySearchResponse?> searchResult(
SearchResultRef ref,
Ref ref,
String query, {
int limit = 25,
}) async {

View file

@ -6,7 +6,7 @@ part of 'search_result_provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$searchResultHash() => r'9baa643cce24f3a5e022f42202e423373939ef95';
String _$searchResultHash() => r'33785de298ad0d53c9d21e8fec88ba2f22f1363f';
/// Copied from Dart SDK
class _SystemHash {
@ -167,6 +167,8 @@ class SearchResultProvider
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SearchResultRef on AutoDisposeFutureProviderRef<LibrarySearchResponse?> {
/// The parameter `query` of this provider.
String get query;
@ -186,4 +188,4 @@ class _SearchResultProviderElement
int get limit => (origin as SearchResultProvider).limit;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -30,7 +30,6 @@ class ExplorePage extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(
title: const Text('Explore'),
backgroundColor: Colors.transparent,
),
body: const MySearchBar(),
);
@ -98,8 +97,10 @@ class MySearchBar extends HookConsumerWidget {
// opacity: 0.5 for the hint text
hintStyle: WidgetStatePropertyAll(
Theme.of(context).textTheme.bodyMedium!.copyWith(
color:
Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.5),
),
),
textInputAction: TextInputAction.search,

View file

@ -425,7 +425,6 @@ class DownloadSheet extends HookConsumerWidget {
class _LibraryItemPlayButton extends HookConsumerWidget {
const _LibraryItemPlayButton({
super.key,
required this.item,
});

View file

@ -15,7 +15,7 @@ import 'package:vaani/settings/app_settings_provider.dart';
import 'package:vaani/shared/extensions/duration_format.dart';
import 'package:vaani/shared/extensions/model_conversions.dart';
import 'package:vaani/shared/widgets/shelves/book_shelf.dart';
import 'package:vaani/theme/theme_from_cover_provider.dart';
import 'package:vaani/theme/providers/theme_from_cover_provider.dart';
class LibraryItemHeroSection extends HookConsumerWidget {
const LibraryItemHeroSection({
@ -78,7 +78,6 @@ class LibraryItemHeroSection extends HookConsumerWidget {
class _BookDetails extends HookConsumerWidget {
const _BookDetails({
super.key,
required this.id,
this.extraMap,
});
@ -136,7 +135,6 @@ class _BookDetails extends HookConsumerWidget {
class _LibraryItemProgressIndicator extends HookConsumerWidget {
const _LibraryItemProgressIndicator({
super.key,
required this.id,
});
@ -201,8 +199,10 @@ class _LibraryItemProgressIndicator extends HookConsumerWidget {
'${remainingTime.smartBinaryFormat} left',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color:
Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.75),
),
),
],
@ -213,7 +213,6 @@ class _LibraryItemProgressIndicator extends HookConsumerWidget {
class _HeroSectionSubLabelWithIcon extends HookConsumerWidget {
const _HeroSectionSubLabelWithIcon({
super.key,
required this.icon,
required this.text,
});
@ -230,7 +229,7 @@ class _HeroSectionSubLabelWithIcon extends HookConsumerWidget {
ref.watch(appSettingsProvider).themeSettings.useMaterialThemeOnItemPage;
final color = useMaterialThemeOnItemPage
? themeData.colorScheme.primary
: themeData.colorScheme.onSurface.withOpacity(0.75);
: themeData.colorScheme.onSurface.withValues(alpha: 0.75);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
@ -260,7 +259,6 @@ class _HeroSectionSubLabelWithIcon extends HookConsumerWidget {
class _BookSeries extends StatelessWidget {
const _BookSeries({
super.key,
required this.itemBookMetadata,
required this.bookDetailsCached,
});
@ -306,7 +304,6 @@ class _BookSeries extends StatelessWidget {
class _BookNarrators extends StatelessWidget {
const _BookNarrators({
super.key,
required this.itemBookMetadata,
required this.bookDetailsCached,
});
@ -342,7 +339,6 @@ class _BookNarrators extends StatelessWidget {
class _BookCover extends HookConsumerWidget {
const _BookCover({
super.key,
required this.itemId,
});
@ -353,16 +349,17 @@ class _BookCover extends HookConsumerWidget {
final coverImage = ref.watch(coverImageProvider(itemId));
final themeData = Theme.of(context);
// final item = ref.watch(libraryItemProvider(itemId));
final useMaterialThemeOnItemPage =
ref.watch(appSettingsProvider).themeSettings.useMaterialThemeOnItemPage;
final themeSettings = ref.watch(appSettingsProvider).themeSettings;
ColorScheme? coverColorScheme;
if (useMaterialThemeOnItemPage) {
if (themeSettings.useMaterialThemeOnItemPage) {
coverColorScheme = ref
.watch(
themeOfLibraryItemProvider(
itemId,
brightness: Theme.of(context).brightness,
highContrast: themeSettings.highContrast ||
MediaQuery.of(context).highContrast,
),
)
.valueOrNull;
@ -371,7 +368,7 @@ class _BookCover extends HookConsumerWidget {
return ThemeSwitcher(
builder: (context) {
// change theme after 2 seconds
if (useMaterialThemeOnItemPage) {
if (themeSettings.useMaterialThemeOnItemPage) {
Future.delayed(150.ms, () {
try {
ThemeSwitcher.of(context).changeTheme(
@ -415,7 +412,6 @@ class _BookCover extends HookConsumerWidget {
class _BookTitle extends StatelessWidget {
const _BookTitle({
super.key,
required this.extraMap,
required this.itemBookMetadata,
});
@ -449,7 +445,7 @@ class _BookTitle extends StatelessWidget {
? const SizedBox.shrink()
: Text(
style: themeData.textTheme.titleSmall?.copyWith(
color: themeData.colorScheme.onSurface.withOpacity(0.8),
color: themeData.colorScheme.onSurface.withValues(alpha: 0.8),
),
itemBookMetadata?.subtitle ?? '',
),
@ -460,7 +456,6 @@ class _BookTitle extends StatelessWidget {
class _BookAuthors extends StatelessWidget {
const _BookAuthors({
super.key,
required this.itemBookMetadata,
required this.bookDetailsCached,
});

View file

@ -96,7 +96,10 @@ class LibraryItemMetadata extends HookConsumerWidget {
return VerticalDivider(
indent: 6,
endIndent: 6,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
);
},
),
@ -109,7 +112,6 @@ class LibraryItemMetadata extends HookConsumerWidget {
/// key-value pair to display as column
class _MetadataItem extends StatelessWidget {
const _MetadataItem({
super.key,
required this.title,
required this.value,
});
@ -126,7 +128,7 @@ class _MetadataItem extends StatelessWidget {
children: [
Text(
style: themeData.textTheme.titleMedium?.copyWith(
color: themeData.colorScheme.onSurface.withOpacity(0.90),
color: themeData.colorScheme.onSurface.withValues(alpha: 0.90),
),
value,
maxLines: 1,
@ -134,7 +136,7 @@ class _MetadataItem extends StatelessWidget {
),
Text(
style: themeData.textTheme.bodySmall?.copyWith(
color: themeData.colorScheme.onSurface.withOpacity(0.7),
color: themeData.colorScheme.onSurface.withValues(alpha: 0.7),
),
title,
maxLines: 1,

View file

@ -3,10 +3,11 @@ import 'dart:math';
import 'package:animated_theme_switcher/animated_theme_switcher.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:vaani/api/library_item_provider.dart';
import 'package:vaani/features/item_viewer/view/library_item_sliver_app_bar.dart';
import 'package:vaani/features/player/providers/player_form.dart';
import 'package:vaani/features/player/view/mini_player_bottom_padding.dart';
import 'package:vaani/router/models/library_item_extras.dart';
import 'package:vaani/shared/widgets/expandable_description.dart';
@ -23,19 +24,89 @@ class LibraryItemPage extends HookConsumerWidget {
final String itemId;
final Object? extra;
static const double _showFabThreshold = 300.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final additionalItemData =
extra is LibraryItemExtras ? extra as LibraryItemExtras : null;
final scrollController = useScrollController();
final showFab = useState(false);
// Effect to listen to scroll changes and update FAB visibility
useEffect(
() {
void listener() {
if (!scrollController.hasClients) {
return; // Ensure controller is attached
}
final shouldShow = scrollController.offset > _showFabThreshold;
// Update state only if it changes and widget is still mounted
if (showFab.value != shouldShow && context.mounted) {
showFab.value = shouldShow;
}
}
scrollController.addListener(listener);
// Initial check in case the view starts scrolled (less likely but safe)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients && context.mounted) {
listener();
}
});
// Cleanup: remove the listener when the widget is disposed
return () => scrollController.removeListener(listener);
},
[scrollController],
); // Re-run effect if scrollController changes
// --- FAB Scroll-to-Top Logic ---
void scrollToTop() {
if (scrollController.hasClients) {
scrollController.animateTo(
0.0, // Target offset (top)
duration: 300.ms,
curve: Curves.easeInOut,
);
}
}
return ThemeProvider(
initTheme: Theme.of(context),
duration: 200.ms,
child: ThemeSwitchingArea(
child: Scaffold(
floatingActionButton: AnimatedSwitcher(
duration: 250.ms,
// A common transition for FABs (fade + scale)
transitionBuilder: (Widget child, Animation<double> animation) {
return ScaleTransition(
scale: animation,
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
child: showFab.value
? FloatingActionButton(
// Key is important for AnimatedSwitcher to differentiate
key: const ValueKey('fab-scroll-top'),
onPressed: scrollToTop,
tooltip: 'Scroll to top',
child: const Icon(Icons.arrow_upward),
)
: const SizedBox.shrink(
key: ValueKey('fab-empty'),
),
),
body: CustomScrollView(
controller: scrollController,
slivers: [
const LibraryItemSliverAppBar(),
LibraryItemSliverAppBar(
id: itemId,
scrollController: scrollController,
),
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: LibraryItemHeroSection(
@ -56,9 +127,7 @@ class LibraryItemPage extends HookConsumerWidget {
child: LibraryItemDescription(id: itemId),
),
// a padding at the bottom to make sure the last item is not hidden by mini player
const SliverToBoxAdapter(
child: SizedBox(height: playerMinHeight),
),
const SliverToBoxAdapter(child: MiniPlayerBottomPadding()),
],
),
),

View file

@ -1,23 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:vaani/api/library_item_provider.dart' show libraryItemProvider;
class LibraryItemSliverAppBar extends StatelessWidget {
class LibraryItemSliverAppBar extends HookConsumerWidget {
const LibraryItemSliverAppBar({
super.key,
required this.id,
required this.scrollController,
});
final String id;
final ScrollController scrollController;
static const double _showTitleThreshold = kToolbarHeight * 0.5;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final item = ref.watch(libraryItemProvider(id)).valueOrNull;
final showTitle = useState(false);
useEffect(
() {
void listener() {
final shouldShow = scrollController.hasClients &&
scrollController.offset > _showTitleThreshold;
if (showTitle.value != shouldShow) {
showTitle.value = shouldShow;
}
}
scrollController.addListener(listener);
// Trigger listener once initially in case the view starts scrolled
// (though unlikely for this specific use case, it's good practice)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
listener();
}
});
return () => scrollController.removeListener(listener);
},
[scrollController],
);
return SliverAppBar(
backgroundColor: Colors.transparent,
elevation: 0,
floating: true,
floating: false,
pinned: true,
primary: true,
snap: true,
actions: [
// cast button
IconButton(onPressed: () {}, icon: const Icon(Icons.cast)),
IconButton(onPressed: () {}, icon: const Icon(Icons.more_vert)),
// IconButton(
// icon: const Icon(Icons.cast),
// onPressed: () {
// // Handle search action
// },
// ),
],
title: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: showTitle.value
? Text(
// Use a Key to help AnimatedSwitcher differentiate widgets
key: const ValueKey('title-text'),
item?.media.metadata.title ?? '',
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
)
: const SizedBox(
// Also give it a key for differentiation
key: ValueKey('empty-title'),
width: 0, // Ensure it takes no space if possible
height: 0,
),
),
centerTitle: false,
);
}
}

View file

@ -1,47 +1,83 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:vaani/router/router.dart';
import 'package:vaani/api/library_provider.dart' show currentLibraryProvider;
import 'package:vaani/features/you/view/widgets/library_switch_chip.dart'
show showLibrarySwitcher;
import 'package:vaani/router/router.dart' show Routes;
import 'package:vaani/shared/icons/abs_icons.dart' show AbsIcons;
import 'package:vaani/shared/widgets/not_implemented.dart'
show showNotImplementedToast;
class LibraryBrowserPage extends HookConsumerWidget {
const LibraryBrowserPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentLibrary = ref.watch(currentLibraryProvider).valueOrNull;
// Determine the icon to use, with a fallback
final IconData libraryIconData =
AbsIcons.getIconByName(currentLibrary?.icon) ?? Icons.library_books;
// Determine the title text
final String appBarTitle = '${currentLibrary?.name ?? 'Your'} Library';
return Scaffold(
appBar: AppBar(
title: const Text('Library'),
backgroundColor: Colors.transparent,
),
// a list redirecting to authors, genres, and series pages
body: ListView(
children: [
ListTile(
title: const Text('Authors'),
leading: const Icon(Icons.person),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
// Use CustomScrollView to enable slivers
body: CustomScrollView(
slivers: <Widget>[
SliverAppBar(
pinned: true,
// floating: true, // Optional: uncomment if you want floating behavior
// snap:
// true, // Optional: uncomment if you want snapping behavior (usually with floating: true)
leading: IconButton(
icon: Icon(libraryIconData),
tooltip: 'Switch Library', // Helpful tooltip for users
onPressed: () {
showLibrarySwitcher(context, ref);
},
),
title: Text(appBarTitle),
),
ListTile(
title: const Text('Genres'),
leading: const Icon(Icons.category),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
ListTile(
title: const Text('Series'),
leading: const Icon(Icons.list),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
// Downloads
ListTile(
title: const Text('Downloads'),
leading: const Icon(Icons.download),
trailing: const Icon(Icons.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed(Routes.downloads.name);
},
SliverList(
delegate: SliverChildListDelegate(
[
ListTile(
title: const Text('Authors'),
leading: const Icon(Icons.person),
trailing: const Icon(Icons.chevron_right),
onTap: () {
showNotImplementedToast(context);
},
),
ListTile(
title: const Text('Genres'),
leading: const Icon(Icons.category),
trailing: const Icon(Icons.chevron_right),
onTap: () {
showNotImplementedToast(context);
},
),
ListTile(
title: const Text('Series'),
leading: const Icon(Icons.list),
trailing: const Icon(Icons.chevron_right),
onTap: () {
showNotImplementedToast(context);
},
),
// Downloads
ListTile(
title: const Text('Downloads'),
leading: const Icon(Icons.download),
trailing: const Icon(Icons.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed(Routes.downloads.name);
},
),
],
),
),
],
),

View file

@ -1,11 +1,11 @@
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:vaani/features/logging/core/logger.dart';
part 'logs_provider.g.dart';
@riverpod
@ -29,11 +29,23 @@ class Logs extends _$Logs {
}
Future<String> getZipFilePath() async {
final String targetZipPath = await generateZipFilePath();
var encoder = ZipFileEncoder();
encoder.create(await generateZipFilePath());
encoder.addFile(File(await getLoggingFilePath()));
encoder.close();
return encoder.zipPath;
encoder.create(targetZipPath);
final logFilePath = await getLoggingFilePath();
final logFile = File(logFilePath);
if (await logFile.exists()) {
// Check if log file exists before adding
await encoder.addFile(logFile);
} else {
// Handle case where log file doesn't exist? Maybe log a warning?
// Or create an empty file inside the zip? For now, just don't add.
debugPrint(
'Warning: Log file not found at $logFilePath, creating potentially empty zip.',
);
}
await encoder.close();
return targetZipPath;
}
}
@ -43,7 +55,7 @@ Future<String> generateZipFilePath() async {
}
String generateZipFileName() {
return 'vaani-${DateTime.now().toIso8601String()}.zip';
return 'vaani-${DateTime.now().microsecondsSinceEpoch}.zip';
}
Level parseLevel(String level) {

View file

@ -6,7 +6,7 @@ part of 'logs_provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$logsHash() => r'901376741d17ddbb889d1b7b96bc2882289720a0';
String _$logsHash() => r'aa9d3d56586cba6ddf69615320ea605d071ea5e2';
/// See also [Logs].
@ProviderFor(Logs)
@ -22,4 +22,4 @@ final logsProvider =
typedef _$Logs = AutoDisposeAsyncNotifier<List<LogRecord>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -1,6 +1,3 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -76,32 +73,77 @@ class LogsPage extends HookConsumerWidget {
}
},
),
IconButton(
tooltip: 'Download logs',
icon: const Icon(Icons.download),
onPressed: () async {
appLogger.info('Preparing logs for download');
final zipLogFilePath =
await ref.read(logsProvider.notifier).getZipFilePath();
// save to folder
String? outputFile = await FilePicker.platform.saveFile(
dialogTitle: 'Please select an output file:',
fileName: zipLogFilePath.split('/').last,
);
if (outputFile != null) {
try {
final file = File(outputFile);
final zipFile = File(zipLogFilePath);
await zipFile.copy(file.path);
} catch (e) {
appLogger.severe('Error saving file: $e');
}
} else {
appLogger.info('Download cancelled');
}
},
),
// downloads disabled since manage external storage permission was removed
// see https://gitlab.com/IzzyOnDroid/repo/-/issues/623#note_2240386369
// IconButton(
// tooltip: 'Download logs',
// icon: const Icon(Icons.download),
// onPressed: () async {
// appLogger.info('Preparing logs for download');
// if (Platform.isAndroid) {
// final androidVersion =
// await ref.watch(deviceSdkVersionProvider.future);
// if ((int.parse(androidVersion)) > 29) {
// final status = await Permission.storage.status;
// if (!status.isGranted) {
// appLogger
// .info('Requesting storage permission');
// final newStatus =
// await Permission.storage.request();
// if (!newStatus.isGranted) {
// appLogger
// .warning('storage permission denied');
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(
// content: Text('Storage permission denied'),
// ),
// );
// return;
// }
// }
// } else {
// final status = await Permission.storage.status;
// if (!status.isGranted) {
// appLogger.info('Requesting storage permission');
// final newStatus = await Permission.storage.request();
// if (!newStatus.isGranted) {
// appLogger.warning('Storage permission denied');
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(
// content: Text('Storage permission denied'),
// ),
// );
// return;
// }
// }
// }
// }
// final zipLogFilePath =
// await ref.read(logsProvider.notifier).getZipFilePath();
// // save to folder
// String? outputFile = await FilePicker.platform.saveFile(
// dialogTitle: 'Please select an output file:',
// fileName: zipLogFilePath.split('/').last,
// bytes: await File(zipLogFilePath).readAsBytes(),
// );
// if (outputFile != null) {
// try {
// final file = File(outputFile);
// final zipFile = File(zipLogFilePath);
// await zipFile.copy(file.path);
// appLogger.info('File saved to: $outputFile');
// } catch (e) {
// appLogger.severe('Error saving file: $e');
// }
// } else {
// appLogger.info('Download cancelled');
// }
// },
// ),
IconButton(
tooltip: 'Refresh logs',
icon: const Icon(Icons.refresh),
@ -122,7 +164,7 @@ class LogsPage extends HookConsumerWidget {
),
],
),
// a column with listview.builder and a scrollable list of logs
// a column with ListView.builder and a scrollable list of logs
body: Column(
children: [
// a filter for log levels, loggers, and search

View file

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:vaani/api/api_provider.dart';
import 'package:vaani/models/error_response.dart';
@ -61,7 +62,7 @@ class OauthFlows extends _$OauthFlows {
/// the code returned by the server in exchange for the verifier
@riverpod
Future<String?> loginInExchangeForCode(
LoginInExchangeForCodeRef ref, {
Ref ref, {
required State oauthState,
required Code code,
ErrorResponseHandler? responseHandler,

View file

@ -7,7 +7,7 @@ part of 'oauth_provider.dart';
// **************************************************************************
String _$loginInExchangeForCodeHash() =>
r'e931254959d9eb8196439c6b0c884c26cbe17c2f';
r'bfc3945529048a0f536052fd5579b76457560fcd';
/// Copied from Dart SDK
class _SystemHash {
@ -179,6 +179,8 @@ class LoginInExchangeForCodeProvider
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin LoginInExchangeForCodeRef on AutoDisposeFutureProviderRef<String?> {
/// The parameter `oauthState` of this provider.
String get oauthState;
@ -221,4 +223,4 @@ final oauthFlowsProvider =
typedef _$OauthFlows = Notifier<Map<State, Flow>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -98,7 +98,6 @@ class BackToLoginButton extends StatelessWidget {
class _SomethingWentWrong extends StatelessWidget {
const _SomethingWentWrong({
super.key,
this.message = 'Error with OAuth flow',
});

View file

@ -13,89 +13,122 @@ class OnboardingSinglePage extends HookConsumerWidget {
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Center(
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 600,
minWidth:
constraints.maxWidth < 600 ? constraints.maxWidth : 0,
),
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 20.0),
child: SafeArea(child: OnboardingBody()),
),
),
),
);
},
),
);
}
}
Widget fadeSlideTransitionBuilder(
Widget child,
Animation<double> animation,
) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.3),
end: const Offset(0, 0),
).animate(animation),
child: child,
),
);
}
class OnboardingBody extends HookConsumerWidget {
const OnboardingBody({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final apiSettings = ref.watch(apiSettingsProvider);
final serverUriController = useTextEditingController(
text: apiSettings.activeServer?.serverUrl.toString() ?? '',
text: apiSettings.activeServer?.serverUrl.toString() ?? 'https://',
);
var audiobookshelfUri = makeBaseUrl(serverUriController.text);
final canUserLogin = useState(apiSettings.activeServer != null);
fadeSlideTransitionBuilder(
Widget child,
Animation<double> animation,
) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.3),
end: const Offset(0, 0),
).animate(animation),
child: child,
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Welcome to Vaani',
style: Theme.of(context).textTheme.headlineSmall,
),
),
);
}
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Welcome to Vaani',
style: Theme.of(context).textTheme.headlineSmall,
),
),
const SizedBox.square(
dimension: 16.0,
),
Padding(
padding: const EdgeInsets.all(8.0),
child: AnimatedSwitcher(
duration: 500.ms,
transitionBuilder: fadeSlideTransitionBuilder,
child: canUserLogin.value
? Text(
'Server connected, please login',
key: const ValueKey('connected'),
style: Theme.of(context).textTheme.bodyMedium,
)
: Text(
'Please enter the URL of your AudiobookShelf Server',
key: const ValueKey('not_connected'),
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: AddNewServer(
controller: serverUriController,
allowEmpty: true,
onPressed: () {
canUserLogin.value = serverUriController.text.isNotEmpty;
},
),
),
AnimatedSwitcher(
const SizedBox.square(
dimension: 16.0,
),
Padding(
padding: const EdgeInsets.all(8.0),
child: AnimatedSwitcher(
duration: 500.ms,
transitionBuilder: fadeSlideTransitionBuilder,
child: canUserLogin.value
? UserLoginWidget(
server: audiobookshelfUri,
? Text(
'Server connected, please login',
key: const ValueKey('connected'),
style: Theme.of(context).textTheme.bodyMedium,
)
// ).animate().fade(duration: 600.ms).slideY(begin: 0.3, end: 0)
: const RedirectToABS().animate().fadeIn().slideY(
curve: Curves.easeInOut,
duration: 500.ms,
),
: Text(
'Please enter the URL of your AudiobookShelf Server',
key: const ValueKey('not_connected'),
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: AddNewServer(
controller: serverUriController,
allowEmpty: true,
onPressed: () {
canUserLogin.value = serverUriController.text.isNotEmpty;
},
),
),
const SizedBox.square(
dimension: 16.0,
),
AnimatedSwitcher(
duration: 500.ms,
transitionBuilder: fadeSlideTransitionBuilder,
child: canUserLogin.value
? UserLoginWidget(
server: audiobookshelfUri,
)
// ).animate().fade(duration: 600.ms).slideY(begin: 0.3, end: 0)
: const RedirectToABS().animate().fadeIn().slideY(
curve: Curves.easeInOut,
duration: 500.ms,
),
),
],
);
}
}

View file

@ -1,33 +1,42 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/api/api_provider.dart';
import 'package:vaani/api/server_provider.dart';
import 'package:vaani/features/onboarding/view/user_login_with_open_id.dart';
import 'package:vaani/features/onboarding/view/user_login_with_password.dart';
import 'package:vaani/features/onboarding/view/user_login_with_token.dart';
import 'package:vaani/hacks/fix_autofill_losing_focus.dart';
import 'package:vaani/models/error_response.dart';
import 'package:vaani/settings/api_settings_provider.dart';
import 'package:shelfsdk/audiobookshelf_api.dart' show AuthMethod;
import 'package:vaani/api/api_provider.dart' show serverStatusProvider;
import 'package:vaani/api/server_provider.dart'
show ServerAlreadyExistsException, audiobookShelfServerProvider;
import 'package:vaani/features/onboarding/view/onboarding_single_page.dart'
show fadeSlideTransitionBuilder;
import 'package:vaani/features/onboarding/view/user_login_with_open_id.dart'
show UserLoginWithOpenID;
import 'package:vaani/features/onboarding/view/user_login_with_password.dart'
show UserLoginWithPassword;
import 'package:vaani/features/onboarding/view/user_login_with_token.dart'
show UserLoginWithToken;
import 'package:vaani/hacks/fix_autofill_losing_focus.dart'
show InactiveFocusScopeObserver;
import 'package:vaani/models/error_response.dart' show ErrorResponseHandler;
import 'package:vaani/settings/api_settings_provider.dart'
show apiSettingsProvider;
import 'package:vaani/settings/models/models.dart' as model;
class UserLoginWidget extends HookConsumerWidget {
UserLoginWidget({
const UserLoginWidget({
super.key,
required this.server,
this.onSuccess,
});
final Uri server;
final serverStatusError = ErrorResponseHandler();
final Function(model.AuthenticatedUser)? onSuccess;
@override
Widget build(BuildContext context, WidgetRef ref) {
final serverStatusError = useMemoized(() => ErrorResponseHandler(), []);
final serverStatus =
ref.watch(serverStatusProvider(server, serverStatusError.storeError));
final api = ref.watch(audiobookshelfApiProvider(server));
return serverStatus.when(
data: (value) {
if (value == null) {
@ -42,6 +51,7 @@ class UserLoginWidget extends HookConsumerWidget {
openIDAvailable:
value.authMethods?.contains(AuthMethod.openid) ?? false,
openIDButtonText: value.authFormData?.authOpenIDButtonText,
onSuccess: onSuccess,
);
},
loading: () {
@ -88,6 +98,7 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
this.openIDAvailable = false,
this.onPressed,
this.openIDButtonText,
this.onSuccess,
});
final Uri server;
@ -95,6 +106,7 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
final bool openIDAvailable;
final void Function()? onPressed;
final String? openIDButtonText;
final Function(model.AuthenticatedUser)? onSuccess;
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -104,8 +116,6 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
localAvailable ? AuthMethodChoice.local : AuthMethodChoice.authToken,
);
final apiSettings = ref.watch(apiSettingsProvider);
model.AudiobookShelfServer addServer() {
var newServer = model.AudiobookShelfServer(
serverUrl: server,
@ -119,9 +129,9 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
newServer = e.server;
} finally {
ref.read(apiSettingsProvider.notifier).updateState(
apiSettings.copyWith(
activeServer: newServer,
),
ref.read(apiSettingsProvider).copyWith(
activeServer: newServer,
),
);
}
return newServer;
@ -130,11 +140,11 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
return Center(
child: InactiveFocusScopeObserver(
child: AutofillGroup(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Wrap(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Wrap(
// mainAxisAlignment: MainAxisAlignment.center,
spacing: 10,
runAlignment: WrapAlignment.center,
@ -172,28 +182,38 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
}
},
),
],
].animate(interval: 100.ms).fadeIn(
duration: 150.ms,
curve: Curves.easeIn,
),
),
const SizedBox.square(
dimension: 8,
),
Padding(
padding: const EdgeInsets.all(8.0),
child: AnimatedSwitcher(
duration: 200.ms,
transitionBuilder: fadeSlideTransitionBuilder,
child: switch (methodChoice.value) {
AuthMethodChoice.authToken => UserLoginWithToken(
server: server,
addServer: addServer,
onSuccess: onSuccess,
),
AuthMethodChoice.local => UserLoginWithPassword(
server: server,
addServer: addServer,
onSuccess: onSuccess,
),
AuthMethodChoice.openid => UserLoginWithOpenID(
server: server,
addServer: addServer,
openIDButtonText: openIDButtonText,
onSuccess: onSuccess,
),
},
),
switch (methodChoice.value) {
AuthMethodChoice.authToken => UserLoginWithToken(
server: server,
addServer: addServer,
),
AuthMethodChoice.local => UserLoginWithPassword(
server: server,
addServer: addServer,
),
AuthMethodChoice.openid => UserLoginWithOpenID(
server: server,
addServer: addServer,
openIDButtonText: openIDButtonText,
),
},
],
),
),
],
),
),
),

View file

@ -20,12 +20,14 @@ class UserLoginWithOpenID extends HookConsumerWidget {
required this.server,
required this.addServer,
this.openIDButtonText,
this.onSuccess,
});
final Uri server;
final model.AudiobookShelfServer Function() addServer;
final String? openIDButtonText;
final responseErrorHandler = ErrorResponseHandler(name: 'OpenID');
final Function(model.AuthenticatedUser)? onSuccess;
@override
Widget build(BuildContext context, WidgetRef ref) {

View file

@ -5,10 +5,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lottie/lottie.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/api/api_provider.dart';
import 'package:vaani/api/authenticated_user_provider.dart';
import 'package:vaani/api/authenticated_users_provider.dart';
import 'package:vaani/hacks/fix_autofill_losing_focus.dart';
import 'package:vaani/models/error_response.dart';
import 'package:vaani/router/router.dart';
import 'package:vaani/settings/constants.dart';
import 'package:vaani/settings/models/models.dart' as model;
import 'package:vaani/shared/utils.dart';
@ -17,17 +18,20 @@ class UserLoginWithPassword extends HookConsumerWidget {
super.key,
required this.server,
required this.addServer,
this.onSuccess,
});
final Uri server;
final model.AudiobookShelfServer Function() addServer;
final serverErrorResponse = ErrorResponseHandler();
final Function(model.AuthenticatedUser)? onSuccess;
@override
Widget build(BuildContext context, WidgetRef ref) {
final usernameController = useTextEditingController();
final passwordController = useTextEditingController();
final isPasswordVisibleAnimationController = useAnimationController(
initialValue: 1,
duration: const Duration(milliseconds: 500),
);
@ -76,92 +80,94 @@ class UserLoginWithPassword extends HookConsumerWidget {
final authenticatedUser = model.AuthenticatedUser(
server: addServer(),
id: success.user.id,
password: password,
username: username,
authToken: api.token!,
);
// add the user to the list of users
ref
.read(authenticatedUserProvider.notifier)
.addUser(authenticatedUser, setActive: true);
// redirect to the library page
GoRouter.of(context).goNamed(Routes.home.name);
if (onSuccess != null) {
onSuccess!(authenticatedUser);
} else {
// add the user to the list of users
ref
.read(authenticatedUsersProvider.notifier)
.addUser(authenticatedUser, setActive: true);
context.goNamed(Routes.home.name);
}
}
return Center(
child: InactiveFocusScopeObserver(
child: AutofillGroup(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextFormField(
controller: usernameController,
autofocus: true,
autofillHints: const [AutofillHints.username],
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: 'Username',
labelStyle: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.8),
),
border: const OutlineInputBorder(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextFormField(
controller: usernameController,
autofocus: true,
autofillHints: const [AutofillHints.username],
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: 'Username',
labelStyle: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.8),
),
border: const OutlineInputBorder(),
),
const SizedBox(height: 10),
TextFormField(
controller: passwordController,
autofillHints: const [AutofillHints.password],
textInputAction: TextInputAction.done,
obscureText: !isPasswordVisible.value,
onFieldSubmitted: (_) {
loginAndSave();
},
decoration: InputDecoration(
labelText: 'Password',
labelStyle: TextStyle(
color: Theme.of(context)
),
const SizedBox(height: 10),
TextFormField(
controller: passwordController,
autofillHints: const [AutofillHints.password],
textInputAction: TextInputAction.done,
obscureText: !isPasswordVisible.value,
onFieldSubmitted: (_) {
loginAndSave();
},
decoration: InputDecoration(
labelText: 'Password',
labelStyle: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.8),
),
border: const OutlineInputBorder(),
suffixIcon: ColorFiltered(
colorFilter: ColorFilter.mode(
Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.8),
.primary
.withValues(alpha: 0.8),
BlendMode.srcIn,
),
border: const OutlineInputBorder(),
suffixIcon: ColorFiltered(
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary.withOpacity(0.8),
BlendMode.srcIn,
),
child: InkWell(
borderRadius: BorderRadius.circular(50),
onTap: () {
isPasswordVisible.value = !isPasswordVisible.value;
},
child: Container(
margin: const EdgeInsets.only(left: 8, right: 8),
child: Lottie.asset(
'assets/animations/Animation - 1714930099660.json',
controller: isPasswordVisibleAnimationController,
),
child: InkWell(
borderRadius: BorderRadius.circular(50),
onTap: () {
isPasswordVisible.value = !isPasswordVisible.value;
},
child: Container(
margin: const EdgeInsets.only(left: 8, right: 8),
child: Lottie.asset(
'assets/animations/Animation - 1714930099660.json',
controller: isPasswordVisibleAnimationController,
),
),
),
suffixIconConstraints: const BoxConstraints(
maxHeight: 45,
),
),
suffixIconConstraints: const BoxConstraints(
maxHeight: 45,
),
),
const SizedBox(height: 30),
ElevatedButton(
onPressed: loginAndSave,
child: const Text('Login'),
),
],
),
),
const SizedBox(height: 30),
ElevatedButton(
onPressed: loginAndSave,
child: const Text('Login'),
),
],
),
),
),
@ -207,8 +213,10 @@ Future<void> handleServerError(
onPressed: () {
// open an issue on the github page
handleLaunchUrl(
Uri.parse(
'https://github.com/Dr-Blank/Vaani/issues',
AppMetadata.githubRepo
// append the issue url
.replace(
path: '${AppMetadata.githubRepo.path}/issues/new',
),
);
},

View file

@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/api/api_provider.dart';
import 'package:vaani/api/authenticated_user_provider.dart';
import 'package:vaani/api/authenticated_users_provider.dart';
import 'package:vaani/models/error_response.dart';
import 'package:vaani/router/router.dart';
import 'package:vaani/settings/models/models.dart' as model;
@ -14,11 +14,13 @@ class UserLoginWithToken extends HookConsumerWidget {
super.key,
required this.server,
required this.addServer,
this.onSuccess,
});
final Uri server;
final model.AudiobookShelfServer Function() addServer;
final serverErrorResponse = ErrorResponseHandler();
final Function(model.AuthenticatedUser)? onSuccess;
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -65,11 +67,14 @@ class UserLoginWithToken extends HookConsumerWidget {
authToken: api.token!,
);
ref
.read(authenticatedUserProvider.notifier)
.addUser(authenticatedUser, setActive: true);
context.goNamed(Routes.home.name);
if (onSuccess != null) {
onSuccess!(authenticatedUser);
} else {
ref
.read(authenticatedUsersProvider.notifier)
.addUser(authenticatedUser, setActive: true);
context.goNamed(Routes.home.name);
}
}
return Form(
@ -84,7 +89,10 @@ class UserLoginWithToken extends HookConsumerWidget {
decoration: InputDecoration(
labelText: 'API Token',
labelStyle: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.8),
),
border: const OutlineInputBorder(),
),

View file

@ -157,6 +157,8 @@ class BookSettingsProvider
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin BookSettingsRef on AutoDisposeNotifierProviderRef<model.BookSettings> {
/// The parameter `bookId` of this provider.
String get bookId;
@ -171,4 +173,4 @@ class _BookSettingsProviderElement
String get bookId => (origin as BookSettingsProvider).bookId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -23,4 +23,4 @@ final playbackReporterProvider =
typedef _$PlaybackReporter = AsyncNotifier<core.PlaybackReporter>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -22,4 +22,4 @@ final playlistProvider =
typedef _$Playlist = AutoDisposeNotifier<AudiobookPlaylist>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -43,4 +43,4 @@ final audiobookPlayerProvider =
typedef _$AudiobookPlayer = Notifier<core.AudiobookPlayer>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -1,3 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
@ -9,7 +10,7 @@ part 'currently_playing_provider.g.dart';
final _logger = Logger('CurrentlyPlayingProvider');
@riverpod
BookExpanded? currentlyPlayingBook(CurrentlyPlayingBookRef ref) {
BookExpanded? currentlyPlayingBook(Ref ref) {
try {
final player = ref.watch(audiobookPlayerProvider);
return player.book;
@ -21,7 +22,7 @@ BookExpanded? currentlyPlayingBook(CurrentlyPlayingBookRef ref) {
/// provided the current chapter of the book being played
@riverpod
BookChapter? currentPlayingChapter(CurrentPlayingChapterRef ref) {
BookChapter? currentPlayingChapter(Ref ref) {
final player = ref.watch(audiobookPlayerProvider);
player.slowPositionStream.listen((_) {
ref.invalidateSelf();
@ -32,7 +33,7 @@ BookChapter? currentPlayingChapter(CurrentPlayingChapterRef ref) {
/// provides the book metadata of the currently playing book
@riverpod
BookMetadataExpanded? currentBookMetadata(CurrentBookMetadataRef ref) {
BookMetadataExpanded? currentBookMetadata(Ref ref) {
final player = ref.watch(audiobookPlayerProvider);
if (player.book == null) return null;
return player.book!.metadata.asBookMetadataExpanded;

View file

@ -7,7 +7,7 @@ part of 'currently_playing_provider.dart';
// **************************************************************************
String _$currentlyPlayingBookHash() =>
r'7440b0d54cb364f66e704783652e8f1490ae90e0';
r'e4258694c8f0d1e89651b330fae0f672ca13a484';
/// See also [currentlyPlayingBook].
@ProviderFor(currentlyPlayingBook)
@ -22,9 +22,11 @@ final currentlyPlayingBookProvider =
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef CurrentlyPlayingBookRef = AutoDisposeProviderRef<BookExpanded?>;
String _$currentPlayingChapterHash() =>
r'a084da724e3d8bb1b1475e867ab3200d7d61d827';
r'73db8b8a9058573bb0c68ec5d5f8aba9306f3d24';
/// provided the current chapter of the book being played
///
@ -41,9 +43,11 @@ final currentPlayingChapterProvider =
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef CurrentPlayingChapterRef = AutoDisposeProviderRef<BookChapter?>;
String _$currentBookMetadataHash() =>
r'9088debba151894b61f2dcba1bba12a89244b9b1';
r'f537ef4ef19280bc952de658ecf6520c535ae344';
/// provides the book metadata of the currently playing book
///
@ -60,6 +64,8 @@ final currentBookMetadataProvider =
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef CurrentBookMetadataRef = AutoDisposeProviderRef<BookMetadataExpanded?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:miniplayer/miniplayer.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:vaani/features/player/providers/audiobook_player.dart';
part 'player_form.g.dart';
@ -26,7 +27,7 @@ extension on Ref {
@Riverpod(keepAlive: true)
Raw<ValueNotifier<double>> playerExpandProgressNotifier(
PlayerExpandProgressNotifierRef ref,
Ref ref,
) {
final ValueNotifier<double> playerExpandProgress =
ValueNotifier(playerMinHeight);
@ -46,7 +47,7 @@ Raw<ValueNotifier<double>> playerExpandProgressNotifier(
// a provider that will listen to the playerExpandProgressNotifier and return the percentage of the player expanded
@Riverpod(keepAlive: true)
double playerHeight(
PlayerHeightRef ref,
Ref ref,
) {
final playerExpandProgress = ref.watch(playerExpandProgressNotifierProvider);
@ -60,3 +61,20 @@ double playerHeight(
}
final audioBookMiniplayerController = MiniplayerController();
@Riverpod(keepAlive: true)
bool isPlayerActive(
Ref ref,
) {
try {
final player = ref.watch(audiobookPlayerProvider);
if (player.book != null) {
return true;
} else {
final playerHeight = ref.watch(playerHeightProvider);
return playerHeight < playerMinHeight;
}
} catch (e) {
return false;
}
}

View file

@ -7,7 +7,7 @@ part of 'player_form.dart';
// **************************************************************************
String _$playerExpandProgressNotifierHash() =>
r'e4817361b9a311b61ca23e51082ed11b0a1120ab';
r'1ac7172d90a070f96222286edd1a176be197f378';
/// See also [playerExpandProgressNotifier].
@ProviderFor(playerExpandProgressNotifier)
@ -22,9 +22,11 @@ final playerExpandProgressNotifierProvider =
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef PlayerExpandProgressNotifierRef
= ProviderRef<Raw<ValueNotifier<double>>>;
String _$playerHeightHash() => r'26dbcb180d494575488d700bd5bdb58c02c224a9';
String _$playerHeightHash() => r'3f031eaffdffbb2c6ddf7eb1aba31bf1619260fc';
/// See also [playerHeight].
@ProviderFor(playerHeight)
@ -37,6 +39,25 @@ final playerHeightProvider = Provider<double>.internal(
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef PlayerHeightRef = ProviderRef<double>;
String _$isPlayerActiveHash() => r'2c7ca125423126fb5f0ef218d37bc8fe0ca9ec98';
/// See also [isPlayerActive].
@ProviderFor(isPlayerActive)
final isPlayerActiveProvider = Provider<bool>.internal(
isPlayerActive,
name: r'isPlayerActiveProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$isPlayerActiveHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef IsPlayerActiveRef = ProviderRef<bool>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -14,7 +14,7 @@ import 'package:vaani/features/player/providers/player_form.dart';
import 'package:vaani/settings/app_settings_provider.dart';
import 'package:vaani/shared/extensions/inverse_lerp.dart';
import 'package:vaani/shared/widgets/shelves/book_shelf.dart';
import 'package:vaani/theme/theme_from_cover_provider.dart';
import 'package:vaani/theme/providers/theme_from_cover_provider.dart';
import 'player_when_expanded.dart';
import 'player_when_minimized.dart';
@ -65,6 +65,8 @@ class AudiobookPlayer extends HookConsumerWidget {
themeOfLibraryItemProvider(
itemBeingPlayed.valueOrNull?.id,
brightness: Theme.of(context).brightness,
highContrast: appSettings.themeSettings.highContrast ||
MediaQuery.of(context).highContrast,
),
);

View file

@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:vaani/features/player/providers/player_form.dart';
class MiniPlayerBottomPadding extends HookConsumerWidget {
const MiniPlayerBottomPadding({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return AnimatedSize(
duration: const Duration(milliseconds: 200),
child: ref.watch(isPlayerActiveProvider)
? const SizedBox(height: playerMinHeight + 8)
: const SizedBox.shrink(),
);
}
}

View file

@ -104,8 +104,10 @@ class PlayerWhenExpanded extends HookConsumerWidget {
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color:
Theme.of(context).colorScheme.primary.withOpacity(0.1),
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.1),
blurRadius: 32 * earlyPercentage,
spreadRadius: 8 * earlyPercentage,
// offset: Offset(0, 16 * earlyPercentage),
@ -171,7 +173,7 @@ class PlayerWhenExpanded extends HookConsumerWidget {
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.7),
.withValues(alpha: 0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,

View file

@ -93,7 +93,7 @@ class PlayerWhenMinimized extends HookConsumerWidget {
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.7),
.withValues(alpha: 0.7),
),
),
],

View file

@ -1,13 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:vaani/features/player/providers/audiobook_player.dart';
import 'package:vaani/features/player/providers/currently_playing_provider.dart';
import 'package:vaani/features/player/view/player_when_expanded.dart';
import 'package:vaani/main.dart';
import 'package:vaani/shared/extensions/chapter.dart';
import 'package:vaani/shared/extensions/duration_format.dart';
import 'package:vaani/shared/hooks.dart';
import 'package:vaani/features/player/providers/audiobook_player.dart'
show audiobookPlayerProvider;
import 'package:vaani/features/player/providers/currently_playing_provider.dart'
show currentPlayingChapterProvider, currentlyPlayingBookProvider;
import 'package:vaani/features/player/view/player_when_expanded.dart'
show pendingPlayerModals;
import 'package:vaani/features/player/view/widgets/playing_indicator_icon.dart';
import 'package:vaani/main.dart' show appLogger;
import 'package:vaani/shared/extensions/chapter.dart' show ChapterDuration;
import 'package:vaani/shared/extensions/duration_format.dart'
show DurationFormat;
import 'package:vaani/shared/hooks.dart' show useTimer;
class ChapterSelectionButton extends HookConsumerWidget {
const ChapterSelectionButton({
@ -67,6 +72,7 @@ class ChapterSelectionModal extends HookConsumerWidget {
useTimer(scrollToCurrentChapter, 500.ms);
// useInterval(scrollToCurrentChapter, 500.ms);
final theme = Theme.of(context);
return Column(
children: [
ListTile(
@ -81,24 +87,41 @@ class ChapterSelectionModal extends HookConsumerWidget {
child: currentBook?.chapters == null
? const Text('No chapters found')
: Column(
children: [
for (final chapter in currentBook!.chapters)
ListTile(
title: Text(chapter.title),
trailing: Text(
'(${chapter.duration.smartBinaryFormat})',
),
selected: currentChapterIndex == chapter.id,
key: currentChapterIndex == chapter.id
? chapterKey
children: currentBook!.chapters.map(
(chapter) {
final isCurrent = currentChapterIndex == chapter.id;
final isPlayed = currentChapterIndex != null &&
chapter.id < currentChapterIndex;
return ListTile(
autofocus: isCurrent,
iconColor: isPlayed && !isCurrent
? theme.disabledColor
: null,
title: Text(
chapter.title,
style: isPlayed && !isCurrent
? TextStyle(color: theme.disabledColor)
: null,
),
subtitle: Text(
'(${chapter.duration.smartBinaryFormat})',
style: isPlayed && !isCurrent
? TextStyle(color: theme.disabledColor)
: null,
),
trailing: isCurrent
? const PlayingIndicatorIcon()
: const Icon(Icons.play_arrow),
selected: isCurrent,
key: isCurrent ? chapterKey : null,
onTap: () {
Navigator.of(context).pop();
notifier.seek(chapter.start + 90.ms);
notifier.play();
},
),
],
);
},
).toList(),
),
),
),

View file

@ -0,0 +1,194 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
/// An icon that animates like audio equalizer bars to indicate playback.
///
/// Creates multiple vertical bars that independently animate their height
/// in a looping, visually dynamic pattern.
class PlayingIndicatorIcon extends StatefulWidget {
/// The number of vertical bars in the indicator.
final int barCount;
/// The total width and height of the icon area.
final double size;
/// The color of the bars. Defaults to the current [IconTheme] color.
final Color? color;
/// The minimum height factor for a bar (relative to [size]).
/// When [centerSymmetric] is true, this represents the minimum height
/// extending from the center line (so total minimum height is 2 * minHeightFactor * size).
/// When false, it's the minimum height from the bottom.
final double minHeightFactor;
/// The maximum height factor for a bar (relative to [size]).
/// When [centerSymmetric] is true, this represents the maximum height
/// extending from the center line (so total maximum height is 2 * maxHeightFactor * size).
/// When false, it's the maximum height from the bottom.
final double maxHeightFactor;
/// Base duration for a full up/down animation cycle for a single bar.
/// Actual duration will vary slightly per bar.
final Duration baseCycleDuration;
/// If true, the bars animate symmetrically expanding/collapsing from the
/// horizontal center line. If false (default), they expand/collapse from
/// the bottom edge.
final bool centerSymmetric;
const PlayingIndicatorIcon({
super.key,
this.barCount = 4,
this.size = 20.0,
this.color,
this.minHeightFactor = 0.2,
this.maxHeightFactor = 1.0,
this.baseCycleDuration = const Duration(milliseconds: 350),
this.centerSymmetric = true,
});
@override
State<PlayingIndicatorIcon> createState() => _PlayingIndicatorIconState();
}
class _PlayingIndicatorIconState extends State<PlayingIndicatorIcon> {
late List<_BarAnimationParams> _animationParams;
final _random = Random();
@override
void initState() {
super.initState();
_animationParams =
List.generate(widget.barCount, _createRandomParams, growable: false);
}
// Helper to generate random parameters for one bar's animation cycle
_BarAnimationParams _createRandomParams(int index) {
final duration1 =
(widget.baseCycleDuration * (0.8 + _random.nextDouble() * 0.4));
final duration2 =
(widget.baseCycleDuration * (0.8 + _random.nextDouble() * 0.4));
// Note: These factors represent the scale relative to the *half-height*
// if centerSymmetric is true, controlled by the alignment in scaleY.
final targetHeightFactor1 = widget.minHeightFactor +
_random.nextDouble() *
(widget.maxHeightFactor - widget.minHeightFactor);
final targetHeightFactor2 = widget.minHeightFactor +
_random.nextDouble() *
(widget.maxHeightFactor - widget.minHeightFactor);
// --- Random initial delay ---
final initialDelay =
(_random.nextDouble() * (widget.baseCycleDuration.inMilliseconds / 4))
.ms;
return _BarAnimationParams(
duration1: duration1,
duration2: duration2,
targetHeightFactor1: targetHeightFactor1,
targetHeightFactor2: targetHeightFactor2,
initialDelay: initialDelay,
);
}
@override
Widget build(BuildContext context) {
final color = widget.color ??
IconTheme.of(context).color ??
Theme.of(context).colorScheme.primary;
// --- Bar geometry calculation ---
final double totalSpacing = widget.size * 0.2;
// Ensure at least 1px spacing if size is very small
final double barSpacing = max(1.0, totalSpacing / (widget.barCount + 1));
final double availableWidthForBars =
widget.size - (barSpacing * (widget.barCount + 1));
final double barWidth = max(1.0, availableWidthForBars / widget.barCount);
// Max height remains the full size potential for the container
final double maxHeight = widget.size;
// Determine the alignment for scaling based on the symmetric flag
final Alignment scaleAlignment =
widget.centerSymmetric ? Alignment.center : Alignment.bottomCenter;
// Determine the cross axis alignment for the Row
final CrossAxisAlignment rowAlignment = widget.centerSymmetric
? CrossAxisAlignment.center
: CrossAxisAlignment.end;
return SizedBox(
width: widget.size,
height: widget.size,
// Clip ensures bars don't draw outside the SizedBox bounds
// especially important for center alignment if maxFactor > 0.5
child: ClipRect(
child: Row(
// Use calculated alignment
crossAxisAlignment: rowAlignment,
// Use spaceEvenly for better distribution, especially with center alignment
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
widget.barCount,
(index) {
final params = _animationParams[index];
// The actual bar widget that will be animated
return Container(
width: barWidth,
// Set initial height to the max potential height
// The scaleY animation will control the visible height
height: maxHeight,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(barWidth / 2),
),
)
.animate(
delay: params.initialDelay,
onPlay: (controller) => controller.repeat(
reverse: true,
),
)
// 1. Scale to targetHeightFactor1
.scaleY(
begin:
widget.minHeightFactor, // Scale factor starts near min
end: params.targetHeightFactor1,
duration: params.duration1,
curve: Curves.easeInOutCirc,
alignment: scaleAlignment, // Apply chosen alignment
)
// 2. Then scale to targetHeightFactor2
.then()
.scaleY(
end: params.targetHeightFactor2,
duration: params.duration2,
curve: Curves.easeInOutCirc,
alignment: scaleAlignment, // Apply chosen alignment
);
},
growable: false,
),
),
),
);
}
}
// Helper class: Renamed height fields for clarity
class _BarAnimationParams {
final Duration duration1;
final Duration duration2;
final double targetHeightFactor1; // Factor relative to total size
final double targetHeightFactor2; // Factor relative to total size
final Duration initialDelay;
_BarAnimationParams({
required this.duration1,
required this.duration2,
required this.targetHeightFactor1,
required this.targetHeightFactor2,
required this.initialDelay,
});
}

View file

@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:list_wheel_scroll_view_nls/list_wheel_scroll_view_nls.dart';
import 'package:vaani/features/player/providers/audiobook_player.dart';
import 'package:vaani/settings/app_settings_provider.dart';
import 'package:vaani/shared/hooks.dart';
const double itemExtent = 25;

View file

@ -23,4 +23,4 @@ final shakeDetectorProvider =
typedef _$ShakeDetector = AutoDisposeNotifier<core.ShakeDetector?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -22,4 +22,4 @@ final sleepTimerProvider =
typedef _$SleepTimer = Notifier<core.SleepTimer?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -11,7 +11,6 @@ import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart'
import 'package:vaani/main.dart';
import 'package:vaani/settings/app_settings_provider.dart';
import 'package:vaani/shared/extensions/duration_format.dart';
import 'package:vaani/shared/hooks.dart';
class SleepTimerButton extends HookConsumerWidget {
const SleepTimerButton({

View file

@ -2,16 +2,22 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:vaani/api/api_provider.dart';
import 'package:vaani/api/authenticated_user_provider.dart';
import 'package:vaani/api/server_provider.dart';
import 'package:vaani/main.dart';
import 'package:vaani/models/error_response.dart';
import 'package:vaani/router/router.dart';
import 'package:vaani/settings/api_settings_provider.dart';
import 'package:vaani/api/api_provider.dart' show makeBaseUrl;
import 'package:vaani/api/authenticated_users_provider.dart'
show authenticatedUsersProvider;
import 'package:vaani/api/server_provider.dart'
show ServerAlreadyExistsException, audiobookShelfServerProvider;
import 'package:vaani/features/onboarding/view/user_login.dart'
show UserLoginWidget;
import 'package:vaani/features/player/view/mini_player_bottom_padding.dart'
show MiniPlayerBottomPadding;
import 'package:vaani/main.dart' show appLogger;
import 'package:vaani/router/router.dart' show Routes;
import 'package:vaani/settings/api_settings_provider.dart'
show apiSettingsProvider;
import 'package:vaani/settings/models/models.dart' as model;
import 'package:vaani/shared/extensions/obfuscation.dart';
import 'package:vaani/shared/widgets/add_new_server.dart';
import 'package:vaani/shared/extensions/obfuscation.dart' show ObfuscateSet;
import 'package:vaani/shared/widgets/add_new_server.dart' show AddNewServer;
class ServerManagerPage extends HookConsumerWidget {
const ServerManagerPage({
@ -20,15 +26,6 @@ class ServerManagerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final apiSettings = ref.watch(apiSettingsProvider);
final registeredServers = ref.watch(audiobookShelfServerProvider);
final registeredServersAsList = registeredServers.toList();
final availableUsers = ref.watch(authenticatedUserProvider);
final serverURIController = useTextEditingController();
final formKey = GlobalKey<FormState>();
appLogger.fine('registered servers: ${registeredServers.obfuscate()}');
appLogger.fine('available users: ${availableUsers.obfuscate()}');
return Scaffold(
appBar: AppBar(
title: const Text('Manage Accounts'),
@ -36,240 +33,118 @@ class ServerManagerPage extends HookConsumerWidget {
body: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
// crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Text(
'Registered Servers',
),
Expanded(
child: ListView.builder(
itemCount: registeredServers.length,
reverse: true,
itemBuilder: (context, index) {
var registeredServer = registeredServersAsList[index];
return ExpansionTile(
title: Text(registeredServer.serverUrl.toString()),
subtitle: Text(
'Users: ${availableUsers.where((element) => element.server == registeredServer).length}',
),
// trailing: _DeleteServerButton(
// registeredServer: registeredServer,
// ),
// children are list of users of this server
children: availableUsers
.where(
(element) => element.server == registeredServer,
)
.map(
(e) => ListTile(
selected: apiSettings.activeUser == e,
leading: apiSettings.activeUser == e
? const Icon(Icons.person)
: const Icon(Icons.person_off_outlined),
title: Text(e.username ?? 'Anonymous'),
onTap: apiSettings.activeUser == e
? null
: () {
ref
.read(apiSettingsProvider.notifier)
.updateState(
apiSettings.copyWith(
activeUser: e,
),
);
// pop all routes and go to the home page
// while (context.canPop()) {
// context.pop();
// }
context.goNamed(
Routes.home.name,
);
},
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete User'),
content: const Text(
'Are you sure you want to delete this user?',
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref
.read(
authenticatedUserProvider
.notifier,
)
.removeUser(e);
Navigator.of(context).pop();
},
child: const Text('Delete'),
),
],
);
},
);
},
),
),
)
.nonNulls
.toList()
// add buttons of delete server and add user to server at the end
..addAll([
ListTile(
leading: const Icon(Icons.person_add),
title: const Text('Add User'),
onTap: () async {
// open a dialog to add a new user with username and password or another method using only auth token
final addedUser = await showDialog(
context: context,
builder: (context) {
return _AddUserDialog(
server: registeredServer,
);
},
);
// if (addedUser != null) {
// // show a snackbar that the user has been added and ask if change to this user
// ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(
// content: const Text(
// 'User added successfully, do you want to switch to this user?',
// ),
// action: SnackBarAction(
// label: 'Switch',
// onPressed: () {
// // set the active user
// ref
// .read(apiSettingsProvider.notifier)
// .updateState(
// apiSettings.copyWith(
// activeUser: addedUser,
// ),
// );
// context.goNamed(Routes.home.name);
// },
// ),
// ),
// );
// }
},
),
ListTile(
leading: const Icon(Icons.delete),
title: const Text('Delete Server'),
onTap: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete Server'),
content: const Text(
'Are you sure you want to delete this server and all its users?',
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref
.read(
audiobookShelfServerProvider
.notifier,
)
.removeServer(
registeredServer,
removeUsers: true,
);
Navigator.of(context).pop();
},
child: const Text('Delete'),
),
],
);
},
);
},
),
]),
);
},
),
),
const SizedBox(height: 20),
const Padding(
padding: EdgeInsets.all(8.0),
child: Text('Add New Server'),
),
Form(
key: formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: AddNewServer(
controller: serverURIController,
onPressed: () {
if (formKey.currentState!.validate()) {
try {
final newServer = model.AudiobookShelfServer(
serverUrl: makeBaseUrl(serverURIController.text),
);
ref
.read(audiobookShelfServerProvider.notifier)
.addServer(
newServer,
);
ref.read(apiSettingsProvider.notifier).updateState(
apiSettings.copyWith(
activeServer: newServer,
),
);
serverURIController.clear();
} on ServerAlreadyExistsException catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
),
);
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid URL'),
),
);
}
},
),
),
],
),
child: ServerManagerBody(),
),
),
);
}
}
class _AddUserDialog extends HookConsumerWidget {
const _AddUserDialog({
class ServerManagerBody extends HookConsumerWidget {
const ServerManagerBody({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final registeredServers = ref.watch(audiobookShelfServerProvider);
final registeredServersAsList = registeredServers.toList();
final availableUsers = ref.watch(authenticatedUsersProvider);
final apiSettings = ref.watch(apiSettingsProvider);
final serverURIController = useTextEditingController();
final formKey = GlobalKey<FormState>();
appLogger.fine('registered servers: ${registeredServers.obfuscate()}');
appLogger.fine('available users: ${availableUsers.obfuscate()}');
return Column(
// crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Text(
'Registered Servers',
),
Expanded(
child: ListView.builder(
itemCount: registeredServers.length,
reverse: true,
itemBuilder: (context, index) {
var registeredServer = registeredServersAsList[index];
return ExpansionTile(
title: Text(registeredServer.serverUrl.toString()),
subtitle: Text(
'Users: ${availableUsers.where((element) => element.server == registeredServer).length}',
),
// children are list of users of this server
children: availableUsers
.where(
(element) => element.server == registeredServer,
)
.map<Widget>(
(e) => AvailableUserTile(user: e),
)
.nonNulls
.toList()
// add buttons of delete server and add user to server at the end
..addAll([
AddUserTile(server: registeredServer),
DeleteServerTile(server: registeredServer),
]),
);
},
),
),
const SizedBox(height: 20),
const Padding(
padding: EdgeInsets.all(8.0),
child: Text('Add New Server'),
),
Form(
key: formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: AddNewServer(
controller: serverURIController,
onPressed: () {
if (formKey.currentState!.validate()) {
try {
final newServer = model.AudiobookShelfServer(
serverUrl: makeBaseUrl(serverURIController.text),
);
ref.read(audiobookShelfServerProvider.notifier).addServer(
newServer,
);
ref.read(apiSettingsProvider.notifier).updateState(
apiSettings.copyWith(
activeServer: newServer,
),
);
serverURIController.clear();
} on ServerAlreadyExistsException catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
),
);
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid URL'),
),
);
}
},
),
),
MiniPlayerBottomPadding(),
],
);
}
}
class DeleteServerTile extends HookConsumerWidget {
const DeleteServerTile({
super.key,
required this.server,
});
@ -278,178 +153,220 @@ class _AddUserDialog extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final usernameController = useTextEditingController();
final passwordController = useTextEditingController();
final authTokensController = useTextEditingController();
final isPasswordVisible = useState(false);
final apiSettings = ref.watch(apiSettingsProvider);
final isMethodAuth = useState(false);
final api = ref.watch(audiobookshelfApiProvider(server.serverUrl));
final formKey = GlobalKey<FormState>();
final serverErrorResponse = ErrorResponseHandler();
/// Login to the server and save the user
Future<model.AuthenticatedUser?> loginAndSave() async {
model.AuthenticatedUser? authenticatedUser;
if (isMethodAuth.value) {
api.token = authTokensController.text;
final success = await api.misc.authorize(
responseErrorHandler: serverErrorResponse.storeError,
);
if (success != null) {
authenticatedUser = model.AuthenticatedUser(
server: server,
id: success.user.id,
username: success.user.username,
authToken: api.token!,
);
}
} else {
final username = usernameController.text;
final password = passwordController.text;
final success = await api.login(
username: username,
password: password,
responseErrorHandler: serverErrorResponse.storeError,
);
if (success != null) {
authenticatedUser = model.AuthenticatedUser(
server: server,
id: success.user.id,
username: username,
authToken: api.token!,
);
}
}
// add the user to the list of users
if (authenticatedUser != null) {
ref.read(authenticatedUserProvider.notifier).addUser(authenticatedUser);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Login failed. Got response: ${serverErrorResponse.response.body} (${serverErrorResponse.response.statusCode})',
),
),
);
}
return authenticatedUser;
}
return AlertDialog(
// title: Text('Add User for ${server.serverUrl}'),
title: Text.rich(
TextSpan(
children: [
TextSpan(
text: 'Add User for ',
style: Theme.of(context).textTheme.labelLarge,
),
TextSpan(
text: server.serverUrl.toString(),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
return ListTile(
leading: const Icon(Icons.delete),
title: const Text('Delete Server'),
onTap: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Remove Server and Users'),
// Make content scrollable in case of smaller screens/keyboard
content: SingleChildScrollView(
child: Text.rich(
TextSpan(
children: [
const TextSpan(
text: 'This will remove the server ',
),
TextSpan(
text: server.serverUrl.host,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const TextSpan(
text: ' and all its users\' login info from this app.',
),
],
),
),
],
),
),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
alignment: WrapAlignment.center,
spacing: 8.0,
children: [
ChoiceChip(
label: const Text('Username/Password'),
selected: !isMethodAuth.value,
onSelected: (selected) {
isMethodAuth.value = !selected;
},
),
ChoiceChip(
label: const Text('Auth Token'),
selected: isMethodAuth.value,
onSelected: (selected) {
isMethodAuth.value = selected;
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref
.read(
audiobookShelfServerProvider.notifier,
)
.removeServer(
server,
removeUsers: true,
);
Navigator.of(context).pop();
},
child: const Text('Delete'),
),
],
),
const SizedBox(height: 16),
if (isMethodAuth.value)
TextFormField(
controller: authTokensController,
decoration: const InputDecoration(labelText: 'Auth Token'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an auth token';
}
return null;
},
)
else ...[
TextFormField(
controller: usernameController,
decoration: const InputDecoration(labelText: 'Username'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a username';
}
return null;
},
),
TextFormField(
controller: passwordController,
decoration: InputDecoration(
labelText: 'Password',
suffixIcon: IconButton(
icon: Icon(
isPasswordVisible.value
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () {
isPasswordVisible.value = !isPasswordVisible.value;
},
),
),
obscureText: !isPasswordVisible.value,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a password';
}
return null;
},
),
],
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
);
},
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
if (formKey.currentState!.validate()) {
final addedUser = await loginAndSave();
if (addedUser != null) {
Navigator.of(context).pop(addedUser);
}
}
},
child: const Text('Add User'),
),
],
);
},
);
}
}
class AddUserTile extends HookConsumerWidget {
const AddUserTile({
super.key,
required this.server,
});
final model.AudiobookShelfServer server;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: const Icon(Icons.person_add),
title: const Text('Add User'),
onTap: () async {
await showDialog(
context: context,
// barrierDismissible: false, // Optional: prevent closing by tapping outside
builder: (dialogContext) {
// Use a different context name to avoid conflicts
return AlertDialog(
title: Text('Add User to ${server.serverUrl.host}'),
// Make content scrollable in case of smaller screens/keyboard
content: SingleChildScrollView(
child: UserLoginWidget(
server: server.serverUrl,
// Pass the callback to pop the dialog on success
onSuccess: (user) {
// Add the user to the server
ref.read(authenticatedUsersProvider.notifier).addUser(user);
Navigator.of(dialogContext).pop(); // Close the dialog
// Optional: Show a confirmation SnackBar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('User added successfully! Switch?'),
action: SnackBarAction(
label: 'Switch',
onPressed: () {
// Switch to the new user
ref.read(apiSettingsProvider.notifier).updateState(
ref.read(apiSettingsProvider).copyWith(
activeUser: user,
),
);
context.goNamed(Routes.home.name);
},
),
),
);
},
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop(); // Close the dialog
},
child: const Text('Cancel'),
),
],
);
},
);
// No need for the SnackBar asking to switch user here anymore.
},
);
}
}
class AvailableUserTile extends HookConsumerWidget {
const AvailableUserTile({
super.key,
required this.user,
});
final model.AuthenticatedUser user;
@override
Widget build(BuildContext context, WidgetRef ref) {
final apiSettings = ref.watch(apiSettingsProvider);
return ListTile(
selected: apiSettings.activeUser == user,
leading: apiSettings.activeUser == user
? const Icon(Icons.person)
: const Icon(Icons.person_off_outlined),
title: Text(user.username ?? 'Anonymous'),
onTap: apiSettings.activeUser == user
? null
: () {
ref.read(apiSettingsProvider.notifier).updateState(
apiSettings.copyWith(
activeUser: user,
),
);
// pop all routes and go to the home page
// while (context.canPop()) {
// context.pop();
// }
context.goNamed(
Routes.home.name,
);
},
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Remove User Login'),
content: Text.rich(
TextSpan(
children: [
const TextSpan(
text: 'This will remove login details of the user ',
),
TextSpan(
text: user.username ?? 'Anonymous',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const TextSpan(
text: ' from this app.',
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref
.read(
authenticatedUsersProvider.notifier,
)
.removeUser(user);
Navigator.of(context).pop();
},
child: const Text('Delete'),
),
],
);
},
);
},
),
);
}
}

View file

@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelfsdk/audiobookshelf_api.dart' show Library;
import 'package:vaani/api/library_provider.dart';
import 'package:vaani/settings/api_settings_provider.dart'
show apiSettingsProvider;
import 'package:vaani/shared/icons/abs_icons.dart';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:vaani/main.dart' show appLogger;
class LibrarySwitchChip extends HookConsumerWidget {
const LibrarySwitchChip({
super.key,
required this.libraries,
});
final List<Library> libraries;
@override
Widget build(BuildContext context, WidgetRef ref) {
final apiSettings = ref.watch(apiSettingsProvider);
return ActionChip(
avatar: Icon(
AbsIcons.getIconByName(
apiSettings.activeLibraryId != null
? libraries
.firstWhere(
(lib) => lib.id == apiSettings.activeLibraryId,
)
.icon
: libraries.first.icon,
),
), // Replace with your icon
label: const Text('Change Library'),
// Enable only if libraries are loaded and not empty
onPressed: libraries.isNotEmpty
? () => showLibrarySwitcher(
context,
ref,
)
: null, // Disable if no libraries
);
}
}
// --- Helper Function to Show the Switcher ---
void showLibrarySwitcher(
BuildContext context,
WidgetRef ref,
) {
final content = _LibrarySelectionContent();
// --- Platform-Specific UI ---
bool isDesktop = false;
if (!kIsWeb) {
// dart:io Platform is not available on web
isDesktop = Platform.isLinux || Platform.isMacOS || Platform.isWindows;
} else {
// Basic web detection (might need refinement based on screen size)
// Consider using MediaQuery for a size-based check instead for web/tablet
final size = MediaQuery.of(context).size;
isDesktop = size.width > 600; // Example threshold for "desktop-like" layout
}
if (isDesktop) {
// --- Desktop: Use AlertDialog ---
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Select Library'),
content: SizedBox(
// Constrain size for dialogs
width: 300, // Adjust as needed
// Make content scrollable if list is long
child: Scrollbar(child: content),
),
actions: [
TextButton(
onPressed: () {
// Invalidate the provider to trigger a refetch
ref.invalidate(librariesProvider);
Navigator.pop(dialogContext);
},
child: const Text('Refresh'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
],
),
);
} else {
// --- Mobile/Tablet: Use BottomSheet ---
showModalBottomSheet(
context: context,
// Make it scrollable and control height
isScrollControlled: true,
constraints: BoxConstraints(
maxHeight:
MediaQuery.of(context).size.height * 0.6, // Max 60% of screen
),
builder: (sheetContext) => Padding(
// Add padding within the bottom sheet
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min, // Take minimum necessary height
children: [
const Text(
'Select Library',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
const Divider(),
Flexible(
// Allow the list to take remaining space and scroll
child: Scrollbar(child: content),
),
const SizedBox(height: 10),
ElevatedButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
onPressed: () {
// Invalidate the provider to trigger a refetch
ref.invalidate(librariesProvider);
},
),
],
),
),
);
}
}
// --- Widget for the Selection List Content (Reusable) ---
class _LibrarySelectionContent extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final librariesAsyncValue = ref.watch(librariesProvider);
final currentLibraryId = ref.watch(
apiSettingsProvider.select((settings) => settings.activeLibraryId),
);
final errorColor = Theme.of(context).colorScheme.error;
return librariesAsyncValue.when(
// --- Loading State ---
loading: () => const Center(child: CircularProgressIndicator()),
// --- Error State ---
error: (error, stackTrace) => Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: errorColor),
const SizedBox(height: 10),
Text(
'Error loading libraries: $error',
textAlign: TextAlign.center,
style: TextStyle(color: errorColor),
),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
onPressed: () {
// Invalidate the provider to trigger a refetch
ref.invalidate(librariesProvider);
},
),
],
),
),
),
// --- Data State ---
data: (libraries) {
// Handle case where data loaded successfully but is empty
if (libraries.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text('No libraries available.'),
),
);
}
// Build the list if libraries are available
return Scrollbar(
// Add scrollbar for potentially long lists
child: ListView.builder(
shrinkWrap: true, // Important for Dialog/BottomSheet sizing
itemCount: libraries.length,
itemBuilder: (context, index) {
final library = libraries[index];
final bool isSelected = library.id == currentLibraryId;
return ListTile(
title: Text(library.name),
leading: Icon(AbsIcons.getIconByName(library.icon)),
selected: isSelected,
trailing: isSelected ? const Icon(Icons.check) : null,
onTap: () {
appLogger.info(
'Selected library: ${library.name} (ID: ${library.id})',
);
// Get current settings state
final currentSettings = ref.read(apiSettingsProvider);
// Update the active library ID
ref.read(apiSettingsProvider.notifier).updateState(
currentSettings.copyWith(activeLibraryId: library.id),
);
// Close the dialog/bottom sheet
Navigator.pop(context);
},
);
},
),
);
},
);
}
}

Some files were not shown because too many files have changed in this diff Show more