Merge pull request #2288 from KRTirtho/new-ui

feat: shadcn_flutter based UI
This commit is contained in:
Kingkor Roy Tirtho 2025-01-30 21:53:07 +06:00 committed by GitHub
commit 2b0b5eae1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
276 changed files with 11644 additions and 13843 deletions

View File

@ -14,3 +14,4 @@ LASTFM_API_SECRET=$LASTFM_API_SECRET
RELEASE_CHANNEL=$RELEASE_CHANNEL RELEASE_CHANNEL=$RELEASE_CHANNEL
HIDE_DONATIONS=$HIDE_DONATIONS HIDE_DONATIONS=$HIDE_DONATIONS
DISABLE_SPOTIFY_IMAGES=$DISABLE_SPOTIFY_IMAGES

View File

@ -1,3 +1,3 @@
{ {
"flutterSdkVersion": "3.27.0" "flutterSdkVersion": "3.27.3"
} }

2
.fvmrc
View File

@ -1,4 +1,4 @@
{ {
"flutter": "3.27.0", "flutter": "3.27.3",
"flavors": {} "flavors": {}
} }

View File

@ -20,7 +20,8 @@ on:
description: Dry run without uploading to release description: Dry run without uploading to release
env: env:
FLUTTER_VERSION: 3.27.0 FLUTTER_VERSION: 3.27.3
FLUTTER_CHANNEL: master
permissions: permissions:
contents: write contents: write
@ -30,44 +31,52 @@ jobs:
strategy: strategy:
matrix: matrix:
include: include:
- os: ubuntu-latest - os: ubuntu-22.04
platform: linux platform: linux
arch: x86
files: | files: |
dist/Spotube-linux-x86_64.deb dist/Spotube-linux-x86_64.deb
dist/Spotube-linux-x86_64.rpm dist/Spotube-linux-x86_64.rpm
dist/spotube-linux-*-x86_64.tar.xz dist/spotube-linux-*-x86_64.tar.xz
- os: ubuntu-latest - os: ubuntu-22.04-arm
platform: linux_arm platform: linux
arch: arm64
files: | files: |
dist/Spotube-linux-aarch64.deb dist/Spotube-linux-aarch64.deb
dist/spotube-linux-*-aarch64.tar.xz dist/spotube-linux-*-aarch64.tar.xz
- os: ubuntu-latest - os: ubuntu-22.04
platform: android platform: android
arch: all
files: | files: |
build/Spotube-android-all-arch.apk build/Spotube-android-all-arch.apk
build/Spotube-playstore-all-arch.aab build/Spotube-playstore-all-arch.aab
- os: windows-latest - os: windows-latest
platform: windows platform: windows
arch: x86
files: | files: |
dist/Spotube-windows-x86_64.nupkg dist/Spotube-windows-x86_64.nupkg
dist/Spotube-windows-x86_64-setup.exe dist/Spotube-windows-x86_64-setup.exe
- os: macos-latest - os: macos-latest
platform: ios platform: ios
arch: all
files: | files: |
Spotube-iOS.ipa Spotube-iOS.ipa
- os: macos-14 - os: macos-14
platform: macos platform: macos
arch: all
files: | files: |
build/Spotube-macos-universal.dmg build/Spotube-macos-universal.dmg
build/Spotube-macos-universal.pkg build/Spotube-macos-universal.pkg
runs-on: ${{matrix.os}} runs-on: ${{matrix.os}}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.12.0 - uses: subosito/flutter-action@v2.18.0
with: with:
cache: true
cache-key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.yaml') }}
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: ${{ env.FLUTTER_CHANNEL }}
cache: true
git-source: https://github.com/flutter/flutter.git
- name: Setup Java - name: Setup Java
if: ${{matrix.platform == 'android'}} if: ${{matrix.platform == 'android'}}
uses: actions/setup-java@v4 uses: actions/setup-java@v4
@ -76,14 +85,8 @@ jobs:
java-version: '17' java-version: '17'
cache: 'gradle' cache: 'gradle'
check-latest: true check-latest: true
- name: Set up QEMU
if: ${{matrix.platform == 'linux_arm'}}
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
if: ${{matrix.platform == 'linux_arm'}}
uses: docker/setup-buildx-action@v3
- name: Setup Rust toolchain - name: Setup Rust toolchain
if: ${{matrix.platform != 'linux_arm'}}
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: stable toolchain: stable
@ -105,28 +108,16 @@ jobs:
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks
echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties
- name: Unessary hosted tools
if: ${{matrix.platform == 'linux_arm'}}
uses: jlumbroso/free-disk-space@main
with:
tool-cache: false
swap-storage: false
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: true
- name: Build ${{matrix.platform}} binaries - name: Build ${{matrix.platform}} binaries
run: dart cli/cli.dart build ${{matrix.platform}} run: dart cli/cli.dart build --arch=${{matrix.arch}} ${{matrix.platform}}
env: env:
CHANNEL: ${{inputs.channel}} CHANNEL: ${{inputs.channel}}
DOTENV: ${{secrets.DOTENV_RELEASE}} DOTENV: ${{secrets.DOTENV_RELEASE}}
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v4
with: with:
if-no-files-found: error if-no-files-found: error
name: Spotube-Release-Binaries name: ${{matrix.platform}}-${{matrix.arch}}
path: ${{matrix.files}} path: ${{matrix.files}}
- name: Debug With SSH When fails - name: Debug With SSH When fails
@ -136,14 +127,13 @@ jobs:
limit-access-to-actor: true limit-access-to-actor: true
upload: upload:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
needs: needs:
- build_platform - build_platform
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v4
with: with:
name: Spotube-Release-Binaries
path: ./Spotube-Release-Binaries path: ./Spotube-Release-Binaries
- name: Install dependencies - name: Install dependencies
@ -152,18 +142,19 @@ jobs:
- name: Generate Checksums - name: Generate Checksums
run: | run: |
tree . tree .
md5sum Spotube-Release-Binaries/* >> RELEASE.md5sum find Spotube-Release-Binaries -type f -exec md5sum {} \; >> RELEASE.md5sum
sha256sum Spotube-Release-Binaries/* >> RELEASE.sha256sum find Spotube-Release-Binaries -type f -exec sha256sum {} \; >> RELEASE.sha256sum
sed -i 's|Spotube-Release-Binaries/.*/\([^/]*\)$|\1|' RELEASE.sha256sum RELEASE.md5sum
sed -i 's|Spotube-Release-Binaries/||' RELEASE.sha256sum RELEASE.md5sum sed -i 's|Spotube-Release-Binaries/||' RELEASE.sha256sum RELEASE.md5sum
- name: Extract pubspec version - name: Extract pubspec version
run: | run: |
echo "PUBSPEC_VERSION=$(grep -oP 'version:\s*\K[^+]+(?=\+)' pubspec.yaml)" >> $GITHUB_ENV echo "PUBSPEC_VERSION=$(grep -oP 'version:\s*\K[^+]+(?=\+)' pubspec.yaml)" >> $GITHUB_ENV
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v4
with: with:
if-no-files-found: error if-no-files-found: error
name: Spotube-Release-Binaries name: sums
path: | path: |
RELEASE.md5sum RELEASE.md5sum
RELEASE.sha256sum RELEASE.sha256sum
@ -178,7 +169,7 @@ jobs:
omitNameDuringUpdate: true omitNameDuringUpdate: true
omitPrereleaseDuringUpdate: true omitPrereleaseDuringUpdate: true
allowUpdates: true allowUpdates: true
artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum artifacts: Spotube-Release-Binaries/**/*,RELEASE.sha256sum,RELEASE.md5sum
- name: Upload Release Binaries (nightly) - name: Upload Release Binaries (nightly)
if: ${{ !inputs.dry_run && inputs.channel == 'nightly' }} if: ${{ !inputs.dry_run && inputs.channel == 'nightly' }}
@ -190,9 +181,16 @@ jobs:
omitNameDuringUpdate: true omitNameDuringUpdate: true
omitPrereleaseDuringUpdate: true omitPrereleaseDuringUpdate: true
allowUpdates: true allowUpdates: true
artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum artifacts: Spotube-Release-Binaries/**/*,RELEASE.sha256sum,RELEASE.md5sum
body: | body: |
Build Number: ${{github.run_number}} Build Number: ${{github.run_number}}
Nightly release includes newest features but may contain bugs Nightly release includes newest features but may contain bugs
It is preferred to use the stable version unless you know what you're doing It is preferred to use the stable version unless you know what you're doing
- name: Debug With SSH When fails
if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true

11
.vscode/launch.json vendored
View File

@ -30,6 +30,17 @@
"request": "launch", "request": "launch",
"program": "lib/main.dart", "program": "lib/main.dart",
"flutterMode": "release" "flutterMode": "release"
},
{
"name": "spotube (mobile) (release)",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"flutterMode": "release",
"args": [
"--flavor",
"dev"
]
} }
], ],
"compounds": [] "compounds": []

View File

@ -13,6 +13,7 @@
"RGBO", "RGBO",
"riverpod", "riverpod",
"Scrobblenaut", "Scrobblenaut",
"shadcn",
"skeletonizer", "skeletonizer",
"songlink", "songlink",
"speechiness", "speechiness",
@ -27,5 +28,5 @@
"README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md", "README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md",
"*.dart": "${capture}.g.dart,${capture}.freezed.dart" "*.dart": "${capture}.g.dart,${capture}.freezed.dart"
}, },
"dart.flutterSdkPath": ".fvm/versions/3.27.0" "dart.flutterSdkPath": ".fvm/versions/3.27.3"
} }

View File

@ -2,6 +2,7 @@ package oss.krtirtho.spotube.glance
import HomeWidgetGlanceState import HomeWidgetGlanceState
import HomeWidgetGlanceStateDefinition import HomeWidgetGlanceStateDefinition
import android.R
import android.content.Context import android.content.Context
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.net.Uri import android.net.Uri
@ -119,16 +120,6 @@ class HomePlayerWidget : GlanceAppWidget() {
} }
, ,
) { ) {
Image(
provider = FlutterAssetImageProvider(
context,
"assets/backgrounds/xmas-effect.png"
),
contentDescription = "Background",
modifier = GlanceModifier
.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box( Box(
modifier = GlanceModifier modifier = GlanceModifier
.background( .background(

View File

@ -0,0 +1,27 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="762"
android:viewportHeight="762">
<path
android:pathData="M309.08,370.99L309.08,479.87C309.08,486.36 314.33,491.6 320.83,491.6C327.31,491.6 332.58,486.36 332.58,479.87L332.58,370.99C332.58,364.51 327.31,359.26 320.83,359.26C314.33,359.26 309.08,364.51 309.08,370.99Z"
android:strokeLineJoin="miter"
android:strokeWidth="14"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="butt"/>
<path
android:pathData="M254.59,491.73L280.46,491.73L280.46,362.47C280.53,361.85 280.64,361.23 280.64,360.6C280.64,304.83 325.72,259.46 381.12,259.46C436.51,259.46 481.59,304.83 481.59,360.6C481.59,361.45 481.71,362.27 481.84,363.1L481.84,491.73L507.71,491.73C525.72,491.73 540.33,476.65 540.33,458.03L540.33,390.62C540.33,375.26 530.37,362.33 516.78,358.26C515.53,284.17 455.17,224.26 381.12,224.26C307.05,224.26 246.69,284.18 245.45,358.29C231.88,362.36 221.96,375.29 221.96,390.63L221.96,458.03C221.96,476.64 236.56,491.73 254.59,491.73Z"
android:strokeLineJoin="miter"
android:strokeWidth="20"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="butt"/>
<path
android:pathData="M431.08,370.99L431.08,479.87C431.08,486.36 436.33,491.6 442.83,491.6C449.31,491.6 454.58,486.36 454.58,479.87L454.58,370.99C454.58,364.51 449.31,359.26 442.83,359.26C436.33,359.26 431.08,364.51 431.08,370.99Z"
android:strokeLineJoin="miter"
android:strokeWidth="14"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="butt"/>
</vector>

View File

@ -1,9 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background" />
<foreground> <foreground android:drawable="@drawable/ic_launcher_foreground" />
<inset <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
android:drawable="@drawable/ic_launcher_foreground" </adaptive-icon>
android:inset="16%" />
</foreground>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -3,7 +3,6 @@ import 'package:args/command_runner.dart';
import 'build/android.dart'; import 'build/android.dart';
import 'build/ios.dart'; import 'build/ios.dart';
import 'build/linux.dart'; import 'build/linux.dart';
import 'build/linux_arm.dart';
import 'build/macos.dart'; import 'build/macos.dart';
import 'build/windows.dart'; import 'build/windows.dart';
@ -18,8 +17,13 @@ class BuildCommand extends Command {
addSubcommand(AndroidBuildCommand()); addSubcommand(AndroidBuildCommand());
addSubcommand(IosBuildCommand()); addSubcommand(IosBuildCommand());
addSubcommand(LinuxBuildCommand()); addSubcommand(LinuxBuildCommand());
addSubcommand(LinuxArmBuildCommand());
addSubcommand(MacosBuildCommand()); addSubcommand(MacosBuildCommand());
addSubcommand(WindowsBuildCommand()); addSubcommand(WindowsBuildCommand());
argParser.addOption(
"arch",
abbr: "a",
defaultsTo: "x86",
allowed: ["x86", "arm64", "all"],
);
} }
} }

View File

@ -63,4 +63,6 @@ mixin BuildCommandCommonSteps on Command {
""", """,
); );
} }
String get architecture => parent?.argResults?.option("arch") as String;
} }

View File

@ -37,23 +37,32 @@ class LinuxBuildCommand extends Command with BuildCommandCommonSteps {
await bootstrap(); await bootstrap();
await shell.run( await shell.run(
""" "flutter_distributor package --platform=linux --targets=deb",
flutter_distributor package --platform=linux --targets=deb
flutter_distributor package --platform=linux --targets=rpm
""",
); );
final tempDir = join(Directory.systemTemp.path, "spotube-tar"); if (architecture == "x86") {
await shell.run(
"flutter_distributor package --platform=linux --targets=rpm",
);
}
final bundleDirPath = final tempDir = join(Directory.systemTemp.path, "spotube-tar");
join(cwd.path, "build", "linux", "x64", "release", "bundle"); final bundleArchName = architecture == "x86" ? "x86_64" : "aarch64";
final bundleDirPath = join(
cwd.path,
"build",
"linux",
architecture == "x86" ? "x64" : architecture,
"release",
"bundle",
);
final tarFile = File(join( final tarFile = File(join(
cwd.path, cwd.path,
"dist", "dist",
"spotube-linux-" "spotube-linux-"
"${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber}" "${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber}"
"-x86_64.tar.xz", "-$bundleArchName.tar.xz",
)); ));
await copyPath(bundleDirPath, tempDir); await copyPath(bundleDirPath, tempDir);
@ -81,25 +90,31 @@ class LinuxBuildCommand extends Command with BuildCommandCommonSteps {
"spotube-${pubspec.version}-linux.deb", "spotube-${pubspec.version}-linux.deb",
), ),
); );
await ogDeb.copy(
final ogRpm = File(
join( join(
cwd.path, cwd.path,
"dist", "dist",
pubspec.version.toString(), "Spotube-linux-$bundleArchName.deb",
"spotube-${pubspec.version}-linux.rpm",
), ),
); );
await ogDeb.copy(
join(cwd.path, "dist", "Spotube-linux-x86_64.deb"),
);
await ogRpm.copy(
join(cwd.path, "dist", "Spotube-linux-x86_64.rpm"),
);
await ogDeb.delete(); await ogDeb.delete();
await ogRpm.delete();
if (architecture == "x86") {
final ogRpm = File(
join(
cwd.path,
"dist",
pubspec.version.toString(),
"spotube-${pubspec.version}-linux.rpm",
),
);
await ogRpm.copy(
join(cwd.path, "dist", "Spotube-linux-$bundleArchName.rpm"),
);
await ogRpm.delete();
}
stdout.writeln("✅ Linux building done"); stdout.writeln("✅ Linux building done");
} }

View File

@ -1,37 +0,0 @@
import 'dart:async';
import 'package:args/command_runner.dart';
import 'package:path/path.dart';
import '../../core/env.dart';
import 'common.dart';
class LinuxArmBuildCommand extends Command with BuildCommandCommonSteps {
@override
String get description => "Build Linux Arm";
@override
String get name => "linux_arm";
@override
FutureOr? run() async {
await bootstrap();
await shell.run(
"docker buildx build --platform=linux/arm64 "
"-f ${join(cwd.path, ".github", "Dockerfile")} ${cwd.path} "
"--build-arg FLUTTER_VERSION=${CliEnv.flutterVersion} "
"--build-arg BUILD_VERSION=${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber} "
"-t krtirtho/spotube_linux_arm:latest "
"--load",
);
await shell.run(
"""
docker images ls
docker create --name spotube_linux_arm krtirtho/spotube_linux_arm:latest
docker cp spotube_linux_arm:/app/dist/ dist/
""",
);
}
}

View File

