diff --git a/.env.example b/.env.example
index 888cbe6b..35c5d563 100644
--- a/.env.example
+++ b/.env.example
@@ -14,3 +14,4 @@ LASTFM_API_SECRET=$LASTFM_API_SECRET
RELEASE_CHANNEL=$RELEASE_CHANNEL
HIDE_DONATIONS=$HIDE_DONATIONS
+DISABLE_SPOTIFY_IMAGES=$DISABLE_SPOTIFY_IMAGES
diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json
index e20d18ad..c0b314bc 100644
--- a/.fvm/fvm_config.json
+++ b/.fvm/fvm_config.json
@@ -1,3 +1,3 @@
{
- "flutterSdkVersion": "3.27.0"
+ "flutterSdkVersion": "3.27.3"
}
\ No newline at end of file
diff --git a/.fvmrc b/.fvmrc
index 34136bbd..74c2c15a 100644
--- a/.fvmrc
+++ b/.fvmrc
@@ -1,4 +1,4 @@
{
- "flutter": "3.27.0",
+ "flutter": "3.27.3",
"flavors": {}
}
\ No newline at end of file
diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml
index 6a1c713f..5cfa5b6e 100644
--- a/.github/workflows/spotube-release-binary.yml
+++ b/.github/workflows/spotube-release-binary.yml
@@ -20,7 +20,8 @@ on:
description: Dry run without uploading to release
env:
- FLUTTER_VERSION: 3.27.0
+ FLUTTER_VERSION: 3.27.3
+ FLUTTER_CHANNEL: master
permissions:
contents: write
@@ -30,44 +31,52 @@ jobs:
strategy:
matrix:
include:
- - os: ubuntu-latest
+ - os: ubuntu-22.04
platform: linux
+ arch: x86
files: |
dist/Spotube-linux-x86_64.deb
dist/Spotube-linux-x86_64.rpm
dist/spotube-linux-*-x86_64.tar.xz
- - os: ubuntu-latest
- platform: linux_arm
+ - os: ubuntu-22.04-arm
+ platform: linux
+ arch: arm64
files: |
dist/Spotube-linux-aarch64.deb
dist/spotube-linux-*-aarch64.tar.xz
- - os: ubuntu-latest
+ - os: ubuntu-22.04
platform: android
+ arch: all
files: |
build/Spotube-android-all-arch.apk
build/Spotube-playstore-all-arch.aab
- os: windows-latest
platform: windows
+ arch: x86
files: |
dist/Spotube-windows-x86_64.nupkg
dist/Spotube-windows-x86_64-setup.exe
- os: macos-latest
platform: ios
+ arch: all
files: |
Spotube-iOS.ipa
- os: macos-14
platform: macos
+ arch: all
files: |
build/Spotube-macos-universal.dmg
build/Spotube-macos-universal.pkg
runs-on: ${{matrix.os}}
steps:
- uses: actions/checkout@v4
- - uses: subosito/flutter-action@v2.12.0
+ - uses: subosito/flutter-action@v2.18.0
with:
- cache: true
- cache-key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.yaml') }}
flutter-version: ${{ env.FLUTTER_VERSION }}
+ channel: ${{ env.FLUTTER_CHANNEL }}
+ cache: true
+ git-source: https://github.com/flutter/flutter.git
+
- name: Setup Java
if: ${{matrix.platform == 'android'}}
uses: actions/setup-java@v4
@@ -76,14 +85,8 @@ jobs:
java-version: '17'
cache: 'gradle'
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
- if: ${{matrix.platform != 'linux_arm'}}
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
@@ -105,28 +108,16 @@ jobs:
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks
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
- run: dart cli/cli.dart build ${{matrix.platform}}
+ run: dart cli/cli.dart build --arch=${{matrix.arch}} ${{matrix.platform}}
env:
CHANNEL: ${{inputs.channel}}
DOTENV: ${{secrets.DOTENV_RELEASE}}
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
with:
if-no-files-found: error
- name: Spotube-Release-Binaries
+ name: ${{matrix.platform}}-${{matrix.arch}}
path: ${{matrix.files}}
- name: Debug With SSH When fails
@@ -136,14 +127,13 @@ jobs:
limit-access-to-actor: true
upload:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-22.04
needs:
- build_platform
steps:
- uses: actions/checkout@v4
- - uses: actions/download-artifact@v3
+ - uses: actions/download-artifact@v4
with:
- name: Spotube-Release-Binaries
path: ./Spotube-Release-Binaries
- name: Install dependencies
@@ -152,18 +142,19 @@ jobs:
- name: Generate Checksums
run: |
tree .
- md5sum Spotube-Release-Binaries/* >> RELEASE.md5sum
- sha256sum Spotube-Release-Binaries/* >> RELEASE.sha256sum
+ find Spotube-Release-Binaries -type f -exec md5sum {} \; >> RELEASE.md5sum
+ 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
- name: Extract pubspec version
run: |
echo "PUBSPEC_VERSION=$(grep -oP 'version:\s*\K[^+]+(?=\+)' pubspec.yaml)" >> $GITHUB_ENV
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
with:
if-no-files-found: error
- name: Spotube-Release-Binaries
+ name: sums
path: |
RELEASE.md5sum
RELEASE.sha256sum
@@ -178,7 +169,7 @@ jobs:
omitNameDuringUpdate: true
omitPrereleaseDuringUpdate: true
allowUpdates: true
- artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum
+ artifacts: Spotube-Release-Binaries/**/*,RELEASE.sha256sum,RELEASE.md5sum
- name: Upload Release Binaries (nightly)
if: ${{ !inputs.dry_run && inputs.channel == 'nightly' }}
@@ -190,9 +181,16 @@ jobs:
omitNameDuringUpdate: true
omitPrereleaseDuringUpdate: true
allowUpdates: true
- artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum
+ artifacts: Spotube-Release-Binaries/**/*,RELEASE.sha256sum,RELEASE.md5sum
body: |
Build Number: ${{github.run_number}}
Nightly release includes newest features but may contain bugs
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
+
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 7a1e8b9b..deabf1d3 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -30,6 +30,17 @@
"request": "launch",
"program": "lib/main.dart",
"flutterMode": "release"
+ },
+ {
+ "name": "spotube (mobile) (release)",
+ "type": "dart",
+ "request": "launch",
+ "program": "lib/main.dart",
+ "flutterMode": "release",
+ "args": [
+ "--flavor",
+ "dev"
+ ]
}
],
"compounds": []
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 1f47bada..ac8518d1 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -13,6 +13,7 @@
"RGBO",
"riverpod",
"Scrobblenaut",
+ "shadcn",
"skeletonizer",
"songlink",
"speechiness",
@@ -27,5 +28,5 @@
"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.flutterSdkPath": ".fvm/versions/3.27.0"
+ "dart.flutterSdkPath": ".fvm/versions/3.27.3"
}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt
index a04a0508..a20af959 100644
--- a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt
+++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt
@@ -2,6 +2,7 @@ package oss.krtirtho.spotube.glance
import HomeWidgetGlanceState
import HomeWidgetGlanceStateDefinition
+import android.R
import android.content.Context
import android.graphics.drawable.Icon
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(
modifier = GlanceModifier
.background(
diff --git a/android/app/src/nightly/res/drawable/ic_launcher_monochrome.xml b/android/app/src/nightly/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 00000000..8aae0e6c
--- /dev/null
+++ b/android/app/src/nightly/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml
index c79c58a3..83e651db 100644
--- a/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,9 +1,6 @@
-
-
-
-
-
+
+
+
+
\ No newline at end of file
diff --git a/assets/patterns/black_white_visualized.jpg b/assets/patterns/black_white_visualized.jpg
new file mode 100644
index 00000000..e56a2780
Binary files /dev/null and b/assets/patterns/black_white_visualized.jpg differ
diff --git a/assets/patterns/brazil_carnival.jpg b/assets/patterns/brazil_carnival.jpg
new file mode 100644
index 00000000..a7cdb3a1
Binary files /dev/null and b/assets/patterns/brazil_carnival.jpg differ
diff --git a/assets/patterns/cotton_balls.jpg b/assets/patterns/cotton_balls.jpg
new file mode 100644
index 00000000..db6f02a8
Binary files /dev/null and b/assets/patterns/cotton_balls.jpg differ
diff --git a/assets/patterns/cute_worms.jpg b/assets/patterns/cute_worms.jpg
new file mode 100644
index 00000000..0c9f4fbb
Binary files /dev/null and b/assets/patterns/cute_worms.jpg differ
diff --git a/assets/patterns/flash_cross_axis.jpg b/assets/patterns/flash_cross_axis.jpg
new file mode 100644
index 00000000..c6e52283
Binary files /dev/null and b/assets/patterns/flash_cross_axis.jpg differ
diff --git a/assets/patterns/memphis_shapes.jpg b/assets/patterns/memphis_shapes.jpg
new file mode 100644
index 00000000..2db8e775
Binary files /dev/null and b/assets/patterns/memphis_shapes.jpg differ
diff --git a/assets/patterns/oval_gloomy.jpg b/assets/patterns/oval_gloomy.jpg
new file mode 100644
index 00000000..b44bf945
Binary files /dev/null and b/assets/patterns/oval_gloomy.jpg differ
diff --git a/assets/patterns/oval_sunny.jpg b/assets/patterns/oval_sunny.jpg
new file mode 100644
index 00000000..bc07ae83
Binary files /dev/null and b/assets/patterns/oval_sunny.jpg differ
diff --git a/assets/patterns/red_nimbuses.jpg b/assets/patterns/red_nimbuses.jpg
new file mode 100644
index 00000000..6527999c
Binary files /dev/null and b/assets/patterns/red_nimbuses.jpg differ
diff --git a/assets/patterns/tree_bark.jpg b/assets/patterns/tree_bark.jpg
new file mode 100644
index 00000000..0dac37d7
Binary files /dev/null and b/assets/patterns/tree_bark.jpg differ
diff --git a/assets/patterns/vibrant_pentagons.jpg b/assets/patterns/vibrant_pentagons.jpg
new file mode 100644
index 00000000..d9e8d537
Binary files /dev/null and b/assets/patterns/vibrant_pentagons.jpg differ
diff --git a/assets/patterns/wiring_pattern.jpg b/assets/patterns/wiring_pattern.jpg
new file mode 100644
index 00000000..9fc3b781
Binary files /dev/null and b/assets/patterns/wiring_pattern.jpg differ
diff --git a/assets/patterns/zigzags_gloomy.jpg b/assets/patterns/zigzags_gloomy.jpg
new file mode 100644
index 00000000..c6ccd2a3
Binary files /dev/null and b/assets/patterns/zigzags_gloomy.jpg differ
diff --git a/assets/patterns/zigzags_sunny.jpg b/assets/patterns/zigzags_sunny.jpg
new file mode 100644
index 00000000..7470d5ef
Binary files /dev/null and b/assets/patterns/zigzags_sunny.jpg differ
diff --git a/cli/commands/build.dart b/cli/commands/build.dart
index fdf35a95..e0c254ff 100644
--- a/cli/commands/build.dart
+++ b/cli/commands/build.dart
@@ -3,7 +3,6 @@ import 'package:args/command_runner.dart';
import 'build/android.dart';
import 'build/ios.dart';
import 'build/linux.dart';
-import 'build/linux_arm.dart';
import 'build/macos.dart';
import 'build/windows.dart';
@@ -18,8 +17,13 @@ class BuildCommand extends Command {
addSubcommand(AndroidBuildCommand());
addSubcommand(IosBuildCommand());
addSubcommand(LinuxBuildCommand());
- addSubcommand(LinuxArmBuildCommand());
addSubcommand(MacosBuildCommand());
addSubcommand(WindowsBuildCommand());
+ argParser.addOption(
+ "arch",
+ abbr: "a",
+ defaultsTo: "x86",
+ allowed: ["x86", "arm64", "all"],
+ );
}
}
diff --git a/cli/commands/build/common.dart b/cli/commands/build/common.dart
index 4c7e3e51..30906b3c 100644
--- a/cli/commands/build/common.dart
+++ b/cli/commands/build/common.dart
@@ -63,4 +63,6 @@ mixin BuildCommandCommonSteps on Command {
""",
);
}
+
+ String get architecture => parent?.argResults?.option("arch") as String;
}
diff --git a/cli/commands/build/linux.dart b/cli/commands/build/linux.dart
index a218720c..3fd8a0b9 100644
--- a/cli/commands/build/linux.dart
+++ b/cli/commands/build/linux.dart
@@ -37,23 +37,32 @@ class LinuxBuildCommand extends Command with BuildCommandCommonSteps {
await bootstrap();
await shell.run(
- """
- flutter_distributor package --platform=linux --targets=deb
- flutter_distributor package --platform=linux --targets=rpm
- """,
+ "flutter_distributor package --platform=linux --targets=deb",
);
- final tempDir = join(Directory.systemTemp.path, "spotube-tar");
+ if (architecture == "x86") {
+ await shell.run(
+ "flutter_distributor package --platform=linux --targets=rpm",
+ );
+ }
- final bundleDirPath =
- join(cwd.path, "build", "linux", "x64", "release", "bundle");
+ final tempDir = join(Directory.systemTemp.path, "spotube-tar");
+ final bundleArchName = architecture == "x86" ? "x86_64" : "aarch64";
+ final bundleDirPath = join(
+ cwd.path,
+ "build",
+ "linux",
+ architecture == "x86" ? "x64" : architecture,
+ "release",
+ "bundle",
+ );
final tarFile = File(join(
cwd.path,
"dist",
"spotube-linux-"
"${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber}"
- "-x86_64.tar.xz",
+ "-$bundleArchName.tar.xz",
));
await copyPath(bundleDirPath, tempDir);
@@ -81,25 +90,31 @@ class LinuxBuildCommand extends Command with BuildCommandCommonSteps {
"spotube-${pubspec.version}-linux.deb",
),
);
-
- final ogRpm = File(
+ await ogDeb.copy(
join(
cwd.path,
"dist",
- pubspec.version.toString(),
- "spotube-${pubspec.version}-linux.rpm",
+ "Spotube-linux-$bundleArchName.deb",
),
);
-
- 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 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");
}
diff --git a/cli/commands/build/linux_arm.dart b/cli/commands/build/linux_arm.dart
deleted file mode 100644
index a09f0980..00000000
--- a/cli/commands/build/linux_arm.dart
+++ /dev/null
@@ -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/
- """,
- );
- }
-}
diff --git a/cli/commands/install-dependencies.dart b/cli/commands/install-dependencies.dart
index dc519cc6..e26b8078 100644
--- a/cli/commands/install-dependencies.dart
+++ b/cli/commands/install-dependencies.dart
@@ -24,6 +24,13 @@ class InstallDependenciesCommand extends Command {
],
mandatory: true,
);
+
+ argParser.addOption(
+ "arch",
+ abbr: "a",
+ allowed: ["x86", "arm64", "all"],
+ defaultsTo: "x86",
+ );
}
@override
@@ -41,14 +48,6 @@ class InstallDependenciesCommand extends Command {
""",
);
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":
await shell.run(
"""
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 63871a3d..bbfc1404 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 70;
+ objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index 5e31d3d3..c53e2b31 100644
--- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -48,6 +48,7 @@
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
+ enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
diff --git a/lib/collections/assets.gen.dart b/lib/collections/assets.gen.dart
index 6825fbd5..004001f2 100644
--- a/lib/collections/assets.gen.dart
+++ b/lib/collections/assets.gen.dart
@@ -9,6 +9,17 @@
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 get values => [xmasEffect];
+}
+
class $AssetsLogosGen {
const $AssetsLogosGen();
@@ -24,6 +35,84 @@ class $AssetsLogosGen {
List 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 get values => [
+ blackWhiteVisualized,
+ brazilCarnival,
+ cottonBalls,
+ cuteWorms,
+ flashCrossAxis,
+ memphisShapes,
+ ovalGloomy,
+ ovalSunny,
+ redNimbuses,
+ treeBark,
+ vibrantPentagons,
+ wiringPattern,
+ zigzagsGloomy,
+ zigzagsSunny
+ ];
+}
+
class $AssetsTutorialGen {
const $AssetsTutorialGen();
@@ -46,6 +135,7 @@ class Assets {
static const String license = 'LICENSE';
static const AssetGenImage albumPlaceholder =
AssetGenImage('assets/album-placeholder.png');
+ static const $AssetsBackgroundsGen backgrounds = $AssetsBackgroundsGen();
static const AssetGenImage bengaliPatternsBg =
AssetGenImage('assets/bengali-patterns-bg.jpg');
static const AssetGenImage branding = AssetGenImage('assets/branding.png');
@@ -55,12 +145,15 @@ class Assets {
static const AssetGenImage likedTracks =
AssetGenImage('assets/liked-tracks.jpg');
static const $AssetsLogosGen logos = $AssetsLogosGen();
+ static const $AssetsPatternsGen patterns = $AssetsPatternsGen();
static const AssetGenImage placeholder =
AssetGenImage('assets/placeholder.png');
static const AssetGenImage spotubeHeroBanner =
AssetGenImage('assets/spotube-hero-banner.png');
static const AssetGenImage spotubeLogoForeground =
AssetGenImage('assets/spotube-logo-foreground.jpg');
+ static const AssetGenImage spotubeLogoMacos =
+ AssetGenImage('assets/spotube-logo-macos.png');
static const AssetGenImage spotubeLogoBmp =
AssetGenImage('assets/spotube-logo.bmp');
static const String spotubeLogoIco = 'assets/spotube-logo.ico';
@@ -104,6 +197,7 @@ class Assets {
placeholder,
spotubeHeroBanner,
spotubeLogoForeground,
+ spotubeLogoMacos,
spotubeLogoBmp,
spotubeLogoIco,
spotubeLogoPng,
diff --git a/lib/collections/env.dart b/lib/collections/env.dart
index eb60851f..feb2a2db 100644
--- a/lib/collections/env.dart
+++ b/lib/collections/env.dart
@@ -38,6 +38,11 @@ abstract class Env {
@EnviedField(varName: "RELEASE_CHANNEL", defaultValue: "nightly")
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"
? ReleaseChannel.stable
: ReleaseChannel.nightly;
diff --git a/lib/collections/fonts.gen.dart b/lib/collections/fonts.gen.dart
new file mode 100644
index 00000000..811e1d36
--- /dev/null
+++ b/lib/collections/fonts.gen.dart
@@ -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';
+}
diff --git a/lib/collections/gradients.dart b/lib/collections/gradients.dart
index e861dde7..a7936ee2 100644
--- a/lib/collections/gradients.dart
+++ b/lib/collections/gradients.dart
@@ -1,4 +1,4 @@
-import 'package:flutter/material.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart';
const gradients = [
LinearGradient(colors: [
diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart
index 4f446831..d0a0c8b6 100644
--- a/lib/collections/intents.dart
+++ b/lib/collections/intents.dart
@@ -7,7 +7,11 @@ import 'package:go_router/go_router.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/modules/player/player_controls.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/search/search.dart';
import 'package:spotube/provider/audio_player/querying_track_info.dart';
@@ -52,8 +56,13 @@ class NavigationAction extends Action {
enum HomeTabs {
browse,
search,
- library,
+
lyrics,
+ userPlaylists,
+ userArtists,
+ userAlbums,
+ userLocalLibrary,
+ userDownloads,
}
class HomeTabIntent extends Intent {
@@ -73,12 +82,24 @@ class HomeTabAction extends Action {
case HomeTabs.search:
router.goNamed(SearchPage.name);
break;
- case HomeTabs.library:
- router.goNamed(LibraryPage.name);
- break;
case HomeTabs.lyrics:
router.goNamed(LyricsPage.name);
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;
}
diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart
index a0380e29..4cd869cd 100644
--- a/lib/collections/routes.dart
+++ b/lib/collections/routes.dart
@@ -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/home.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_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/playlist/liked_playlist.dart';
import 'package:spotube/pages/playlist/playlist.dart';
@@ -99,45 +104,76 @@ final routerProvider = Provider((ref) {
pageBuilder: (context, state) =>
const SpotubePage(child: SearchPage()),
),
- GoRoute(
- path: "/library",
- name: LibraryPage.name,
- pageBuilder: (context, state) =>
- const SpotubePage(child: LibraryPage()),
- routes: [
- GoRoute(
- path: "generate",
- name: PlaylistGeneratorPage.name,
+ ShellRoute(
+ pageBuilder: (context, state, child) =>
+ SpotubePage(child: LibraryPage(child: child)),
+ routes: [
+ GoRoute(
+ path: "/library/playlists",
+ name: UserPlaylistsPage.name,
+ pageBuilder: (context, state) =>
+ 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) =>
- const SpotubePage(child: PlaylistGeneratorPage()),
+ const SpotubePage(child: UserLocalLibraryPage()),
routes: [
GoRoute(
- path: "result",
- name: PlaylistGenerateResultPage.name,
- pageBuilder: (context, state) => SpotubePage(
- child: PlaylistGenerateResultPage(
- state: state.extra as GeneratePlaylistProviderInput,
- ),
- ),
- )
- ],
+ path: "folder",
+ name: LocalLibraryPage.name,
+ parentNavigatorKey: shellRouteNavigatorKey,
+ 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(
+ 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(
path: "/lyrics",
name: LyricsPage.name,
diff --git a/lib/collections/side_bar_tiles.dart b/lib/collections/side_bar_tiles.dart
index 4f23c049..f12517bb 100644
--- a/lib/collections/side_bar_tiles.dart
+++ b/lib/collections/side_bar_tiles.dart
@@ -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:flutter_gen/gen_l10n/app_localizations.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/search/search.dart';
import 'package:spotube/pages/stats/stats.dart';
@@ -34,12 +37,6 @@ List getSidebarTileList(AppLocalizations l10n) => [
icon: SpotubeIcons.search,
title: l10n.search,
),
- SideBarTiles(
- id: "library",
- name: LibraryPage.name,
- icon: SpotubeIcons.library,
- title: l10n.library,
- ),
SideBarTiles(
id: "lyrics",
name: LyricsPage.name,
@@ -54,6 +51,33 @@ List getSidebarTileList(AppLocalizations l10n) => [
),
];
+List 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 getNavbarTileList(AppLocalizations l10n) => [
SideBarTiles(
id: "browse",
@@ -69,7 +93,7 @@ List getNavbarTileList(AppLocalizations l10n) => [
),
SideBarTiles(
id: "library",
- name: LibraryPage.name,
+ name: UserPlaylistsPage.name,
icon: SpotubeIcons.library,
title: l10n.library,
),
diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart
index 5c4df85f..b5fbe5e8 100644
--- a/lib/collections/spotube_icons.dart
+++ b/lib/collections/spotube_icons.dart
@@ -1,5 +1,5 @@
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:simple_icons/simple_icons.dart';
@@ -37,6 +37,7 @@ abstract class SpotubeIcons {
static const share = FeatherIcons.share2;
static const playlistAdd = Icons.playlist_add_rounded;
static const playlistRemove = Icons.playlist_remove_rounded;
+ static const playlist = Icons.playlist_play_rounded;
static const trash = FeatherIcons.trash2;
static const clock = FeatherIcons.clock;
static const lyrics = Icons.lyrics_rounded;
@@ -127,4 +128,10 @@ abstract class SpotubeIcons {
static const cache = FeatherIcons.hardDrive;
static const export = Icons.file_open_outlined;
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;
}
diff --git a/lib/components/adaptive/adaptive_list_tile.dart b/lib/components/adaptive/adaptive_list_tile.dart
index 33df44c1..c6d00bd4 100644
--- a/lib/components/adaptive/adaptive_list_tile.dart
+++ b/lib/components/adaptive/adaptive_list_tile.dart
@@ -1,5 +1,6 @@
-import 'package:flutter/material.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';
class AdaptiveListTile extends HookWidget {
@@ -24,41 +25,39 @@ class AdaptiveListTile extends HookWidget {
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
- return ListTile(
+ return ButtonTile(
title: title,
subtitle: subtitle,
trailing: breakOn ?? mediaQuery.smAndDown
? null
: trailing?.call(context, null),
leading: leading,
- onTap: breakOn ?? mediaQuery.smAndDown
- ? () {
- onTap?.call();
- showDialog(
- context: context,
- barrierDismissible: true,
- builder: (context) {
- return StatefulBuilder(builder: (context, update) {
- return AlertDialog(
- title: title != null
- ? Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- if (leading != null) ...[
- leading!,
- const SizedBox(width: 5)
- ],
- Flexible(child: title!),
- ],
- )
- : Container(),
- content: trailing?.call(context, update),
- );
- });
- },
+ enabled: breakOn ?? mediaQuery.smAndDown,
+ onPressed: () {
+ onTap?.call();
+ showDialog(
+ context: context,
+ barrierDismissible: true,
+ builder: (context) {
+ return StatefulBuilder(builder: (context, update) {
+ return AlertDialog(
+ title: title != null
+ ? Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ spacing: 5,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ if (leading != null) leading!,
+ Flexible(child: title!),
+ ],
+ )
+ : const SizedBox.shrink(),
+ content: Center(child: trailing?.call(context, update)),
);
- }
- : null,
+ });
+ },
+ );
+ },
);
}
}
diff --git a/lib/components/adaptive/adaptive_pop_sheet_list.dart b/lib/components/adaptive/adaptive_pop_sheet_list.dart
index 97dc6132..95d3fae7 100644
--- a/lib/components/adaptive/adaptive_pop_sheet_list.dart
+++ b/lib/components/adaptive/adaptive_pop_sheet_list.dart
@@ -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/extensions/constrains.dart';
-_emptyCB() {}
-
-class PopSheetEntry extends ListTile {
+class AdaptiveMenuButton extends MenuButton {
final T? value;
- const PopSheetEntry({
- this.value,
+ const AdaptiveMenuButton({
super.key,
- super.leading,
- super.title,
- super.subtitle,
+ this.value,
+ required super.child,
+ super.subMenu,
+ super.onPressed,
super.trailing,
- super.isThreeLine = false,
- super.dense,
- super.visualDensity,
- super.shape,
- super.style,
- super.selectedColor,
- super.iconColor,
- super.textColor,
- super.titleTextStyle,
- super.subtitleTextStyle,
- super.leadingAndTrailingTextStyle,
- super.contentPadding,
+ super.leading,
super.enabled = true,
- super.onTap = _emptyCB,
- super.onLongPress,
- super.onFocusChange,
- super.mouseCursor,
- super.selected = false,
- super.focusColor,
- super.hoverColor,
- super.splashColor,
super.focusNode,
- super.autofocus = false,
- super.tileColor,
- super.selectedTileColor,
- super.enableFeedback,
- super.horizontalTitleGap,
- super.minVerticalPadding,
- super.minLeadingWidth,
- super.titleAlignment,
- });
+ super.autoClose = true,
+ super.popoverController,
+ }) : assert(
+ value != null || onPressed != null,
+ 'Either value or onPressed must be provided',
+ );
}
/// An adaptive widget that shows a [PopupMenuButton] when screen size is above
/// or equal to 640px
/// In smaller screen, a [IconButton] with a [showModalBottomSheet] is shown
class AdaptivePopSheetList extends StatelessWidget {
- final List> children;
+ final List> children;
final Widget? icon;
final Widget? child;
final bool useRootNavigator;
final List? headings;
- final String? tooltip;
+ final String tooltip;
final ValueChanged? onSelected;
- final BorderRadius borderRadius;
final Offset offset;
+ final ButtonVariance variance;
+
const AdaptivePopSheetList({
super.key,
required this.children,
@@ -70,166 +49,141 @@ class AdaptivePopSheetList extends StatelessWidget {
this.useRootNavigator = true,
this.headings,
this.onSelected,
- this.borderRadius = const BorderRadius.all(Radius.circular(999)),
- this.tooltip,
+ required this.tooltip,
this.offset = Offset.zero,
+ this.variance = ButtonVariance.ghost,
}) : assert(
!(icon != null && child != null),
'Either icon or child must be provided',
);
- Future showPopupMenu(BuildContext context, RelativeRect position) {
+ Future showDropdownMenu(BuildContext context, Offset position) async {
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(
+ if (mediaQuery.mdAndUp) {
+ await showDropdown(
+ 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,
- useRootNavigator: useRootNavigator,
- constraints: BoxConstraints(
- maxHeight: mediaQuery.size.height * 0.6,
+ enableDrag: true,
+ showDragHandle: true,
+ useRootNavigator: true,
+ shape: RoundedRectangleBorder(
+ borderRadius: context.theme.borderRadiusMd,
),
- position: position,
- items: children
- .map(
- (item) => PopupMenuItem(
- padding: EdgeInsets.zero,
- enabled: false,
- child: _AdaptivePopSheetListItem(
- item: item,
- onSelected: onSelected,
+ backgroundColor: context.theme.colorScheme.card,
+ builder: (context) {
+ return ListView.builder(
+ itemCount: childrenModified.length,
+ shrinkWrap: true,
+ itemBuilder: (context, index) {
+ final data = childrenModified[index];
+
+ return Button(
+ enabled: data.enabled,
+ style: ButtonVariance.ghost.copyWith(
+ padding: (context, state, value) => const EdgeInsets.all(16),
),
- ),
- )
- .toList(),
+ onPressed: () {
+ data.onPressed?.call(context);
+ if (data.autoClose) {
+ Navigator.of(context).pop();
+ }
+ },
+ leading: data.leading,
+ trailing: data.trailing,
+ alignment: Alignment.centerLeft,
+ child: data.child,
+ );
+ },
+ );
+ },
);
}
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
- final theme = Theme.of(context);
if (mediaQuery.mdAndUp) {
- return PopupMenuButton(
- icon: icon,
- tooltip: 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,
+ return Tooltip(
+ tooltip: TooltipContainer(
+ child: Text(tooltip),
),
- builder: (context) {
- return Padding(
- padding: const EdgeInsets.all(8.0).copyWith(top: 0),
- child: DefaultTextStyle(
- style: theme.textTheme.titleMedium!,
- child: SingleChildScrollView(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- if (headings != null) ...[
- ...headings!,
- const SizedBox(height: 8),
- Divider(
- color: theme.colorScheme.primary,
- thickness: 0.3,
- endIndent: 16,
- indent: 16,
- ),
- ],
- ...children.map(
- (item) => _AdaptivePopSheetListItem(
- item: item,
- onSelected: onSelected,
- ),
- )
- ],
- ),
+ child: IconButton(
+ variance: variance,
+ icon: icon ?? const Icon(SpotubeIcons.moreVertical),
+ onPressed: () {
+ final renderBox = context.findRenderObject() as RenderBox;
+ final position = RelativeRect.fromRect(
+ Rect.fromPoints(
+ renderBox.localToGlobal(Offset.zero,
+ ancestor: context.findRenderObject()),
+ renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero),
+ ancestor: context.findRenderObject()),
),
- ),
- );
- },
+ Offset.zero & mediaQuery.size,
+ );
+ final offset = Offset(position.left, position.top);
+ showDropdownMenu(context, offset);
+ },
+ ),
);
}
if (child != null) {
return Tooltip(
- message: tooltip ?? '',
- child: InkWell(
- onTap: showSheet,
- borderRadius: borderRadius,
+ tooltip: TooltipContainer(child: Text(tooltip)),
+ child: Button(
+ onPressed: () => showDropdownMenu(context, Offset.zero),
+ style: variance,
child: IgnorePointer(child: child),
),
);
}
- return IconButton(
- icon: icon ?? const Icon(SpotubeIcons.moreVertical),
- tooltip: tooltip,
- style: theme.iconButtonTheme.style?.copyWith(
- shape: WidgetStatePropertyAll(
- RoundedRectangleBorder(
- borderRadius: borderRadius,
- ),
- ),
- ),
- onPressed: showSheet,
- );
- }
-}
-
-class _AdaptivePopSheetListItem extends StatelessWidget {
- final PopSheetEntry item;
- final ValueChanged? 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),
- ),
+ return Tooltip(
+ tooltip: TooltipContainer(child: Text(tooltip)),
+ child: IconButton(
+ variance: variance,
+ icon: icon ?? const Icon(SpotubeIcons.moreVertical),
+ onPressed: () => showDropdownMenu(context, Offset.zero),
),
);
}
diff --git a/lib/components/adaptive/adaptive_popup_menu_button.dart b/lib/components/adaptive/adaptive_popup_menu_button.dart
deleted file mode 100644
index 02fced52..00000000
--- a/lib/components/adaptive/adaptive_popup_menu_button.dart
+++ /dev/null
@@ -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 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(),
- );
- }
-}
diff --git a/lib/components/adaptive/adaptive_select_tile.dart b/lib/components/adaptive/adaptive_select_tile.dart
index 3f6d2700..2e2e7041 100644
--- a/lib/components/adaptive/adaptive_select_tile.dart
+++ b/lib/components/adaptive/adaptive_select_tile.dart
@@ -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:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/constrains.dart';
@@ -11,7 +12,7 @@ class AdaptiveSelectTile extends HookWidget {
final T value;
final ValueChanged? onChanged;
- final List> options;
+ final List> options;
/// Show the smaller value when the breakpoint is reached
///
@@ -22,6 +23,9 @@ class AdaptiveSelectTile extends HookWidget {
final bool? breakLayout;
+ final BoxConstraints? popupConstraints;
+ final PopoverConstraint? popupWidthConstraint;
+
const AdaptiveSelectTile({
required this.title,
required this.value,
@@ -33,61 +37,35 @@ class AdaptiveSelectTile extends HookWidget {
this.breakLayout,
this.showValueWhenUnfolded = true,
super.key,
+ this.popupConstraints,
+ this.popupWidthConstraint,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
- final rawControl = DecoratedBox(
- decoration: BoxDecoration(
- color: theme.colorScheme.secondaryContainer,
- borderRadius: BorderRadius.circular(10),
- ),
- child: DropdownButton(
- 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(
- value: null,
- child: Container(),
- ),
- )
- .child,
- [value, options]);
- final control = breakLayout ?? mediaQuery.mdAndUp
- ? rawControl
- : showValueWhenUnfolded
- ? Container(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
- decoration: BoxDecoration(
- border: Border.all(
- color: theme.colorScheme.primary,
- width: 2,
- ),
- borderRadius: BorderRadius.circular(10),
- ),
- child: DefaultTextStyle(
- style: TextStyle(
- color: theme.colorScheme.primary,
- ),
- child: controlPlaceholder,
- ),
- )
- : const SizedBox.shrink();
+ Widget? control = Select(
+ itemBuilder: (context, item) {
+ return options.firstWhere((element) => element.value == item).child;
+ },
+ value: value,
+ onChanged: onChanged,
+ popupConstraints: popupConstraints ?? const BoxConstraints(maxWidth: 200),
+ popupWidthConstraint: popupWidthConstraint ?? PopoverConstraint.flexible,
+ children: options,
+ );
+
+ if (mediaQuery.smAndDown) {
+ if (showValueWhenUnfolded) {
+ control = OutlineBadge(
+ child: options.firstWhere((element) => element.value == value).child,
+ );
+ } else {
+ control = null;
+ }
+ }
return ListTile(
title: title,
@@ -104,20 +82,26 @@ class AdaptiveSelectTile extends HookWidget {
showDialog(
context: context,
builder: (context) {
- return SimpleDialog(
- title: title,
- children: [
- for (final option in options)
- RadioListTile(
- title: option.child,
- value: option.value as T,
- groupValue: value,
- onChanged: (v) {
- Navigator.pop(context);
- onChanged?.call(v);
+ return AlertDialog(
+ content: ListView.builder(
+ shrinkWrap: true,
+ itemCount: options.length,
+ itemBuilder: (context, index) {
+ final item = options[index];
+
+ return ListTile(
+ iconColor: theme.colorScheme.primary,
+ leading: item.value == value
+ ? const Icon(SpotubeIcons.radioChecked)
+ : const Icon(SpotubeIcons.radioUnchecked),
+ title: item.child,
+ onTap: () {
+ onChanged?.call(item.value);
+ Navigator.of(context).pop();
},
- ),
- ],
+ );
+ },
+ ),
);
},
);
diff --git a/lib/components/animated_gradient.dart b/lib/components/animated_gradient.dart
index aaba2ff9..a9d4ef2b 100644
--- a/lib/components/animated_gradient.dart
+++ b/lib/components/animated_gradient.dart
@@ -1,4 +1,4 @@
-import 'package:flutter/material.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class AnimateGradient extends HookWidget {
diff --git a/lib/components/bordered_text.dart b/lib/components/bordered_text.dart
deleted file mode 100644
index f25f2208..00000000
--- a/lib/components/bordered_text.dart
+++ /dev/null
@@ -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: [
- 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,
- ],
- );
- }
-}
diff --git a/lib/components/button/back_button.dart b/lib/components/button/back_button.dart
new file mode 100644
index 00000000..17b93cea
--- /dev/null
+++ b/lib/components/button/back_button.dart
@@ -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(),
+ );
+ }
+}
diff --git a/lib/components/compact_search.dart b/lib/components/compact_search.dart
deleted file mode 100644
index d37cb673..00000000
--- a/lib/components/compact_search.dart
+++ /dev/null
@@ -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? 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),
- );
- }
-}
diff --git a/lib/components/dialogs/confirm_download_dialog.dart b/lib/components/dialogs/confirm_download_dialog.dart
index 897c64cb..a2df0e9c 100644
--- a/lib/components/dialogs/confirm_download_dialog.dart
+++ b/lib/components/dialogs/confirm_download_dialog.dart
@@ -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/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
@@ -9,13 +8,15 @@ class ConfirmDownloadDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return AlertDialog(
- title: Padding(
- padding: const EdgeInsets.all(15),
- child: Row(
+ final screenSize = MediaQuery.sizeOf(context);
+
+ return ConstrainedBox(
+ constraints: BoxConstraints(maxWidth: Breakpoints.sm),
+ child: AlertDialog(
+ title: Row(
+ spacing: 10,
children: [
Text(context.l10n.are_you_sure),
- const SizedBox(width: 10),
const UniversalImage(
path:
"https://c.tenor.com/kHcmsxlKHEAAAAAM/rock-one-eyebrow-raised-rock-staring.gif",
@@ -24,58 +25,53 @@ class ConfirmDownloadDialog extends StatelessWidget {
)
],
),
- ),
- content: Container(
- padding: const EdgeInsets.all(15),
- constraints: BoxConstraints(maxWidth: Breakpoints.sm),
- child: SingleChildScrollView(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- 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,
+ content: Expanded(
+ flex: screenSize.smAndUp ? 0 : 1,
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ context.l10n.download_warning,
+ textAlign: TextAlign.justify,
),
- textAlign: TextAlign.justify,
- ),
- const SizedBox(height: 10),
- Text(
- 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),
- ],
+ 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(
+ 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),
- ),
- ],
);
}
}
diff --git a/lib/components/dialogs/piped_down_dialog.dart b/lib/components/dialogs/piped_down_dialog.dart
deleted file mode 100644
index b1717a2a..00000000
--- a/lib/components/dialogs/piped_down_dialog.dart
+++ /dev/null
@@ -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),
- ),
- ],
- );
- }
-}
diff --git a/lib/components/dialogs/playlist_add_track_dialog.dart b/lib/components/dialogs/playlist_add_track_dialog.dart
index 5af9c9e4..5098bf9d 100644
--- a/lib/components/dialogs/playlist_add_track_dialog.dart
+++ b/lib/components/dialogs/playlist_add_track_dialog.dart
@@ -1,7 +1,6 @@
-import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
@@ -22,7 +21,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
- final ThemeData(:textTheme) = Theme.of(context);
+ final typography = Theme.of(context).typography;
final userPlaylists = ref.watch(favoritePlaylistsProvider);
final favoritePlaylistsNotifier =
ref.watch(favoritePlaylistsProvider.notifier);
@@ -64,67 +63,86 @@ class PlaylistAddTrackDialog extends HookConsumerWidget {
tracks.map((e) => e.id!).toList(),
),
),
- ).then((_) => Navigator.pop(context, true));
+ ).then((_) => context.mounted ? Navigator.pop(context, true) : null);
}
- return AlertDialog(
- insetPadding: EdgeInsets.zero,
- title: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- context.l10n.add_to_playlist,
- style: textTheme.titleMedium,
+ return ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 400),
+ child: AlertDialog(
+ title: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ 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(),
],
- ),
- actions: [
- OutlinedButton(
- child: Text(context.l10n.cancel),
- onPressed: () {
- Navigator.pop(context, false);
- },
- ),
- FilledButton(
- onPressed: onAdd,
- child: Text(context.l10n.add),
- ),
- ],
- content: SizedBox(
- height: 300,
- width: 300,
- child: userPlaylists.isLoading
- ? const Center(child: CircularProgressIndicator())
- : ListView.builder(
- shrinkWrap: true,
- itemCount: filteredPlaylists.length,
- itemBuilder: (context, index) {
- final playlist = filteredPlaylists.elementAt(index);
- return CheckboxListTile(
- secondary: CircleAvatar(
- backgroundImage: UniversalImage.imageProvider(
- playlist.images.asUrlString(
- placeholder: ImagePlaceholder.collection,
+ content: SizedBox(
+ height: 300,
+ child: userPlaylists.isLoading
+ ? const Center(child: CircularProgressIndicator())
+ : ListView.builder(
+ shrinkWrap: true,
+ itemCount: filteredPlaylists.length,
+ itemBuilder: (context, index) {
+ final playlist = filteredPlaylists.elementAt(index);
+ return Button.ghost(
+ style: ButtonVariance.ghost.copyWith(
+ padding: (context, _, __) {
+ return const EdgeInsets.symmetric(vertical: 8);
+ },
+ ),
+ leading: Avatar(
+ initials:
+ Avatar.getInitials(playlist.name ?? "Playlist"),
+ provider: UniversalImage.imageProvider(
+ playlist.images.asUrlString(
+ placeholder: ImagePlaceholder.collection,
+ ),
),
),
- ),
- contentPadding: EdgeInsets.zero,
- title: Padding(
- padding: const EdgeInsets.only(left: 8.0),
- child: Text(playlist.name!),
- ),
- value: playlistsCheck.value[playlist.id] ?? false,
- onChanged: (val) {
- playlistsCheck.value = {
- ...playlistsCheck.value,
- playlist.id!: val == true
- };
- },
- );
- },
- ),
+ trailing: Checkbox(
+ state: (playlistsCheck.value[playlist.id] ?? false)
+ ? CheckboxState.checked
+ : CheckboxState.unchecked,
+ onChanged: (val) {
+ playlistsCheck.value = {
+ ...playlistsCheck.value,
+ playlist.id!: val == CheckboxState.checked,
+ };
+ },
+ ),
+ 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!),
+ ),
+ );
+ },
+ ),
+ ),
),
);
}
diff --git a/lib/components/dialogs/prompt_dialog.dart b/lib/components/dialogs/prompt_dialog.dart
index 30a63bcf..3498bf02 100644
--- a/lib/components/dialogs/prompt_dialog.dart
+++ b/lib/components/dialogs/prompt_dialog.dart
@@ -1,4 +1,4 @@
-import 'package:flutter/material.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/extensions/context.dart';
Future showPromptDialog({
@@ -16,13 +16,13 @@ Future showPromptDialog({
content: Text(message),
actions: [
if (cancelText != null)
- OutlinedButton(
+ Button.outline(
onPressed: () => Navigator.of(context).pop(false),
child: Text(
cancelText == "Cancel" ? context.l10n.cancel : cancelText,
),
),
- FilledButton(
+ Button.primary(
child: Text(okText == "Ok" ? context.l10n.ok : okText),
onPressed: () => Navigator.of(context).pop(true),
),
diff --git a/lib/components/dialogs/replace_downloaded_dialog.dart b/lib/components/dialogs/replace_downloaded_dialog.dart
index 00461d34..3a0f3a1d 100644
--- a/lib/components/dialogs/replace_downloaded_dialog.dart
+++ b/lib/components/dialogs/replace_downloaded_dialog.dart
@@ -1,5 +1,5 @@
-import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/context.dart';
@@ -13,45 +13,35 @@ class ReplaceDownloadedDialog extends ConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final groupValue = ref.watch(replaceDownloadedFileState);
- final theme = Theme.of(context);
final replaceAll = ref.watch(replaceDownloadedFileState);
return AlertDialog(
title: Text(context.l10n.track_exists(track.name ?? "")),
- content: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(context.l10n.do_you_want_to_replace),
- RadioListTile(
- dense: true,
- contentPadding: EdgeInsets.zero,
- activeColor: theme.colorScheme.primary,
- value: true,
- groupValue: groupValue,
- onChanged: (value) {
- if (value != null) {
- ref.read(replaceDownloadedFileState.notifier).state = true;
- }
- },
- title: Text(context.l10n.replace_downloaded_tracks),
- ),
- RadioListTile(
- dense: true,
- 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),
- ),
- ],
+ content: RadioGroup(
+ value: groupValue,
+ onChanged: (value) {
+ ref.read(replaceDownloadedFileState.notifier).state = value;
+ },
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(context.l10n.do_you_want_to_replace),
+ const Gap(16),
+ RadioItem(
+ value: true,
+ trailing: Text(context.l10n.replace_downloaded_tracks),
+ ),
+ const Gap(8),
+ RadioItem(
+ value: false,
+ trailing: Text(context.l10n.skip_download_tracks),
+ ),
+ ],
+ ),
),
actions: [
- OutlinedButton(
+ Button.outline(
onPressed: replaceAll == true
? null
: () {
@@ -59,7 +49,7 @@ class ReplaceDownloadedDialog extends ConsumerWidget {
},
child: Text(context.l10n.skip),
),
- FilledButton(
+ Button.primary(
onPressed: replaceAll == false
? null
: () {
diff --git a/lib/components/dialogs/select_device_dialog.dart b/lib/components/dialogs/select_device_dialog.dart
index 3a3bde60..5392a403 100644
--- a/lib/components/dialogs/select_device_dialog.dart
+++ b/lib/components/dialogs/select_device_dialog.dart
@@ -1,6 +1,6 @@
-import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/connect/clients.dart';
@@ -16,31 +16,31 @@ class SelectDeviceDialog extends HookConsumerWidget {
return AlertDialog(
title: Text(context.l10n.choose_the_device),
- insetPadding: const EdgeInsets.all(16),
- content: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(context.l10n.multiple_device_connected),
- RadioListTile.adaptive(
- title: Text(remoteService.name),
- value: true,
- groupValue: isRemoteService.value,
- onChanged: (value) {
- isRemoteService.value = value!;
- },
- ),
- RadioListTile.adaptive(
- title: Text(context.l10n.this_device),
- value: false,
- groupValue: isRemoteService.value,
- onChanged: (value) {
- isRemoteService.value = !value!;
- },
- ),
- ],
+ content: RadioGroup(
+ value: isRemoteService.value,
+ onChanged: (value) {
+ isRemoteService.value = value;
+ },
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(context.l10n.multiple_device_connected),
+ const Gap(16),
+ RadioItem(
+ trailing: Text(remoteService.name),
+ value: true,
+ ),
+ const Gap(8),
+ RadioItem(
+ trailing: Text(context.l10n.this_device),
+ value: false,
+ ),
+ ],
+ ),
),
actions: [
- TextButton(
+ Button.primary(
onPressed: () {
Navigator.of(context).pop(isRemoteService.value);
},
@@ -51,7 +51,8 @@ class SelectDeviceDialog extends HookConsumerWidget {
}
}
-Future showSelectDeviceDialog(BuildContext context, WidgetRef ref) async {
+Future showSelectDeviceDialog(
+ BuildContext context, WidgetRef ref) async {
final connectClients = ref.read(connectClientsProvider);
if (connectClients.asData?.value.resolvedService == null) {
@@ -63,5 +64,5 @@ Future showSelectDeviceDialog(BuildContext context, WidgetRef ref) async {
builder: (context) => const SelectDeviceDialog(),
);
- return isRemote ?? false;
+ return isRemote;
}
diff --git a/lib/components/dialogs/track_details_dialog.dart b/lib/components/dialogs/track_details_dialog.dart
index 61bca7b1..1296ae0e 100644
--- a/lib/components/dialogs/track_details_dialog.dart
+++ b/lib/components/dialogs/track_details_dialog.dart
@@ -1,5 +1,5 @@
-import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/links/artist_link.dart';
@@ -73,17 +73,15 @@ class TrackDetailsDialog extends HookWidget {
};
return AlertDialog(
- contentPadding: const EdgeInsets.all(16),
- insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 100),
- scrollable: true,
+ surfaceBlur: 0,
+ surfaceOpacity: 1,
title: Row(
- mainAxisAlignment: MainAxisAlignment.center,
+ spacing: 8,
children: [
const Icon(SpotubeIcons.info),
- const SizedBox(width: 8),
Text(
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,
child: Table(
columnWidths: const {
- 0: FixedColumnWidth(95),
- 1: FixedColumnWidth(10),
- 2: FlexColumnWidth(1),
+ 0: FixedTableSize(95),
+ 1: FixedTableSize(10),
+ 2: FlexTableSize(),
},
- defaultVerticalAlignment: TableCellVerticalAlignment.middle,
- children: [
+ theme: const TableTheme(
+ backgroundColor: Colors.transparent,
+ cellTheme: TableCellTheme(
+ backgroundColor: WidgetStatePropertyAll(Colors.transparent),
+ ),
+ ),
+ rowHeights: const {0: FixedTableSize(40)},
+ rows: [
for (final entry in detailsMap.entries)
TableRow(
- children: [
+ cells: [
TableCell(
- verticalAlignment: TableCellVerticalAlignment.top,
child: Text(
entry.key,
- style: theme.textTheme.titleMedium,
+ style: theme.typography.bold,
),
),
const TableCell(
- verticalAlignment: TableCellVerticalAlignment.top,
child: Text(":"),
),
- if (entry.value is Widget)
- entry.value as Widget
- else if (entry.value is String)
- Text(
- entry.value as String,
- style: theme.textTheme.bodyMedium,
- ),
+ TableCell(
+ child: entry.value is Widget
+ ? entry.value as Widget
+ : (entry.value is String)
+ ? Text(
+ 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)
TableRow(
- children: [
+ cells: [
TableCell(
- verticalAlignment: TableCellVerticalAlignment.top,
child: Text(
entry.key,
- style: theme.textTheme.titleMedium,
+ style: theme.typography.bold,
),
),
const TableCell(
- verticalAlignment: TableCellVerticalAlignment.top,
child: Text(":"),
),
- if (entry.value is Widget)
- entry.value as Widget
- else
- Text(
- entry.value,
- maxLines: 2,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.bodyMedium,
- ),
+ TableCell(
+ child: entry.value is Widget
+ ? entry.value as Widget
+ : Text(
+ entry.value,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: theme.typography.normal,
+ ),
+ ),
],
),
],
diff --git a/lib/components/expandable_search/expandable_search.dart b/lib/components/expandable_search/expandable_search.dart
index 157e180f..0c40b843 100644
--- a/lib/components/expandable_search/expandable_search.dart
+++ b/lib/components/expandable_search/expandable_search.dart
@@ -1,5 +1,5 @@
-import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart';
@@ -39,11 +39,8 @@ class ExpandableSearchField extends StatelessWidget {
child: TextField(
focusNode: searchFocus,
controller: searchController,
- decoration: InputDecoration(
- hintText: context.l10n.search_tracks,
- isDense: true,
- prefixIcon: const Icon(SpotubeIcons.search),
- ),
+ placeholder: Text(context.l10n.search_tracks),
+ leading: const Icon(SpotubeIcons.search),
),
),
),
@@ -69,16 +66,9 @@ class ExpandableSearchButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
- final theme = Theme.of(context);
-
return IconButton(
icon: icon,
- style: IconButton.styleFrom(
- backgroundColor:
- isFiltering ? theme.colorScheme.secondaryContainer : null,
- foregroundColor: isFiltering ? theme.colorScheme.secondary : null,
- minimumSize: const Size(25, 25),
- ),
+ variance: isFiltering ? ButtonVariance.secondary : ButtonVariance.outline,
onPressed: () {
if (isFiltering) {
searchFocus.requestFocus();
diff --git a/lib/components/fallbacks/anonymous_fallback.dart b/lib/components/fallbacks/anonymous_fallback.dart
index 62ed8ddd..373e0454 100644
--- a/lib/components/fallbacks/anonymous_fallback.dart
+++ b/lib/components/fallbacks/anonymous_fallback.dart
@@ -1,9 +1,12 @@
-import 'package:flutter/material.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/pages/settings/settings.dart';
import 'package:spotube/provider/authentication/authentication.dart';
+import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/service_utils.dart';
class AnonymousFallback extends ConsumerWidget {
@@ -25,10 +28,17 @@ class AnonymousFallback extends ConsumerWidget {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
+ spacing: 10,
children: [
+ Undraw(
+ illustration: kIsMobile
+ ? UndrawIllustration.accessDenied
+ : UndrawIllustration.secureLogin,
+ height: 200 * context.theme.scaling,
+ color: context.theme.colorScheme.primary,
+ ),
Text(context.l10n.not_logged_in),
- const SizedBox(height: 10),
- FilledButton(
+ Button.primary(
child: Text(context.l10n.login_with_spotify),
onPressed: () => ServiceUtils.pushNamed(context, SettingsPage.name),
)
diff --git a/lib/components/fallbacks/not_found.dart b/lib/components/fallbacks/not_found.dart
index ce168f17..9a994446 100644
--- a/lib/components/fallbacks/not_found.dart
+++ b/lib/components/fallbacks/not_found.dart
@@ -1,32 +1,27 @@
-import 'package:flutter/material.dart';
-import 'package:spotube/collections/assets.gen.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';
class NotFound extends StatelessWidget {
- final bool vertical;
- const NotFound({super.key, this.vertical = false});
+ const NotFound({super.key});
@override
Widget build(BuildContext context) {
- final theme = Theme.of(context);
- final widgets = [
- SizedBox(
- height: 150,
- width: 150,
- child: Assets.emptyBox.image(),
- ),
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Text(context.l10n.nothing_found, style: theme.textTheme.titleLarge),
- Text(
- context.l10n.the_box_is_empty,
- style: theme.textTheme.titleMedium,
- ),
- ],
- ),
- ];
- return vertical ? Column(children: widgets) : Row(children: widgets);
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Undraw(
+ illustration: UndrawIllustration.empty,
+ height: 200 * context.theme.scaling,
+ color: context.theme.colorScheme.primary,
+ ),
+ const Gap(10),
+ Text(
+ context.l10n.nothing_found,
+ textAlign: TextAlign.center,
+ ).muted().small()
+ ],
+ );
}
}
diff --git a/lib/components/form/checkbox_form_field.dart b/lib/components/form/checkbox_form_field.dart
new file mode 100644
index 00000000..0e794833
--- /dev/null
+++ b/lib/components/form/checkbox_form_field.dart
@@ -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? validator;
+
+ final ValueChanged? 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(
+ 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,
+ );
+ },
+ );
+ }
+}
diff --git a/lib/components/form/text_form_field.dart b/lib/components/form/text_form_field.dart
new file mode 100644
index 00000000..ef3514c5
--- /dev/null
+++ b/lib/components/form/text_form_field.dart
@@ -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? 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? 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? onChanged;
+ final Iterable? autofillHints;
+ final void Function(PointerDownEvent event)? onTapOutside;
+ final List? 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(
+ 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,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/components/framework/app_pop_scope.dart b/lib/components/framework/app_pop_scope.dart
index b8e35767..fe923958 100644
--- a/lib/components/framework/app_pop_scope.dart
+++ b/lib/components/framework/app_pop_scope.dart
@@ -1,6 +1,6 @@
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
/// https://github.com/flutter/flutter/issues/140869#issuecomment-2247181468
diff --git a/lib/components/heart_button/heart_button.dart b/lib/components/heart_button/heart_button.dart
index fa4318cc..56cb22ab 100644
--- a/lib/components/heart_button/heart_button.dart
+++ b/lib/components/heart_button/heart_button.dart
@@ -1,5 +1,5 @@
-import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/heart_button/use_track_toggle_like.dart';
@@ -13,12 +13,16 @@ class HeartButton extends HookConsumerWidget {
final IconData? icon;
final Color? color;
final String? tooltip;
+ final ButtonVariance variance;
+ final ButtonSize size;
const HeartButton({
required this.isLiked,
required this.onPressed,
this.color,
this.tooltip,
this.icon,
+ this.variance = ButtonVariance.ghost,
+ this.size = ButtonSize.normal,
super.key,
});
@@ -28,28 +32,32 @@ class HeartButton extends HookConsumerWidget {
if (auth.asData?.value == null) return const SizedBox.shrink();
- return IconButton(
- tooltip: tooltip,
- icon: AnimatedSwitcher(
- switchInCurve: Curves.fastOutSlowIn,
- switchOutCurve: Curves.fastOutSlowIn,
- duration: const Duration(milliseconds: 300),
- transitionBuilder: (child, animation) {
- return ScaleTransition(
- scale: animation,
- child: child,
- );
- },
- child: Icon(
- icon ??
- (isLiked
- ? Icons.favorite_rounded
- : Icons.favorite_outline_rounded),
- key: ValueKey(isLiked),
- color: color ?? (isLiked ? color ?? Colors.red : null),
+ return Tooltip(
+ tooltip: TooltipContainer(child: Text(tooltip ?? "")),
+ child: IconButton(
+ variance: variance,
+ size: size,
+ icon: AnimatedSwitcher(
+ switchInCurve: Curves.fastOutSlowIn,
+ switchOutCurve: Curves.fastOutSlowIn,
+ duration: const Duration(milliseconds: 300),
+ transitionBuilder: (child, animation) {
+ return ScaleTransition(
+ scale: animation,
+ child: child,
+ );
+ },
+ child: Icon(
+ icon ??
+ (isLiked
+ ? Icons.favorite_rounded
+ : Icons.favorite_outline_rounded),
+ key: ValueKey(isLiked),
+ color: color ?? (isLiked ? color ?? Colors.red : null),
+ ),
),
+ onPressed: onPressed,
),
- onPressed: onPressed,
);
}
}
diff --git a/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart
index 16204952..47fb0f33 100644
--- a/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart
+++ b/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart
@@ -1,14 +1,14 @@
import 'dart:ui';
-import 'package:flutter/material.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:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/modules/album/album_card.dart';
import 'package:spotube/modules/artist/artist_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';
class HorizontalPlaybuttonCardView extends HookWidget {
@@ -36,14 +36,9 @@ class HorizontalPlaybuttonCardView extends HookWidget {
@override
Widget build(BuildContext context) {
- final ThemeData(:textTheme) = Theme.of(context);
final scrollController = useScrollController();
- final height = useBreakpointValue(
- xs: 226,
- sm: 226,
- md: 236,
- others: 266,
- );
+ final isArtist = items.every((s) => s is Artist);
+ final scale = context.theme.scaling;
return Padding(
padding: const EdgeInsets.all(8.0),
@@ -54,15 +49,21 @@ class HorizontalPlaybuttonCardView extends HookWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- DefaultTextStyle(
- style: textTheme.titleMedium!,
- child: title,
+ Flexible(
+ child: DefaultTextStyle(
+ style: context.theme.typography.h4.copyWith(
+ color: context.theme.colorScheme.foreground,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ child: title,
+ ),
),
if (titleTrailing != null) titleTrailing!,
],
),
SizedBox(
- height: height,
+ height: isArtist ? 250 : 225,
child: NotificationListener(
// disable multiple scrollbar to use this
onNotification: (notification) => true,
@@ -86,10 +87,13 @@ class HorizontalPlaybuttonCardView extends HookWidget {
onFetchData: onFetchMore,
loadingBuilder: (context) => Skeletonizer(
enabled: true,
- child: AlbumCard(FakeData.albumSimple),
+ child: isArtist
+ ? ArtistCard(FakeData.artist)
+ : AlbumCard(FakeData.albumSimple),
),
isLoading: isLoadingNextPage,
hasReachedMax: !hasNextPage,
+ separatorBuilder: (context, index) => Gap(12 * scale),
itemBuilder: (context, index) {
final item = items[index];
@@ -97,11 +101,7 @@ class HorizontalPlaybuttonCardView extends HookWidget {
PlaylistSimple() =>
PlaylistCard(item as PlaylistSimple),
AlbumSimple() => AlbumCard(item as AlbumSimple),
- Artist() => Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: 12.0),
- child: ArtistCard(item as Artist),
- ),
+ Artist() => ArtistCard(item as Artist),
_ => const SizedBox.shrink(),
};
}),
diff --git a/lib/components/inter_scrollbar/inter_scrollbar.dart b/lib/components/inter_scrollbar/inter_scrollbar.dart
index 8a86b643..415ba6da 100644
--- a/lib/components/inter_scrollbar/inter_scrollbar.dart
+++ b/lib/components/inter_scrollbar/inter_scrollbar.dart
@@ -1,5 +1,5 @@
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:spotube/utils/platform.dart';
diff --git a/lib/components/links/anchor_button.dart b/lib/components/links/anchor_button.dart
index c6f0b889..a0b3fa73 100644
--- a/lib/components/links/anchor_button.dart
+++ b/lib/components/links/anchor_button.dart
@@ -1,5 +1,5 @@
-import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart';
class AnchorButton extends HookWidget {
final String text;
diff --git a/lib/components/links/artist_link.dart b/lib/components/links/artist_link.dart
index 9f06f1b3..c6ea5c14 100644
--- a/lib/components/links/artist_link.dart
+++ b/lib/components/links/artist_link.dart
@@ -1,4 +1,4 @@
-import 'package:flutter/material.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/links/anchor_button.dart';
import 'package:spotube/extensions/context.dart';
diff --git a/lib/components/links/hyper_link.dart b/lib/components/links/hyper_link.dart
index 32d715e0..647edaca 100644
--- a/lib/components/links/hyper_link.dart
+++ b/lib/components/links/hyper_link.dart
@@ -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:url_launcher/url_launcher_string.dart';
diff --git a/lib/components/links/link_text.dart b/lib/components/links/link_text.dart
index 0cab71d0..a54c8b9f 100644
--- a/lib/components/links/link_text.dart
+++ b/lib/components/links/link_text.dart
@@ -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/utils/service_utils.dart';
diff --git a/lib/components/panels/controller.dart b/lib/components/panels/controller.dart
deleted file mode 100644
index 4e367701..00000000
--- a/lib/components/panels/controller.dart
+++ /dev/null
@@ -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 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 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 open() async {
- assert(isAttached, "PanelController must be attached to a SlidingUpPanel");
- await _panelState!._open();
- notifyListeners();
- }
-
- /// Hides the sliding panel (i.e. is invisible)
- Future 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 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 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 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;
- }
-}
diff --git a/lib/components/panels/helpers.dart b/lib/components/panels/helpers.dart
deleted file mode 100644
index d79fa97c..00000000
--- a/lib/components/panels/helpers.dart
+++ /dev/null
@@ -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;
-}
diff --git a/lib/components/panels/sliding_up_panel.dart b/lib/components/panels/sliding_up_panel.dart
deleted file mode 100644
index e99fe261..00000000
--- a/lib/components/panels/sliding_up_panel.dart
+++ /dev/null
@@ -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
- 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: [
- //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: [
- //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 _close() {
- return _animationController.fling(velocity: -1.0);
- }
-
- //open the panel
- Future _open() {
- return _animationController.fling(velocity: 1.0);
- }
-
- //hide the panel (completely offscreen)
- Future _hide() {
- return _animationController.fling(velocity: -1.0).then((x) {
- setState(() {
- _isPanelVisible = false;
- });
- });
- }
-
- //show the panel (in collapsed mode)
- Future _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 _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 _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;
-}
diff --git a/lib/components/playbutton_card.dart b/lib/components/playbutton_card.dart
deleted file mode 100644
index ae9050d8..00000000
--- a/lib/components/playbutton_card.dart
+++ /dev/null
@@ -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(
- xs: 130,
- sm: 130,
- md: 150,
- others: 170,
- );
-
- final end = useBreakpointValue(
- 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),
- ],
- ),
- ],
- ),
- ),
- ),
- );
- }
-}
diff --git a/lib/components/playbutton_view/playbutton_card.dart b/lib/components/playbutton_view/playbutton_card.dart
new file mode 100644
index 00000000..05efef38
--- /dev/null
+++ b/lib/components/playbutton_view/playbutton_card.dart
@@ -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,
+ ),
+ );
+ }
+}
diff --git a/lib/components/playbutton_view/playbutton_tile.dart b/lib/components/playbutton_view/playbutton_tile.dart
new file mode 100644
index 00000000..ec1ca95f
--- /dev/null
+++ b/lib/components/playbutton_view/playbutton_tile.dart
@@ -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(),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/components/playbutton_view/playbutton_view.dart b/lib/components/playbutton_view/playbutton_view.dart
new file mode 100644
index 00000000..46e67e25
--- /dev/null
+++ b/lib/components/playbutton_view/playbutton_view.dart
@@ -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()
+ ],
+ );
+ },
+ ),
+ }
+ ],
+ );
+ }),
+ );
+ }
+}
diff --git a/lib/components/shimmers/shimmer_lyrics.dart b/lib/components/shimmers/shimmer_lyrics.dart
index 03816202..f8d29722 100644
--- a/lib/components/shimmers/shimmer_lyrics.dart
+++ b/lib/components/shimmers/shimmer_lyrics.dart
@@ -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:gap/gap.dart';
diff --git a/lib/components/sort_tracks_dropdown.dart b/lib/components/sort_tracks_dropdown.dart
deleted file mode 100644
index 16727013..00000000
--- a/lib/components/sort_tracks_dropdown.dart
+++ /dev/null
@@ -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(
- 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),
- ],
- ),
- ),
- ),
- ),
- );
- }
-}
diff --git a/lib/components/spotube_page_route.dart b/lib/components/spotube_page_route.dart
index 22e4d2f1..6d152dd5 100644
--- a/lib/components/spotube_page_route.dart
+++ b/lib/components/spotube_page_route.dart
@@ -1,4 +1,4 @@
-import 'package:flutter/material.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:go_router/go_router.dart';
class SpotubePage extends MaterialPage {
diff --git a/lib/components/themed_button_tab_bar.dart b/lib/components/themed_button_tab_bar.dart
deleted file mode 100644
index c245e5f4..00000000
--- a/lib/components/themed_button_tab_bar.dart
+++ /dev/null
@@ -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 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);
-}
diff --git a/lib/components/titlebar/mouse_state.dart b/lib/components/titlebar/mouse_state.dart
deleted file mode 100644
index 9af2a8b0..00000000
--- a/lib/components/titlebar/mouse_state.dart
+++ /dev/null
@@ -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? 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 {
- 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),
- ),
- );
- }
-}
diff --git a/lib/components/titlebar/titlebar.dart b/lib/components/titlebar/titlebar.dart
index 76a5ec8a..5b86f6ad 100644
--- a/lib/components/titlebar/titlebar.dart
+++ b/lib/components/titlebar/titlebar.dart
@@ -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: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/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/utils/platform.dart';
-
import 'package:window_manager/window_manager.dart';
-class PageWindowTitleBar extends StatefulHookConsumerWidget
- implements PreferredSizeWidget {
- final Widget? leading;
+final kTitlebarVisible = kIsWindows || kIsLinux;
+
+class TitleBar extends HookConsumerWidget implements PreferredSizeWidget {
final bool automaticallyImplyLeading;
- final List? actions;
+ final List trailing;
+ final List 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? foregroundColor;
- final IconThemeData? actionsIconTheme;
- final bool? centerTitle;
- final double? titleSpacing;
- final double toolbarOpacity;
- final double? leadingWidth;
- final TextStyle? toolbarTextStyle;
- final TextStyle? titleTextStyle;
- final double? titleWidth;
- final Widget? title;
+ final double? leadingGap;
+ final double? trailingGap;
+ final EdgeInsetsGeometry? padding;
+ final double? height;
+ final bool useSafeArea;
+ final double? surfaceBlur;
+ final double? surfaceOpacity;
- final bool _sliver;
-
- const PageWindowTitleBar({
+ const TitleBar({
super.key,
- this.actions,
+ this.automaticallyImplyLeading = true,
+ this.trailing = const [],
+ this.leading = const [],
this.title,
- this.toolbarOpacity = 1,
+ this.header,
+ this.subtitle,
+ this.child,
+ this.trailingExpanded = false,
+ this.alignment = Alignment.center,
+ this.padding,
this.backgroundColor,
- this.actionsIconTheme,
- this.automaticallyImplyLeading = false,
- this.centerTitle,
this.foregroundColor,
- this.leading,
- this.leadingWidth,
- this.titleSpacing,
- this.titleTextStyle,
- this.titleWidth,
- this.toolbarTextStyle,
- }) : _sliver = false,
- pinned = false,
- floating = false,
- snap = false,
- stretch = false;
+ this.leadingGap,
+ this.trailingGap,
+ this.height,
+ this.surfaceBlur,
+ this.surfaceOpacity,
+ this.useSafeArea = false,
+ });
- final bool pinned;
- 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 createState() => _PageWindowTitleBarState();
-}
-
-class _PageWindowTitleBarState extends ConsumerState {
- void onDrag(details) {
+ void onDrag(WidgetRef ref) {
final systemTitleBar =
ref.read(userPreferencesProvider.select((s) => s.systemTitleBar));
if (kIsDesktop && !systemTitleBar) {
@@ -91,89 +62,75 @@ class _PageWindowTitleBarState extends ConsumerState {
}
@override
- Widget build(BuildContext context) {
- final mediaQuery = MediaQuery.of(context);
+ Widget build(BuildContext context, ref) {
+ final hasLeadingOrCanPop = leading.isNotEmpty || Navigator.canPop(context);
+ final lastClicked = useRef(DateTime.now().millisecondsSinceEpoch);
- if (widget._sliver) {
- return SliverLayoutBuilder(
+ return SizedBox(
+ height: height ?? (48 * context.theme.scaling),
+ child: LayoutBuilder(
builder: (context, constraints) {
final hasFullscreen =
- mediaQuery.size.width == constraints.crossAxisExtent;
- final hasLeadingOrCanPop =
- widget.leading != null || Navigator.canPop(context);
+ MediaQuery.sizeOf(context).width == constraints.maxWidth;
- return SliverPadding(
- padding: EdgeInsets.only(
- left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0,
- ),
- sliver: SliverAppBar(
- leading: widget.leading,
- automaticallyImplyLeading: widget.automaticallyImplyLeading,
- actions: [
- ...?widget.actions,
- WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
+ return GestureDetector(
+ onHorizontalDragStart: (_) => onDrag(ref),
+ onVerticalDragStart: (_) => onDrag(ref),
+ onTapDown: (details) async {
+ final systemTitlebar = ref.read(
+ userPreferencesProvider.select((s) => s.systemTitleBar));
+ if (!kIsDesktop || systemTitlebar) return;
+
+ int currMills = DateTime.now().millisecondsSinceEpoch;
+
+ 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,
- foregroundColor: widget.foregroundColor,
- actionsIconTheme: widget.actionsIconTheme,
- centerTitle: widget.centerTitle,
- titleSpacing: widget.titleSpacing,
- leadingWidth: widget.leadingWidth,
- toolbarTextStyle: widget.toolbarTextStyle,
- titleTextStyle: widget.titleTextStyle,
- title: SizedBox(
- width: double.infinity, // workaround to force dragging
- child: widget.title ?? const Text(""),
- ),
- pinned: widget.pinned,
- floating: widget.floating,
- snap: widget.snap,
- stretch: widget.stretch,
- ),
+ title: title,
+ header: header,
+ subtitle: subtitle,
+ trailingExpanded: trailingExpanded,
+ alignment: alignment,
+ padding: padding ?? EdgeInsets.zero,
+ backgroundColor: backgroundColor,
+ leadingGap: leadingGap,
+ trailingGap: trailingGap,
+ height: height ?? (48 * context.theme.scaling),
+ surfaceBlur: surfaceBlur,
+ surfaceOpacity: surfaceOpacity,
+ useSafeArea: useSafeArea,
+ child: child,
+ ).withPadding(
+ 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);
}
diff --git a/lib/components/titlebar/titlebar_buttons.dart b/lib/components/titlebar/titlebar_buttons.dart
index 35cdf08e..30d88508 100644
--- a/lib/components/titlebar/titlebar_buttons.dart
+++ b/lib/components/titlebar/titlebar_buttons.dart
@@ -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: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/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/utils/platform.dart';
import 'package:titlebar_buttons/titlebar_buttons.dart';
@@ -25,6 +29,15 @@ class WindowTitleBarButtons extends HookConsumerWidget {
await windowManager.close();
}
+ useWindowListener(
+ onWindowMaximize: () {
+ isMaximized.value = true;
+ },
+ onWindowUnmaximize: () {
+ isMaximized.value = false;
+ },
+ );
+
useEffect(() {
if (kIsDesktop) {
windowManager.isMaximized().then((value) {
@@ -34,91 +47,73 @@ class WindowTitleBarButtons extends HookConsumerWidget {
return null;
}, []);
- if (!kIsDesktop || kIsMacOS || preferences.systemTitleBar) {
+ if (!kTitlebarVisible || preferences.systemTitleBar) {
return const SizedBox.shrink();
}
if (kIsWindows) {
- final theme = Theme.of(context);
- final colors = WindowButtonColors(
- normal: Colors.transparent,
- iconNormal: foregroundColor ?? theme.colorScheme.onSurface,
- mouseOver: theme.colorScheme.onSurface.withOpacity(0.1),
- mouseDown: theme.colorScheme.onSurface.withOpacity(0.2),
- iconMouseOver: theme.colorScheme.onSurface,
- iconMouseDown: theme.colorScheme.onSurface,
- );
-
- final closeColors = WindowButtonColors(
- normal: Colors.transparent,
- iconNormal: foregroundColor ?? theme.colorScheme.onSurface,
- mouseOver: Colors.red,
- mouseDown: Colors.red[800]!,
- iconMouseOver: Colors.white,
- iconMouseDown: Colors.black,
- );
-
- return Padding(
- padding: const EdgeInsets.only(bottom: 25),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- MinimizeWindowButton(
- onPressed: windowManager.minimize,
- colors: colors,
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ ShadcnWindowButton(
+ icon: MinimizeIcon(color: context.theme.colorScheme.foreground),
+ onPressed: windowManager.minimize,
+ ),
+ if (isMaximized.value != true)
+ ShadcnWindowButton(
+ icon: MaximizeIcon(color: context.theme.colorScheme.foreground),
+ onPressed: () {
+ windowManager.maximize();
+ isMaximized.value = true;
+ },
+ )
+ else
+ ShadcnWindowButton(
+ icon: RestoreIcon(color: context.theme.colorScheme.foreground),
+ onPressed: () {
+ windowManager.unmaximize();
+ isMaximized.value = false;
+ },
),
- if (isMaximized.value != true)
- MaximizeWindowButton(
- colors: colors,
- onPressed: () {
- windowManager.maximize();
- isMaximized.value = true;
- },
- )
- else
- RestoreWindowButton(
- colors: colors,
- onPressed: () {
- windowManager.unmaximize();
- isMaximized.value = false;
- },
+ HoverBuilder(builder: (context, isHovered) {
+ return ShadcnWindowButton(
+ icon: CloseIcon(
+ color: isHovered
+ ? Colors.white
+ : context.theme.colorScheme.foreground,
),
- CloseWindowButton(
- colors: closeColors,
onPressed: onClose,
- ),
- ],
- ),
+ hoverBackgroundColor: const Color(0xFFD32F2F),
+ );
+ }),
+ ],
);
}
- return Padding(
- padding: const EdgeInsets.only(bottom: 20, left: 10),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- DecoratedMinimizeButton(
- type: type,
- onPressed: windowManager.minimize,
- ),
- DecoratedMaximizeButton(
- type: type,
- onPressed: () async {
- if (await windowManager.isMaximized()) {
- await windowManager.unmaximize();
- isMaximized.value = false;
- } else {
- await windowManager.maximize();
- isMaximized.value = true;
- }
- },
- ),
- DecoratedCloseButton(
- type: type,
- onPressed: onClose,
- ),
- ],
- ),
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ DecoratedMinimizeButton(
+ type: type,
+ onPressed: windowManager.minimize,
+ ),
+ DecoratedMaximizeButton(
+ type: type,
+ onPressed: () async {
+ if (await windowManager.isMaximized()) {
+ await windowManager.unmaximize();
+ isMaximized.value = false;
+ } else {
+ await windowManager.maximize();
+ isMaximized.value = true;
+ }
+ },
+ ),
+ DecoratedCloseButton(
+ type: type,
+ onPressed: onClose,
+ ),
+ ],
);
}
}
diff --git a/lib/components/titlebar/titlebar_icon_buttons.dart b/lib/components/titlebar/titlebar_icon_buttons.dart
index 70170262..481a22ce 100644
--- a/lib/components/titlebar/titlebar_icon_buttons.dart
+++ b/lib/components/titlebar/titlebar_icon_buttons.dart
@@ -1,56 +1,50 @@
import 'dart:math';
-import 'package:flutter/material.dart';
-import 'package:spotube/components/titlebar/window_button.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart';
+import 'package:spotube/extensions/button_variance.dart';
-class MinimizeWindowButton extends WindowButton {
- MinimizeWindowButton(
- {super.key, super.colors, super.onPressed, bool? animate})
- : super(
- animate: animate ?? false,
- iconBuilder: (buttonContext) =>
- MinimizeIcon(color: buttonContext.iconColor),
- );
+class ShadcnWindowButton extends StatelessWidget {
+ final Widget icon;
+ final VoidCallback onPressed;
+ final Color? hoverBackgroundColor;
+
+ const ShadcnWindowButton({
+ 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
class CloseIcon extends StatelessWidget {
final Color color;
@@ -149,8 +143,9 @@ class _AlignedPaint extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Align(
- alignment: Alignment.center,
- child: CustomPaint(size: const Size(10, 10), painter: painter));
+ alignment: Alignment.center,
+ child: CustomPaint(size: const Size(10, 10), painter: painter),
+ );
}
}
diff --git a/lib/components/titlebar/window_button.dart b/lib/components/titlebar/window_button.dart
deleted file mode 100644
index 3201d191..00000000
--- a/lib/components/titlebar/window_button.dart
+++ /dev/null
@@ -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!();
- },
- );
- }
-}
diff --git a/lib/components/track_presentation/presentation_actions.dart b/lib/components/track_presentation/presentation_actions.dart
new file mode 100644
index 00000000..01228524
--- /dev/null
+++ b/lib/components/track_presentation/presentation_actions.dart
@@ -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(
+ 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(
+ 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),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/components/track_presentation/presentation_list.dart b/lib/components/track_presentation/presentation_list.dart
new file mode 100644
index 00000000..dda7dffa
--- /dev/null
+++ b/lib/components/track_presentation/presentation_list.dart
@@ -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();
+ },
+ );
+ },
+ );
+ }
+}
diff --git a/lib/components/track_presentation/presentation_modifiers.dart b/lib/components/track_presentation/presentation_modifiers.dart
new file mode 100644
index 00000000..4d781d24
--- /dev/null
+++ b/lib/components/track_presentation/presentation_modifiers.dart
@@ -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(),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ });
+ }
+}
diff --git a/lib/components/tracks_view/track_view_props.dart b/lib/components/track_presentation/presentation_props.dart
similarity index 60%
rename from lib/components/tracks_view/track_view_props.dart
rename to lib/components/track_presentation/presentation_props.dart
index b0a00ae2..144cf0e8 100644
--- a/lib/components/tracks_view/track_view_props.dart
+++ b/lib/components/track_presentation/presentation_props.dart
@@ -1,6 +1,6 @@
import 'dart:async';
-import 'package:flutter/material.dart' hide Page;
+import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
class PaginationProps {
@@ -38,31 +38,33 @@ class PaginationProps {
onRefresh.hashCode;
}
-class InheritedTrackView extends InheritedWidget {
+class TrackPresentationOptions {
final Object collection;
final String title;
final String? description;
+ final String? owner;
+ final String? ownerImage;
final String image;
final String routePath;
final List