@ -24,6 +24,13 @@ class InstallDependenciesCommand extends Command {
], ],
mandatory: true, mandatory: true,
); );
argParser.addOption(
"arch",
abbr: "a",
allowed: ["x86", "arm64", "all"],
defaultsTo: "x86",
);
} }
@override @override
@ -41,14 +48,6 @@ class InstallDependenciesCommand extends Command {
""", """,
); );
break; break;
case "linux_arm":
await shell.run(
"""
sudo apt-get update -y
sudo apt-get install -y pkg-config make python3-pip python3-setuptools
""",
);
break;
case "macos": case "macos":
await shell.run( await shell.run(
""" """

View File

@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 70; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */

View File

@ -48,6 +48,7 @@
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
debugServiceExtension = "internal" debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES"> allowLocationSimulation = "YES">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">

View File

@ -9,6 +9,17 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class $AssetsBackgroundsGen {
const $AssetsBackgroundsGen();
/// File path: assets/backgrounds/xmas-effect.png
AssetGenImage get xmasEffect =>
const AssetGenImage('assets/backgrounds/xmas-effect.png');
/// List of all assets
List<AssetGenImage> get values => [xmasEffect];
}
class $AssetsLogosGen { class $AssetsLogosGen {
const $AssetsLogosGen(); const $AssetsLogosGen();
@ -24,6 +35,84 @@ class $AssetsLogosGen {
List<AssetGenImage> get values => [songlinkTransparent, songlink]; List<AssetGenImage> get values => [songlinkTransparent, songlink];
} }
class $AssetsPatternsGen {
const $AssetsPatternsGen();
/// File path: assets/patterns/black_white_visualized.jpg
AssetGenImage get blackWhiteVisualized =>
const AssetGenImage('assets/patterns/black_white_visualized.jpg');
/// File path: assets/patterns/brazil_carnival.jpg
AssetGenImage get brazilCarnival =>
const AssetGenImage('assets/patterns/brazil_carnival.jpg');
/// File path: assets/patterns/cotton_balls.jpg
AssetGenImage get cottonBalls =>
const AssetGenImage('assets/patterns/cotton_balls.jpg');
/// File path: assets/patterns/cute_worms.jpg
AssetGenImage get cuteWorms =>
const AssetGenImage('assets/patterns/cute_worms.jpg');
/// File path: assets/patterns/flash_cross_axis.jpg
AssetGenImage get flashCrossAxis =>
const AssetGenImage('assets/patterns/flash_cross_axis.jpg');
/// File path: assets/patterns/memphis_shapes.jpg
AssetGenImage get memphisShapes =>
const AssetGenImage('assets/patterns/memphis_shapes.jpg');
/// File path: assets/patterns/oval_gloomy.jpg
AssetGenImage get ovalGloomy =>
const AssetGenImage('assets/patterns/oval_gloomy.jpg');
/// File path: assets/patterns/oval_sunny.jpg
AssetGenImage get ovalSunny =>
const AssetGenImage('assets/patterns/oval_sunny.jpg');
/// File path: assets/patterns/red_nimbuses.jpg
AssetGenImage get redNimbuses =>
const AssetGenImage('assets/patterns/red_nimbuses.jpg');
/// File path: assets/patterns/tree_bark.jpg
AssetGenImage get treeBark =>
const AssetGenImage('assets/patterns/tree_bark.jpg');
/// File path: assets/patterns/vibrant_pentagons.jpg
AssetGenImage get vibrantPentagons =>
const AssetGenImage('assets/patterns/vibrant_pentagons.jpg');
/// File path: assets/patterns/wiring_pattern.jpg
AssetGenImage get wiringPattern =>
const AssetGenImage('assets/patterns/wiring_pattern.jpg');
/// File path: assets/patterns/zigzags_gloomy.jpg
AssetGenImage get zigzagsGloomy =>
const AssetGenImage('assets/patterns/zigzags_gloomy.jpg');
/// File path: assets/patterns/zigzags_sunny.jpg
AssetGenImage get zigzagsSunny =>
const AssetGenImage('assets/patterns/zigzags_sunny.jpg');
/// List of all assets
List<AssetGenImage> get values => [
blackWhiteVisualized,
brazilCarnival,
cottonBalls,
cuteWorms,
flashCrossAxis,
memphisShapes,
ovalGloomy,
ovalSunny,
redNimbuses,
treeBark,
vibrantPentagons,
wiringPattern,
zigzagsGloomy,
zigzagsSunny
];
}
class $AssetsTutorialGen { class $AssetsTutorialGen {
const $AssetsTutorialGen(); const $AssetsTutorialGen();
@ -46,6 +135,7 @@ class Assets {
static const String license = 'LICENSE'; static const String license = 'LICENSE';
static const AssetGenImage albumPlaceholder = static const AssetGenImage albumPlaceholder =
AssetGenImage('assets/album-placeholder.png'); AssetGenImage('assets/album-placeholder.png');
static const $AssetsBackgroundsGen backgrounds = $AssetsBackgroundsGen();
static const AssetGenImage bengaliPatternsBg = static const AssetGenImage bengaliPatternsBg =
AssetGenImage('assets/bengali-patterns-bg.jpg'); AssetGenImage('assets/bengali-patterns-bg.jpg');
static const AssetGenImage branding = AssetGenImage('assets/branding.png'); static const AssetGenImage branding = AssetGenImage('assets/branding.png');
@ -55,12 +145,15 @@ class Assets {
static const AssetGenImage likedTracks = static const AssetGenImage likedTracks =
AssetGenImage('assets/liked-tracks.jpg'); AssetGenImage('assets/liked-tracks.jpg');
static const $AssetsLogosGen logos = $AssetsLogosGen(); static const $AssetsLogosGen logos = $AssetsLogosGen();
static const $AssetsPatternsGen patterns = $AssetsPatternsGen();
static const AssetGenImage placeholder = static const AssetGenImage placeholder =
AssetGenImage('assets/placeholder.png'); AssetGenImage('assets/placeholder.png');
static const AssetGenImage spotubeHeroBanner = static const AssetGenImage spotubeHeroBanner =
AssetGenImage('assets/spotube-hero-banner.png'); AssetGenImage('assets/spotube-hero-banner.png');
static const AssetGenImage spotubeLogoForeground = static const AssetGenImage spotubeLogoForeground =
AssetGenImage('assets/spotube-logo-foreground.jpg'); AssetGenImage('assets/spotube-logo-foreground.jpg');
static const AssetGenImage spotubeLogoMacos =
AssetGenImage('assets/spotube-logo-macos.png');
static const AssetGenImage spotubeLogoBmp = static const AssetGenImage spotubeLogoBmp =
AssetGenImage('assets/spotube-logo.bmp'); AssetGenImage('assets/spotube-logo.bmp');
static const String spotubeLogoIco = 'assets/spotube-logo.ico'; static const String spotubeLogoIco = 'assets/spotube-logo.ico';
@ -104,6 +197,7 @@ class Assets {
placeholder, placeholder,
spotubeHeroBanner, spotubeHeroBanner,
spotubeLogoForeground, spotubeLogoForeground,
spotubeLogoMacos,
spotubeLogoBmp, spotubeLogoBmp,
spotubeLogoIco, spotubeLogoIco,
spotubeLogoPng, spotubeLogoPng,

View File

@ -38,6 +38,11 @@ abstract class Env {
@EnviedField(varName: "RELEASE_CHANNEL", defaultValue: "nightly") @EnviedField(varName: "RELEASE_CHANNEL", defaultValue: "nightly")
static final String _releaseChannel = _Env._releaseChannel; static final String _releaseChannel = _Env._releaseChannel;
@EnviedField(varName: "DISABLE_SPOTIFY_IMAGES", defaultValue: "0")
static final String _disableSpotifyImages = _Env._disableSpotifyImages;
static bool get disableSpotifyImages => _disableSpotifyImages == "1";
static ReleaseChannel get releaseChannel => _releaseChannel == "stable" static ReleaseChannel get releaseChannel => _releaseChannel == "stable"
? ReleaseChannel.stable ? ReleaseChannel.stable
: ReleaseChannel.nightly; : ReleaseChannel.nightly;

View File

@ -0,0 +1,24 @@
/// GENERATED CODE - DO NOT MODIFY BY HAND
/// *****************************************************
/// FlutterGen
/// *****************************************************
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: directives_ordering,unnecessary_import,implicit_dynamic_list_literal,deprecated_member_use
class FontFamily {
FontFamily._();
/// Font family: BootstrapIcons
static const String bootstrapIcons = 'BootstrapIcons';
/// Font family: GeistMono
static const String geistMono = 'GeistMono';
/// Font family: GeistSans
static const String geistSans = 'GeistSans';
/// Font family: RadixIcons
static const String radixIcons = 'RadixIcons';
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
const gradients = [ const gradients = [
LinearGradient(colors: [ LinearGradient(colors: [

View File

@ -7,7 +7,11 @@ import 'package:go_router/go_router.dart';
import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/routes.dart';
import 'package:spotube/modules/player/player_controls.dart'; import 'package:spotube/modules/player/player_controls.dart';
import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/library/library.dart'; import 'package:spotube/pages/library/user_albums.dart';
import 'package:spotube/pages/library/user_artists.dart';
import 'package:spotube/pages/library/user_downloads.dart';
import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart';
import 'package:spotube/pages/library/user_playlists.dart';
import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/search/search.dart';
import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/provider/audio_player/querying_track_info.dart';
@ -52,8 +56,13 @@ class NavigationAction extends Action<NavigationIntent> {
enum HomeTabs { enum HomeTabs {
browse, browse,
search, search,
library,
lyrics, lyrics,
userPlaylists,
userArtists,
userAlbums,
userLocalLibrary,
userDownloads,
} }
class HomeTabIntent extends Intent { class HomeTabIntent extends Intent {
@ -73,12 +82,24 @@ class HomeTabAction extends Action<HomeTabIntent> {
case HomeTabs.search: case HomeTabs.search:
router.goNamed(SearchPage.name); router.goNamed(SearchPage.name);
break; break;
case HomeTabs.library:
router.goNamed(LibraryPage.name);
break;
case HomeTabs.lyrics: case HomeTabs.lyrics:
router.goNamed(LyricsPage.name); router.goNamed(LyricsPage.name);
break; break;
case HomeTabs.userPlaylists:
router.goNamed(UserPlaylistsPage.name);
break;
case HomeTabs.userArtists:
router.goNamed(UserArtistsPage.name);
break;
case HomeTabs.userAlbums:
router.goNamed(UserAlbumsPage.name);
break;
case HomeTabs.userLocalLibrary:
router.goNamed(UserLocalLibraryPage.name);
break;
case HomeTabs.userDownloads:
router.goNamed(UserDownloadsPage.name);
break;
} }
return null; return null;
} }

View File

@ -13,9 +13,14 @@ import 'package:spotube/pages/home/genres/genre_playlists.dart';
import 'package:spotube/pages/home/genres/genres.dart'; import 'package:spotube/pages/home/genres/genres.dart';
import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/lastfm_login/lastfm_login.dart'; import 'package:spotube/pages/lastfm_login/lastfm_login.dart';
import 'package:spotube/pages/library/local_folder.dart'; import 'package:spotube/pages/library/user_local_tracks/local_folder.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart';
import 'package:spotube/pages/library/user_albums.dart';
import 'package:spotube/pages/library/user_artists.dart';
import 'package:spotube/pages/library/user_downloads.dart';
import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart';
import 'package:spotube/pages/library/user_playlists.dart';
import 'package:spotube/pages/lyrics/mini_lyrics.dart'; import 'package:spotube/pages/lyrics/mini_lyrics.dart';
import 'package:spotube/pages/playlist/liked_playlist.dart'; import 'package:spotube/pages/playlist/liked_playlist.dart';
import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/pages/playlist/playlist.dart';
@ -99,45 +104,76 @@ final routerProvider = Provider((ref) {
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: SearchPage()), const SpotubePage(child: SearchPage()),
), ),
GoRoute( ShellRoute(
path: "/library", pageBuilder: (context, state, child) =>
name: LibraryPage.name, SpotubePage(child: LibraryPage(child: child)),
pageBuilder: (context, state) => routes: [
const SpotubePage(child: LibraryPage()), GoRoute(
routes: [ path: "/library/playlists",
GoRoute( name: UserPlaylistsPage.name,
path: "generate", pageBuilder: (context, state) =>
name: PlaylistGeneratorPage.name, const SpotubePage(child: UserPlaylistsPage()),
),
GoRoute(
path: "/library/artists",
name: UserArtistsPage.name,
pageBuilder: (context, state) =>
const SpotubePage(child: UserArtistsPage()),
),
GoRoute(
path: "/library/album",
name: UserAlbumsPage.name,
pageBuilder: (context, state) =>
const SpotubePage(child: UserAlbumsPage()),
),
GoRoute(
path: "/library/local",
name: UserLocalLibraryPage.name,
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: PlaylistGeneratorPage()), const SpotubePage(child: UserLocalLibraryPage()),
routes: [ routes: [
GoRoute( GoRoute(
path: "result", path: "folder",
name: PlaylistGenerateResultPage.name, name: LocalLibraryPage.name,
pageBuilder: (context, state) => SpotubePage( parentNavigatorKey: shellRouteNavigatorKey,
child: PlaylistGenerateResultPage( pageBuilder: (context, state) {
state: state.extra as GeneratePlaylistProviderInput, assert(state.extra is String);
), return SpotubePage(
), child: LocalLibraryPage(
) state.extra as String,
], isDownloads:
state.uri.queryParameters["downloads"] != null,
isCache: state.uri.queryParameters["cache"] != null,
),
);
},
),
]),
GoRoute(
path: "/library/downloads",
name: UserDownloadsPage.name,
pageBuilder: (context, state) =>
const SpotubePage(child: UserDownloadsPage()),
),
],
),
GoRoute(
path: "/library/generate",
name: PlaylistGeneratorPage.name,
pageBuilder: (context, state) =>
const SpotubePage(child: PlaylistGeneratorPage()),
routes: [
GoRoute(
path: "result",
name: PlaylistGenerateResultPage.name,
pageBuilder: (context, state) => SpotubePage(
child: PlaylistGenerateResultPage(
state: state.extra as GeneratePlaylistProviderInput,
),
), ),
GoRoute( )
path: "local", ],
name: LocalLibraryPage.name, ),
pageBuilder: (context, state) {
assert(state.extra is String);
return SpotubePage(
child: LocalLibraryPage(
state.extra as String,
isDownloads:
state.uri.queryParameters["downloads"] != null,
isCache: state.uri.queryParameters["cache"] != null,
),
);
},
),
]),
GoRoute( GoRoute(
path: "/lyrics", path: "/lyrics",
name: LyricsPage.name, name: LyricsPage.name,

View File

@ -1,8 +1,11 @@
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/library/library.dart'; import 'package:spotube/pages/library/user_albums.dart';
import 'package:spotube/pages/library/user_artists.dart';
import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart';
import 'package:spotube/pages/library/user_playlists.dart';
import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/search/search.dart';
import 'package:spotube/pages/stats/stats.dart'; import 'package:spotube/pages/stats/stats.dart';
@ -34,12 +37,6 @@ List<SideBarTiles> getSidebarTileList(AppLocalizations l10n) => [
icon: SpotubeIcons.search, icon: SpotubeIcons.search,
title: l10n.search, title: l10n.search,
), ),
SideBarTiles(
id: "library",
name: LibraryPage.name,
icon: SpotubeIcons.library,
title: l10n.library,
),
SideBarTiles( SideBarTiles(
id: "lyrics", id: "lyrics",
name: LyricsPage.name, name: LyricsPage.name,
@ -54,6 +51,33 @@ List<SideBarTiles> getSidebarTileList(AppLocalizations l10n) => [
), ),
]; ];
List<SideBarTiles> getSidebarLibraryTileList(AppLocalizations l10n) => [
SideBarTiles(
id: "playlists",
title: l10n.playlists,
name: UserPlaylistsPage.name,
icon: SpotubeIcons.playlist,
),
SideBarTiles(
id: "artists",
title: l10n.artists,
name: UserArtistsPage.name,
icon: SpotubeIcons.artist,
),
SideBarTiles(
id: "albums",
title: l10n.albums,
name: UserAlbumsPage.name,
icon: SpotubeIcons.album,
),
SideBarTiles(
id: "local_library",
title: l10n.local_library,
name: UserLocalLibraryPage.name,
icon: SpotubeIcons.device,
),
];
List<SideBarTiles> getNavbarTileList(AppLocalizations l10n) => [ List<SideBarTiles> getNavbarTileList(AppLocalizations l10n) => [
SideBarTiles( SideBarTiles(
id: "browse", id: "browse",
@ -69,7 +93,7 @@ List<SideBarTiles> getNavbarTileList(AppLocalizations l10n) => [
), ),
SideBarTiles( SideBarTiles(
id: "library", id: "library",
name: LibraryPage.name, name: UserPlaylistsPage.name,
icon: SpotubeIcons.library, icon: SpotubeIcons.library,
title: l10n.library, title: l10n.library,
), ),

View File

@ -1,5 +1,5 @@
import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart'; import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:simple_icons/simple_icons.dart'; import 'package:simple_icons/simple_icons.dart';
@ -37,6 +37,7 @@ abstract class SpotubeIcons {
static const share = FeatherIcons.share2; static const share = FeatherIcons.share2;
static const playlistAdd = Icons.playlist_add_rounded; static const playlistAdd = Icons.playlist_add_rounded;
static const playlistRemove = Icons.playlist_remove_rounded; static const playlistRemove = Icons.playlist_remove_rounded;
static const playlist = Icons.playlist_play_rounded;
static const trash = FeatherIcons.trash2; static const trash = FeatherIcons.trash2;
static const clock = FeatherIcons.clock; static const clock = FeatherIcons.clock;
static const lyrics = Icons.lyrics_rounded; static const lyrics = Icons.lyrics_rounded;
@ -127,4 +128,10 @@ abstract class SpotubeIcons {
static const cache = FeatherIcons.hardDrive; static const cache = FeatherIcons.hardDrive;
static const export = Icons.file_open_outlined; static const export = Icons.file_open_outlined;
static const delete = FeatherIcons.trash2; static const delete = FeatherIcons.trash2;
static const open = FeatherIcons.externalLink;
static const radioChecked = Icons.radio_button_on_rounded;
static const radioUnchecked = Icons.radio_button_off_rounded;
static const grid = FeatherIcons.grid;
static const list = FeatherIcons.list;
static const device = FeatherIcons.smartphone;
} }

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
class AdaptiveListTile extends HookWidget { class AdaptiveListTile extends HookWidget {
@ -24,41 +25,39 @@ class AdaptiveListTile extends HookWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
return ListTile( return ButtonTile(
title: title, title: title,
subtitle: subtitle, subtitle: subtitle,
trailing: breakOn ?? mediaQuery.smAndDown trailing: breakOn ?? mediaQuery.smAndDown
? null ? null
: trailing?.call(context, null), : trailing?.call(context, null),
leading: leading, leading: leading,
onTap: breakOn ?? mediaQuery.smAndDown enabled: breakOn ?? mediaQuery.smAndDown,
? () { onPressed: () {
onTap?.call(); onTap?.call();
showDialog( showDialog(
context: context, context: context,
barrierDismissible: true, barrierDismissible: true,
builder: (context) { builder: (context) {
return StatefulBuilder(builder: (context, update) { return StatefulBuilder(builder: (context, update) {
return AlertDialog( return AlertDialog(
title: title != null title: title != null
? Row( ? Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ spacing: 5,
if (leading != null) ...[ mainAxisAlignment: MainAxisAlignment.center,
leading!, children: [
const SizedBox(width: 5) if (leading != null) leading!,
], Flexible(child: title!),
Flexible(child: title!), ],
], )
) : const SizedBox.shrink(),
: Container(), content: Center(child: trailing?.call(context, update)),
content: trailing?.call(context, update),
);
});
},
); );
} });
: null, },
);
},
); );
} }
} }

View File

@ -1,67 +1,46 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart' show showModalBottomSheet;
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
_emptyCB() {} class AdaptiveMenuButton<T> extends MenuButton {
class PopSheetEntry<T> extends ListTile {
final T? value; final T? value;
const PopSheetEntry({ const AdaptiveMenuButton({
this.value,
super.key, super.key,
super.leading, this.value,
super.title, required super.child,
super.subtitle, super.subMenu,
super.onPressed,
super.trailing, super.trailing,
super.isThreeLine = false, super.leading,
super.dense,
super.visualDensity,
super.shape,
super.style,
super.selectedColor,
super.iconColor,
super.textColor,
super.titleTextStyle,
super.subtitleTextStyle,
super.leadingAndTrailingTextStyle,
super.contentPadding,
super.enabled = true, super.enabled = true,
super.onTap = _emptyCB,
super.onLongPress,
super.onFocusChange,
super.mouseCursor,
super.selected = false,
super.focusColor,
super.hoverColor,
super.splashColor,
super.focusNode, super.focusNode,
super.autofocus = false, super.autoClose = true,
super.tileColor, super.popoverController,
super.selectedTileColor, }) : assert(
super.enableFeedback, value != null || onPressed != null,
super.horizontalTitleGap, 'Either value or onPressed must be provided',
super.minVerticalPadding, );
super.minLeadingWidth,
super.titleAlignment,
});
} }
/// An adaptive widget that shows a [PopupMenuButton] when screen size is above /// An adaptive widget that shows a [PopupMenuButton] when screen size is above
/// or equal to 640px /// or equal to 640px
/// In smaller screen, a [IconButton] with a [showModalBottomSheet] is shown /// In smaller screen, a [IconButton] with a [showModalBottomSheet] is shown
class AdaptivePopSheetList<T> extends StatelessWidget { class AdaptivePopSheetList<T> extends StatelessWidget {
final List<PopSheetEntry<T>> children; final List<AdaptiveMenuButton<T>> children;
final Widget? icon; final Widget? icon;
final Widget? child; final Widget? child;
final bool useRootNavigator; final bool useRootNavigator;
final List<Widget>? headings; final List<Widget>? headings;
final String? tooltip; final String tooltip;
final ValueChanged<T>? onSelected; final ValueChanged<T>? onSelected;
final BorderRadius borderRadius;
final Offset offset; final Offset offset;
final ButtonVariance variance;
const AdaptivePopSheetList({ const AdaptivePopSheetList({
super.key, super.key,
required this.children, required this.children,
@ -70,166 +49,141 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
this.useRootNavigator = true, this.useRootNavigator = true,
this.headings, this.headings,
this.onSelected, this.onSelected,
this.borderRadius = const BorderRadius.all(Radius.circular(999)), required this.tooltip,
this.tooltip,
this.offset = Offset.zero, this.offset = Offset.zero,
this.variance = ButtonVariance.ghost,
}) : assert( }) : assert(
!(icon != null && child != null), !(icon != null && child != null),
'Either icon or child must be provided', 'Either icon or child must be provided',
); );
Future<T?> showPopupMenu(BuildContext context, RelativeRect position) { Future<void> showDropdownMenu(BuildContext context, Offset position) async {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final childrenModified = children.map((s) {
if (s.onPressed == null) {
return MenuButton(
key: s.key,
autoClose: s.autoClose,
enabled: s.enabled,
leading: s.leading,
focusNode: s.focusNode,
onPressed: (context) {
if (s.value != null) {
onSelected?.call(s.value as T);
}
},
popoverController: s.popoverController,
subMenu: s.subMenu,
trailing: s.trailing,
child: s.child,
);
}
return s;
}).toList();
return showMenu<T>( if (mediaQuery.mdAndUp) {
await showDropdown<T?>(
context: context,
rootOverlay: useRootNavigator,
// heightConstraint: PopoverConstraint.anchorFixedSize,
// constraints: BoxConstraints(
// maxHeight: mediaQuery.size.height * 0.6,
// ),
position: position,
builder: (context) {
return DropdownMenu(
children: childrenModified,
);
},
).future;
return;
}
showModalBottomSheet(
context: context, context: context,
useRootNavigator: useRootNavigator, enableDrag: true,
constraints: BoxConstraints( showDragHandle: true,
maxHeight: mediaQuery.size.height * 0.6, useRootNavigator: true,
shape: RoundedRectangleBorder(
borderRadius: context.theme.borderRadiusMd,
), ),
position: position, backgroundColor: context.theme.colorScheme.card,
items: children builder: (context) {
.map( return ListView.builder(
(item) => PopupMenuItem<T>( itemCount: childrenModified.length,
padding: EdgeInsets.zero, shrinkWrap: true,
enabled: false, itemBuilder: (context, index) {
child: _AdaptivePopSheetListItem<T>( final data = childrenModified[index];
item: item,
onSelected: onSelected, return Button(
enabled: data.enabled,
style: ButtonVariance.ghost.copyWith(
padding: (context, state, value) => const EdgeInsets.all(16),
), ),
), onPressed: () {
) data.onPressed?.call(context);
.toList(), if (data.autoClose) {
Navigator.of(context).pop();
}
},
leading: data.leading,
trailing: data.trailing,
alignment: Alignment.centerLeft,
child: data.child,
);
},
);
},
); );
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final theme = Theme.of(context);
if (mediaQuery.mdAndUp) { if (mediaQuery.mdAndUp) {
return PopupMenuButton( return Tooltip(
icon: icon, tooltip: TooltipContainer(
tooltip: tooltip, child: Text(tooltip),
offset: offset,
child: child == null ? null : IgnorePointer(child: child),
itemBuilder: (context) => children
.map(
(item) => PopupMenuItem(
padding: EdgeInsets.zero,
enabled: false,
child: _AdaptivePopSheetListItem(
item: item,
onSelected: onSelected,
),
),
)
.toList(),
);
}
void showSheet() {
showModalBottomSheet(
context: context,
useRootNavigator: useRootNavigator,
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(
maxHeight: mediaQuery.size.height * 0.6,
), ),
builder: (context) { child: IconButton(
return Padding( variance: variance,
padding: const EdgeInsets.all(8.0).copyWith(top: 0), icon: icon ?? const Icon(SpotubeIcons.moreVertical),
child: DefaultTextStyle( onPressed: () {
style: theme.textTheme.titleMedium!, final renderBox = context.findRenderObject() as RenderBox;
child: SingleChildScrollView( final position = RelativeRect.fromRect(
child: Column( Rect.fromPoints(
mainAxisSize: MainAxisSize.min, renderBox.localToGlobal(Offset.zero,
children: [ ancestor: context.findRenderObject()),
if (headings != null) ...[ renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero),
...headings!, ancestor: context.findRenderObject()),
const SizedBox(height: 8),
Divider(
color: theme.colorScheme.primary,
thickness: 0.3,
endIndent: 16,
indent: 16,
),
],
...children.map(
(item) => _AdaptivePopSheetListItem(
item: item,
onSelected: onSelected,
),
)
],
),
), ),
), Offset.zero & mediaQuery.size,
); );
}, final offset = Offset(position.left, position.top);
showDropdownMenu(context, offset);
},
),
); );
} }
if (child != null) { if (child != null) {
return Tooltip( return Tooltip(
message: tooltip ?? '', tooltip: TooltipContainer(child: Text(tooltip)),
child: InkWell( child: Button(
onTap: showSheet, onPressed: () => showDropdownMenu(context, Offset.zero),
borderRadius: borderRadius, style: variance,
child: IgnorePointer(child: child), child: IgnorePointer(child: child),
), ),
); );
} }
return IconButton( return Tooltip(
icon: icon ?? const Icon(SpotubeIcons.moreVertical), tooltip: TooltipContainer(child: Text(tooltip)),
tooltip: tooltip, child: IconButton(
style: theme.iconButtonTheme.style?.copyWith( variance: variance,
shape: WidgetStatePropertyAll( icon: icon ?? const Icon(SpotubeIcons.moreVertical),
RoundedRectangleBorder( onPressed: () => showDropdownMenu(context, Offset.zero),
borderRadius: borderRadius,
),
),
),
onPressed: showSheet,
);
}
}
class _AdaptivePopSheetListItem<T> extends StatelessWidget {
final PopSheetEntry<T> item;
final ValueChanged<T>? onSelected;
const _AdaptivePopSheetListItem({
super.key,
required this.item,
this.onSelected,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
borderRadius: (theme.listTileTheme.shape as RoundedRectangleBorder?)
?.borderRadius as BorderRadius? ??
const BorderRadius.all(Radius.circular(10)),
onTap: !item.enabled
? null
: () {
item.onTap?.call();
if (item.value != null) {
Navigator.pop(context);
onSelected?.call(item.value as T);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: IconTheme.merge(
data: const IconThemeData(opacity: 1),
child: IgnorePointer(child: item),
),
), ),
); );
} }

View File

@ -1,106 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:popover/popover.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/constrains.dart';
class Action extends StatelessWidget {
final Widget text;
final Widget icon;
final void Function() onPressed;
final bool isExpanded;
final Color? backgroundColor;
const Action({
super.key,
required this.icon,
required this.text,
required this.onPressed,
this.isExpanded = true,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
if (isExpanded != true) {
return IconButton(
icon: icon,
onPressed: onPressed,
style: IconButton.styleFrom(
backgroundColor: backgroundColor,
),
tooltip: text is Text
? (text as Text).data
: text.toStringShallow().split(",").last.replaceAll(
"\"",
"",
),
);
}
return ListTile(
tileColor: backgroundColor,
onTap: onPressed,
leading: icon,
title: text,
);
}
}
class AdaptiveActions extends HookWidget {
final List<Action> actions;
final bool? breakOn;
const AdaptiveActions({
required this.actions,
this.breakOn,
super.key,
});
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
if (breakOn ?? mediaQuery.lgAndUp) {
return IconButton(
icon: const Icon(SpotubeIcons.moreHorizontal),
onPressed: () {
showPopover(
context: context,
direction: PopoverDirection.left,
bodyBuilder: (context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: actions
.map(
(action) => SizedBox(
width: 200,
child: Row(
children: [
Expanded(child: action),
],
),
),
)
.toList(),
);
},
backgroundColor: Theme.of(context).cardColor,
);
},
);
}
return Row(
children: actions.map((action) {
return Action(
icon: action.icon,
onPressed: action.onPressed,
text: action.text,
backgroundColor: action.backgroundColor,
isExpanded: false,
);
}).toList(),
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart' show ListTile, ListTileControlAffinity;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
@ -11,7 +12,7 @@ class AdaptiveSelectTile<T> extends HookWidget {
final T value; final T value;
final ValueChanged<T?>? onChanged; final ValueChanged<T?>? onChanged;
final List<DropdownMenuItem<T>> options; final List<SelectItemButton<T>> options;
/// Show the smaller value when the breakpoint is reached /// Show the smaller value when the breakpoint is reached
/// ///
@ -22,6 +23,9 @@ class AdaptiveSelectTile<T> extends HookWidget {
final bool? breakLayout; final bool? breakLayout;
final BoxConstraints? popupConstraints;
final PopoverConstraint? popupWidthConstraint;
const AdaptiveSelectTile({ const AdaptiveSelectTile({
required this.title, required this.title,
required this.value, required this.value,
@ -33,61 +37,35 @@ class AdaptiveSelectTile<T> extends HookWidget {
this.breakLayout, this.breakLayout,
this.showValueWhenUnfolded = true, this.showValueWhenUnfolded = true,
super.key, super.key,
this.popupConstraints,
this.popupWidthConstraint,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final rawControl = DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: DropdownButton<T>(
items: options,
value: value,
onChanged: onChanged,
menuMaxHeight: mediaQuery.size.height * 0.6,
underline: const SizedBox.shrink(),
padding: const EdgeInsets.symmetric(horizontal: 10),
borderRadius: BorderRadius.circular(10),
icon: const Icon(SpotubeIcons.angleDown),
dropdownColor: theme.colorScheme.secondaryContainer,
),
);
final controlPlaceholder = useMemoized(
() => options
.firstWhere(
(element) => element.value == value,
orElse: () => DropdownMenuItem<T>(
value: null,
child: Container(),
),
)
.child,
[value, options]);
final control = breakLayout ?? mediaQuery.mdAndUp Widget? control = Select<T>(
? rawControl itemBuilder: (context, item) {
: showValueWhenUnfolded return options.firstWhere((element) => element.value == item).child;
? Container( },
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), value: value,
decoration: BoxDecoration( onChanged: onChanged,
border: Border.all( popupConstraints: popupConstraints ?? const BoxConstraints(maxWidth: 200),
color: theme.colorScheme.primary, popupWidthConstraint: popupWidthConstraint ?? PopoverConstraint.flexible,
width: 2, children: options,
), );
borderRadius: BorderRadius.circular(10),
), if (mediaQuery.smAndDown) {
child: DefaultTextStyle( if (showValueWhenUnfolded) {
style: TextStyle( control = OutlineBadge(
color: theme.colorScheme.primary, child: options.firstWhere((element) => element.value == value).child,
), );
child: controlPlaceholder, } else {
), control = null;
) }
: const SizedBox.shrink(); }
return ListTile( return ListTile(
title: title, title: title,
@ -104,20 +82,26 @@ class AdaptiveSelectTile<T> extends HookWidget {
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
return SimpleDialog( return AlertDialog(
title: title, content: ListView.builder(
children: [ shrinkWrap: true,
for (final option in options) itemCount: options.length,
RadioListTile<T>( itemBuilder: (context, index) {
title: option.child, final item = options[index];
value: option.value as T,
groupValue: value, return ListTile(
onChanged: (v) { iconColor: theme.colorScheme.primary,
Navigator.pop(context); leading: item.value == value
onChanged?.call(v); ? const Icon(SpotubeIcons.radioChecked)
: const Icon(SpotubeIcons.radioUnchecked),
title: item.child,
onTap: () {
onChanged?.call(item.value);
Navigator.of(context).pop();
}, },
), );
], },
),
); );
}, },
); );

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
class AnimateGradient extends HookWidget { class AnimateGradient extends HookWidget {

View File

@ -1,88 +0,0 @@
library bordered_text;
import 'package:flutter/widgets.dart';
/// Adds stroke to text widget
/// We can apply a very thin and subtle stroke to a [Text]
/// ```dart
/// BorderedText(
/// strokeWidth: 1.0,
/// text: Text(
/// 'Bordered Text',
/// style: TextStyle(
/// decoration: TextDecoration.none,
/// decorationStyle: TextDecorationStyle.wavy,
/// decorationColor: Colors.red,
/// ),
/// ),
/// )
/// ```
class BorderedText extends StatelessWidget {
const BorderedText({
super.key,
required this.child,
this.strokeCap = StrokeCap.round,
this.strokeJoin = StrokeJoin.round,
this.strokeWidth = 6.0,
this.strokeColor = const Color.fromRGBO(53, 0, 71, 1),
});
/// the stroke cap style
final StrokeCap strokeCap;
/// the stroke joint style
final StrokeJoin strokeJoin;
/// the stroke width
final double strokeWidth;
/// the stroke color
final Color strokeColor;
/// the [Text] widget to apply stroke on
final Text child;
@override
Widget build(BuildContext context) {
TextStyle style;
if (child.style != null) {
style = child.style!.copyWith(
foreground: Paint()
..style = PaintingStyle.stroke
..strokeCap = strokeCap
..strokeJoin = strokeJoin
..strokeWidth = strokeWidth
..color = strokeColor,
color: null,
);
} else {
style = TextStyle(
foreground: Paint()
..style = PaintingStyle.stroke
..strokeCap = strokeCap
..strokeJoin = strokeJoin
..strokeWidth = strokeWidth
..color = strokeColor,
);
}
return Stack(
alignment: Alignment.center,
textDirection: child.textDirection,
children: <Widget>[
Text(
child.data!,
style: style,
maxLines: child.maxLines,
overflow: child.overflow,
semanticsLabel: child.semanticsLabel,
softWrap: child.softWrap,
strutStyle: child.strutStyle,
textAlign: child.textAlign,
textDirection: child.textDirection,
textScaler: child.textScaler,
),
child,
],
);
}
}

View File

@ -0,0 +1,21 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
class BackButton extends StatelessWidget {
final Color? color;
const BackButton({
super.key,
this.color,
});
@override
Widget build(BuildContext context) {
return IconButton.ghost(
size: const ButtonSize(.9),
icon: color != null
? Icon(SpotubeIcons.angleLeft, color: color)
: const Icon(SpotubeIcons.angleLeft),
onPressed: () => Navigator.of(context).pop(),
);
}
}

View File

@ -1,52 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:popover/popover.dart';
import 'package:spotube/collections/spotube_icons.dart';
class CompactSearch extends HookWidget {
final ValueChanged<String>? onChanged;
final String placeholder;
final IconData icon;
final Color? iconColor;
const CompactSearch({
super.key,
this.onChanged,
this.placeholder = "Search...",
this.icon = SpotubeIcons.search,
this.iconColor,
});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () {
showPopover(
context: context,
backgroundColor: Theme.of(context).cardColor,
transitionDuration: const Duration(milliseconds: 100),
barrierColor: Colors.transparent,
arrowDxOffset: -6,
bodyBuilder: (context) {
return Container(
padding: const EdgeInsets.all(8.0),
width: 300,
child: TextField(
autofocus: true,
onChanged: onChanged,
decoration: InputDecoration(
hintText: placeholder,
prefixIcon: Icon(icon),
),
),
);
},
height: 60,
);
},
tooltip: placeholder,
icon: Icon(icon, color: iconColor),
);
}
}

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
@ -9,13 +8,15 @@ class ConfirmDownloadDialog extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( final screenSize = MediaQuery.sizeOf(context);
title: Padding(
padding: const EdgeInsets.all(15), return ConstrainedBox(
child: Row( constraints: BoxConstraints(maxWidth: Breakpoints.sm),
child: AlertDialog(
title: Row(
spacing: 10,
children: [ children: [
Text(context.l10n.are_you_sure), Text(context.l10n.are_you_sure),
const SizedBox(width: 10),
const UniversalImage( const UniversalImage(
path: path:
"https://c.tenor.com/kHcmsxlKHEAAAAAM/rock-one-eyebrow-raised-rock-staring.gif", "https://c.tenor.com/kHcmsxlKHEAAAAAM/rock-one-eyebrow-raised-rock-staring.gif",
@ -24,58 +25,53 @@ class ConfirmDownloadDialog extends StatelessWidget {
) )
], ],
), ),
), content: Expanded(
content: Container( flex: screenSize.smAndUp ? 0 : 1,
padding: const EdgeInsets.all(15), child: SingleChildScrollView(
constraints: BoxConstraints(maxWidth: Breakpoints.sm), child: Column(
child: SingleChildScrollView( mainAxisSize: MainAxisSize.min,
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, children: [
crossAxisAlignment: CrossAxisAlignment.start, Text(
children: [ context.l10n.download_warning,
Text( textAlign: TextAlign.justify,
context.l10n.download_warning,
textAlign: TextAlign.justify,
),
const SizedBox(height: 10),
Text(
context.l10n.download_ip_ban_warning,
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
), ),
textAlign: TextAlign.justify, const SizedBox(height: 10),
), Text(
const SizedBox(height: 10), context.l10n.download_ip_ban_warning,
Text( style: const TextStyle(
context.l10n.by_clicking_accept_terms, color: Colors.red,
), fontWeight: FontWeight.bold,
const SizedBox(height: 10), ),
BulletPoint(context.l10n.download_agreement_1), textAlign: TextAlign.justify,
const SizedBox(height: 10), ),
BulletPoint(context.l10n.download_agreement_2), const SizedBox(height: 10),
const SizedBox(height: 10), Text(
BulletPoint(context.l10n.download_agreement_3), context.l10n.by_clicking_accept_terms,
], ),
const SizedBox(height: 10),
BulletPoint(context.l10n.download_agreement_1),
const SizedBox(height: 10),
BulletPoint(context.l10n.download_agreement_2),
const SizedBox(height: 10),
BulletPoint(context.l10n.download_agreement_3),
],
),
), ),
), ),
actions: [
Button.outline(
child: Text(context.l10n.decline),
onPressed: () {
Navigator.pop(context, false);
},
),
Button.destructive(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.accept),
),
],
), ),
actions: [
OutlinedButton(
child: Text(context.l10n.decline),
onPressed: () {
Navigator.pop(context, false);
},
),
FilledButton(
style: FilledButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.red,
),
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.accept),
),
],
); );
} }
} }

View File

@ -1,46 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
class PipedDownDialog extends HookConsumerWidget {
const PipedDownDialog({super.key});
@override
Widget build(BuildContext context, ref) {
final pipedInstance =
ref.watch(userPreferencesProvider.select((s) => s.pipedInstance));
final ThemeData(:colorScheme) = Theme.of(context);
return AlertDialog(
insetPadding: const EdgeInsets.all(6),
contentPadding: const EdgeInsets.all(6),
icon: Icon(
SpotubeIcons.error,
color: colorScheme.error,
),
title: Text(
context.l10n.piped_api_down,
style: TextStyle(color: colorScheme.error),
),
content: Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child:
Text(context.l10n.piped_down_error_instructions(pipedInstance)),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.ok),
),
FilledButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.settings),
),
],
);
}
}

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
@ -22,7 +21,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context); final typography = Theme.of(context).typography;
final userPlaylists = ref.watch(favoritePlaylistsProvider); final userPlaylists = ref.watch(favoritePlaylistsProvider);
final favoritePlaylistsNotifier = final favoritePlaylistsNotifier =
ref.watch(favoritePlaylistsProvider.notifier); ref.watch(favoritePlaylistsProvider.notifier);
@ -64,67 +63,86 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
tracks.map((e) => e.id!).toList(), tracks.map((e) => e.id!).toList(),
), ),
), ),
).then((_) => Navigator.pop(context, true)); ).then((_) => context.mounted ? Navigator.pop(context, true) : null);
} }
return AlertDialog( return ConstrainedBox(
insetPadding: EdgeInsets.zero, constraints: const BoxConstraints(maxWidth: 400),
title: Row( child: AlertDialog(
mainAxisAlignment: MainAxisAlignment.spaceBetween, title: Row(
children: [ mainAxisAlignment: MainAxisAlignment.spaceBetween,
Text( mainAxisSize: MainAxisSize.min,
context.l10n.add_to_playlist, children: [
style: textTheme.titleMedium, Text(
context.l10n.add_to_playlist,
style: typography.large,
),
const Spacer(),
const PlaylistCreateDialogButton(),
],
),
actions: [
OutlineButton(
child: Text(context.l10n.cancel),
onPressed: () {
Navigator.pop(context, false);
},
),
PrimaryButton(
onPressed: onAdd,
child: Text(context.l10n.add),
), ),
const Gap(20),
const PlaylistCreateDialogButton(),
], ],
), content: SizedBox(
actions: [ height: 300,
OutlinedButton( child: userPlaylists.isLoading
child: Text(context.l10n.cancel), ? const Center(child: CircularProgressIndicator())
onPressed: () { : ListView.builder(
Navigator.pop(context, false); shrinkWrap: true,
}, itemCount: filteredPlaylists.length,
), itemBuilder: (context, index) {
FilledButton( final playlist = filteredPlaylists.elementAt(index);
onPressed: onAdd, return Button.ghost(
child: Text(context.l10n.add), style: ButtonVariance.ghost.copyWith(
), padding: (context, _, __) {
], return const EdgeInsets.symmetric(vertical: 8);
content: SizedBox( },
height: 300, ),
width: 300, leading: Avatar(
child: userPlaylists.isLoading initials:
? const Center(child: CircularProgressIndicator()) Avatar.getInitials(playlist.name ?? "Playlist"),
: ListView.builder( provider: UniversalImage.imageProvider(
shrinkWrap: true, playlist.images.asUrlString(
itemCount: filteredPlaylists.length, placeholder: ImagePlaceholder.collection,
itemBuilder: (context, index) { ),
final playlist = filteredPlaylists.elementAt(index);
return CheckboxListTile(
secondary: CircleAvatar(
backgroundImage: UniversalImage.imageProvider(
playlist.images.asUrlString(
placeholder: ImagePlaceholder.collection,
), ),
), ),
), trailing: Checkbox(
contentPadding: EdgeInsets.zero, state: (playlistsCheck.value[playlist.id] ?? false)
title: Padding( ? CheckboxState.checked
padding: const EdgeInsets.only(left: 8.0), : CheckboxState.unchecked,
child: Text(playlist.name!), onChanged: (val) {
), playlistsCheck.value = {
value: playlistsCheck.value[playlist.id] ?? false, ...playlistsCheck.value,
onChanged: (val) { playlist.id!: val == CheckboxState.checked,
playlistsCheck.value = { };
...playlistsCheck.value, },
playlist.id!: val == true ),
}; onPressed: () {
}, playlistsCheck.value = {
); ...playlistsCheck.value,
}, playlist.id!:
), !(playlistsCheck.value[playlist.id] ?? false),
};
},
child: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(playlist.name!),
),
);
},
),
),
), ),
); );
} }

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
Future<bool> showPromptDialog({ Future<bool> showPromptDialog({
@ -16,13 +16,13 @@ Future<bool> showPromptDialog({
content: Text(message), content: Text(message),
actions: [ actions: [
if (cancelText != null) if (cancelText != null)
OutlinedButton( Button.outline(
onPressed: () => Navigator.of(context).pop(false), onPressed: () => Navigator.of(context).pop(false),
child: Text( child: Text(
cancelText == "Cancel" ? context.l10n.cancel : cancelText, cancelText == "Cancel" ? context.l10n.cancel : cancelText,
), ),
), ),
FilledButton( Button.primary(
child: Text(okText == "Ok" ? context.l10n.ok : okText), child: Text(okText == "Ok" ? context.l10n.ok : okText),
onPressed: () => Navigator.of(context).pop(true), onPressed: () => Navigator.of(context).pop(true),
), ),

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
@ -13,45 +13,35 @@ class ReplaceDownloadedDialog extends ConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final groupValue = ref.watch(replaceDownloadedFileState); final groupValue = ref.watch(replaceDownloadedFileState);
final theme = Theme.of(context);
final replaceAll = ref.watch(replaceDownloadedFileState); final replaceAll = ref.watch(replaceDownloadedFileState);
return AlertDialog( return AlertDialog(
title: Text(context.l10n.track_exists(track.name ?? "")), title: Text(context.l10n.track_exists(track.name ?? "")),
content: Column( content: RadioGroup(
mainAxisSize: MainAxisSize.min, value: groupValue,
children: [ onChanged: (value) {
Text(context.l10n.do_you_want_to_replace), ref.read(replaceDownloadedFileState.notifier).state = value;
RadioListTile<bool>( },
dense: true, child: Column(
contentPadding: EdgeInsets.zero, mainAxisSize: MainAxisSize.min,
activeColor: theme.colorScheme.primary, crossAxisAlignment: CrossAxisAlignment.start,
value: true, children: [
groupValue: groupValue, Text(context.l10n.do_you_want_to_replace),
onChanged: (value) { const Gap(16),
if (value != null) { RadioItem<bool>(
ref.read(replaceDownloadedFileState.notifier).state = true; value: true,
} trailing: Text(context.l10n.replace_downloaded_tracks),
}, ),
title: Text(context.l10n.replace_downloaded_tracks), const Gap(8),
), RadioItem<bool>(
RadioListTile<bool>( value: false,
dense: true, trailing: Text(context.l10n.skip_download_tracks),
contentPadding: EdgeInsets.zero, ),
activeColor: theme.colorScheme.primary, ],
value: false, ),
groupValue: groupValue,
onChanged: (value) {
if (value != null) {
ref.read(replaceDownloadedFileState.notifier).state = false;
}
},
title: Text(context.l10n.skip_download_tracks),
),
],
), ),
actions: [ actions: [
OutlinedButton( Button.outline(
onPressed: replaceAll == true onPressed: replaceAll == true
? null ? null
: () { : () {
@ -59,7 +49,7 @@ class ReplaceDownloadedDialog extends ConsumerWidget {
}, },
child: Text(context.l10n.skip), child: Text(context.l10n.skip),
), ),
FilledButton( Button.primary(
onPressed: replaceAll == false onPressed: replaceAll == false
? null ? null
: () { : () {

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/connect/clients.dart';
@ -16,31 +16,31 @@ class SelectDeviceDialog extends HookConsumerWidget {
return AlertDialog( return AlertDialog(
title: Text(context.l10n.choose_the_device), title: Text(context.l10n.choose_the_device),
insetPadding: const EdgeInsets.all(16), content: RadioGroup(
content: Column( value: isRemoteService.value,
mainAxisSize: MainAxisSize.min, onChanged: (value) {
children: [ isRemoteService.value = value;
Text(context.l10n.multiple_device_connected), },
RadioListTile.adaptive( child: Column(
title: Text(remoteService.name), mainAxisSize: MainAxisSize.min,
value: true, crossAxisAlignment: CrossAxisAlignment.start,
groupValue: isRemoteService.value, children: [
onChanged: (value) { Text(context.l10n.multiple_device_connected),
isRemoteService.value = value!; const Gap(16),
}, RadioItem(
), trailing: Text(remoteService.name),
RadioListTile.adaptive( value: true,
title: Text(context.l10n.this_device), ),
value: false, const Gap(8),
groupValue: isRemoteService.value, RadioItem(
onChanged: (value) { trailing: Text(context.l10n.this_device),
isRemoteService.value = !value!; value: false,
}, ),
), ],
], ),
), ),
actions: [ actions: [
TextButton( Button.primary(
onPressed: () { onPressed: () {
Navigator.of(context).pop(isRemoteService.value); Navigator.of(context).pop(isRemoteService.value);
}, },
@ -51,7 +51,8 @@ class SelectDeviceDialog extends HookConsumerWidget {
} }
} }
Future<bool> showSelectDeviceDialog(BuildContext context, WidgetRef ref) async { Future<bool?> showSelectDeviceDialog(
BuildContext context, WidgetRef ref) async {
final connectClients = ref.read(connectClientsProvider); final connectClients = ref.read(connectClientsProvider);
if (connectClients.asData?.value.resolvedService == null) { if (connectClients.asData?.value.resolvedService == null) {
@ -63,5 +64,5 @@ Future<bool> showSelectDeviceDialog(BuildContext context, WidgetRef ref) async {
builder: (context) => const SelectDeviceDialog(), builder: (context) => const SelectDeviceDialog(),
); );
return isRemote ?? false; return isRemote;
} }

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/components/links/artist_link.dart';
@ -73,17 +73,15 @@ class TrackDetailsDialog extends HookWidget {
}; };
return AlertDialog( return AlertDialog(
contentPadding: const EdgeInsets.all(16), surfaceBlur: 0,
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 100), surfaceOpacity: 1,
scrollable: true,
title: Row( title: Row(
mainAxisAlignment: MainAxisAlignment.center, spacing: 8,
children: [ children: [
const Icon(SpotubeIcons.info), const Icon(SpotubeIcons.info),
const SizedBox(width: 8),
Text( Text(
context.l10n.details, context.l10n.details,
style: theme.textTheme.titleMedium, style: theme.typography.h4,
), ),
], ],
), ),
@ -91,65 +89,64 @@ class TrackDetailsDialog extends HookWidget {
width: mediaQuery.mdAndUp ? double.infinity : 700, width: mediaQuery.mdAndUp ? double.infinity : 700,
child: Table( child: Table(
columnWidths: const { columnWidths: const {
0: FixedColumnWidth(95), 0: FixedTableSize(95),
1: FixedColumnWidth(10), 1: FixedTableSize(10),
2: FlexColumnWidth(1), 2: FlexTableSize(),
}, },
defaultVerticalAlignment: TableCellVerticalAlignment.middle, theme: const TableTheme(
children: [ backgroundColor: Colors.transparent,
cellTheme: TableCellTheme(
backgroundColor: WidgetStatePropertyAll(Colors.transparent),
),
),
rowHeights: const {0: FixedTableSize(40)},
rows: [
for (final entry in detailsMap.entries) for (final entry in detailsMap.entries)
TableRow( TableRow(
children: [ cells: [
TableCell( TableCell(
verticalAlignment: TableCellVerticalAlignment.top,
child: Text( child: Text(
entry.key, entry.key,
style: theme.textTheme.titleMedium, style: theme.typography.bold,
), ),
), ),
const TableCell( const TableCell(
verticalAlignment: TableCellVerticalAlignment.top,
child: Text(":"), child: Text(":"),
), ),
if (entry.value is Widget) TableCell(
entry.value as Widget child: entry.value is Widget
else if (entry.value is String) ? entry.value as Widget
Text( : (entry.value is String)
entry.value as String, ? Text(
style: theme.textTheme.bodyMedium, entry.value as String,
), style: theme.typography.normal,
)
: const Text(""),
),
], ],
), ),
const TableRow(
children: [
SizedBox(height: 16),
SizedBox(height: 16),
SizedBox(height: 16),
],
),
for (final entry in ytTracksDetailsMap.entries) for (final entry in ytTracksDetailsMap.entries)
TableRow( TableRow(
children: [ cells: [
TableCell( TableCell(
verticalAlignment: TableCellVerticalAlignment.top,
child: Text( child: Text(
entry.key, entry.key,
style: theme.textTheme.titleMedium, style: theme.typography.bold,
), ),
), ),
const TableCell( const TableCell(
verticalAlignment: TableCellVerticalAlignment.top,
child: Text(":"), child: Text(":"),
), ),
if (entry.value is Widget) TableCell(
entry.value as Widget child: entry.value is Widget
else ? entry.value as Widget
Text( : Text(
entry.value, entry.value,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium, style: theme.typography.normal,
), ),
),
], ],
), ),
], ],

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
@ -39,11 +39,8 @@ class ExpandableSearchField extends StatelessWidget {
child: TextField( child: TextField(
focusNode: searchFocus, focusNode: searchFocus,
controller: searchController, controller: searchController,
decoration: InputDecoration( placeholder: Text(context.l10n.search_tracks),
hintText: context.l10n.search_tracks, leading: const Icon(SpotubeIcons.search),
isDense: true,
prefixIcon: const Icon(SpotubeIcons.search),
),
), ),
), ),
), ),
@ -69,16 +66,9 @@ class ExpandableSearchButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return IconButton( return IconButton(
icon: icon, icon: icon,
style: IconButton.styleFrom( variance: isFiltering ? ButtonVariance.secondary : ButtonVariance.outline,
backgroundColor:
isFiltering ? theme.colorScheme.secondaryContainer : null,
foregroundColor: isFiltering ? theme.colorScheme.secondary : null,
minimumSize: const Size(25, 25),
),
onPressed: () { onPressed: () {
if (isFiltering) { if (isFiltering) {
searchFocus.requestFocus(); searchFocus.requestFocus();

View File

@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_undraw/flutter_undraw.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/pages/settings/settings.dart';
import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
class AnonymousFallback extends ConsumerWidget { class AnonymousFallback extends ConsumerWidget {
@ -25,10 +28,17 @@ class AnonymousFallback extends ConsumerWidget {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
spacing: 10,
children: [ children: [
Undraw(
illustration: kIsMobile
? UndrawIllustration.accessDenied
: UndrawIllustration.secureLogin,
height: 200 * context.theme.scaling,
color: context.theme.colorScheme.primary,
),
Text(context.l10n.not_logged_in), Text(context.l10n.not_logged_in),
const SizedBox(height: 10), Button.primary(
FilledButton(
child: Text(context.l10n.login_with_spotify), child: Text(context.l10n.login_with_spotify),
onPressed: () => ServiceUtils.pushNamed(context, SettingsPage.name), onPressed: () => ServiceUtils.pushNamed(context, SettingsPage.name),
) )

View File

@ -1,32 +1,27 @@
import 'package:flutter/material.dart'; import 'package:flutter_undraw/flutter_undraw.dart';
import 'package:spotube/collections/assets.gen.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
class NotFound extends StatelessWidget { class NotFound extends StatelessWidget {
final bool vertical; const NotFound({super.key});
const NotFound({super.key, this.vertical = false});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); return Column(
final widgets = [ mainAxisSize: MainAxisSize.min,
SizedBox( children: [
height: 150, Undraw(
width: 150, illustration: UndrawIllustration.empty,
child: Assets.emptyBox.image(), height: 200 * context.theme.scaling,
), color: context.theme.colorScheme.primary,
Column( ),
crossAxisAlignment: CrossAxisAlignment.start, const Gap(10),
mainAxisAlignment: MainAxisAlignment.center, Text(
children: [ context.l10n.nothing_found,
Text(context.l10n.nothing_found, style: theme.textTheme.titleLarge), textAlign: TextAlign.center,
Text( ).muted().small()
context.l10n.the_box_is_empty, ],
style: theme.textTheme.titleMedium, );
),
],
),
];
return vertical ? Column(children: widgets) : Row(children: widgets);
} }
} }

View File

@ -0,0 +1,45 @@
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class CheckboxFormBuilderField extends StatelessWidget {
final String name;
final FormFieldValidator<bool>? validator;
final ValueChanged<CheckboxState>? onChanged;
final Widget? leading;
final Widget? trailing;
final bool tristate;
const CheckboxFormBuilderField({
super.key,
required this.name,
this.validator,
this.onChanged,
this.leading,
this.trailing,
this.tristate = false,
});
@override
Widget build(BuildContext context) {
return FormBuilderField<bool>(
name: name,
validator: validator,
builder: (field) {
return Checkbox(
state: tristate && field.value == null
? CheckboxState.indeterminate
: field.value == true
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: (state) {
field.didChange(state == CheckboxState.checked);
onChanged?.call(state);
},
leading: leading,
trailing: trailing,
tristate: tristate,
);
},
);
}
}

View File

@ -0,0 +1,187 @@
import 'package:flutter/services.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
class TextFormBuilderField extends StatelessWidget {
final String name;
final FormFieldValidator<String>? validator;
final Widget? label;
final TextEditingController? controller;
final bool filled;
final Widget? placeholder;
final AlignmentGeometry? placeholderAlignment;
final AlignmentGeometry? leadingAlignment;
final AlignmentGeometry? trailingAlignment;
final bool border;
final Widget? leading;
final Widget? trailing;
final EdgeInsetsGeometry? padding;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onEditingComplete;
final FocusNode? focusNode;
final VoidCallback? onTap;
final bool enabled;
final bool readOnly;
final bool obscureText;
final String obscuringCharacter;
final String? initialValue;
final int? maxLength;
final MaxLengthEnforcement? maxLengthEnforcement;
final int? maxLines;
final int? minLines;
final BorderRadiusGeometry? borderRadius;
final TextAlign textAlign;
final bool expands;
final TextAlignVertical? textAlignVertical;
final UndoHistoryController? undoController;
final ValueChanged<String>? onChanged;
final Iterable<String>? autofillHints;
final void Function(PointerDownEvent event)? onTapOutside;
final List<TextInputFormatter>? inputFormatters;
final TextStyle? style;
final EditableTextContextMenuBuilder? contextMenuBuilder;
final bool useNativeContextMenu;
final bool? isCollapsed;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final Clip clipBehavior;
final bool autofocus;
final WidgetStatesController? statesController;
const TextFormBuilderField({
super.key,
required this.name,
this.label,
this.validator,
this.controller,
this.maxLength,
this.maxLengthEnforcement,
this.maxLines = 1,
this.minLines,
this.filled = false,
this.placeholder,
this.border = true,
this.leading,
this.trailing,
this.padding,
this.onSubmitted,
this.onEditingComplete,
this.focusNode,
this.onTap,
this.enabled = true,
this.readOnly = false,
this.obscureText = false,
this.obscuringCharacter = '',
this.initialValue,
this.borderRadius,
this.keyboardType,
this.textAlign = TextAlign.start,
this.expands = false,
this.textAlignVertical = TextAlignVertical.center,
this.autofillHints,
this.undoController,
this.onChanged,
this.onTapOutside,
this.inputFormatters,
this.style,
this.contextMenuBuilder = TextField.defaultContextMenuBuilder,
this.useNativeContextMenu = false,
this.isCollapsed,
this.textInputAction,
this.clipBehavior = Clip.hardEdge,
this.autofocus = false,
this.placeholderAlignment,
this.leadingAlignment,
this.trailingAlignment,
this.statesController,
});
@override
Widget build(BuildContext context) {
return FormBuilderField<String>(
name: name,
validator: validator,
onChanged: (value) {
if (value == null) return;
onChanged?.call(value);
},
builder: (field) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 5,
children: [
if (label != null)
DefaultTextStyle(
style: context.theme.typography.semiBold.copyWith(
color: field.hasError
? context.theme.colorScheme.destructive
: context.theme.colorScheme.foreground,
),
child: label!,
),
TextField(
controller: controller,
maxLength: maxLength,
maxLengthEnforcement: maxLengthEnforcement,
maxLines: maxLines,
minLines: minLines,
filled: filled,
placeholder: placeholder,
border: border,
leading: leading,
trailing: trailing,
padding: padding,
onSubmitted: (value) {
field.validate();
field.save();
onSubmitted?.call(value);
},
onEditingComplete: () {
field.save();
onEditingComplete?.call();
},
focusNode: focusNode,
onTap: onTap,
enabled: enabled,
readOnly: readOnly,
obscureText: obscureText,
obscuringCharacter: obscuringCharacter,
initialValue: field.value,
borderRadius: borderRadius,
textAlign: textAlign,
expands: expands,
textAlignVertical: textAlignVertical,
autofillHints: autofillHints,
undoController: undoController,
onChanged: (value) {
field.didChange(value);
},
onTapOutside: onTapOutside,
inputFormatters: inputFormatters,
style: style,
contextMenuBuilder: contextMenuBuilder,
useNativeContextMenu: useNativeContextMenu,
isCollapsed: isCollapsed,
keyboardType: keyboardType,
textInputAction: textInputAction,
clipBehavior: clipBehavior,
autofocus: autofocus,
placeholderAlignment: placeholderAlignment,
leadingAlignment: leadingAlignment,
trailingAlignment: trailingAlignment,
statesController: statesController,
),
if (field.hasError)
Text(
field.errorText ?? "",
style: TextStyle(
color: context.theme.colorScheme.destructive,
),
),
],
),
);
}
}

View File

@ -1,6 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
/// A temporary workaround for [WillPopScope] and [PopScope] not working in GoRouter /// A temporary workaround for [WillPopScope] and [PopScope] not working in GoRouter
/// https://github.com/flutter/flutter/issues/140869#issuecomment-2247181468 /// https://github.com/flutter/flutter/issues/140869#issuecomment-2247181468

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/heart_button/use_track_toggle_like.dart'; import 'package:spotube/components/heart_button/use_track_toggle_like.dart';
@ -13,12 +13,16 @@ class HeartButton extends HookConsumerWidget {
final IconData? icon; final IconData? icon;
final Color? color; final Color? color;
final String? tooltip; final String? tooltip;
final ButtonVariance variance;
final ButtonSize size;
const HeartButton({ const HeartButton({
required this.isLiked, required this.isLiked,
required this.onPressed, required this.onPressed,
this.color, this.color,
this.tooltip, this.tooltip,
this.icon, this.icon,
this.variance = ButtonVariance.ghost,
this.size = ButtonSize.normal,
super.key, super.key,
}); });
@ -28,28 +32,32 @@ class HeartButton extends HookConsumerWidget {
if (auth.asData?.value == null) return const SizedBox.shrink(); if (auth.asData?.value == null) return const SizedBox.shrink();
return IconButton( return Tooltip(
tooltip: tooltip, tooltip: TooltipContainer(child: Text(tooltip ?? "")),
icon: AnimatedSwitcher( child: IconButton(
switchInCurve: Curves.fastOutSlowIn, variance: variance,
switchOutCurve: Curves.fastOutSlowIn, size: size,
duration: const Duration(milliseconds: 300), icon: AnimatedSwitcher(
transitionBuilder: (child, animation) { switchInCurve: Curves.fastOutSlowIn,
return ScaleTransition( switchOutCurve: Curves.fastOutSlowIn,
scale: animation, duration: const Duration(milliseconds: 300),
child: child, transitionBuilder: (child, animation) {
); return ScaleTransition(
}, scale: animation,
child: Icon( child: child,
icon ?? );
(isLiked },
? Icons.favorite_rounded child: Icon(
: Icons.favorite_outline_rounded), icon ??
key: ValueKey(isLiked), (isLiked
color: color ?? (isLiked ? color ?? Colors.red : null), ? Icons.favorite_rounded
: Icons.favorite_outline_rounded),
key: ValueKey(isLiked),
color: color ?? (isLiked ? color ?? Colors.red : null),
),
), ),
onPressed: onPressed,
), ),
onPressed: onPressed,
); );
} }
} }

View File

@ -1,14 +1,14 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/fake.dart';
import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/modules/album/album_card.dart';
import 'package:spotube/modules/artist/artist_card.dart'; import 'package:spotube/modules/artist/artist_card.dart';
import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/modules/playlist/playlist_card.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class HorizontalPlaybuttonCardView<T> extends HookWidget { class HorizontalPlaybuttonCardView<T> extends HookWidget {
@ -36,14 +36,9 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ThemeData(:textTheme) = Theme.of(context);
final scrollController = useScrollController(); final scrollController = useScrollController();
final height = useBreakpointValue<double>( final isArtist = items.every((s) => s is Artist);
xs: 226, final scale = context.theme.scaling;
sm: 226,
md: 236,
others: 266,
);
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
@ -54,15 +49,21 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
DefaultTextStyle( Flexible(
style: textTheme.titleMedium!, child: DefaultTextStyle(
child: title, style: context.theme.typography.h4.copyWith(
color: context.theme.colorScheme.foreground,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: title,
),
), ),
if (titleTrailing != null) titleTrailing!, if (titleTrailing != null) titleTrailing!,
], ],
), ),
SizedBox( SizedBox(
height: height, height: isArtist ? 250 : 225,
child: NotificationListener( child: NotificationListener(
// disable multiple scrollbar to use this // disable multiple scrollbar to use this
onNotification: (notification) => true, onNotification: (notification) => true,
@ -86,10 +87,13 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
onFetchData: onFetchMore, onFetchData: onFetchMore,
loadingBuilder: (context) => Skeletonizer( loadingBuilder: (context) => Skeletonizer(
enabled: true, enabled: true,
child: AlbumCard(FakeData.albumSimple), child: isArtist
? ArtistCard(FakeData.artist)
: AlbumCard(FakeData.albumSimple),
), ),
isLoading: isLoadingNextPage, isLoading: isLoadingNextPage,
hasReachedMax: !hasNextPage, hasReachedMax: !hasNextPage,
separatorBuilder: (context, index) => Gap(12 * scale),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = items[index]; final item = items[index];
@ -97,11 +101,7 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
PlaylistSimple() => PlaylistSimple() =>
PlaylistCard(item as PlaylistSimple), PlaylistCard(item as PlaylistSimple),
AlbumSimple() => AlbumCard(item as AlbumSimple), AlbumSimple() => AlbumCard(item as AlbumSimple),
Artist() => Padding( Artist() => ArtistCard(item as Artist),
padding: const EdgeInsets.symmetric(
horizontal: 12.0),
child: ArtistCard(item as Artist),
),
_ => const SizedBox.shrink(), _ => const SizedBox.shrink(),
}; };
}), }),

View File

@ -1,5 +1,5 @@
import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:draggable_scrollbar/draggable_scrollbar.dart';
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class AnchorButton<T> extends HookWidget { class AnchorButton<T> extends HookWidget {
final String text; final String text;

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/links/anchor_button.dart'; import 'package:spotube/components/links/anchor_button.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/components/links/anchor_button.dart'; import 'package:spotube/components/links/anchor_button.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/components/links/anchor_button.dart'; import 'package:spotube/components/links/anchor_button.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';

View File

@ -1,146 +0,0 @@
part of 'sliding_up_panel.dart';
class PanelController extends ChangeNotifier {
SlidingUpPanelState? _panelState;
void _addState(SlidingUpPanelState panelState) {
_panelState = panelState;
notifyListeners();
}
bool _forceScrollChange = false;
/// use this function when scroll change in func
/// Example:
/// panelController.forseScrollChange(scrollController.animateTo(100, duration: Duration(milliseconds: 400), curve: Curves.ease))
Future<void> forceScrollChange(Future func) async {
_forceScrollChange = true;
_panelState!._scrollingEnabled = true;
await func;
// if (_panelState!._sc.offset == 0) {
// _panelState!._scrollingEnabled = true;
// }
if (panelPosition < 1) {
_panelState!._scMinOffset = _panelState!._scrollController.offset;
}
_forceScrollChange = false;
}
bool __nowTargetForceDraggable = false;
bool get _nowTargetForceDraggable => __nowTargetForceDraggable;
set _nowTargetForceDraggable(bool value) {
__nowTargetForceDraggable = value;
notifyListeners();
}
/// Determine if the panelController is attached to an instance
/// of the SlidingUpPanel (this property must return true before any other
/// functions can be used)
bool get isAttached => _panelState != null;
/// Closes the sliding panel to its collapsed state (i.e. to the minHeight)
Future<void> close() async {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
await _panelState!._close();
notifyListeners();
}
/// Opens the sliding panel fully
/// (i.e. to the maxHeight)
Future<void> open() async {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
await _panelState!._open();
notifyListeners();
}
/// Hides the sliding panel (i.e. is invisible)
Future<void> hide() async {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
await _panelState!._hide();
notifyListeners();
}
/// Shows the sliding panel in its collapsed state
/// (i.e. "un-hide" the sliding panel)
Future<void> show() async {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
await _panelState!._show();
notifyListeners();
}
/// Animates the panel position to the value.
/// The value must between 0.0 and 1.0
/// where 0.0 is fully collapsed and 1.0 is completely open.
/// (optional) duration specifies the time for the animation to complete
/// (optional) curve specifies the easing behavior of the animation.
Future<void> animatePanelToPosition(double value,
{Duration? duration, Curve curve = Curves.linear}) {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
assert(0.0 <= value && value <= 1.0);
return _panelState!
._animatePanelToPosition(value, duration: duration, curve: curve);
}
/// Animates the panel position to the snap point
/// Requires that the SlidingUpPanel snapPoint property is not null
/// (optional) duration specifies the time for the animation to complete
/// (optional) curve specifies the easing behavior of the animation.
Future<void> animatePanelToSnapPoint(
{Duration? duration, Curve curve = Curves.linear}) {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
assert(_panelState!.widget.snapPoint != null,
"SlidingUpPanel snapPoint property must not be null");
return _panelState!
._animatePanelToSnapPoint(duration: duration, curve: curve);
}
/// Sets the panel position (without animation).
/// The value must between 0.0 and 1.0
/// where 0.0 is fully collapsed and 1.0 is completely open.
set panelPosition(double value) {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
assert(0.0 <= value && value <= 1.0);
_panelState!._panelPosition = value;
}
/// Gets the current panel position.
/// Returns the % offset from collapsed state
/// to the open state
/// as a decimal between 0.0 and 1.0
/// where 0.0 is fully collapsed and
/// 1.0 is full open.
double get panelPosition {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
return _panelState!._panelPosition;
}
/// Returns whether or not the panel is
/// currently animating.
bool get isPanelAnimating {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
return _panelState!._isPanelAnimating;
}
/// Returns whether or not the
/// panel is open.
bool get isPanelOpen {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
return _panelState!._isPanelOpen;
}
/// Returns whether or not the
/// panel is closed.
bool get isPanelClosed {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
return _panelState!._isPanelClosed;
}
/// Returns whether or not the
/// panel is shown/hidden.
bool get isPanelShown {
assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
return _panelState!._isPanelShown;
}
}

View File

@ -1,95 +0,0 @@
part of "sliding_up_panel.dart";
/// if you want to prevent the panel from being dragged using the widget,
/// wrap the widget with this
class IgnoreDraggableWidget extends SingleChildRenderObjectWidget {
const IgnoreDraggableWidget({
super.key,
required super.child,
});
@override
IgnoreDraggableWidgetWidgetRenderBox createRenderObject(
BuildContext context,
) {
return IgnoreDraggableWidgetWidgetRenderBox();
}
}
class IgnoreDraggableWidgetWidgetRenderBox extends RenderPointerListener {
@override
HitTestBehavior get behavior => HitTestBehavior.opaque;
}
/// if you want to force the panel to be dragged using the widget,
/// wrap the widget with this
/// For example, use [Scrollable] inside to allow the panel to be dragged
/// even if the scroll is not at position 0.
class ForceDraggableWidget extends SingleChildRenderObjectWidget {
const ForceDraggableWidget({
super.key,
required super.child,
});
@override
ForceDraggableWidgetRenderBox createRenderObject(
BuildContext context,
) {
return ForceDraggableWidgetRenderBox();
}
}
class ForceDraggableWidgetRenderBox extends RenderPointerListener {
@override
HitTestBehavior get behavior => HitTestBehavior.opaque;
}
/// To make [ForceDraggableWidget] work in [Scrollable] widgets
class PanelScrollPhysics extends ScrollPhysics {
final PanelController controller;
const PanelScrollPhysics({required this.controller, super.parent});
@override
PanelScrollPhysics applyTo(ScrollPhysics? ancestor) {
return PanelScrollPhysics(
controller: controller, parent: buildParent(ancestor));
}
@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
if (controller._nowTargetForceDraggable) return 0.0;
return super.applyPhysicsToUserOffset(position, offset);
}
@override
Simulation? createBallisticSimulation(
ScrollMetrics position, double velocity) {
if (controller._nowTargetForceDraggable) {
return super.createBallisticSimulation(position, 0);
}
return super.createBallisticSimulation(position, velocity);
}
@override
bool get allowImplicitScrolling => false;
}
/// if you want to prevent unwanted panel dragging when scrolling widgets [Scrollable] with horizontal axis
/// wrap the widget with this
class HorizontalScrollableWidget extends SingleChildRenderObjectWidget {
const HorizontalScrollableWidget({
super.key,
required super.child,
});
@override
HorizontalScrollableWidgetRenderBox createRenderObject(
BuildContext context,
) {
return HorizontalScrollableWidgetRenderBox();
}
}
class HorizontalScrollableWidgetRenderBox extends RenderPointerListener {
@override
HitTestBehavior get behavior => HitTestBehavior.opaque;
}

View File

@ -1,685 +0,0 @@
/*
Name: Zotov Vladimir
Date: 18/06/22
Purpose: Defines the package: sliding_up_panel2
Copyright: © 2022, Zotov Vladimir. All rights reserved.
Licensing: More information can be found here: https://github.com/Zotov-VD/sliding_up_panel/blob/master/LICENSE
This product includes software developed by Akshath Jain (https://akshathjain.com)
*/
library panels;
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
part 'controller.dart';
part 'helpers.dart';
enum SlideDirection { up, down }
enum PanelState { open, closed }
class SlidingUpPanel extends StatefulWidget {
/// Returns the Widget that slides into view. When the
/// panel is collapsed and if [collapsed] is null,
/// then top portion of this Widget will be displayed;
/// otherwise, [collapsed] will be displayed overtop
/// of this Widget.
final Widget? Function(double position)? panelBuilder;
/// The Widget displayed overtop the [panel] when collapsed.
/// This fades out as the panel is opened.
final Widget? collapsed;
/// The Widget that lies underneath the sliding panel.
/// This Widget automatically sizes itself
/// to fill the screen.
final Widget? body;
/// Optional persistent widget that floats above the [panel] and attaches
/// to the top of the [panel]. Content at the top of the panel will be covered
/// by this widget. Add padding to the bottom of the `panel` to
/// avoid coverage.
final Widget? header;
/// Optional persistent widget that floats above the [panel] and
/// attaches to the bottom of the [panel]. Content at the bottom of the panel
/// will be covered by this widget. Add padding to the bottom of the `panel`
/// to avoid coverage.
final Widget? footer;
/// The height of the sliding panel when fully collapsed.
final double minHeight;
/// The height of the sliding panel when fully open.
final double maxHeight;
/// A point between [minHeight] and [maxHeight] that the panel snaps to
/// while animating. A fast swipe on the panel will disregard this point
/// and go directly to the open/close position. This value is represented as a
/// percentage of the total animation distance ([maxHeight] - [minHeight]),
/// so it must be between 0.0 and 1.0, exclusive.
final double? snapPoint;
/// The amount to inset the children of the sliding panel sheet.
final EdgeInsetsGeometry? padding;
/// Empty space surrounding the sliding panel sheet.
final EdgeInsetsGeometry? margin;
/// Set to false to disable the panel from snapping open or closed.
final bool panelSnapping;
/// Disable panel draggable on scrolling. Defaults to false.
final bool disableDraggableOnScrolling;
/// If non-null, this can be used to control the state of the panel.
final PanelController? controller;
/// If non-null, shows a darkening shadow over the [body] as the panel slides open.
final bool backdropEnabled;
/// Shows a darkening shadow of this [Color] over the [body] as the panel slides open.
final Color backdropColor;
/// The opacity of the backdrop when the panel is fully open.
/// This value can range from 0.0 to 1.0 where 0.0 is completely transparent
/// and 1.0 is completely opaque.
final double backdropOpacity;
/// Flag that indicates whether or not tapping the
/// backdrop closes the panel. Defaults to true.
final bool backdropTapClosesPanel;
/// If non-null, this callback
/// is called as the panel slides around with the
/// current position of the panel. The position is a double
/// between 0.0 and 1.0 where 0.0 is fully collapsed and 1.0 is fully open.
final void Function(double position)? onPanelSlide;
/// If non-null, this callback is called when the
/// panel is fully opened
final VoidCallback? onPanelOpened;
/// If non-null, this callback is called when the panel
/// is fully collapsed.
final VoidCallback? onPanelClosed;
/// If non-null and true, the SlidingUpPanel exhibits a
/// parallax effect as the panel slides up. Essentially,
/// the body slides up as the panel slides up.
final bool parallaxEnabled;
/// Allows for specifying the extent of the parallax effect in terms
/// of the percentage the panel has slid up/down. Recommended values are
/// within 0.0 and 1.0 where 0.0 is no parallax and 1.0 mimics a
/// one-to-one scrolling effect. Defaults to a 10% parallax.
final double parallaxOffset;
/// Allows toggling of the draggability of the SlidingUpPanel.
/// Set this to false to prevent the user from being able to drag
/// the panel up and down. Defaults to true.
final bool isDraggable;
/// Either SlideDirection.UP or SlideDirection.DOWN. Indicates which way
/// the panel should slide. Defaults to UP. If set to DOWN, the panel attaches
/// itself to the top of the screen and is fully opened when the user swipes
/// down on the panel.
final SlideDirection slideDirection;
/// The default state of the panel; either PanelState.OPEN or PanelState.CLOSED.
/// This value defaults to PanelState.CLOSED which indicates that the panel is
/// in the closed position and must be opened. PanelState.OPEN indicates that
/// by default the Panel is open and must be swiped closed by the user.
final PanelState defaultPanelState;
/// To attach to a [Scrollable] on a panel that
/// links the panel's position to the scroll position. Useful for implementing
/// infinite scroll behavior
final ScrollController? scrollController;
final BoxDecoration? panelDecoration;
const SlidingUpPanel(
{super.key,
this.body,
this.collapsed,
this.minHeight = 100.0,
this.maxHeight = 500.0,
this.snapPoint,
this.padding,
this.margin,
this.panelDecoration,
this.panelSnapping = true,
this.disableDraggableOnScrolling = false,
this.controller,
this.backdropEnabled = false,
this.backdropColor = Colors.black,
this.backdropOpacity = 0.5,
this.backdropTapClosesPanel = true,
this.onPanelSlide,
this.onPanelOpened,
this.onPanelClosed,
this.parallaxEnabled = false,
this.parallaxOffset = 0.1,
this.isDraggable = true,
this.slideDirection = SlideDirection.up,
this.defaultPanelState = PanelState.closed,
this.header,
this.footer,
this.scrollController,
this.panelBuilder})
: assert(panelBuilder != null),
assert(0 <= backdropOpacity && backdropOpacity <= 1.0),
assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0);
@override
SlidingUpPanelState createState() => SlidingUpPanelState();
}
class SlidingUpPanelState extends State<SlidingUpPanel>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late final ScrollController _scrollController;
bool _scrollingEnabled = false;
final VelocityTracker _velocityTracker =
VelocityTracker.withKind(PointerDeviceKind.touch);
bool _isPanelVisible = true;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
value: widget.defaultPanelState == PanelState.closed
? 0.0
: 1.0 //set the default panel state (i.e. set initial value of _ac)
)
..addListener(() {
if (widget.onPanelSlide != null) {
widget.onPanelSlide!(_animationController.value);
}
if (widget.onPanelOpened != null &&
(_animationController.value == 1.0 ||
_animationController.value == 0.0)) {
widget.onPanelOpened!();
}
});
// prevent the panel content from being scrolled only if the widget is
// draggable and panel scrolling is enabled
_scrollController = widget.scrollController ?? ScrollController();
_scrollController.addListener(() {
if (widget.isDraggable &&
!widget.disableDraggableOnScrolling &&
(!_scrollingEnabled || _panelPosition < 1) &&
widget.controller?._forceScrollChange != true) {
_scrollController.jumpTo(_scMinOffset);
}
});
widget.controller?._addState(this);
}
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
return Stack(
alignment: widget.slideDirection == SlideDirection.up
? Alignment.bottomCenter
: Alignment.topCenter,
children: <Widget>[
//make the back widget take up the entire back side
if (widget.body != null)
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Positioned(
top: widget.parallaxEnabled ? _getParallax() : 0.0,
child: child ?? const SizedBox(),
);
},
child: SizedBox(
height: mediaQuery.size.height,
width: mediaQuery.size.width,
child: widget.body,
),
),
//the backdrop to overlay on the body
if (widget.backdropEnabled)
GestureDetector(
onVerticalDragEnd: widget.backdropTapClosesPanel
? (DragEndDetails details) {
// only trigger a close if the drag is towards panel close position
if ((widget.slideDirection == SlideDirection.up ? 1 : -1) *
details.velocity.pixelsPerSecond.dy >
0) _close();
}
: null,
onTap: widget.backdropTapClosesPanel ? () => _close() : null,
child: AnimatedBuilder(
animation: _animationController,
builder: (context, _) {
return Container(
height: mediaQuery.size.height,
width: mediaQuery.size.width,
//set color to null so that touch events pass through
//to the body when the panel is closed, otherwise,
//if a color exists, then touch events won't go through
color: _animationController.value == 0.0
? null
: widget.backdropColor.withOpacity(
widget.backdropOpacity * _animationController.value,
),
);
}),
),
//the actual sliding part
if (_isPanelVisible)
_gestureHandler(
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Container(
height: _animationController.value *
(widget.maxHeight - widget.minHeight) +
widget.minHeight,
margin: widget.margin,
padding: widget.padding,
decoration: widget.panelDecoration,
child: child,
);
},
child: Stack(
children: <Widget>[
//open panel
Positioned(
top:
widget.slideDirection == SlideDirection.up ? 0.0 : null,
bottom: widget.slideDirection == SlideDirection.down
? 0.0
: null,
width: mediaQuery.size.width -
(widget.margin != null
? widget.margin!.horizontal
: 0) -
(widget.padding != null
? widget.padding!.horizontal
: 0),
child: SizedBox(
height: widget.maxHeight,
child: widget.panelBuilder!(
_animationController.value,
),
),
),
// footer
if (widget.footer != null)
Positioned(
top: widget.slideDirection == SlideDirection.up
? null
: 0.0,
bottom: widget.slideDirection == SlideDirection.down
? null
: 0.0,
child: widget.footer ?? const SizedBox()),
// header
if (widget.header != null)
Positioned(
top: widget.slideDirection == SlideDirection.up
? 0.0
: null,
bottom: widget.slideDirection == SlideDirection.down
? 0.0
: null,
child: widget.header ?? const SizedBox(),
),
// collapsed panel
Positioned(
top:
widget.slideDirection == SlideDirection.up ? 0.0 : null,
bottom: widget.slideDirection == SlideDirection.down
? 0.0
: null,
width: mediaQuery.size.width -
(widget.margin != null
? widget.margin!.horizontal
: 0) -
(widget.padding != null
? widget.padding!.horizontal
: 0),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
height: widget.minHeight,
child: widget.collapsed == null
? null
: FadeTransition(
opacity: Tween(begin: 1.0, end: 0.0)
.animate(_animationController),
// if the panel is open ignore pointers (touch events) on the collapsed
// child so that way touch events go through to whatever is underneath
child: IgnorePointer(
ignoring: _animationController.value == 1.0,
child: widget.collapsed,
),
),
),
),
],
),
),
),
],
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
double _getParallax() {
if (widget.slideDirection == SlideDirection.up) {
return -_animationController.value *
(widget.maxHeight - widget.minHeight) *
widget.parallaxOffset;
} else {
return _animationController.value *
(widget.maxHeight - widget.minHeight) *
widget.parallaxOffset;
}
}
bool _ignoreScrollable = false;
bool _isHorizontalScrollableWidget = false;
Axis? _scrollableAxis;
// returns a gesture detector if panel is used
// and a listener if panelBuilder is used.
// this is because the listener is designed only for use with linking the scrolling of
// panels and using it for panels that don't want to linked scrolling yields odd results
Widget _gestureHandler({required Widget child}) {
if (!widget.isDraggable) return child;
return Listener(
onPointerDown: (PointerDownEvent e) {
var rb = context.findRenderObject() as RenderBox;
var result = BoxHitTestResult();
rb.hitTest(result, position: e.position);
if (_panelPosition == 1) {
_scMinOffset = 0.0;
}
// if there any widget in the path that must force graggable,
// stop it right here
if (result.path.any((entry) =>
entry.target.runtimeType == ForceDraggableWidgetRenderBox)) {
widget.controller?._nowTargetForceDraggable = true;
_scMinOffset = _scrollController.offset;
_isHorizontalScrollableWidget = false;
} else if (result.path.any((entry) =>
entry.target.runtimeType == HorizontalScrollableWidgetRenderBox)) {
_isHorizontalScrollableWidget = true;
widget.controller?._nowTargetForceDraggable = false;
} else if (result.path.any((entry) =>
entry.target.runtimeType == IgnoreDraggableWidgetWidgetRenderBox)) {
_ignoreScrollable = true;
widget.controller?._nowTargetForceDraggable = false;
_isHorizontalScrollableWidget = false;
return;
} else {
widget.controller?._nowTargetForceDraggable = false;
_isHorizontalScrollableWidget = false;
}
_ignoreScrollable = false;
_velocityTracker.addPosition(e.timeStamp, e.position);
},
onPointerMove: (PointerMoveEvent e) {
if (_scrollableAxis == null) {
if (e.delta.dx.abs() > e.delta.dy.abs()) {
_scrollableAxis = Axis.horizontal;
} else {
_scrollableAxis = Axis.vertical;
}
}
if (_isHorizontalScrollableWidget &&
_scrollableAxis == Axis.horizontal) {
return;
}
if (_ignoreScrollable) return;
_velocityTracker.addPosition(
e.timeStamp,
e.position,
); // add current position for velocity tracking
_onGestureSlide(e.delta.dy);
},
onPointerUp: (PointerUpEvent e) {
if (_ignoreScrollable) return;
_scrollableAxis = null;
_onGestureEnd(_velocityTracker.getVelocity());
},
child: child,
);
}
double _scMinOffset = 0.0;
// handles the sliding gesture
void _onGestureSlide(double dy) {
// only slide the panel if scrolling is not enabled
if (widget.controller?._nowTargetForceDraggable == false &&
widget.disableDraggableOnScrolling) {
return;
}
if ((!_scrollingEnabled) ||
_panelPosition < 1 ||
widget.controller?._nowTargetForceDraggable == true) {
if (widget.slideDirection == SlideDirection.up) {
_animationController.value -=
dy / (widget.maxHeight - widget.minHeight);
} else {
_animationController.value +=
dy / (widget.maxHeight - widget.minHeight);
}
}
// if the panel is open and the user hasn't scrolled, we need to determine
// whether to enable scrolling if the user swipes up, or disable closing and
// begin to close the panel if the user swipes down
if (_isPanelOpen &&
_scrollController.hasClients &&
_scrollController.offset <= _scMinOffset) {
setState(() {
if (dy < 0) {
_scrollingEnabled = true;
} else {
_scrollingEnabled = false;
}
});
}
}
// handles when user stops sliding
void _onGestureEnd(Velocity v) {
if (widget.controller?._nowTargetForceDraggable == false &&
widget.disableDraggableOnScrolling) {
return;
}
double minFlingVelocity = 365.0;
double kSnap = 8;
//let the current animation finish before starting a new one
if (_animationController.isAnimating) return;
// if scrolling is allowed and the panel is open, we don't want to close
// the panel if they swipe up on the scrollable
if (_isPanelOpen && _scrollingEnabled) return;
//check if the velocity is sufficient to constitute fling to end
double visualVelocity =
-v.pixelsPerSecond.dy / (widget.maxHeight - widget.minHeight);
// reverse visual velocity to account for slide direction
if (widget.slideDirection == SlideDirection.down) {
visualVelocity = -visualVelocity;
}
// get minimum distances to figure out where the panel is at
double d2Close = _animationController.value;
double d2Open = 1 - _animationController.value;
double d2Snap = ((widget.snapPoint ?? 3) - _animationController.value)
.abs(); // large value if null results in not every being the min
double minDistance = min(d2Close, min(d2Snap, d2Open));
// check if velocity is sufficient for a fling
if (v.pixelsPerSecond.dy.abs() >= minFlingVelocity) {
// snapPoint exists
if (widget.panelSnapping && widget.snapPoint != null) {
if (v.pixelsPerSecond.dy.abs() >= kSnap * minFlingVelocity ||
minDistance == d2Snap) {
_animationController.fling(velocity: visualVelocity);
} else {
_flingPanelToPosition(widget.snapPoint!, visualVelocity);
}
// no snap point exists
} else if (widget.panelSnapping) {
_animationController.fling(velocity: visualVelocity);
// panel snapping disabled
} else {
_animationController.animateTo(
_animationController.value + visualVelocity * 0.16,
duration: const Duration(milliseconds: 410),
curve: Curves.decelerate,
);
}
return;
}
// check if the controller is already halfway there
if (widget.panelSnapping) {
if (minDistance == d2Close) {
_close();
} else if (minDistance == d2Snap) {
_flingPanelToPosition(widget.snapPoint!, visualVelocity);
} else {
_open();
}
}
}
void _flingPanelToPosition(double targetPos, double velocity) {
final Simulation simulation = SpringSimulation(
SpringDescription.withDampingRatio(
mass: 1.0,
stiffness: 500.0,
ratio: 1.0,
),
_animationController.value,
targetPos,
velocity);
_animationController.animateWith(simulation);
}
//---------------------------------
//PanelController related functions
//---------------------------------
//close the panel
Future<void> _close() {
return _animationController.fling(velocity: -1.0);
}
//open the panel
Future<void> _open() {
return _animationController.fling(velocity: 1.0);
}
//hide the panel (completely offscreen)
Future<void> _hide() {
return _animationController.fling(velocity: -1.0).then((x) {
setState(() {
_isPanelVisible = false;
});
});
}
//show the panel (in collapsed mode)
Future<void> _show() {
return _animationController.fling(velocity: -1.0).then((x) {
setState(() {
_isPanelVisible = true;
});
});
}
//animate the panel position to value - must
//be between 0.0 and 1.0
Future<void> _animatePanelToPosition(double value,
{Duration? duration, Curve curve = Curves.linear}) {
assert(0.0 <= value && value <= 1.0);
return _animationController.animateTo(value,
duration: duration, curve: curve);
}
//animate the panel position to the snap point
//REQUIRES that widget.snapPoint != null
Future<void> _animatePanelToSnapPoint(
{Duration? duration, Curve curve = Curves.linear}) {
assert(widget.snapPoint != null);
return _animationController.animateTo(widget.snapPoint!,
duration: duration, curve: curve);
}
//set the panel position to value - must
//be between 0.0 and 1.0
set _panelPosition(double value) {
assert(0.0 <= value && value <= 1.0);
_animationController.value = value;
}
//get the current panel position
//returns the % offset from collapsed state
//as a decimal between 0.0 and 1.0
double get _panelPosition => _animationController.value;
//returns whether or not
//the panel is still animating
bool get _isPanelAnimating => _animationController.isAnimating;
//returns whether or not the
//panel is open
bool get _isPanelOpen => _animationController.value == 1.0;
//returns whether or not the
//panel is closed
bool get _isPanelClosed => _animationController.value == 0.0;
//returns whether or not the
//panel is shown/hidden
bool get _isPanelShown => _isPanelVisible;
}

View File

@ -1,220 +0,0 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/hover_builder.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/string.dart';
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart';
class PlaybuttonCard extends HookWidget {
final void Function()? onTap;
final void Function()? onPlaybuttonPressed;
final void Function()? onAddToQueuePressed;
final String? description;
final EdgeInsetsGeometry? margin;
final String imageUrl;
final bool isPlaying;
final bool isLoading;
final String title;
final bool isOwner;
const PlaybuttonCard({
required this.imageUrl,
required this.isPlaying,
required this.isLoading,
required this.title,
this.margin,
this.description,
this.onPlaybuttonPressed,
this.onAddToQueuePressed,
this.onTap,
this.isOwner = false,
super.key,
});
@override
Widget build(BuildContext context) {
final textsKey = useMemoized(() => GlobalKey(), []);
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final radius = BorderRadius.circular(15);
final double size = useBreakpointValue<double>(
xs: 130,
sm: 130,
md: 150,
others: 170,
);
final end = useBreakpointValue<double>(
xs: 7,
sm: 7,
others: 15,
);
final unescapeHtml = description?.unescapeHtml().cleanHtml();
return Container(
constraints: BoxConstraints(maxWidth: size),
margin: margin,
child: Material(
color: Color.lerp(
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.surface,
useBrightnessValue(.9, .7),
),
borderRadius: radius,
shadowColor: theme.colorScheme.surface,
elevation: 3,
child: InkWell(
mouseCursor: SystemMouseCursors.click,
onTap: onTap,
borderRadius: radius,
splashFactory: theme.splashFactory,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
margin: const EdgeInsets.fromLTRB(8, 8, 8, 0),
padding: const EdgeInsets.only(
left: 8,
right: 8,
top: 8,
),
height: mediaQuery.smAndDown
? 120
: mediaQuery.mdAndDown
? 130
: 150,
decoration: BoxDecoration(
borderRadius: radius,
image: DecorationImage(
image: UniversalImage.imageProvider(imageUrl),
fit: BoxFit.cover,
),
),
),
if (isOwner)
Positioned(
top: 15,
left: 15,
child: AnimatedSize(
duration: const Duration(milliseconds: 150),
alignment: Alignment.centerLeft,
curve: Curves.easeInExpo,
child: HoverBuilder(builder: (context, isHovered) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blueAccent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
SpotubeIcons.user,
color: Colors.white,
size: 16,
),
if (isHovered)
Text(
context.l10n.owned_by_you,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
),
),
],
),
);
}),
),
),
Positioned(
right: end,
bottom: -15,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isPlaying)
Skeleton.keep(
child: IconButton(
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.surface,
foregroundColor: theme.colorScheme.primary,
minimumSize: const Size.square(10),
),
icon: const Icon(SpotubeIcons.queueAdd),
onPressed: isLoading ? null : onAddToQueuePressed,
),
),
const Gap(5),
IconButton(
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.primary,
minimumSize: const Size.square(10),
),
icon: Skeleton.keep(
child: isLoading
? SizedBox.fromSize(
size: const Size.square(15),
child: const CircularProgressIndicator(
strokeWidth: 2),
)
: isPlaying
? const Icon(SpotubeIcons.pause)
: const Icon(SpotubeIcons.play),
),
onPressed: isLoading ? null : onPlaybuttonPressed,
),
],
),
),
],
),
Column(
key: textsKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 15),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: AutoSizeText(
title,
maxLines: 1,
minFontSize: theme.textTheme.bodyMedium!.fontSize!,
overflow: TextOverflow.ellipsis,
),
),
if (description != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: AutoSizeText(
unescapeHtml!,
maxLines: 2,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(.5),
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 10),
],
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,166 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/string.dart';
import 'package:spotube/utils/platform.dart';
class PlaybuttonCard extends StatelessWidget {
final void Function()? onTap;
final void Function()? onPlaybuttonPressed;
final void Function()? onAddToQueuePressed;
final String? description;
final String? imageUrl;
final Widget? image;
final bool isPlaying;
final bool isLoading;
final String title;
final bool isOwner;
const PlaybuttonCard({
required this.isPlaying,
required this.isLoading,
required this.title,
this.description,
this.onPlaybuttonPressed,
this.onAddToQueuePressed,
this.onTap,
this.isOwner = false,
this.imageUrl,
this.image,
super.key,
}) : assert(
imageUrl != null || image != null,
"imageUrl and image can't be null at the same time",
);
@override
Widget build(BuildContext context) {
final unescapeHtml = description?.unescapeHtml().cleanHtml() ?? "";
final scale = context.theme.scaling;
return SizedBox(
width: 150 * scale,
child: CardImage(
image: Stack(
children: [
if (imageUrl != null)
Container(
width: 150 * scale,
height: 150 * scale,
decoration: BoxDecoration(
borderRadius: context.theme.borderRadiusMd,
image: DecorationImage(
image: UniversalImage.imageProvider(imageUrl!),
fit: BoxFit.cover,
),
),
)
else
SizedBox(
width: 150 * scale,
height: 150 * scale,
child: ClipRRect(
borderRadius: context.theme.borderRadiusMd,
child: image!,
),
),
StatedWidget.builder(
builder: (context, states) {
return Positioned(
right: 8,
bottom: 8,
child: Column(
children: [
AnimatedScale(
curve: Curves.easeOutBack,
duration: const Duration(milliseconds: 300),
scale: (states.contains(WidgetState.hovered) ||
kIsMobile) &&
!isLoading
? 1
: 0.7,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: (states.contains(WidgetState.hovered) ||
kIsMobile) &&
!isLoading
? 1
: 0,
child: IconButton.secondary(
icon: const Icon(SpotubeIcons.queueAdd),
onPressed: onAddToQueuePressed,
size: ButtonSize.small,
),
),
),
const Gap(5),
AnimatedScale(
curve: Curves.easeOutBack,
duration: const Duration(milliseconds: 150),
scale: states.contains(WidgetState.hovered) ||
kIsMobile ||
isPlaying ||
isLoading
? 1
: 0.7,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: states.contains(WidgetState.hovered) ||
kIsMobile ||
isPlaying ||
isLoading
? 1
: 0,
child: IconButton.secondary(
icon: switch ((isLoading, isPlaying)) {
(true, _) => const CircularProgressIndicator(
size: 15,
),
(false, false) => const Icon(SpotubeIcons.play),
(false, true) => const Icon(SpotubeIcons.pause)
},
enabled: !isLoading,
onPressed: onPlaybuttonPressed,
size: ButtonSize.small,
),
),
),
],
),
);
},
),
if (isOwner)
const Positioned(
right: 5,
top: 5,
child: SecondaryBadge(
style: ButtonStyle.secondaryIcon(
shape: ButtonShape.circle,
size: ButtonSize.small,
),
child: Icon(SpotubeIcons.user),
),
),
],
),
title: Tooltip(
tooltip: TooltipContainer(child: Text(title)),
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
subtitle: Text(
unescapeHtml.isEmpty ? "\n" : unescapeHtml,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onPressed: onTap,
),
);
}
}

View File

@ -0,0 +1,115 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/string.dart';
class PlaybuttonTile extends StatelessWidget {
final void Function()? onTap;
final void Function()? onPlaybuttonPressed;
final void Function()? onAddToQueuePressed;
final String? description;
final String? imageUrl;
final Widget? image;
final bool isPlaying;
final bool isLoading;
final String title;
final bool isOwner;
const PlaybuttonTile({
required this.isPlaying,
required this.isLoading,
required this.title,
this.description,
this.onPlaybuttonPressed,
this.onAddToQueuePressed,
this.onTap,
this.isOwner = false,
this.imageUrl,
this.image,
super.key,
}) : assert(
imageUrl != null || image != null,
"imageUrl and image can't be null at the same time",
);
@override
Widget build(BuildContext context) {
final cleanDescription = description?.unescapeHtml().cleanHtml() ?? "";
final scale = context.theme.scaling;
return Button(
leading: imageUrl != null
? Container(
width: 50 * scale,
height: 50 * scale,
decoration: BoxDecoration(
borderRadius: context.theme.borderRadiusMd,
image: DecorationImage(
image: UniversalImage.imageProvider(imageUrl!),
fit: BoxFit.cover,
),
),
)
: SizedBox(
width: 50 * scale,
height: 50 * scale,
child: ClipRRect(
borderRadius: context.theme.borderRadiusMd,
child: image,
),
),
style: ButtonVariance.ghost.copyWith(
padding: (context, states, value) {
return (ButtonVariance.ghost.padding(context, states) as EdgeInsets)
.copyWith(right: 0, left: 0);
},
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
tooltip: TooltipContainer(child: Text(context.l10n.add_to_queue)),
child: IconButton.outline(
icon: const Icon(SpotubeIcons.queueAdd),
onPressed: onAddToQueuePressed,
enabled: !isLoading,
),
),
const Gap(8),
Tooltip(
tooltip: TooltipContainer(child: Text(context.l10n.play)),
child: IconButton.secondary(
icon: switch ((isLoading, isPlaying)) {
(true, _) => const CircularProgressIndicator(
size: 22,
),
(false, false) => const Icon(SpotubeIcons.play),
(false, true) => const Icon(SpotubeIcons.pause)
},
onPressed: onPlaybuttonPressed,
enabled: !isLoading,
),
),
],
),
enabled: !isLoading,
onPressed: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title),
if (cleanDescription.isNotEmpty)
Text(
description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
).xSmall().muted(),
],
),
);
}
}

View File

@ -0,0 +1,200 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_undraw/flutter_undraw.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playbutton_view/playbutton_card.dart';
import 'package:spotube/components/playbutton_view/playbutton_tile.dart';
import 'package:spotube/components/waypoint.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
const _dummyPlaybuttonCard = PlaybuttonCard(
imageUrl: 'https://placehold.co/150x150.png',
isLoading: false,
isPlaying: false,
title: "Playbutton",
description: "A really cool playbutton",
isOwner: false,
);
const _dummyPlaybuttonTile = PlaybuttonTile(
imageUrl: 'https://placehold.co/150x150.png',
isLoading: false,
isPlaying: false,
title: "Playbutton",
description: "A really cool playbutton",
isOwner: false,
);
/// A [PlaybuttonCard] grid/list view (selectable) sliver widget
/// with support for infinite scrolling
class PlaybuttonView extends StatelessWidget {
final int itemCount;
final Widget Function(BuildContext context, int index) gridItemBuilder;
final Widget Function(BuildContext context, int index) listItemBuilder;
final bool hasMore;
final bool isLoading;
final VoidCallback onRequestMore;
final ScrollController controller;
const PlaybuttonView({
super.key,
required this.itemCount,
required this.gridItemBuilder,
required this.listItemBuilder,
required this.hasMore,
required this.isLoading,
required this.onRequestMore,
required this.controller,
});
@override
Widget build(BuildContext context) {
final scale = context.theme.scaling;
return SliverLayoutBuilder(
builder: (context, constrains) => HookBuilder(builder: (context) {
final isGrid = useState(constrains.mdAndUp);
final hasUserInteracted = useRef(false);
useEffect(() {
if (hasUserInteracted.value) return null;
if (isGrid.value != constrains.mdAndUp) {
isGrid.value = constrains.mdAndUp;
}
return null;
}, [constrains]);
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Toggle(
value: isGrid.value,
style:
const ButtonStyle.outline(density: ButtonDensity.icon),
onChanged: (value) {
isGrid.value = value;
hasUserInteracted.value = true;
},
child: const Icon(SpotubeIcons.grid),
),
const SizedBox(width: 8),
Toggle(
value: !isGrid.value,
style:
const ButtonStyle.outline(density: ButtonDensity.icon),
onChanged: (value) {
isGrid.value = !value;
hasUserInteracted.value = true;
},
child: const Icon(SpotubeIcons.list),
),
],
),
),
const SliverGap(10),
// Toggle between grid and list view
switch ((isGrid.value, isLoading)) {
(true, _) => !isLoading && itemCount == 0
? SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8),
sliver: SliverToBoxAdapter(
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 10,
children: [
Undraw(
height: 200 * context.theme.scaling,
illustration: UndrawIllustration.taken,
color: Theme.of(context).colorScheme.primary,
),
Text(
context.l10n.nothing_found,
textAlign: TextAlign.center,
).muted().small()
],
),
),
)
: SliverGrid.builder(
itemCount: isLoading ? 6 : itemCount + 1,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150 * scale,
mainAxisExtent: 225 * scale,
crossAxisSpacing: 12 * scale,
mainAxisSpacing: 12 * scale,
),
itemBuilder: (context, index) {
if (isLoading) {
return const Skeletonizer(
enabled: true,
child: _dummyPlaybuttonCard,
);
}
if (index == itemCount) {
if (!hasMore) return const SizedBox.shrink();
return Waypoint(
controller: controller,
isGrid: true,
onTouchEdge: onRequestMore,
child: const Skeletonizer(
enabled: true,
child: _dummyPlaybuttonCard,
),
);
}
return gridItemBuilder(context, index);
},
),
(false, true) => Skeletonizer.sliver(
enabled: true,
child: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _dummyPlaybuttonTile,
childCount: 6,
),
),
),
(false, false) => SliverInfiniteList(
itemCount: itemCount,
loadingBuilder: (context) => const Skeletonizer(
enabled: true,
child: _dummyPlaybuttonTile,
),
itemBuilder: listItemBuilder,
onFetchData: onRequestMore,
hasReachedMax: !hasMore,
isLoading: isLoading,
emptyBuilder: (context) {
return Column(
mainAxisSize: MainAxisSize.min,
spacing: 10,
children: [
Undraw(
height: 200 * context.theme.scaling,
illustration: UndrawIllustration.taken,
color: Theme.of(context).colorScheme.primary,
),
Text(
context.l10n.nothing_found,
textAlign: TextAlign.center,
).muted().small()
],
);
},
),
}
],
);
}),
);
}
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';

View File

@ -1,88 +0,0 @@
import 'package:flutter/material.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/modules/library/user_local_tracks.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/extensions/context.dart';
class SortTracksDropdown extends StatelessWidget {
final SortBy? value;
final void Function(SortBy)? onChanged;
const SortTracksDropdown({
this.onChanged,
this.value,
super.key,
});
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return ListTileTheme(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: AdaptivePopSheetList<SortBy>(
children: [
PopSheetEntry(
value: SortBy.none,
enabled: value != SortBy.none,
title: Text(context.l10n.none),
),
PopSheetEntry(
value: SortBy.ascending,
enabled: value != SortBy.ascending,
title: Text(context.l10n.sort_a_z),
),
PopSheetEntry(
value: SortBy.descending,
enabled: value != SortBy.descending,
title: Text(context.l10n.sort_z_a),
),
PopSheetEntry(
value: SortBy.newest,
enabled: value != SortBy.newest,
title: Text(context.l10n.sort_newest),
),
PopSheetEntry(
value: SortBy.oldest,
enabled: value != SortBy.oldest,
title: Text(context.l10n.sort_oldest),
),
PopSheetEntry(
value: SortBy.duration,
enabled: value != SortBy.duration,
title: Text(context.l10n.sort_duration),
),
PopSheetEntry(
value: SortBy.artist,
enabled: value != SortBy.artist,
title: Text(context.l10n.sort_artist),
),
PopSheetEntry(
value: SortBy.album,
enabled: value != SortBy.album,
title: Text(context.l10n.sort_album),
),
],
headings: [
Text(context.l10n.sort_tracks),
],
onSelected: onChanged,
tooltip: context.l10n.sort_tracks,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: DefaultTextStyle(
style: theme.textTheme.titleSmall!,
child: Row(
children: [
const Icon(SpotubeIcons.sort),
const SizedBox(width: 8),
Text(context.l10n.sort_tracks),
],
),
),
),
),
);
}
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class SpotubePage<T> extends MaterialPage<T> { class SpotubePage<T> extends MaterialPage<T> {

View File

@ -1,50 +0,0 @@
import 'package:buttons_tabbar/buttons_tabbar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart';
class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
final List<Widget> tabs;
final TabController? controller;
const ThemedButtonsTabBar({super.key, required this.tabs, this.controller});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bgColor = useBrightnessValue(
theme.colorScheme.primaryContainer,
Color.lerp(theme.colorScheme.primary, Colors.black, 0.7)!,
);
return Padding(
padding: const EdgeInsets.only(
top: 8,
bottom: 8,
),
child: ButtonsTabBar(
controller: controller,
radius: 100,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(15),
),
labelStyle: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
borderWidth: 0,
unselectedDecoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(15),
),
unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
),
tabs: tabs,
),
);
}
@override
Size get preferredSize => const Size.fromHeight(50);
}

View File

@ -1,73 +0,0 @@
import 'package:flutter/material.dart';
typedef MouseStateBuilderCB = Widget Function(
BuildContext context, MouseState mouseState);
class MouseState {
bool isMouseOver = false;
bool isMouseDown = false;
MouseState();
@override
String toString() {
return "isMouseDown: $isMouseDown - isMouseOver: $isMouseOver";
}
}
T? _ambiguate<T>(T? value) => value;
class MouseStateBuilder extends StatefulWidget {
final MouseStateBuilderCB builder;
final VoidCallback? onPressed;
const MouseStateBuilder({super.key, required this.builder, this.onPressed});
@override
// ignore: library_private_types_in_public_api
_MouseStateBuilderState createState() => _MouseStateBuilderState();
}
class _MouseStateBuilderState extends State<MouseStateBuilder> {
late MouseState _mouseState;
_MouseStateBuilderState() {
_mouseState = MouseState();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (event) {
setState(() {
_mouseState.isMouseOver = true;
});
},
onExit: (event) {
setState(() {
_mouseState.isMouseOver = false;
});
},
child: GestureDetector(
onTapDown: (_) {
setState(() {
_mouseState.isMouseDown = true;
});
},
onTapCancel: () {
setState(() {
_mouseState.isMouseDown = false;
});
},
onTap: () {
setState(() {
_mouseState.isMouseDown = false;
_mouseState.isMouseOver = false;
});
_ambiguate(WidgetsBinding.instance)!.addPostFrameCallback((_) {
if (widget.onPressed != null) {
widget.onPressed!();
}
});
},
onTapUp: (_) {},
child: widget.builder(context, _mouseState),
),
);
}
}

View File

@ -1,88 +1,59 @@
import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/components/button/back_button.dart';
import 'package:spotube/components/titlebar/titlebar_buttons.dart'; import 'package:spotube/components/titlebar/titlebar_buttons.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
class PageWindowTitleBar extends StatefulHookConsumerWidget final kTitlebarVisible = kIsWindows || kIsLinux;
implements PreferredSizeWidget {
final Widget? leading; class TitleBar extends HookConsumerWidget implements PreferredSizeWidget {
final bool automaticallyImplyLeading; final bool automaticallyImplyLeading;
final List<Widget>? actions; final List<Widget> trailing;
final List<Widget> leading;
final Widget? child;
final Widget? title;
final Widget? header; // small widget placed on top of title
final Widget? subtitle; // small widget placed below title
final bool
trailingExpanded; // expand the trailing instead of the main content
final AlignmentGeometry alignment;
final Color? backgroundColor; final Color? backgroundColor;
final Color? foregroundColor; final Color? foregroundColor;
final IconThemeData? actionsIconTheme; final double? leadingGap;
final bool? centerTitle; final double? trailingGap;
final double? titleSpacing; final EdgeInsetsGeometry? padding;
final double toolbarOpacity; final double? height;
final double? leadingWidth; final bool useSafeArea;
final TextStyle? toolbarTextStyle; final double? surfaceBlur;
final TextStyle? titleTextStyle; final double? surfaceOpacity;
final double? titleWidth;
final Widget? title;
final bool _sliver; const TitleBar({
const PageWindowTitleBar({
super.key, super.key,
this.actions, this.automaticallyImplyLeading = true,
this.trailing = const [],
this.leading = const [],
this.title, this.title,
this.toolbarOpacity = 1, this.header,
this.subtitle,
this.child,
this.trailingExpanded = false,
this.alignment = Alignment.center,
this.padding,
this.backgroundColor, this.backgroundColor,
this.actionsIconTheme,
this.automaticallyImplyLeading = false,
this.centerTitle,
this.foregroundColor, this.foregroundColor,
this.leading, this.leadingGap,
this.leadingWidth, this.trailingGap,
this.titleSpacing, this.height,
this.titleTextStyle, this.surfaceBlur,
this.titleWidth, this.surfaceOpacity,
this.toolbarTextStyle, this.useSafeArea = false,
}) : _sliver = false, });
pinned = false,
floating = false,
snap = false,
stretch = false;
final bool pinned; void onDrag(WidgetRef ref) {
final bool floating;
final bool snap;
final bool stretch;
const PageWindowTitleBar.sliver({
super.key,
this.actions,
this.title,
this.backgroundColor,
this.actionsIconTheme,
this.automaticallyImplyLeading = false,
this.centerTitle,
this.foregroundColor,
this.leading,
this.leadingWidth,
this.titleSpacing,
this.titleTextStyle,
this.titleWidth,
this.toolbarTextStyle,
this.pinned = false,
this.floating = false,
this.snap = false,
this.stretch = false,
}) : _sliver = true,
toolbarOpacity = 1;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
ConsumerState<PageWindowTitleBar> createState() => _PageWindowTitleBarState();
}
class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
void onDrag(details) {
final systemTitleBar = final systemTitleBar =
ref.read(userPreferencesProvider.select((s) => s.systemTitleBar)); ref.read(userPreferencesProvider.select((s) => s.systemTitleBar));
if (kIsDesktop && !systemTitleBar) { if (kIsDesktop && !systemTitleBar) {
@ -91,89 +62,75 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, ref) {
final mediaQuery = MediaQuery.of(context); final hasLeadingOrCanPop = leading.isNotEmpty || Navigator.canPop(context);
final lastClicked = useRef<int>(DateTime.now().millisecondsSinceEpoch);
if (widget._sliver) { return SizedBox(
return SliverLayoutBuilder( height: height ?? (48 * context.theme.scaling),
child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final hasFullscreen = final hasFullscreen =
mediaQuery.size.width == constraints.crossAxisExtent; MediaQuery.sizeOf(context).width == constraints.maxWidth;
final hasLeadingOrCanPop =
widget.leading != null || Navigator.canPop(context);
return SliverPadding( return GestureDetector(
padding: EdgeInsets.only( onHorizontalDragStart: (_) => onDrag(ref),
left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0, onVerticalDragStart: (_) => onDrag(ref),
), onTapDown: (details) async {
sliver: SliverAppBar( final systemTitlebar = ref.read(
leading: widget.leading, userPreferencesProvider.select((s) => s.systemTitleBar));
automaticallyImplyLeading: widget.automaticallyImplyLeading, if (!kIsDesktop || systemTitlebar) return;
actions: [
...?widget.actions, int currMills = DateTime.now().millisecondsSinceEpoch;
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
if ((currMills - lastClicked.value) < 500) {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
} else {
lastClicked.value = currMills;
}
},
child: AppBar(
leading: leading.isEmpty &&
automaticallyImplyLeading &&
Navigator.canPop(context)
? [
const BackButton(),
]
: leading,
trailing: [
...trailing,
Align(
alignment: Alignment.topRight,
child:
WindowTitleBarButtons(foregroundColor: foregroundColor),
),
], ],
backgroundColor: widget.backgroundColor, title: title,
foregroundColor: widget.foregroundColor, header: header,
actionsIconTheme: widget.actionsIconTheme, subtitle: subtitle,
centerTitle: widget.centerTitle, trailingExpanded: trailingExpanded,
titleSpacing: widget.titleSpacing, alignment: alignment,
leadingWidth: widget.leadingWidth, padding: padding ?? EdgeInsets.zero,
toolbarTextStyle: widget.toolbarTextStyle, backgroundColor: backgroundColor,
titleTextStyle: widget.titleTextStyle, leadingGap: leadingGap,
title: SizedBox( trailingGap: trailingGap,
width: double.infinity, // workaround to force dragging height: height ?? (48 * context.theme.scaling),
child: widget.title ?? const Text(""), surfaceBlur: surfaceBlur,
), surfaceOpacity: surfaceOpacity,
pinned: widget.pinned, useSafeArea: useSafeArea,
floating: widget.floating, child: child,
snap: widget.snap, ).withPadding(
stretch: widget.stretch, left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0),
),
); );
}, },
); ),
} );
return LayoutBuilder(builder: (context, constrains) {
final hasFullscreen = mediaQuery.size.width == constrains.maxWidth;
final hasLeadingOrCanPop =
widget.leading != null || Navigator.canPop(context);
return GestureDetector(
onHorizontalDragStart: onDrag,
onVerticalDragStart: onDrag,
child: Padding(
padding: EdgeInsets.only(
left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0,
),
child: AppBar(
leading: widget.leading,
automaticallyImplyLeading: widget.automaticallyImplyLeading,
actions: [
...?widget.actions,
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
],
backgroundColor: widget.backgroundColor,
foregroundColor: widget.foregroundColor,
actionsIconTheme: widget.actionsIconTheme,
centerTitle: widget.centerTitle,
titleSpacing: widget.titleSpacing,
toolbarOpacity: widget.toolbarOpacity,
leadingWidth: widget.leadingWidth,
toolbarTextStyle: widget.toolbarTextStyle,
titleTextStyle: widget.titleTextStyle,
title: SizedBox(
width: double.infinity, // workaround to force dragging
child: widget.title ?? const Text(""),
),
scrolledUnderElevation: 0,
shadowColor: Colors.transparent,
forceMaterialTransparency: true,
elevation: 0,
),
),
);
});
} }
@override
Size get preferredSize => Size.fromHeight(height ?? 48);
} }

View File

@ -1,8 +1,12 @@
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/components/hover_builder.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/components/titlebar/titlebar_icon_buttons.dart'; import 'package:spotube/components/titlebar/titlebar_icon_buttons.dart';
import 'package:spotube/components/titlebar/window_button.dart';
import 'package:spotube/hooks/configurators/use_window_listener.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:titlebar_buttons/titlebar_buttons.dart'; import 'package:titlebar_buttons/titlebar_buttons.dart';
@ -25,6 +29,15 @@ class WindowTitleBarButtons extends HookConsumerWidget {
await windowManager.close(); await windowManager.close();
} }
useWindowListener(
onWindowMaximize: () {
isMaximized.value = true;
},
onWindowUnmaximize: () {
isMaximized.value = false;
},
);
useEffect(() { useEffect(() {
if (kIsDesktop) { if (kIsDesktop) {
windowManager.isMaximized().then((value) { windowManager.isMaximized().then((value) {
@ -34,91 +47,73 @@ class WindowTitleBarButtons extends HookConsumerWidget {
return null; return null;
}, []); }, []);
if (!kIsDesktop || kIsMacOS || preferences.systemTitleBar) { if (!kTitlebarVisible || preferences.systemTitleBar) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
if (kIsWindows) { if (kIsWindows) {
final theme = Theme.of(context); return Row(
final colors = WindowButtonColors( crossAxisAlignment: CrossAxisAlignment.start,
normal: Colors.transparent, children: [
iconNormal: foregroundColor ?? theme.colorScheme.onSurface, ShadcnWindowButton(
mouseOver: theme.colorScheme.onSurface.withOpacity(0.1), icon: MinimizeIcon(color: context.theme.colorScheme.foreground),
mouseDown: theme.colorScheme.onSurface.withOpacity(0.2), onPressed: windowManager.minimize,
iconMouseOver: theme.colorScheme.onSurface, ),
iconMouseDown: theme.colorScheme.onSurface, if (isMaximized.value != true)
); ShadcnWindowButton(
icon: MaximizeIcon(color: context.theme.colorScheme.foreground),
final closeColors = WindowButtonColors( onPressed: () {
normal: Colors.transparent, windowManager.maximize();
iconNormal: foregroundColor ?? theme.colorScheme.onSurface, isMaximized.value = true;
mouseOver: Colors.red, },
mouseDown: Colors.red[800]!, )
iconMouseOver: Colors.white, else
iconMouseDown: Colors.black, ShadcnWindowButton(
); icon: RestoreIcon(color: context.theme.colorScheme.foreground),
onPressed: () {
return Padding( windowManager.unmaximize();
padding: const EdgeInsets.only(bottom: 25), isMaximized.value = false;
child: Row( },
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MinimizeWindowButton(
onPressed: windowManager.minimize,
colors: colors,
), ),
if (isMaximized.value != true) HoverBuilder(builder: (context, isHovered) {
MaximizeWindowButton( return ShadcnWindowButton(
colors: colors, icon: CloseIcon(
onPressed: () { color: isHovered
windowManager.maximize(); ? Colors.white
isMaximized.value = true; : context.theme.colorScheme.foreground,
},
)
else
RestoreWindowButton(
colors: colors,
onPressed: () {
windowManager.unmaximize();
isMaximized.value = false;
},
), ),
CloseWindowButton(
colors: closeColors,
onPressed: onClose, onPressed: onClose,
), hoverBackgroundColor: const Color(0xFFD32F2F),
], );
), }),
],
); );
} }
return Padding( return Row(
padding: const EdgeInsets.only(bottom: 20, left: 10), crossAxisAlignment: CrossAxisAlignment.start,
child: Row( children: [
crossAxisAlignment: CrossAxisAlignment.start, DecoratedMinimizeButton(
children: [ type: type,
DecoratedMinimizeButton( onPressed: windowManager.minimize,
type: type, ),
onPressed: windowManager.minimize, DecoratedMaximizeButton(
), type: type,
DecoratedMaximizeButton( onPressed: () async {
type: type, if (await windowManager.isMaximized()) {
onPressed: () async { await windowManager.unmaximize();
if (await windowManager.isMaximized()) { isMaximized.value = false;
await windowManager.unmaximize(); } else {
isMaximized.value = false; await windowManager.maximize();
} else { isMaximized.value = true;
await windowManager.maximize(); }
isMaximized.value = true; },
} ),
}, DecoratedCloseButton(
), type: type,
DecoratedCloseButton( onPressed: onClose,
type: type, ),
onPressed: onClose, ],
),
],
),
); );
} }
} }

View File

@ -1,56 +1,50 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/components/titlebar/window_button.dart'; import 'package:spotube/extensions/button_variance.dart';
class MinimizeWindowButton extends WindowButton { class ShadcnWindowButton extends StatelessWidget {
MinimizeWindowButton( final Widget icon;
{super.key, super.colors, super.onPressed, bool? animate}) final VoidCallback onPressed;
: super( final Color? hoverBackgroundColor;
animate: animate ?? false,
iconBuilder: (buttonContext) => const ShadcnWindowButton({
MinimizeIcon(color: buttonContext.iconColor), super.key,
); required this.icon,
required this.onPressed,
this.hoverBackgroundColor,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 45,
height: 32,
child: IconButton(
variance: ButtonVariance.ghost.copyWith(
decoration: (context, states) {
final decoration = ButtonVariance.ghost.decoration(context, states)
as BoxDecoration;
if (hoverBackgroundColor != null &&
states.contains(WidgetState.hovered)) {
return decoration.copyWith(
borderRadius: BorderRadius.zero,
color: hoverBackgroundColor,
);
}
return decoration.copyWith(
borderRadius: BorderRadius.zero,
);
},
),
icon: icon,
onPressed: onPressed,
),
);
}
} }
class MaximizeWindowButton extends WindowButton {
MaximizeWindowButton(
{super.key, super.colors, super.onPressed, bool? animate})
: super(
animate: animate ?? false,
iconBuilder: (buttonContext) =>
MaximizeIcon(color: buttonContext.iconColor),
);
}
class RestoreWindowButton extends WindowButton {
RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate})
: super(
animate: animate ?? false,
iconBuilder: (buttonContext) =>
RestoreIcon(color: buttonContext.iconColor),
);
}
final _defaultCloseButtonColors = WindowButtonColors(
mouseOver: const Color(0xFFD32F2F),
mouseDown: const Color(0xFFB71C1C),
iconNormal: const Color(0xFF805306),
iconMouseOver: const Color(0xFFFFFFFF));
class CloseWindowButton extends WindowButton {
CloseWindowButton(
{super.key, WindowButtonColors? colors, super.onPressed, bool? animate})
: super(
colors: colors ?? _defaultCloseButtonColors,
animate: animate ?? false,
iconBuilder: (buttonContext) =>
CloseIcon(color: buttonContext.iconColor),
);
}
// Switched to CustomPaint icons by https://github.com/esDotDev
/// Close /// Close
class CloseIcon extends StatelessWidget { class CloseIcon extends StatelessWidget {
final Color color; final Color color;
@ -149,8 +143,9 @@ class _AlignedPaint extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Align( return Align(
alignment: Alignment.center, alignment: Alignment.center,
child: CustomPaint(size: const Size(10, 10), painter: painter)); child: CustomPaint(size: const Size(10, 10), painter: painter),
);
} }
} }

View File

@ -1,133 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:spotube/components/titlebar/mouse_state.dart';
typedef WindowButtonIconBuilder = Widget Function(
WindowButtonContext buttonContext);
typedef WindowButtonBuilder = Widget Function(
WindowButtonContext buttonContext, Widget icon);
class WindowButtonContext {
BuildContext context;
MouseState mouseState;
Color? backgroundColor;
Color iconColor;
WindowButtonContext(
{required this.context,
required this.mouseState,
this.backgroundColor,
required this.iconColor});
}
class WindowButtonColors {
late Color normal;
late Color mouseOver;
late Color mouseDown;
late Color iconNormal;
late Color iconMouseOver;
late Color iconMouseDown;
WindowButtonColors(
{Color? normal,
Color? mouseOver,
Color? mouseDown,
Color? iconNormal,
Color? iconMouseOver,
Color? iconMouseDown}) {
this.normal = normal ?? _defaultButtonColors.normal;
this.mouseOver = mouseOver ?? _defaultButtonColors.mouseOver;
this.mouseDown = mouseDown ?? _defaultButtonColors.mouseDown;
this.iconNormal = iconNormal ?? _defaultButtonColors.iconNormal;
this.iconMouseOver = iconMouseOver ?? _defaultButtonColors.iconMouseOver;
this.iconMouseDown = iconMouseDown ?? _defaultButtonColors.iconMouseDown;
}
}
final _defaultButtonColors = WindowButtonColors(
normal: Colors.transparent,
iconNormal: const Color(0xFF805306),
mouseOver: const Color(0xFF404040),
mouseDown: const Color(0xFF202020),
iconMouseOver: const Color(0xFFFFFFFF),
iconMouseDown: const Color(0xFFF0F0F0),
);
class WindowButton extends StatelessWidget {
final WindowButtonBuilder? builder;
final WindowButtonIconBuilder? iconBuilder;
late final WindowButtonColors colors;
final bool animate;
final EdgeInsets? padding;
final VoidCallback? onPressed;
WindowButton(
{super.key,
WindowButtonColors? colors,
this.builder,
@required this.iconBuilder,
this.padding,
this.onPressed,
this.animate = false}) {
this.colors = colors ?? _defaultButtonColors;
}
Color getBackgroundColor(MouseState mouseState) {
if (mouseState.isMouseDown) return colors.mouseDown;
if (mouseState.isMouseOver) return colors.mouseOver;
return colors.normal;
}
Color getIconColor(MouseState mouseState) {
if (mouseState.isMouseDown) return colors.iconMouseDown;
if (mouseState.isMouseOver) return colors.iconMouseOver;
return colors.iconNormal;
}
@override
Widget build(BuildContext context) {
if (kIsWeb) {
return Container();
} else {
// Don't show button on macOS
if (Platform.isMacOS) {
return Container();
}
}
return MouseStateBuilder(
builder: (context, mouseState) {
WindowButtonContext buttonContext = WindowButtonContext(
mouseState: mouseState,
context: context,
backgroundColor: getBackgroundColor(mouseState),
iconColor: getIconColor(mouseState));
var icon =
(iconBuilder != null) ? iconBuilder!(buttonContext) : Container();
var fadeOutColor =
getBackgroundColor(MouseState()..isMouseOver = true).withOpacity(0);
var padding = this.padding ?? const EdgeInsets.all(10);
var animationMs =
mouseState.isMouseOver ? (animate ? 100 : 0) : (animate ? 200 : 0);
Widget iconWithPadding = Padding(padding: padding, child: icon);
iconWithPadding = AnimatedContainer(
curve: Curves.easeOut,
duration: Duration(milliseconds: animationMs),
color: buttonContext.backgroundColor ?? fadeOutColor,
child: iconWithPadding);
var button =
(builder != null) ? builder!(buttonContext, icon) : iconWithPadding;
return SizedBox(
width: 45,
height: 32,
child: button,
);
},
onPressed: () {
if (onPressed != null) onPressed!();
},
);
}
}

View File

@ -0,0 +1,221 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/dialogs/confirm_download_dialog.dart';
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
class TrackPresentationActionsSection extends HookConsumerWidget {
const TrackPresentationActionsSection({super.key});
showToastForAction(BuildContext context, String action, int count) {
final message = switch (action) {
"download" => (context.l10n.download_count(count), SpotubeIcons.download),
"add-to-playlist" => (
context.l10n.add_count_to_playlist(count),
SpotubeIcons.playlistAdd
),
"add-to-queue" => (
context.l10n.add_count_to_queue(count),
SpotubeIcons.queueAdd
),
"play-next" => (
context.l10n.play_count_next(count),
SpotubeIcons.lightning
),
_ => ("", SpotubeIcons.error),
};
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Basic(
leading: Icon(message.$2),
title: Text(message.$1),
leadingAlignment: Alignment.center,
trailing: IconButton.ghost(
size: ButtonSize.small,
icon: const Icon(SpotubeIcons.close),
onPressed: () {
overlay.close();
},
),
),
);
},
);
}
@override
Widget build(BuildContext context, ref) {
final options = TrackPresentationOptions.of(context);
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final audioSource =
ref.watch(userPreferencesProvider.select((s) => s.audioSource));
final state = ref.watch(presentationStateProvider(options.collection));
final notifier =
ref.watch(presentationStateProvider(options.collection).notifier);
final selectedTracks = state.selectedTracks;
return AdaptivePopSheetList(
tooltip: context.l10n.more_actions,
headings: [
Text(
context.l10n.more_actions,
style: context.theme.typography.large,
),
],
onSelected: (action) async {
var tracks = selectedTracks;
if (selectedTracks.isEmpty) {
tracks = await options.pagination.onFetchAll();
notifier.selectAllTracks();
}
if (!context.mounted) return;
switch (action) {
case "download":
{
final confirmed = audioSource == AudioSource.piped ||
(await showDialog<bool?>(
context: context,
builder: (context) {
return const ConfirmDownloadDialog();
},
) ??
false);
if (confirmed != true) return;
downloader.batchAddToQueue(tracks);
notifier.deselectAllTracks();
if (!context.mounted) return;
showToastForAction(context, action, tracks.length);
break;
}
case "add-to-playlist":
{
if (context.mounted) {
final worked = await showDialog<bool>(
context: context,
builder: (context) {
return PlaylistAddTrackDialog(
openFromPlaylist: options.collectionId,
tracks: tracks.toList(),
);
},
);
if (!context.mounted || worked != true) return;
showToastForAction(context, action, tracks.length);
}
break;
}
case "play-next":
{
playlistNotifier.addTracksAtFirst(tracks);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([options.collection as PlaylistSimple]);
}
notifier.deselectAllTracks();
if (!context.mounted) return;
showToastForAction(context, action, tracks.length);
break;
}
case "add-to-queue":
{
playlistNotifier.addTracks(tracks);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([options.collection as PlaylistSimple]);
}
notifier.deselectAllTracks();
if (!context.mounted) return;
showToastForAction(context, action, tracks.length);
break;
}
default:
}
if (!context.mounted) return;
},
icon: const Icon(SpotubeIcons.moreVertical),
variance: ButtonVariance.outline,
children: [
AdaptiveMenuButton(
value: "download",
leading: const Icon(SpotubeIcons.download),
child: selectedTracks.isEmpty ||
selectedTracks.length == options.tracks.length
? Text(
context.l10n.download_all,
)
: Text(
context.l10n.download_count(selectedTracks.length),
),
),
AdaptiveMenuButton(
value: "add-to-playlist",
leading: const Icon(SpotubeIcons.playlistAdd),
child: selectedTracks.isEmpty ||
selectedTracks.length == options.tracks.length
? Text(
context.l10n.add_all_to_playlist,
)
: Text(
context.l10n.add_count_to_playlist(selectedTracks.length),
),
),
AdaptiveMenuButton(
value: "add-to-queue",
leading: const Icon(SpotubeIcons.queueAdd),
child: selectedTracks.isEmpty ||
selectedTracks.length == options.tracks.length
? Text(
context.l10n.add_all_to_queue,
)
: Text(
context.l10n.add_count_to_queue(selectedTracks.length),
),
),
AdaptiveMenuButton(
value: "play-next",
leading: const Icon(SpotubeIcons.lightning),
child: selectedTracks.isEmpty ||
selectedTracks.length == options.tracks.length
? Text(
context.l10n.play_all_next,
)
: Text(
context.l10n.play_count_next(selectedTracks.length),
),
),
],
);
}
}

View File

@ -0,0 +1,111 @@
import 'package:flutter/services.dart';
import 'package:flutter_undraw/flutter_undraw.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/components/track_presentation/use_track_tile_play_callback.dart';
import 'package:spotube/components/track_tile/track_tile.dart';
import 'package:spotube/components/track_presentation/use_is_user_playlist.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PresentationListSection extends HookConsumerWidget {
const PresentationListSection({super.key});
@override
Widget build(BuildContext context, ref) {
final options = TrackPresentationOptions.of(context);
final playlist = ref.watch(audioPlayerProvider);
final state = ref.watch(presentationStateProvider(options.collection));
final notifier =
ref.read(presentationStateProvider(options.collection).notifier);
final isUserPlaylist = useIsUserPlaylist(ref, options.collectionId);
final onTileTap = useTrackTilePlayCallback(ref);
if (state.presentationTracks.isEmpty && !options.pagination.isLoading) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Undraw(
illustration: UndrawIllustration.dreamer,
color: context.theme.colorScheme.primary,
height: 200 * context.theme.scaling,
),
Text(
isUserPlaylist
? context.l10n.no_tracks_added_yet
: context.l10n.no_tracks,
textAlign: TextAlign.center,
).muted().small(),
],
),
),
);
}
return SliverInfiniteList(
isLoading: options.pagination.isLoading,
onFetchData: options.pagination.onFetchMore,
itemCount: state.presentationTracks.length,
hasReachedMax: !options.pagination.hasNextPage,
loadingBuilder: (context) {
return Skeletonizer(
enabled: true,
child: TrackTile(
index: 0,
playlist: playlist,
track: FakeData.track,
),
);
},
emptyBuilder: (context) => Skeletonizer(
enabled: true,
child: Column(
children: List.generate(
10,
(index) => TrackTile(
track: FakeData.track,
index: index,
playlist: playlist,
),
),
),
),
itemBuilder: (context, index) {
final track = state.presentationTracks[index];
final isSelected = state.selectedTracks.any((e) => e.id == track.id);
return TrackTile(
userPlaylist: isUserPlaylist,
playlistId: options.collectionId,
index: index,
playlist: playlist,
track: track,
selected: isSelected,
onTap: () => onTileTap(track, index),
onChanged: state.selectedTracks.isEmpty
? null
: (isSelected) {
if (isSelected == true) {
notifier.selectTrack(track);
} else {
notifier.deselectTrack(track);
}
},
onLongPress: () {
notifier.selectTrack(track);
HapticFeedback.selectionClick();
},
);
},
);
}
}

View File

@ -0,0 +1,124 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/track_presentation/sort_tracks_dropdown.dart';
import 'package:spotube/components/track_presentation/presentation_actions.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
class TrackPresentationModifiersSection extends HookConsumerWidget {
final FocusNode? focusNode;
const TrackPresentationModifiersSection({
super.key,
this.focusNode,
});
@override
Widget build(BuildContext context, ref) {
final options = TrackPresentationOptions.of(context);
final state = ref.watch(presentationStateProvider(options.collection));
final notifier = ref.watch(
presentationStateProvider(options.collection).notifier,
);
final controller = useTextEditingController();
final scale = context.theme.scaling;
return LayoutBuilder(builder: (context, constrains) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal: (constrains.mdAndUp ? 16 : 8) * scale,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
state: state.selectedTracks.length == options.tracks.length
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: (value) {
if (value == CheckboxState.checked) {
notifier.selectAllTracks();
} else {
notifier.deselectAllTracks();
}
},
),
],
),
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Flexible(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 320 * scale,
maxHeight: 38 * scale,
),
child: TextField(
controller: controller,
focusNode: focusNode,
leading: Icon(
SpotubeIcons.search,
color: context.theme.colorScheme.mutedForeground,
),
placeholder: Text(context.l10n.search_tracks),
onChanged: (value) {
if (value.isEmpty) {
notifier.clearFilter();
} else {
notifier.filterTracks(value);
}
},
trailing: ListenableBuilder(
listenable: controller,
builder: (context, _) {
return AnimatedCrossFade(
duration: const Duration(milliseconds: 300),
crossFadeState: controller.text.isEmpty
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild:
const SizedBox.square(dimension: 20),
secondChild: AnimatedScale(
duration: const Duration(milliseconds: 300),
scale: controller.text.isEmpty ? 0 : 1,
child: IconButton.ghost(
size: const ButtonSize(.6),
icon: const Icon(SpotubeIcons.close),
onPressed: () {
controller.clear();
notifier.clearFilter();
},
),
),
);
}),
),
),
),
SortTracksDropdown(
value: state.sortBy,
onChanged: (value) {
notifier.sortTracks(value);
},
),
const TrackPresentationActionsSection(),
],
),
),
],
),
);
});
}
}

View File

@ -1,6 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart' hide Page; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
class PaginationProps { class PaginationProps {
@ -38,31 +38,33 @@ class PaginationProps {
onRefresh.hashCode; onRefresh.hashCode;
} }
class InheritedTrackView extends InheritedWidget { class TrackPresentationOptions {
final Object collection; final Object collection;
final String title; final String title;
final String? description; final String? description;
final String? owner;
final String? ownerImage;
final String image; final String image;
final String routePath; final String routePath;
final List<Track> tracks; final List<Track> tracks;
final PaginationProps pagination; final PaginationProps pagination;
final bool isLiked; final bool isLiked;
final String shareUrl; final String? shareUrl;
// events // events
final FutureOr<bool?> Function()? onHeart; // if null heart button will hidden final FutureOr<bool?> Function()? onHeart; // if null heart button will hidden
const InheritedTrackView({ const TrackPresentationOptions({
super.key,
required super.child,
required this.collection, required this.collection,
required this.title, required this.title,
this.description, this.description,
this.owner,
this.ownerImage,
required this.image, required this.image,
required this.tracks, required this.tracks,
required this.pagination, required this.pagination,
required this.routePath, required this.routePath,
required this.shareUrl, this.shareUrl,
this.isLiked = false, this.isLiked = false,
this.onHeart, this.onHeart,
}) : assert(collection is AlbumSimple || collection is PlaylistSimple); }) : assert(collection is AlbumSimple || collection is PlaylistSimple);
@ -71,29 +73,36 @@ class InheritedTrackView extends InheritedWidget {
? (collection as AlbumSimple).id! ? (collection as AlbumSimple).id!
: (collection as PlaylistSimple).id!; : (collection as PlaylistSimple).id!;
@override static TrackPresentationOptions of(BuildContext context) {
bool updateShouldNotify(InheritedTrackView oldWidget) { return Data.of<TrackPresentationOptions>(context);
return oldWidget.title != title ||
oldWidget.description != description ||
oldWidget.image != image ||
oldWidget.tracks != tracks ||
oldWidget.pagination != pagination ||
oldWidget.isLiked != isLiked ||
oldWidget.onHeart != onHeart ||
oldWidget.shareUrl != shareUrl ||
oldWidget.routePath != routePath ||
oldWidget.collection != collection ||
oldWidget.child != child;
} }
static InheritedTrackView of(BuildContext context) { @override
final widget = operator ==(Object other) {
context.dependOnInheritedWidgetOfExactType<InheritedTrackView>(); return other is TrackPresentationOptions &&
if (widget == null) { other.collection == collection &&
throw Exception( other.title == title &&
'InheritedTrackView not found. Make sure to wrap [TrackView] with [InheritedTrackView]', other.description == description &&
); other.image == image &&
} other.routePath == routePath &&
return widget; other.tracks == tracks &&
other.pagination == pagination &&
other.isLiked == isLiked &&
other.shareUrl == shareUrl &&
other.onHeart == onHeart;
} }
@override
int get hashCode =>
super.hashCode ^
collection.hashCode ^
title.hashCode ^
description.hashCode ^
image.hashCode ^
routePath.hashCode ^
tracks.hashCode ^
pagination.hashCode ^
isLiked.hashCode ^
shareUrl.hashCode ^
onHeart.hashCode;
} }

View File

@ -0,0 +1,173 @@
import 'package:collection/collection.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/utils/service_utils.dart';
class PresentationState {
final List<Track> selectedTracks;
final List<Track> presentationTracks;
final SortBy sortBy;
const PresentationState({
required this.selectedTracks,
required this.presentationTracks,
required this.sortBy,
});
PresentationState copyWith({
List<Track>? selectedTracks,
List<Track>? presentationTracks,
SortBy? sortBy,
}) {
return PresentationState(
selectedTracks: selectedTracks ?? this.selectedTracks,
presentationTracks: presentationTracks ?? this.presentationTracks,
sortBy: sortBy ?? this.sortBy,
);
}
}
class PresentationStateNotifier
extends AutoDisposeFamilyNotifier<PresentationState, Object> {
@override
PresentationState build(collection) {
if (arg case PlaylistSimple() || AlbumSimple()) {
if (isSavedTrackPlaylist) {
ref.listen(
likedTracksProvider,
(previous, next) {
next.whenData((value) {
state = state.copyWith(
presentationTracks: ServiceUtils.sortTracks(
value,
state.sortBy,
),
);
});
},
);
} else {
ref.listen(
arg is PlaylistSimple
? playlistTracksProvider((arg as PlaylistSimple).id!)
: albumTracksProvider((arg as AlbumSimple)),
(previous, next) {
next.whenData((value) {
state = state.copyWith(
presentationTracks: ServiceUtils.sortTracks(
value.items,
state.sortBy,
),
);
});
},
);
}
}
return PresentationState(
selectedTracks: [],
presentationTracks: tracks,
sortBy: SortBy.none,
);
}
bool get isSavedTrackPlaylist =>
arg is PlaylistSimple &&
(arg as PlaylistSimple).id == "user-liked-tracks";
List<Track> get tracks {
assert(
arg is PlaylistSimple || arg is AlbumSimple,
"arg must be PlaylistSimple or AlbumSimple",
);
final isPlaylist = arg is PlaylistSimple;
final tracks = switch ((isPlaylist, isSavedTrackPlaylist)) {
(true, true) => ref.read(likedTracksProvider).asData?.value,
(true, false) => ref
.read(playlistTracksProvider((arg as PlaylistSimple).id!))
.asData
?.value
.items,
_ => ref
.read(albumTracksProvider((arg as AlbumSimple)))
.asData
?.value
.items,
} ??
[];
return tracks;
}
void selectTrack(Track track) {
if (state.selectedTracks.any((e) => e.id == track.id)) {
return;
}
state = state.copyWith(
selectedTracks: [...state.selectedTracks, track],
);
}
void selectAllTracks() {
state = state.copyWith(
selectedTracks: tracks,
);
}
void deselectTrack(Track track) {
state = state.copyWith(
selectedTracks: state.selectedTracks.where((e) => e != track).toList(),
);
}
void deselectAllTracks() {
state = state.copyWith(
selectedTracks: [],
);
}
void filterTracks(String query) {
if (query.isEmpty) {
return;
}
state = state.copyWith(
presentationTracks: ServiceUtils.sortTracks(
tracks
.map((e) => (weightedRatio(e.name!, query), e))
.sorted((a, b) => b.$1.compareTo(a.$1))
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList(),
state.sortBy,
),
);
}
void clearFilter() {
state = state.copyWith(
presentationTracks: ServiceUtils.sortTracks(tracks, state.sortBy),
);
}
void sortTracks(SortBy sortBy) {
state = state.copyWith(
presentationTracks: sortBy == SortBy.none
? tracks
: ServiceUtils.sortTracks(state.presentationTracks, sortBy),
sortBy: sortBy,
);
}
}
final presentationStateProvider = AutoDisposeNotifierProviderFamily<
PresentationStateNotifier, PresentationState, Object>(
() => PresentationStateNotifier(),
);

View File

@ -0,0 +1,278 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/env.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/heart_button/heart_button.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/use_action_callbacks.dart';
import 'package:spotube/components/track_presentation/use_is_user_playlist.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
import 'package:spotube/provider/spotify/spotify.dart';
class TrackPresentationTopSection extends HookConsumerWidget {
const TrackPresentationTopSection({super.key});
@override
Widget build(BuildContext context, ref) {
final mediaQuery = MediaQuery.sizeOf(context);
final options = TrackPresentationOptions.of(context);
final scale = context.theme.scaling;
final isUserPlaylist = useIsUserPlaylist(ref, options.collectionId);
final playlistImage = (options.collection is PlaylistSimple &&
(options.collection as PlaylistSimple).owner?.displayName ==
"Spotify" &&
Env.disableSpotifyImages)
? ref.watch(playlistImageProvider(options.collectionId))
: null;
final decorationImage = playlistImage != null
? DecorationImage(
image: AssetImage(playlistImage.src),
fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
playlistImage.color,
playlistImage.colorBlendMode,
),
)
: DecorationImage(
image: UniversalImage.imageProvider(options.image),
fit: BoxFit.cover,
);
final imageDimension = mediaQuery.mdAndUp ? 200 : 120;
final (:isLoading, :isActive, :onPlay, :onShuffle) =
useActionCallbacks(ref);
final playbackActions = Row(
spacing: 8 * scale,
children: [
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.shuffle_playlist),
),
child: IconButton.secondary(
icon: isLoading
? const Center(
child:
CircularProgressIndicator(onSurface: false, size: 20),
)
: const Icon(SpotubeIcons.shuffle),
enabled: !isLoading && !isActive,
onPressed: onShuffle,
),
),
if (mediaQuery.width <= 320)
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.add_to_queue),
),
child: IconButton.secondary(
icon: const Icon(SpotubeIcons.queueAdd),
enabled: !isLoading && !isActive,
onPressed: () {},
),
)
else
Button.secondary(
leading: const Icon(SpotubeIcons.add),
enabled: !isLoading && !isActive,
child: Text(context.l10n.queue),
onPressed: () {},
),
Button.primary(
alignment: Alignment.center,
leading: switch ((isActive, isLoading)) {
(true, false) => const Icon(SpotubeIcons.pause),
(false, true) => const Center(
child: CircularProgressIndicator(onSurface: true, size: 18),
),
_ => const Icon(SpotubeIcons.play),
},
onPressed: onPlay,
enabled: !isLoading && !isActive,
child: isActive ? Text(context.l10n.pause) : Text(context.l10n.play),
),
],
);
final additionalActions = Row(
spacing: 8 * scale,
children: [
if (isUserPlaylist)
IconButton.outline(
size: ButtonSize.small,
icon: const Icon(SpotubeIcons.edit),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return PlaylistCreateDialog(
playlistId: options.collectionId,
trackIds: options.tracks.map((e) => e.id!).toList(),
);
},
);
},
),
if (options.shareUrl != null)
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.share),
),
child: IconButton.outline(
icon: const Icon(SpotubeIcons.share),
size: ButtonSize.small,
onPressed: () async {
await Clipboard.setData(
ClipboardData(text: options.shareUrl!),
);
if (!context.mounted) return;
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n
.copied_shareurl_to_clipboard(options.shareUrl!),
).small(),
);
},
);
},
),
),
if (options.onHeart != null)
HeartButton(
isLiked: options.isLiked,
tooltip: options.isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
variance: ButtonVariance.outline,
size: ButtonSize.small,
onPressed: options.onHeart,
),
],
);
return SliverMainAxisGroup(
slivers: [
if (mediaQuery.mdAndUp) SliverGap(16 * scale),
SliverPadding(
padding: EdgeInsets.symmetric(
horizontal: (mediaQuery.mdAndUp ? 16 : 8.0) * scale,
),
sliver: SliverList.list(
children: [
DecoratedBox(
decoration: BoxDecoration(
image: decorationImage,
borderRadius: BorderRadius.circular(45),
),
child: OutlinedContainer(
surfaceOpacity: context.theme.surfaceOpacity,
surfaceBlur: context.theme.surfaceBlur,
padding: EdgeInsets.all(24 * scale),
borderRadius: BorderRadius.circular(22 * scale),
borderWidth: 2,
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 16 * scale,
children: [
Row(
spacing: 16 * scale,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: imageDimension * scale,
width: imageDimension * scale,
decoration: BoxDecoration(
borderRadius: context.theme.borderRadiusXl,
image: decorationImage,
),
),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AutoSizeText(
options.title,
maxLines: 2,
minFontSize: 16,
style: context.theme.typography.h3,
),
if (options.description != null)
AutoSizeText(
options.description!,
maxLines: 2,
minFontSize: 14,
maxFontSize: 18,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: context
.theme.colorScheme.mutedForeground,
fontSize: 18,
),
),
const Gap(16),
Flex(
crossAxisAlignment: CrossAxisAlignment.start,
direction: mediaQuery.smAndUp
? Axis.horizontal
: Axis.vertical,
spacing: 8 * scale,
children: [
if (options.owner != null)
OutlineBadge(
leading: options.ownerImage != null
? Avatar(
initials:
options.owner?[0] ?? "U",
provider: UniversalImage
.imageProvider(
options.ownerImage!,
),
)
: null,
child: Text(
options.owner!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).small(),
),
additionalActions,
],
),
if (mediaQuery.mdAndUp) ...[
const Gap(16),
playbackActions
],
],
),
),
],
),
if (mediaQuery.smAndDown) playbackActions,
],
),
),
),
],
),
)
],
);
}
}

View File

@ -0,0 +1,70 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/extensions/context.dart';
class SortTracksDropdown extends StatelessWidget {
final SortBy? value;
final void Function(SortBy)? onChanged;
const SortTracksDropdown({
this.onChanged,
this.value,
super.key,
});
@override
Widget build(BuildContext context) {
return AdaptivePopSheetList<SortBy>(
variance: ButtonVariance.outline,
headings: [
Text(context.l10n.sort_tracks),
],
onSelected: onChanged,
tooltip: context.l10n.sort_tracks,
icon: const Icon(SpotubeIcons.sort),
children: [
AdaptiveMenuButton(
value: SortBy.none,
enabled: value != SortBy.none,
child: Text(context.l10n.none),
),
AdaptiveMenuButton(
value: SortBy.ascending,
enabled: value != SortBy.ascending,
child: Text(context.l10n.sort_a_z),
),
AdaptiveMenuButton(
value: SortBy.descending,
enabled: value != SortBy.descending,
child: Text(context.l10n.sort_z_a),
),
AdaptiveMenuButton(
value: SortBy.newest,
enabled: value != SortBy.newest,
child: Text(context.l10n.sort_newest),
),
AdaptiveMenuButton(
value: SortBy.oldest,
enabled: value != SortBy.oldest,
child: Text(context.l10n.sort_oldest),
),
AdaptiveMenuButton(
value: SortBy.duration,
enabled: value != SortBy.duration,
child: Text(context.l10n.sort_duration),
),
AdaptiveMenuButton(
value: SortBy.artist,
enabled: value != SortBy.artist,
child: Text(context.l10n.sort_artist),
),
AdaptiveMenuButton(
value: SortBy.album,
enabled: value != SortBy.album,
child: Text(context.l10n.sort_album),
),
],
);
}
}

View File

@ -0,0 +1,98 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/components/track_presentation/presentation_list.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_top.dart';
import 'package:spotube/components/track_presentation/presentation_modifiers.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/utils/platform.dart';
class TrackPresentation extends HookConsumerWidget {
final TrackPresentationOptions options;
const TrackPresentation({
super.key,
required this.options,
});
@override
Widget build(BuildContext context, ref) {
final scrollController = useScrollController();
final focusNode = useFocusNode();
final scale = context.theme.scaling;
useEffect(() {
if (!kIsMobile) return null;
void listener() {
if (!scrollController.hasClients) return;
if (focusNode.hasFocus) {
scrollController.animateTo(
300 * scale,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
focusNode.addListener(listener);
return () {
focusNode.removeListener(listener);
};
}, [focusNode, scrollController, scale]);
return Data<TrackPresentationOptions>.inherit(
data: options,
child: SafeArea(
bottom: false,
child: Scaffold(
headers: const [TitleBar()],
child: CustomScrollView(
controller: scrollController,
slivers: [
const TrackPresentationTopSection(),
const SliverGap(16),
SliverLayoutBuilder(
builder: (context, constrains) {
return SliverList.list(
children: [
TrackPresentationModifiersSection(
focusNode: focusNode,
),
Basic(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
leading: constrains.mdAndUp ? const Text(" #") : null,
title: Row(
children: [
Expanded(
flex: constrains.lgAndUp ? 5 : 6,
child: Text(context.l10n.title),
),
if (constrains.mdAndUp)
Expanded(
flex: 3,
child: Text(context.l10n.album),
),
Text(context.l10n.duration),
],
),
).small().muted(),
],
);
},
),
const PresentationListSection(),
const SliverSafeArea(sliver: SliverGap(10)),
],
),
),
),
);
}
}

View File

@ -0,0 +1,137 @@
import 'dart:math';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
typedef UseActionCallbacks = ({
bool isActive,
bool isLoading,
Future<void> Function() onShuffle,
Future<void> Function() onPlay,
});
UseActionCallbacks useActionCallbacks(WidgetRef ref) {
final isLoading = useState(false);
final context = useContext();
final options = TrackPresentationOptions.of(context);
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final isActive = useMemoized(
() => playlist.collections.contains(options.collectionId),
[playlist.collections, options.collectionId],
);
final onShuffle = useCallback(() async {
try {
isLoading.value = true;
final initialTracks = options.tracks;
if (!context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice == null) return;
if (isRemoteDevice) {
final allTracks = await options.pagination.onFetchAll();
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
options.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: allTracks,
collection: options.collection as AlbumSimple,
initialIndex: Random().nextInt(allTracks.length))
: WebSocketLoadEventData.playlist(
tracks: allTracks,
collection: options.collection as PlaylistSimple,
initialIndex: Random().nextInt(allTracks.length),
),
);
await remotePlayback.setShuffle(true);
} else {
await playlistNotifier.load(
initialTracks,
autoPlay: true,
initialIndex: Random().nextInt(initialTracks.length),
);
await audioPlayer.setShuffle(true);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([options.collection as PlaylistSimple]);
}
final allTracks = await options.pagination.onFetchAll();
await playlistNotifier.addTracks(
allTracks.sublist(initialTracks.length),
);
}
} finally {
isLoading.value = false;
}
}, [options, playlistNotifier, historyNotifier]);
final onPlay = useCallback(() async {
try {
isLoading.value = true;
final initialTracks = options.tracks;
if (!context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice == null) return;
if (isRemoteDevice) {
final allTracks = await options.pagination.onFetchAll();
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
options.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: allTracks,
collection: options.collection as AlbumSimple,
)
: WebSocketLoadEventData.playlist(
tracks: allTracks,
collection: options.collection as PlaylistSimple,
),
);
} else {
await playlistNotifier.load(initialTracks, autoPlay: true);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([options.collection as PlaylistSimple]);
}
final allTracks = await options.pagination.onFetchAll();
await playlistNotifier.addTracks(
allTracks.sublist(initialTracks.length),
);
}
} finally {
if (context.mounted) {
isLoading.value = false;
}
}
}, [options, playlistNotifier, historyNotifier]);
return (
isActive: isActive,
isLoading: isLoading.value,
onShuffle: onShuffle,
onPlay: onPlay,
);
}

View File

@ -0,0 +1,89 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/extensions/list.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
Future<void> Function(Track track, int index) useTrackTilePlayCallback(
WidgetRef ref,
) {
final context = useContext();
final options = TrackPresentationOptions.of(context);
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final isActive = useMemoized(
() => playlist.collections.contains(options.collectionId),
[playlist.collections, options.collectionId],
);
final onTapTrackTile = useCallback((Track track, int index) async {
final state = ref.read(presentationStateProvider(options.collection));
final notifier =
ref.read(presentationStateProvider(options.collection).notifier);
if (state.selectedTracks.isNotEmpty) {
if (state.selectedTracks.contains(track)) {
notifier.deselectTrack(track);
} else {
notifier.selectTrack(track);
}
return;
}
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice == null) return;
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
final remoteQueue = ref.read(queueProvider);
if (remoteQueue.collections.contains(options.collectionId) ||
remoteQueue.tracks.any((s) => s.id == track.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await options.pagination.onFetchAll();
await remotePlayback.load(
options.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: tracks,
collection: options.collection as AlbumSimple,
initialIndex: index,
)
: WebSocketLoadEventData.playlist(
tracks: tracks,
collection: options.collection as PlaylistSimple,
initialIndex: index,
),
);
}
} else {
if (isActive || playlist.tracks.containsBy(track, (a) => a.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await options.pagination.onFetchAll();
await playlistNotifier.load(
tracks,
initialIndex: index,
autoPlay: true,
);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([options.collection as PlaylistSimple]);
}
}
}
}, [isActive, playlist, options, playlistNotifier, historyNotifier]);
return onTapTrackTile;
}

View File

@ -1,11 +1,13 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart' hide Page;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotify/spotify.dart' hide Offset;
import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
@ -67,16 +69,20 @@ class TrackOptions extends HookConsumerWidget {
void actionShare(BuildContext context, Track track) { void actionShare(BuildContext context, Track track) {
final data = "https://open.spotify.com/track/${track.id}"; final data = "https://open.spotify.com/track/${track.id}";
Clipboard.setData(ClipboardData(text: data)).then((_) { Clipboard.setData(ClipboardData(text: data)).then((_) {
ScaffoldMessenger.of(context).showSnackBar( if (context.mounted) {
SnackBar( showToast(
width: 300, context: context,
behavior: SnackBarBehavior.floating, location: ToastLocation.topRight,
content: Text( builder: (context, overlay) {
context.l10n.copied_to_clipboard(data), return SurfaceCard(
textAlign: TextAlign.center, child: Text(
), context.l10n.copied_to_clipboard(data),
), textAlign: TextAlign.center,
); ),
);
},
);
}
}); });
} }
@ -159,7 +165,6 @@ class TrackOptions extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final router = GoRouter.of(context); final router = GoRouter.of(context);
final ThemeData(:colorScheme) = Theme.of(context); final ThemeData(:colorScheme) = Theme.of(context);
@ -202,6 +207,7 @@ class TrackOptions extends HookConsumerWidget {
final isLocalTrack = track is LocalTrack; final isLocalTrack = track is LocalTrack;
final adaptivePopSheetList = AdaptivePopSheetList<TrackOptionValue>( final adaptivePopSheetList = AdaptivePopSheetList<TrackOptionValue>(
tooltip: context.l10n.more_actions,
onSelected: (value) async { onSelected: (value) async {
switch (value) { switch (value) {
case TrackOptionValue.album: case TrackOptionValue.album:
@ -217,36 +223,57 @@ class TrackOptions extends HookConsumerWidget {
case TrackOptionValue.addToQueue: case TrackOptionValue.addToQueue:
await playback.addTrack(track); await playback.addTrack(track);
if (context.mounted) { if (context.mounted) {
scaffoldMessenger.showSnackBar( showToast(
SnackBar( context: context,
content: Text( location: ToastLocation.topRight,
context.l10n.added_track_to_queue(track.name!), builder: (context, overlay) {
), return SurfaceCard(
), child: Text(
context.l10n.added_track_to_queue(track.name!),
textAlign: TextAlign.center,
),
);
},
); );
} }
break; break;
case TrackOptionValue.playNext: case TrackOptionValue.playNext:
playback.addTracksAtFirst([track]); playback.addTracksAtFirst([track]);
scaffoldMessenger.showSnackBar(
SnackBar( if (context.mounted) {
content: Text( showToast(
context.l10n.track_will_play_next(track.name!), context: context,
), location: ToastLocation.topRight,
), builder: (context, overlay) {
); return SurfaceCard(
child: Text(
context.l10n.track_will_play_next(track.name!),
textAlign: TextAlign.center,
),
);
},
);
}
break; break;
case TrackOptionValue.removeFromQueue: case TrackOptionValue.removeFromQueue:
playback.removeTrack(track.id!); playback.removeTrack(track.id!);
scaffoldMessenger.showSnackBar(
SnackBar( if (context.mounted) {
content: Text( showToast(
context.l10n.removed_track_from_queue( context: context,
track.name!, location: ToastLocation.topRight,
), builder: (context, overlay) {
), return SurfaceCard(
), child: Text(
); context.l10n.removed_track_from_queue(
track.name!,
),
textAlign: TextAlign.center,
),
);
},
);
}
break; break;
case TrackOptionValue.favorite: case TrackOptionValue.favorite:
favorites.toggleTrackLike(track); favorites.toggleTrackLike(track);
@ -283,7 +310,10 @@ class TrackOptions extends HookConsumerWidget {
case TrackOptionValue.details: case TrackOptionValue.details:
showDialog( showDialog(
context: context, context: context,
builder: (context) => TrackDetailsDialog(track: track), builder: (context) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: TrackDetailsDialog(track: track),
),
); );
break; break;
case TrackOptionValue.download: case TrackOptionValue.download:
@ -296,8 +326,7 @@ class TrackOptions extends HookConsumerWidget {
}, },
icon: icon ?? const Icon(SpotubeIcons.moreHorizontal), icon: icon ?? const Icon(SpotubeIcons.moreHorizontal),
headings: [ headings: [
ListTile( Basic(
dense: true,
leading: AspectRatio( leading: AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: ClipRRect( child: ClipRRect(
@ -313,8 +342,7 @@ class TrackOptions extends HookConsumerWidget {
track.name!, track.name!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium, ).semiBold(),
),
subtitle: Align( subtitle: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: ArtistLink( child: ArtistLink(
@ -332,38 +360,47 @@ class TrackOptions extends HookConsumerWidget {
], ],
children: [ children: [
if (isLocalTrack) if (isLocalTrack)
PopSheetEntry( AdaptiveMenuButton(
value: TrackOptionValue.delete, value: TrackOptionValue.delete,
leading: const Icon(SpotubeIcons.trash), leading: const Icon(SpotubeIcons.trash),
title: Text(context.l10n.delete), child: Text(context.l10n.delete),
), ),
if (mediaQuery.smAndDown && !isLocalTrack) if (mediaQuery.smAndDown && !isLocalTrack)
PopSheetEntry( AdaptiveMenuButton(
value: TrackOptionValue.album, value: TrackOptionValue.album,
leading: const Icon(SpotubeIcons.album), leading: const Icon(SpotubeIcons.album),
title: Text(context.l10n.go_to_album), child: Column(
subtitle: Text(track.album!.name!), mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.go_to_album),
Text(
track.album!.name!,
style: context.theme.typography.xSmall,
),
],
),
), ),
if (!playlist.containsTrack(track)) ...[ if (!playlist.containsTrack(track)) ...[
PopSheetEntry( AdaptiveMenuButton(
value: TrackOptionValue.addToQueue, value: TrackOptionValue.addToQueue,
leading: const Icon(SpotubeIcons.queueAdd), leading: const Icon(SpotubeIcons.queueAdd),
title: Text(context.l10n.add_to_queue), child: Text(context.l10n.add_to_queue),
), ),
PopSheetEntry( AdaptiveMenuButton(
value: TrackOptionValue.playNext, value: TrackOptionValue.playNext,
leading: const Icon(SpotubeIcons.lightning), leading: const Icon(SpotubeIcons.lightning),
title: Text(context.l10n.play_next), child: Text(context.l10n.play_next),
), ),
] else ] else
PopSheetEntry( AdaptiveMenuButton(
value: TrackOptionValue.removeFromQueue, value: TrackOptionValue.removeFromQueue,
enabled: playlist.activeTrack?.id != track.id, enabled: playlist.activeTrack?.id != track.id,
leading: const Icon(SpotubeIcons.queueRemove), leading: const Icon(SpotubeIcons.queueRemove),
title: Text(context.l10n.remove_from_queue), child: Text(context.l10n.remove_from_queue),
), ),
if (me.asData?.value != null && !isLocalTrack) if (me.asData?.value != null && !isLocalTrack)
PopSheetEntry( AdaptiveMenuButton(
value: TrackOptionValue.favorite, value: TrackOptionValue.favorite,
leading: favorites.isLiked leading: favorites.isLiked
? const Icon( ? const Icon(
@ -371,32 +408,32 @@ class TrackOptions extends HookConsumerWidget {
color: Colors.pink, color: Colors.pink,
) )
: const Icon(SpotubeIcons.heart), : const Icon(SpotubeIcons.heart),
title: Text( child: Text(
favorites.isLiked favorites.isLiked
? context.l10n.remove_from_favorites ? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite, : context.l10n.save_as_favorite,
), ),
), ),
if (auth.asData?.value != null && !isLocalTrack) ...[ if (auth.asData?.value != null && !isLocalTrack) ...[
PopSheetEntry( AdaptiveMenuButton(
value: TrackOptionValue.startRadio, value: TrackOptionValue.startRadio,
leading: const Icon(SpotubeIcons.radio), leading: const Icon(SpotubeIcons.radio),
title: Text(context.l10n.start_a_radio), child: Text(context.l10n.start_a_radio),
), ),
PopSheetEntry( AdaptiveMenuButton(
value: TrackOptionValue.addToPlaylist, value: TrackOptionValue.addToPlaylist,
leading: const Icon(SpotubeIcons.playlistAdd), leading: const Icon(SpotubeIcons.playlistAdd),
title: Text(context.l10n.add_to_playlist), child: Text(context.l10n.add_to_playlist),
), ),
], ],
if (userPlaylist && auth.asData?.value != null && !isLocalTrack) if (userPlaylist && auth.asData?.value != null && !isLocalTrack)
PopSheetEntry( AdaptiveMenuButton(
value: TrackOptionValue.removeFromPlaylist, value: TrackOptionValue.removeFromPlaylist,
leading: const Icon(SpotubeIcons.removeFilled), leading: const Icon(SpotubeIcons.removeFilled),
title: Text(context.l10n.remove_from_playlist), child: Text(context.l10n.remove_from_playlist),
), ),
if (!isLocalTrack) if (!isLocalTrack)
PopSheetEntry( AdaptiveMenuButton(
value: TrackOptionValue.download, value: TrackOptionValue.download,
enabled: !isInQueue, enabled: !isInQueue,
leading: isInQueue leading: isInQueue
@ -407,55 +444,58 @@ class TrackOptions extends HookConsumerWidget {
); );
}) })
: const Icon(SpotubeIcons.download), : const Icon(SpotubeIcons.download),
title: Text(context.l10n.download_track), child: Text(context.l10n.download_track),
), ),
if (!isLocalTrack) if (!isLocalTrack)
PopSheetEntry( AdaptiveMenuButton(
value: TrackOptionValue.blacklist, value: TrackOptionValue.blacklist,
leading: const Icon(SpotubeIcons.playlistRemove), leading: Icon(
iconColor: isBlackListed != true ? Colors.red[400] : null, SpotubeIcons.playlistRemove,
textColor: isBlackListed != true ? Colors.red[400] : null, color: isBlackListed != true ? Colors.red[400] : null,
title: Text( ),
child: Text(
isBlackListed == true isBlackListed == true
? context.l10n.remove_from_blacklist ? context.l10n.remove_from_blacklist
: context.l10n.add_to_blacklist, : context.l10n.add_to_blacklist,
style: TextStyle(
color: isBlackListed != true ? Colors.red[400] : null,
),
), ),
), ),
if (!isLocalTrack) if (!isLocalTrack)
PopSheetEntry( AdaptiveMenuButton(
value: TrackOptionValue.share, value: TrackOptionValue.share,
leading: const Icon(SpotubeIcons.share), leading: const Icon(SpotubeIcons.share),
title: Text(context.l10n.share), child: Text(context.l10n.share),
), ),
if (!isLocalTrack) if (!isLocalTrack)
PopSheetEntry( AdaptiveMenuButton(
value: TrackOptionValue.songlink, value: TrackOptionValue.songlink,
leading: Assets.logos.songlinkTransparent.image( leading: Assets.logos.songlinkTransparent.image(
width: 22, width: 22,
height: 22, height: 22,
color: colorScheme.onSurface.withOpacity(0.5), color: colorScheme.foreground.withOpacity(0.5),
), ),
title: Text(context.l10n.song_link), child: Text(context.l10n.song_link),
), ),
if (!isLocalTrack) if (!isLocalTrack)
PopSheetEntry( AdaptiveMenuButton(
value: TrackOptionValue.details, value: TrackOptionValue.details,
leading: const Icon(SpotubeIcons.info), leading: const Icon(SpotubeIcons.info),
title: Text(context.l10n.details), child: Text(context.l10n.details),
), ),
], ],
); );
//! This is the most ANTI pattern I've ever done, but it works //! This is the most ANTI pattern I've ever done, but it works
showMenuCbRef?.value = (relativeRect) { showMenuCbRef?.value = (relativeRect) {
adaptivePopSheetList.showPopupMenu(context, relativeRect); final offsetFromRect = Offset(
relativeRect.left,
relativeRect.top,
);
adaptivePopSheetList.showDropdownMenu(context, offsetFromRect);
}; };
return ListTileTheme( return adaptivePopSheetList;
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: adaptivePopSheetList,
);
} }
} }

View File

@ -1,10 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
@ -13,7 +14,9 @@ import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/components/links/artist_link.dart';
import 'package:spotube/components/links/link_text.dart'; import 'package:spotube/components/links/link_text.dart';
import 'package:spotube/components/track_tile/track_options.dart'; import 'package:spotube/components/track_tile/track_options.dart';
import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/button_variance.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
@ -88,9 +91,10 @@ class TrackTile extends HookConsumerWidget {
}, },
child: HoverBuilder( child: HoverBuilder(
permanentState: isSelected || constrains.smAndDown ? true : null, permanentState: isSelected || constrains.smAndDown ? true : null,
builder: (context, isHovering) => ListTile( builder: (context, isHovering) => ButtonTile(
selected: isSelected, selected: isSelected,
onTap: () async { onPressed: () async {
if (isBlackListed) return;
try { try {
isLoading.value = true; isLoading.value = true;
await onTap?.call(); await onTap?.call();
@ -101,46 +105,58 @@ class TrackTile extends HookConsumerWidget {
} }
}, },
onLongPress: onLongPress, onLongPress: onLongPress,
enabled: !isBlackListed, style: (isBlackListed
contentPadding: EdgeInsets.zero, ? ButtonVariance.destructive
tileColor: isBlackListed ? theme.colorScheme.errorContainer : null, : ButtonVariance.ghost)
horizontalTitleGap: 12, .copyWith(
leadingAndTrailingTextStyle: theme.textTheme.bodyMedium, padding: (context, states) =>
const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
),
leading: Row( leading: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
...?leadingActions, ...?leadingActions,
if (index != null && onChanged == null && constrains.mdAndUp) AnimatedCrossFade(
SizedBox( duration: const Duration(milliseconds: 300),
width: 50, crossFadeState: index != null && onChanged == null
child: Padding( ? CrossFadeState.showSecond
padding: const EdgeInsets.symmetric(horizontal: 6), : CrossFadeState.showFirst,
child: Text( firstChild: Checkbox(
'${(index ?? 0) + 1}', state: selected
maxLines: 1, ? CheckboxState.checked
style: theme.textTheme.bodySmall, : CheckboxState.unchecked,
textAlign: TextAlign.center, onChanged: (state) =>
), onChanged?.call(state == CheckboxState.checked),
),
)
else if (constrains.smAndDown)
const SizedBox(width: 16),
if (onChanged != null)
Checkbox(
value: selected,
onChanged: onChanged,
), ),
secondChild: constrains.smAndDown
? const SizedBox(width: 16)
: SizedBox(
width: 50,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'${(index ?? 0) + 1}',
maxLines: 1,
style: theme.typography.small,
textAlign: TextAlign.center,
),
),
),
),
Stack( Stack(
children: [ children: [
ClipRRect( Container(
borderRadius: BorderRadius.circular(4), height: 40,
child: AspectRatio( width: 40,
aspectRatio: 1, decoration: BoxDecoration(
child: UniversalImage( borderRadius: theme.borderRadiusMd,
path: (track.album?.images).asUrlString( image: DecorationImage(
placeholder: ImagePlaceholder.albumArt,
),
fit: BoxFit.cover, fit: BoxFit.cover,
image: UniversalImage.imageProvider(
(track.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
),
), ),
), ),
), ),
@ -148,46 +164,49 @@ class TrackTile extends HookConsumerWidget {
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4), borderRadius: theme.borderRadiusMd,
color: isHovering color: isHovering
? Colors.black.withOpacity(0.4) ? Colors.black.withAlpha(102)
: Colors.transparent, : Colors.transparent,
), ),
), ),
), ),
Positioned.fill( Positioned.fill(
child: Center( child: Center(
child: IconTheme( child: Skeleton.ignore(
data: theme.iconTheme child: Consumer(
.copyWith(size: 26, color: Colors.white), builder: (context, ref, _) {
child: Skeleton.ignore( final isFetchingActiveTrack =
child: Consumer( ref.watch(queryingTrackInfoProvider);
builder: (context, ref, _) { return AnimatedSwitcher(
final isFetchingActiveTrack = duration: const Duration(milliseconds: 300),
ref.watch(queryingTrackInfoProvider); child: switch ((
return AnimatedSwitcher( isPlaying,
duration: const Duration(milliseconds: 300), isFetchingActiveTrack,
child: (isPlaying && isFetchingActiveTrack) || isPlaying,
isLoading.value isHovering,
? const SizedBox( isLoading.value
width: 26, )) {
height: 26, (true, true, _, _, _) ||
child: CircularProgressIndicator( (_, _, _, _, true) =>
strokeWidth: 1.5, const SizedBox(
color: Colors.white, width: 26,
), height: 26,
) child:
: isPlaying CircularProgressIndicator(size: 1.5),
? Icon( ),
SpotubeIcons.pause, (_, _, true, _, _) => Icon(
color: theme.colorScheme.primary, SpotubeIcons.pause,
) color: theme.colorScheme.primary,
: !isHovering ),
? const SizedBox.shrink() (_, _, _, true, _) => const Icon(
: const Icon(SpotubeIcons.play), SpotubeIcons.play,
); color: Colors.white,
}, ),
), _ => const SizedBox.shrink(),
},
);
},
), ),
), ),
), ),
@ -206,12 +225,30 @@ class TrackTile extends HookConsumerWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
_ => LinkText( _ => Row(
track.name!, mainAxisSize: MainAxisSize.min,
"/track/${track.id}", children: [
push: true, Flexible(
maxLines: 1, child: Button(
overflow: TextOverflow.ellipsis, style: ButtonVariance.link.copyWith(
padding: (context, states) => EdgeInsets.zero,
),
onPressed: () {
context.pushNamed(
TrackPage.name,
pathParameters: {
"id": track.id!,
},
);
},
child: Text(
track.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
), ),
}, },
), ),

View File

@ -1,192 +0,0 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/expandable_search/expandable_search.dart';
import 'package:spotube/components/track_tile/track_tile.dart';
import 'package:spotube/components/tracks_view/sections/body/track_view_body_headers.dart';
import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/list.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class TrackViewBodySection extends HookConsumerWidget {
const TrackViewBodySection({super.key});
@override
Widget build(BuildContext context, ref) {
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks));
final searchController = useTextEditingController();
final searchFocus = useFocusNode();
useValueListenable(searchController);
final searchQuery = searchController.text;
final isFiltering = useState(false);
final uniqTracks = useMemoized(() {
final trackIds = props.tracks.map((e) => e.id).toSet();
return props.tracks.where((e) => trackIds.remove(e.id)).toList();
}, [props.tracks]);
final tracks = useMemoized(() {
List<Track> filteredTracks;
if (searchQuery.isEmpty) {
filteredTracks = uniqTracks;
} else {
filteredTracks = uniqTracks
.map((e) => (weightedRatio(e.name!, searchQuery), e))
.sorted((a, b) => b.$1.compareTo(a.$1))
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList();
}
return ServiceUtils.sortTracks(filteredTracks, trackViewState.sortBy);
}, [trackViewState.sortBy, searchQuery, uniqTracks]);
final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId);
final isActive = playlist.collections.contains(props.collectionId);
final onTapTrackTile = useCallback((Track track, int index) async {
if (trackViewState.isSelecting) {
trackViewState.toggleTrackSelection(track.id!);
return;
}
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
final remoteQueue = ref.read(queueProvider);
if (remoteQueue.collections.contains(props.collectionId) ||
remoteQueue.tracks.any((s) => s.id == track.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await props.pagination.onFetchAll();
await remotePlayback.load(
props.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: tracks,
collection: props.collection as AlbumSimple,
initialIndex: index,
)
: WebSocketLoadEventData.playlist(
tracks: tracks,
collection: props.collection as PlaylistSimple,
initialIndex: index,
),
);
}
} else {
if (isActive || playlist.tracks.containsBy(track, (a) => a.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await props.pagination.onFetchAll();
await playlistNotifier.load(
tracks,
initialIndex: index,
autoPlay: true,
);
playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([props.collection as PlaylistSimple]);
}
}
}
}, [isActive, playlist, props, playlistNotifier, historyNotifier]);
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: TrackViewBodyHeaders(
isFiltering: isFiltering,
searchFocus: searchFocus,
),
),
const SliverGap(8),
SliverToBoxAdapter(
child: ExpandableSearchField(
isFiltering: isFiltering.value,
onChangeFiltering: (value) {
isFiltering.value = value;
},
searchController: searchController,
searchFocus: searchFocus,
),
),
SliverSafeArea(
top: false,
sliver: SliverInfiniteList(
itemCount: tracks.length,
onFetchData: props.pagination.onFetchMore,
isLoading: props.pagination.isLoading,
hasReachedMax: !props.pagination.hasNextPage,
loadingBuilder: (context) => Skeletonizer(
enabled: true,
child: TrackTile(
playlist: playlist,
track: FakeData.track,
index: 0,
),
),
emptyBuilder: (context) => Skeletonizer(
enabled: true,
child: Column(
children: List.generate(
10,
(index) => TrackTile(
track: FakeData.track,
index: index,
playlist: playlist,
),
),
),
),
itemBuilder: (context, index) {
final track = tracks[index];
return TrackTile(
playlist: playlist,
track: track,
index: index,
selected: trackViewState.selectedTrackIds.contains(track.id!),
playlistId: props.collectionId,
userPlaylist: isUserPlaylist,
onChanged: !trackViewState.isSelecting
? null
: (value) {
trackViewState.toggleTrackSelection(track.id!);
},
onLongPress: () {
trackViewState.selectTrack(track.id!);
HapticFeedback.selectionClick();
},
onTap: () => onTapTrackTile(track, index),
);
},
),
),
],
);
}
}

View File

@ -1,105 +0,0 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/expandable_search/expandable_search.dart';
import 'package:spotube/components/sort_tracks_dropdown.dart';
import 'package:spotube/components/tracks_view/sections/body/track_view_options.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/utils/platform.dart';
class TrackViewBodyHeaders extends HookConsumerWidget {
final ValueNotifier<bool> isFiltering;
final FocusNode searchFocus;
const TrackViewBodyHeaders({
super.key,
required this.isFiltering,
required this.searchFocus,
});
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context);
final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks));
return LayoutBuilder(
builder: (context, constrains) {
return Row(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: animation,
child: child,
),
);
},
child: Checkbox(
value: trackViewState.hasSelectedAll,
onChanged: (checked) {
if (checked == true) {
trackViewState.selectAll();
} else {
trackViewState.deselectAll();
}
},
),
),
Expanded(
flex: 7,
child: Row(
children: [
Text(
context.l10n.title,
style: textTheme.bodyLarge,
overflow: TextOverflow.ellipsis,
),
],
),
),
// used alignment of this table-head
if (constrains.mdAndUp)
Expanded(
flex: 3,
child: Row(
children: [
Text(
context.l10n.album,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyLarge,
),
],
),
),
SortTracksDropdown(
value: trackViewState.sortBy,
onChanged: (value) {
trackViewState.sort(value);
},
),
ExpandableSearchButton(
isFiltering: isFiltering.value,
searchFocus: searchFocus,
onPressed: (value) {
isFiltering.value = value;
if (value) {
searchFocus.requestFocus();
} else {
searchFocus.unfocus();
}
},
),
const TrackViewBodyOptions(),
if (kIsDesktop) const Gap(10),
],
);
},
);
}
}

View File

@ -1,140 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/dialogs/confirm_download_dialog.dart';
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
class TrackViewBodyOptions extends HookConsumerWidget {
const TrackViewBodyOptions({super.key});
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final ThemeData(:textTheme) = Theme.of(context);
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final audioSource =
ref.watch(userPreferencesProvider.select((s) => s.audioSource));
final trackViewState = ref.watch(trackViewProvider(props.tracks));
final selectedTracks = trackViewState.selectedTracks;
return AdaptivePopSheetList(
tooltip: context.l10n.more_actions,
headings: [
Text(
context.l10n.more_actions,
style: textTheme.bodyLarge,
),
],
onSelected: (action) async {
switch (action) {
case "download":
{
final confirmed = audioSource == AudioSource.piped ||
await showDialog(
context: context,
builder: (context) {
return const ConfirmDownloadDialog();
},
);
if (confirmed != true) return;
await downloader.batchAddToQueue(selectedTracks);
trackViewState.deselectAll();
break;
}
case "add-to-playlist":
{
if (context.mounted) {
await showDialog(
context: context,
builder: (context) {
return PlaylistAddTrackDialog(
openFromPlaylist: props.collectionId,
tracks: selectedTracks.toList(),
);
},
);
}
break;
}
case "play-next":
{
playlistNotifier.addTracksAtFirst(selectedTracks);
playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
trackViewState.deselectAll();
break;
}
case "add-to-queue":
{
playlistNotifier.addTracks(selectedTracks);
playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
trackViewState.deselectAll();
break;
}
default:
}
},
icon: const Icon(SpotubeIcons.moreVertical),
children: [
PopSheetEntry(
value: "download",
leading: const Icon(SpotubeIcons.download),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n.download_count(selectedTracks.length),
),
),
PopSheetEntry(
value: "add-to-playlist",
leading: const Icon(SpotubeIcons.playlistAdd),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n.add_count_to_playlist(selectedTracks.length),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "add-to-queue",
leading: const Icon(SpotubeIcons.queueAdd),
title: Text(
context.l10n.add_count_to_queue(selectedTracks.length),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "play-next",
leading: const Icon(SpotubeIcons.lightning),
title: Text(
context.l10n.play_count_next(selectedTracks.length),
),
),
],
);
}
}

View File

@ -1,167 +0,0 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/tracks_view/sections/header/header_actions.dart';
import 'package:spotube/components/tracks_view/sections/header/header_buttons.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:gap/gap.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/string.dart';
import 'package:spotube/hooks/utils/use_palette_color.dart';
import 'package:spotube/utils/platform.dart';
class TrackViewFlexHeader extends HookConsumerWidget {
const TrackViewFlexHeader({super.key});
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final ThemeData(:colorScheme, :textTheme, :iconTheme) = Theme.of(context);
final defaultTextStyle = DefaultTextStyle.of(context);
final mediaQuery = MediaQuery.of(context);
final palette = usePaletteColor(props.image, ref);
return IconTheme(
data: iconTheme.copyWith(color: palette.bodyTextColor),
child: SliverLayoutBuilder(
builder: (context, constrains) {
final isExpanded = constrains.scrollOffset < 350;
final headingStyle = (mediaQuery.mdAndDown
? textTheme.headlineSmall
: textTheme.headlineMedium)
?.copyWith(
color: palette.bodyTextColor,
);
return SliverAppBar(
iconTheme: iconTheme.copyWith(
color: palette.bodyTextColor,
size: 16,
),
actions: isExpanded
? []
: [
const TrackViewHeaderActions(),
TrackViewHeaderButtons(compact: true, color: palette),
],
floating: false,
pinned: true,
expandedHeight: 450,
automaticallyImplyLeading: kIsMobile,
backgroundColor: palette.color,
title: isExpanded ? null : Text(props.title, style: headingStyle),
flexibleSpace: FlexibleSpaceBar(
background: Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(props.image),
fit: BoxFit.cover,
),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black45,
colorScheme.surface,
],
begin: const FractionalOffset(0, 0),
end: const FractionalOffset(0, 1),
tileMode: TileMode.clamp,
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: mediaQuery.mdAndDown
? mediaQuery.size.width
: 800,
),
child: Flex(
direction: mediaQuery.mdAndDown
? Axis.vertical
: Axis.horizontal,
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: props.image,
width: 200,
height: 200,
placeholder: Assets.albumPlaceholder.path,
),
),
const Gap(20),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: mediaQuery.mdAndDown
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
Text(
props.title,
style: headingStyle,
textAlign: mediaQuery.mdAndDown
? TextAlign.center
: TextAlign.start,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 10),
if (props.description != null &&
props.description!.isNotEmpty)
Text(
props.description!
.unescapeHtml()
.cleanHtml(),
style:
defaultTextStyle.style.copyWith(
color: palette.bodyTextColor,
),
textAlign: mediaQuery.mdAndDown
? TextAlign.center
: TextAlign.start,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Gap(10),
const TrackViewHeaderActions(),
const Gap(10),
TrackViewHeaderButtons(color: palette),
],
),
),
],
),
),
],
),
),
),
),
),
),
),
);
},
),
);
}
}

View File

@ -1,111 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/heart_button/heart_button.dart';
import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
class TrackViewHeaderActions extends HookConsumerWidget {
const TrackViewHeaderActions({super.key});
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final isActive = playlist.collections.contains(props.collectionId);
final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final auth = ref.watch(authenticationProvider);
final copiedText =
context.l10n.copied_shareurl_to_clipboard(props.shareUrl);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: context.l10n.share,
icon: const Icon(SpotubeIcons.share),
onPressed: () async {
await Clipboard.setData(
ClipboardData(text: props.shareUrl),
);
scaffoldMessenger.showSnackBar(
SnackBar(
width: 300,
behavior: SnackBarBehavior.floating,
content: Text(
copiedText,
textAlign: TextAlign.center,
),
),
);
},
),
IconButton(
icon: const Icon(SpotubeIcons.queueAdd),
tooltip: context.l10n.add_to_queue,
onPressed: isActive || props.tracks.isEmpty
? null
: () async {
final tracks = await props.pagination.onFetchAll();
await playlistNotifier.addTracks(tracks);
playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier
.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
},
),
if (props.onHeart != null && auth.asData?.value != null)
HeartButton(
isLiked: props.isLiked,
icon: isUserPlaylist ? SpotubeIcons.trash : null,
tooltip: props.isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
onPressed: () async {
final shouldPop = await props.onHeart?.call();
if (isUserPlaylist && shouldPop == true && context.mounted) {
context.pop();
}
},
),
if (isUserPlaylist)
IconButton(
icon: const Icon(SpotubeIcons.edit),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return PlaylistCreateDialog(
playlistId: props.collectionId,
trackIds: props.tracks.map((e) => e.id!).toList(),
);
},
);
},
),
],
);
}
}

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