From b4bbc072e482122484abd9ec293e7d79dd5c4642 Mon Sep 17 00:00:00 2001 From: aidewoode Date: Wed, 12 Jun 2024 16:54:41 +0800 Subject: [PATCH 01/24] Migrate build configuration from Groovy to Kotlin and upgrade Kotlin to 2.0 --- .gitignore | 3 +- app/build.gradle | 94 -------------------------- app/build.gradle.kts | 91 +++++++++++++++++++++++++ app/proguard-rules.pro | 2 +- build.gradle | 11 --- build.gradle.kts | 7 ++ fastlane/actions/get_version_name.rb | 4 +- settings.gradle => settings.gradle.kts | 2 +- 8 files changed, 104 insertions(+), 110 deletions(-) delete mode 100644 app/build.gradle create mode 100644 app/build.gradle.kts delete mode 100644 build.gradle create mode 100644 build.gradle.kts rename settings.gradle => settings.gradle.kts (95%) diff --git a/.gitignore b/.gitignore index 832f622..0896ce2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ local.properties .env .idea /app/release/ -/fastlane/report.xml \ No newline at end of file +/fastlane/report.xml +.kotlin \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index e1e090a..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,94 +0,0 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' - id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.10' -} - -android { - namespace 'org.blackcandy.android' - compileSdk 34 - - defaultConfig { - applicationId "org.blackcandy.android" - minSdk 26 - targetSdk 34 - versionCode 3 - versionName "1.0.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - lint { - abortOnError true - } - - buildTypes { - release { - shrinkResources true - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - - buildFeatures { - viewBinding = true - compose = true - } - - composeOptions { - kotlinCompilerExtensionVersion "$compose_compiler_version" - } -} - -dependencies { - def ktor_version = "2.3.4" - def media3_version = "1.3.1" - - implementation platform('androidx.compose:compose-bom:2024.05.00') - implementation "androidx.activity:activity-compose:1.9.0" - implementation "androidx.compose.material3:material3:1.2.1" - implementation 'androidx.compose.material3:material3-window-size-class-android:1.2.1' - implementation "androidx.compose.ui:ui-tooling" - implementation 'androidx.compose.ui:ui:1.6.7' - implementation "androidx.compose.ui:ui-tooling-preview" - implementation 'androidx.window:window:1.2.0' - implementation 'androidx.window:window-core:1.2.0' - implementation 'androidx.navigation:navigation-compose:2.7.7' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' - implementation 'androidx.core:core-ktx:1.13.1' - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation "androidx.security:security-crypto:1.0.0" - implementation "androidx.datastore:datastore-preferences:1.1.1" - - implementation "androidx.media3:media3-exoplayer:$media3_version" - implementation "androidx.media3:media3-session:$media3_version" - implementation "androidx.media3:media3-datasource-okhttp:$media3_version" - - implementation 'com.google.android.material:material:1.12.0' - implementation 'com.google.accompanist:accompanist-themeadapter-material3:0.30.1' - - implementation 'dev.hotwire:turbo:7.0.3' - implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1' - implementation "io.insert-koin:koin-androidx-compose:3.5.0" - implementation "io.ktor:ktor-client-core:$ktor_version" - implementation "io.ktor:ktor-client-okhttp:$ktor_version" - implementation "io.ktor:ktor-client-content-negotiation:$ktor_version" - implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version" - implementation "io.ktor:ktor-client-auth:$ktor_version" - implementation "io.coil-kt:coil-compose:2.5.0" - implementation "sh.calvin.reorderable:reorderable:1.3.3" - - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..c0fb749 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,91 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.0" +} + +android { + namespace = "org.blackcandy.android" + compileSdk = 34 + + defaultConfig { + applicationId = "org.blackcandy.android" + minSdk = 26 + targetSdk = 34 + versionCode = 3 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + lint { + abortOnError = true + } + + buildTypes { + getByName("release") { + isShrinkResources = true + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + buildFeatures { + viewBinding = true + compose = true + } +} + +dependencies { + val ktorVersion = "2.3.4" + val media3Version = "1.3.1" + + implementation(platform("androidx.compose:compose-bom:2024.05.00")) + implementation("androidx.activity:activity-compose:1.9.0") + implementation("androidx.compose.material3:material3:1.2.1") + implementation("androidx.compose.material3:material3-window-size-class-android:1.2.1") + implementation("androidx.compose.ui:ui-tooling") + implementation("androidx.compose.ui:ui:1.6.7") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.window:window:1.2.0") + implementation("androidx.window:window-core:1.2.0") + implementation("androidx.navigation:navigation-compose:2.7.7") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.security:security-crypto:1.0.0") + implementation("androidx.datastore:datastore-preferences:1.1.1") + + implementation("androidx.media3:media3-exoplayer:$media3Version") + implementation("androidx.media3:media3-session:$media3Version") + implementation("androidx.media3:media3-datasource-okhttp:$media3Version") + + implementation("com.google.android.material:material:1.12.0") + implementation("com.google.accompanist:accompanist-themeadapter-material3:0.30.1") + + implementation("dev.hotwire:turbo:7.0.3") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1") + implementation("io.insert-koin:koin-androidx-compose:3.5.0") + implementation("io.ktor:ktor-client-core:$ktorVersion") + implementation("io.ktor:ktor-client-okhttp:$ktorVersion") + implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") + implementation("io.ktor:ktor-client-auth:$ktorVersion") + implementation("io.coil-kt:coil-compose:2.5.0") + implementation("sh.calvin.reorderable:reorderable:1.3.3") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 3ed4389..9f5f6c5 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,6 +1,6 @@ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. +# proguardFiles setting in build.gradle.kts. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 7603603..0000000 --- a/build.gradle +++ /dev/null @@ -1,11 +0,0 @@ -buildscript { - ext { - compose_compiler_version = '1.4.0' - } -} -// Top-level build file where you can add configuration options common to all sub-projects/modules. -plugins { - id 'com.android.application' version '8.1.2' apply false - id 'com.android.library' version '8.1.2' apply false - id 'org.jetbrains.kotlin.android' version '1.8.0' apply false -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..94dba04 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,7 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id("com.android.application") version "8.1.2" apply false + id("com.android.library") version "8.1.2" apply false + id("org.jetbrains.kotlin.android") version "2.0.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" apply false +} diff --git a/fastlane/actions/get_version_name.rb b/fastlane/actions/get_version_name.rb index 749717e..e92fff1 100644 --- a/fastlane/actions/get_version_name.rb +++ b/fastlane/actions/get_version_name.rb @@ -2,9 +2,9 @@ module Fastlane module Actions class GetVersionNameAction < Action def self.run(params) - File.open("app/build.gradle", "r") do |file| + File.open("app/build.gradle.kts", "r") do |file| version_name_line = file.find { |line| line.include?("versionName") } - matched_data = version_name_line&.match(/versionName\s+"(.+)"/) + matched_data = version_name_line&.match(/versionName\s+=\s+"(.+)"/) matched_data[1] if matched_data end diff --git a/settings.gradle b/settings.gradle.kts similarity index 95% rename from settings.gradle rename to settings.gradle.kts index 79e7716..ac1c95d 100644 --- a/settings.gradle +++ b/settings.gradle.kts @@ -13,4 +13,4 @@ dependencyResolutionManagement { } } rootProject.name = "BlackCandy" -include ':app' +include(":app") From 14a932036d75e2193dc6f6ed05f49b5540ce2013 Mon Sep 17 00:00:00 2001 From: aidewoode Date: Thu, 13 Jun 2024 15:52:33 +0800 Subject: [PATCH 02/24] Migrate to version catalogs --- app/build.gradle.kts | 79 ++++++++++++------------ build.gradle.kts | 8 +-- gradle/libs.versions.toml | 68 ++++++++++++++++++++ gradle/wrapper/gradle-wrapper.properties | 2 +- 4 files changed, 111 insertions(+), 46 deletions(-) create mode 100644 gradle/libs.versions.toml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c0fb749..6553a18 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,8 +1,8 @@ plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") - id("org.jetbrains.kotlin.plugin.compose") - id("org.jetbrains.kotlin.plugin.serialization") version "2.0.0" + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) } android { @@ -47,45 +47,42 @@ android { } dependencies { - val ktorVersion = "2.3.4" - val media3Version = "1.3.1" + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.windowSizeClass) + implementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.window) + implementation(libs.androidx.window.core) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.security.crypto) + implementation(libs.androidx.datastore.preferences) - implementation(platform("androidx.compose:compose-bom:2024.05.00")) - implementation("androidx.activity:activity-compose:1.9.0") - implementation("androidx.compose.material3:material3:1.2.1") - implementation("androidx.compose.material3:material3-window-size-class-android:1.2.1") - implementation("androidx.compose.ui:ui-tooling") - implementation("androidx.compose.ui:ui:1.6.7") - implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.window:window:1.2.0") - implementation("androidx.window:window-core:1.2.0") - implementation("androidx.navigation:navigation-compose:2.7.7") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") - implementation("androidx.core:core-ktx:1.13.1") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.security:security-crypto:1.0.0") - implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.session) + implementation(libs.androidx.media3.datasource.okhttp) - implementation("androidx.media3:media3-exoplayer:$media3Version") - implementation("androidx.media3:media3-session:$media3Version") - implementation("androidx.media3:media3-datasource-okhttp:$media3Version") + implementation(libs.google.material) + implementation(libs.google.accompanist.themeadapter.material3) - implementation("com.google.android.material:material:1.12.0") - implementation("com.google.accompanist:accompanist-themeadapter-material3:0.30.1") + implementation(libs.turbo) + implementation(libs.kotlinx.serialization.json) + implementation(libs.koin.androidx.compose) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.auth) + implementation(libs.coil.compose) + implementation(libs.reorderable) - implementation("dev.hotwire:turbo:7.0.3") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1") - implementation("io.insert-koin:koin-androidx-compose:3.5.0") - implementation("io.ktor:ktor-client-core:$ktorVersion") - implementation("io.ktor:ktor-client-okhttp:$ktorVersion") - implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") - implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") - implementation("io.ktor:ktor-client-auth:$ktorVersion") - implementation("io.coil-kt:coil-compose:2.5.0") - implementation("sh.calvin.reorderable:reorderable:1.3.3") - - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) } diff --git a/build.gradle.kts b/build.gradle.kts index 94dba04..5ca1837 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id("com.android.application") version "8.1.2" apply false - id("com.android.library") version "8.1.2" apply false - id("org.jetbrains.kotlin.android") version "2.0.0" apply false - id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..36b2a90 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,68 @@ +[versions] +androidxActivity = "1.9.0" +androidxWindow = "1.2.0" +androidxNavigationCompose = "2.7.7" +androidxComposeBom = "2024.05.00" +androidxLifecycleRuntimeKtx = "2.7.0" +androidxCore = "1.13.1" +androidxAppcompat = "1.6.1" +androidxConstraintlayout = "2.1.4" +androidxSecurityCrypto = "1.0.0" +androidxDatastorePreferences = "1.1.1" +androidxJunit = "1.1.5" +androidxEspresso = "3.5.1" +coilCompose = "2.5.0" +googleAccompanistThemeadapterMaterial3 = "0.30.1" +googleMaterial = "1.12.0" +junit = "4.13.2" +koinAndroidxCompose = "3.5.0" +kotlinxSerializationJson = "1.5.1" +ktor = "2.3.4" +media3 = "1.3.1" +reorderable = "1.3.3" +turbo = "7.0.3" +kotlin = "2.0.0" +androidGradlePlugin = "8.4.2" + +[libraries] +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3-windowSizeClass = { module = "androidx.compose.material3:material3-window-size-class-android" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidxConstraintlayout" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDatastorePreferences" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycleRuntimeKtx" } +androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" } +androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigationCompose" } +androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "androidxSecurityCrypto" } +androidx-window = { group = "androidx.window", name = "window", version.ref = "androidxWindow" } +androidx-window-core = { group = "androidx.window", name = "window-core", version.ref = "androidxWindow" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" } +google-accompanist-themeadapter-material3 = { group = "com.google.accompanist", name = "accompanist-themeadapter-material3", version.ref = "googleAccompanistThemeadapterMaterial3" } +google-material = { group = "com.google.android.material", name = "material", version.ref = "googleMaterial" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koinAndroidxCompose" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" } +ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { group = "io.ktor", name ="ktor-serialization-kotlinx-json", version.ref = "ktor" } +reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "reorderable" } +turbo = { group = "dev.hotwire", name = "turbo", version.ref = "turbo" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } +android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d67a4cd..adec10f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Mar 28 13:30:18 CST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME From 6c35433d2f52d70b96a3f711d22c0443696963a3 Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Thu, 26 Jun 2025 18:23:32 +0900 Subject: [PATCH 03/24] Upgrade Gradle and Kotlin --- gradle/libs.versions.toml | 4 ++-- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 36b2a90..cc37d2c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,8 +21,8 @@ ktor = "2.3.4" media3 = "1.3.1" reorderable = "1.3.3" turbo = "7.0.3" -kotlin = "2.0.0" -androidGradlePlugin = "8.4.2" +kotlin = "2.2.0" +androidGradlePlugin = "8.11.0" [libraries] androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index adec10f..5e3546d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Mar 28 13:30:18 CST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME From 1fc57b5e395dc3e99e432ca9c70cf281acb66338 Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Tue, 25 Nov 2025 16:27:06 +0900 Subject: [PATCH 04/24] Upgrade AGP and add annotation-experimental dependency --- app/build.gradle.kts | 1 + gradle/libs.versions.toml | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6553a18..d98a05e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.androidx.security.crypto) implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.annotation.experimental) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.session) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc37d2c..5e7c18a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ androidxSecurityCrypto = "1.0.0" androidxDatastorePreferences = "1.1.1" androidxJunit = "1.1.5" androidxEspresso = "3.5.1" +annotationExperimental = "1.5.1" coilCompose = "2.5.0" googleAccompanistThemeadapterMaterial3 = "0.30.1" googleMaterial = "1.12.0" @@ -22,10 +23,11 @@ media3 = "1.3.1" reorderable = "1.3.3" turbo = "7.0.3" kotlin = "2.2.0" -androidGradlePlugin = "8.11.0" +androidGradlePlugin = "8.13.1" [libraries] androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } +androidx-annotation-experimental = { group = "androidx.annotation", name = "annotation-experimental", version.ref = "annotationExperimental" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } androidx-compose-material3 = { module = "androidx.compose.material3:material3" } From 181e4c34cbf51d2ebb14d9f86c7457dc15aa85d9 Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Tue, 25 Nov 2025 19:17:16 +0900 Subject: [PATCH 05/24] Add shared module --- app/build.gradle.kts | 2 + .../org/blackcandy/android/MainActivity.kt | 14 +- .../blackcandy/android/api/ApiException.kt | 3 - .../android/api/BlackCandyService.kt | 110 +++++++-------- .../android/compose/player/PlayerControl.kt | 4 +- .../android/data/CurrentPlaylistRepository.kt | 36 ++--- .../data/EncryptedPreferencesDataSource.kt | 4 +- .../data/FavoritePlaylistRepository.kt | 2 +- .../android/data/PreferencesDataSource.kt | 8 +- .../android/data/ServerAddressRepository.kt | 8 +- .../android/data/SystemInfoRepository.kt | 8 +- .../blackcandy/android/data/UserRepository.kt | 15 ++- .../org/blackcandy/android/di/AppModule.kt | 32 ++--- .../fragments/navs/MainNavHostFragment.kt | 4 +- .../android/fragments/web/WebFragment.kt | 4 +- .../android/fragments/web/WebHomeFragment.kt | 4 +- .../blackcandy/android/media/MusicService.kt | 20 +-- .../android/media/MusicServiceController.kt | 127 ++++++++++-------- .../blackcandy/android/models/AlertMessage.kt | 4 +- .../org/blackcandy/android/models/Song.kt | 12 +- .../blackcandy/android/utils/SnackbarUtil.kt | 18 +-- .../blackcandy/android/utils/TaskResult.kt | 7 - .../viewmodels/AccountSheetViewModel.kt | 2 +- .../android/viewmodels/LoginViewModel.kt | 8 +- .../android/viewmodels/MainViewModel.kt | 2 +- .../android/viewmodels/NavHostViewModel.kt | 10 +- .../android/viewmodels/PlayerViewModel.kt | 30 ++++- build.gradle.kts | 3 + gradle/libs.versions.toml | 13 +- settings.gradle.kts | 1 + shared/.gitignore | 1 + shared/build.gradle.kts | 101 ++++++++++++++ .../shared/ExampleInstrumentedTest.kt | 22 +++ .../org/blackcandy/shared/ExampleUnitTest.kt | 16 +++ shared/src/androidMain/AndroidManifest.xml | 4 + .../org/blackcandy/shared}/api/ApiError.kt | 2 +- .../org/blackcandy/shared/api/ApiException.kt | 6 + .../org/blackcandy/shared}/api/ApiResponse.kt | 12 +- .../shared}/models/AuthenticationResponse.kt | 2 +- .../blackcandy/shared}/models/SystemInfo.kt | 2 +- .../org/blackcandy/shared}/models/User.kt | 2 +- .../org/blackcandy/shared}/utils/Constants.kt | 2 +- .../shared}/utils/DurationFormatter.kt | 2 +- .../org/blackcandy/shared/utils/TaskResult.kt | 11 ++ .../org/blackcandy/shared}/utils/Theme.kt | 2 +- 45 files changed, 441 insertions(+), 261 deletions(-) delete mode 100644 app/src/main/java/org/blackcandy/android/api/ApiException.kt delete mode 100644 app/src/main/java/org/blackcandy/android/utils/TaskResult.kt create mode 100644 shared/.gitignore create mode 100644 shared/build.gradle.kts create mode 100644 shared/src/androidDeviceTest/kotlin/org/blackcandy/shared/ExampleInstrumentedTest.kt create mode 100644 shared/src/androidHostTest/kotlin/org/blackcandy/shared/ExampleUnitTest.kt create mode 100644 shared/src/androidMain/AndroidManifest.xml rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/api/ApiError.kt (78%) create mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/api/ApiException.kt rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/api/ApiResponse.kt (66%) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/models/AuthenticationResponse.kt (74%) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/models/SystemInfo.kt (92%) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/models/User.kt (79%) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/utils/Constants.kt (72%) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/utils/DurationFormatter.kt (89%) create mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/utils/TaskResult.kt rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/utils/Theme.kt (58%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d98a05e..8c75bde 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,8 @@ dependencies { implementation(libs.coil.compose) implementation(libs.reorderable) + implementation(project(":shared")) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/org/blackcandy/android/MainActivity.kt b/app/src/main/java/org/blackcandy/android/MainActivity.kt index 7182e73..1b2583a 100644 --- a/app/src/main/java/org/blackcandy/android/MainActivity.kt +++ b/app/src/main/java/org/blackcandy/android/MainActivity.kt @@ -35,7 +35,10 @@ import org.blackcandy.android.databinding.ActivityMainBinding import org.blackcandy.android.viewmodels.MainViewModel import org.koin.androidx.viewmodel.ext.android.viewModel -class MainActivity : AppCompatActivity(), TurboActivity, OnItemSelectedListener { +class MainActivity : + AppCompatActivity(), + TurboActivity, + OnItemSelectedListener { companion object { private const val SELECTED_NAV_ITEM_ID_KEY = "selected_nav_item_id" } @@ -117,16 +120,17 @@ class MainActivity : AppCompatActivity(), TurboActivity, OnItemSelectedListener super.onSaveInstanceState(outState) } - override fun onNavigationItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { + override fun onNavigationItemSelected(item: MenuItem): Boolean = + when (item.itemId) { R.id.nav_menu_home, R.id.nav_menu_library -> { showSelectedNavItem(item.itemId) true } - else -> false + else -> { + false + } } - } private fun requireLogin(): Boolean { runBlocking { viewModel.currentUserFlow.first() } ?: return true diff --git a/app/src/main/java/org/blackcandy/android/api/ApiException.kt b/app/src/main/java/org/blackcandy/android/api/ApiException.kt deleted file mode 100644 index 508d69f..0000000 --- a/app/src/main/java/org/blackcandy/android/api/ApiException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.blackcandy.android.api - -class ApiException(val code: Int?, message: String?) : Exception(message) diff --git a/app/src/main/java/org/blackcandy/android/api/BlackCandyService.kt b/app/src/main/java/org/blackcandy/android/api/BlackCandyService.kt index 87d1a5e..134e4fe 100644 --- a/app/src/main/java/org/blackcandy/android/api/BlackCandyService.kt +++ b/app/src/main/java/org/blackcandy/android/api/BlackCandyService.kt @@ -19,10 +19,12 @@ import kotlinx.serialization.json.boolean import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import org.blackcandy.android.models.AuthenticationResponse import org.blackcandy.android.models.Song -import org.blackcandy.android.models.SystemInfo -import org.blackcandy.android.models.User +import org.blackcandy.shared.api.ApiException +import org.blackcandy.shared.api.ApiResponse +import org.blackcandy.shared.models.AuthenticationResponse +import org.blackcandy.shared.models.SystemInfo +import org.blackcandy.shared.models.User interface BlackCandyService { suspend fun getSystemInfo(): ApiResponse @@ -63,8 +65,8 @@ interface BlackCandyService { class BlackCandyServiceImpl( private val client: HttpClient, ) : BlackCandyService { - override suspend fun getSystemInfo(): ApiResponse { - return handleResponse { + override suspend fun getSystemInfo(): ApiResponse = + handleResponse { val response = client.get("system") val responseUrl = response.request.url val systemInfo: SystemInfo = response.body() @@ -78,13 +80,12 @@ class BlackCandyServiceImpl( systemInfo } - } override suspend fun createAuthentication( email: String, password: String, - ): ApiResponse { - return handleResponse { + ): ApiResponse = + handleResponse { val response: HttpResponse = client.submitForm( url = "authentication", @@ -115,94 +116,85 @@ class BlackCandyServiceImpl( cookies = cookies, ) } - } - override suspend fun removeAuthentication(): ApiResponse { - return handleResponse { + override suspend fun removeAuthentication(): ApiResponse = + handleResponse { client.delete("authentication").body() } - } - override suspend fun getSongsFromCurrentPlaylist(): ApiResponse> { - return handleResponse { + override suspend fun getSongsFromCurrentPlaylist(): ApiResponse> = + handleResponse { client.get("current_playlist/songs").body() } - } - override suspend fun addSongToFavorite(songId: Int): ApiResponse { - return handleResponse { - client.post("favorite_playlist/songs") { - parameter("song_id", songId.toString()) - }.body() + override suspend fun addSongToFavorite(songId: Int): ApiResponse = + handleResponse { + client + .post("favorite_playlist/songs") { + parameter("song_id", songId.toString()) + }.body() } - } - override suspend fun removeSongFromFavorite(songId: Int): ApiResponse { - return handleResponse { + override suspend fun removeSongFromFavorite(songId: Int): ApiResponse = + handleResponse { client.delete("favorite_playlist/songs/$songId").body() } - } - override suspend fun removeAllSongsFromCurrentPlaylist(): ApiResponse { - return handleResponse { + override suspend fun removeAllSongsFromCurrentPlaylist(): ApiResponse = + handleResponse { client.delete("current_playlist/songs").body() } - } - override suspend fun removeSongFromCurrentPlaylist(songId: Int): ApiResponse { - return handleResponse { + override suspend fun removeSongFromCurrentPlaylist(songId: Int): ApiResponse = + handleResponse { client.delete("current_playlist/songs/$songId").body() } - } override suspend fun moveSongInCurrentPlaylist( songId: Int, destinationSongId: Int, - ): ApiResponse { - return handleResponse { - client.put("current_playlist/songs/$songId/move") { - parameter("destination_song_id", destinationSongId.toString()) - }.body() + ): ApiResponse = + handleResponse { + client + .put("current_playlist/songs/$songId/move") { + parameter("destination_song_id", destinationSongId.toString()) + }.body() } - } - override suspend fun replaceCurrentPlaylistWithAlbumSongs(albumId: Int): ApiResponse> { - return handleResponse { + override suspend fun replaceCurrentPlaylistWithAlbumSongs(albumId: Int): ApiResponse> = + handleResponse { client.put("current_playlist/songs/albums/$albumId").body() } - } - override suspend fun replaceCurrentPlaylistWithPlaylistSongs(playlistId: Int): ApiResponse> { - return handleResponse { + override suspend fun replaceCurrentPlaylistWithPlaylistSongs(playlistId: Int): ApiResponse> = + handleResponse { client.put("current_playlist/songs/playlists/$playlistId").body() } - } override suspend fun addSongToCurrentPlaylist( songId: Int, currentSongId: Int?, location: String?, - ): ApiResponse { - return handleResponse { - client.post("current_playlist/songs") { - parameter("song_id", songId.toString()) - - if (currentSongId != null) { - parameter("current_song_id", currentSongId.toString()) - } - - if (location != null) { - parameter("location", location) - } - }.body() + ): ApiResponse = + handleResponse { + client + .post("current_playlist/songs") { + parameter("song_id", songId.toString()) + + if (currentSongId != null) { + parameter("current_song_id", currentSongId.toString()) + } + + if (location != null) { + parameter("location", location) + } + }.body() } - } - private suspend fun handleResponse(request: suspend () -> T): ApiResponse { - return try { + private suspend fun handleResponse(request: suspend () -> T): ApiResponse = + try { ApiResponse.Success(request()) } catch (e: ApiException) { ApiResponse.Failure(e) } - } } diff --git a/app/src/main/java/org/blackcandy/android/compose/player/PlayerControl.kt b/app/src/main/java/org/blackcandy/android/compose/player/PlayerControl.kt index 0d57a66..7b7d682 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/PlayerControl.kt +++ b/app/src/main/java/org/blackcandy/android/compose/player/PlayerControl.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import org.blackcandy.android.R -import org.blackcandy.android.utils.DurationFormatter -import org.blackcandy.android.utils.NONE_DURATION_TEXT +import org.blackcandy.shared.utils.DurationFormatter +import org.blackcandy.shared.utils.NONE_DURATION_TEXT @Composable fun PlayerControl( diff --git a/app/src/main/java/org/blackcandy/android/data/CurrentPlaylistRepository.kt b/app/src/main/java/org/blackcandy/android/data/CurrentPlaylistRepository.kt index 7afe3c3..02d6845 100644 --- a/app/src/main/java/org/blackcandy/android/data/CurrentPlaylistRepository.kt +++ b/app/src/main/java/org/blackcandy/android/data/CurrentPlaylistRepository.kt @@ -2,46 +2,32 @@ package org.blackcandy.android.data import org.blackcandy.android.api.BlackCandyService import org.blackcandy.android.models.Song -import org.blackcandy.android.utils.TaskResult +import org.blackcandy.shared.utils.TaskResult class CurrentPlaylistRepository( private val service: BlackCandyService, ) { - suspend fun getSongs(): TaskResult> { - return service.getSongsFromCurrentPlaylist().asResult() - } + suspend fun getSongs(): TaskResult> = service.getSongsFromCurrentPlaylist().asResult() - suspend fun removeAllSongs(): TaskResult { - return service.removeAllSongsFromCurrentPlaylist().asResult() - } + suspend fun removeAllSongs(): TaskResult = service.removeAllSongsFromCurrentPlaylist().asResult() - suspend fun removeSong(songId: Int): TaskResult { - return service.removeSongFromCurrentPlaylist(songId).asResult() - } + suspend fun removeSong(songId: Int): TaskResult = service.removeSongFromCurrentPlaylist(songId).asResult() suspend fun moveSong( songId: Int, destinationSongId: Int, - ): TaskResult { - return service.moveSongInCurrentPlaylist(songId, destinationSongId).asResult() - } + ): TaskResult = service.moveSongInCurrentPlaylist(songId, destinationSongId).asResult() - suspend fun replaceWithAlbumSongs(albumId: Int): TaskResult> { - return service.replaceCurrentPlaylistWithAlbumSongs(albumId).asResult() - } + suspend fun replaceWithAlbumSongs(albumId: Int): TaskResult> = + service.replaceCurrentPlaylistWithAlbumSongs(albumId).asResult() - suspend fun replaceWithPlaylistSongs(playlistId: Int): TaskResult> { - return service.replaceCurrentPlaylistWithPlaylistSongs(playlistId).asResult() - } + suspend fun replaceWithPlaylistSongs(playlistId: Int): TaskResult> = + service.replaceCurrentPlaylistWithPlaylistSongs(playlistId).asResult() suspend fun addSongToNext( songId: Int, currentSongId: Int, - ): TaskResult { - return service.addSongToCurrentPlaylist(songId, currentSongId, null).asResult() - } + ): TaskResult = service.addSongToCurrentPlaylist(songId, currentSongId, null).asResult() - suspend fun addSongToLast(songId: Int): TaskResult { - return service.addSongToCurrentPlaylist(songId, null, "last").asResult() - } + suspend fun addSongToLast(songId: Int): TaskResult = service.addSongToCurrentPlaylist(songId, null, "last").asResult() } diff --git a/app/src/main/java/org/blackcandy/android/data/EncryptedPreferencesDataSource.kt b/app/src/main/java/org/blackcandy/android/data/EncryptedPreferencesDataSource.kt index 07de68c..4ec58ad 100644 --- a/app/src/main/java/org/blackcandy/android/data/EncryptedPreferencesDataSource.kt +++ b/app/src/main/java/org/blackcandy/android/data/EncryptedPreferencesDataSource.kt @@ -9,9 +9,7 @@ class EncryptedPreferencesDataSource( private const val API_TOKEN_KEY = "api_token_key" } - fun getApiToken(): String? { - return encryptedSharedPrefs.getString(API_TOKEN_KEY, null) - } + fun getApiToken(): String? = encryptedSharedPrefs.getString(API_TOKEN_KEY, null) fun updateApiToken(apiToken: String) { with(encryptedSharedPrefs.edit()) { diff --git a/app/src/main/java/org/blackcandy/android/data/FavoritePlaylistRepository.kt b/app/src/main/java/org/blackcandy/android/data/FavoritePlaylistRepository.kt index 44507c3..5b8ae1e 100644 --- a/app/src/main/java/org/blackcandy/android/data/FavoritePlaylistRepository.kt +++ b/app/src/main/java/org/blackcandy/android/data/FavoritePlaylistRepository.kt @@ -2,7 +2,7 @@ package org.blackcandy.android.data import org.blackcandy.android.api.BlackCandyService import org.blackcandy.android.models.Song -import org.blackcandy.android.utils.TaskResult +import org.blackcandy.shared.utils.TaskResult class FavoritePlaylistRepository( private val service: BlackCandyService, diff --git a/app/src/main/java/org/blackcandy/android/data/PreferencesDataSource.kt b/app/src/main/java/org/blackcandy/android/data/PreferencesDataSource.kt index 2c5ffdf..61d8f1d 100644 --- a/app/src/main/java/org/blackcandy/android/data/PreferencesDataSource.kt +++ b/app/src/main/java/org/blackcandy/android/data/PreferencesDataSource.kt @@ -15,15 +15,11 @@ class PreferencesDataSource( private val SERVER_ADDRESS_KEY = stringPreferencesKey("server_address") } - suspend fun getServerAddress(): String { - return dataStore.data.first()[SERVER_ADDRESS_KEY] ?: "" - } + suspend fun getServerAddress(): String = dataStore.data.first()[SERVER_ADDRESS_KEY] ?: "" suspend fun updateServerAddress(serverAddress: String) { dataStore.edit { it[SERVER_ADDRESS_KEY] = serverAddress } } - fun getServerAddressFlow(): Flow { - return dataStore.data.map { it[SERVER_ADDRESS_KEY] ?: "" } - } + fun getServerAddressFlow(): Flow = dataStore.data.map { it[SERVER_ADDRESS_KEY] ?: "" } } diff --git a/app/src/main/java/org/blackcandy/android/data/ServerAddressRepository.kt b/app/src/main/java/org/blackcandy/android/data/ServerAddressRepository.kt index cbfe971..cc87dbb 100644 --- a/app/src/main/java/org/blackcandy/android/data/ServerAddressRepository.kt +++ b/app/src/main/java/org/blackcandy/android/data/ServerAddressRepository.kt @@ -5,15 +5,11 @@ import kotlinx.coroutines.flow.Flow class ServerAddressRepository( private val preferencesDataSource: PreferencesDataSource, ) { - suspend fun getServerAddress(): String { - return preferencesDataSource.getServerAddress() - } + suspend fun getServerAddress(): String = preferencesDataSource.getServerAddress() suspend fun updateServerAddress(serverAddress: String) { preferencesDataSource.updateServerAddress(serverAddress) } - fun getServerAddressFlow(): Flow { - return preferencesDataSource.getServerAddressFlow() - } + fun getServerAddressFlow(): Flow = preferencesDataSource.getServerAddressFlow() } diff --git a/app/src/main/java/org/blackcandy/android/data/SystemInfoRepository.kt b/app/src/main/java/org/blackcandy/android/data/SystemInfoRepository.kt index 4da85fe..446d9d1 100644 --- a/app/src/main/java/org/blackcandy/android/data/SystemInfoRepository.kt +++ b/app/src/main/java/org/blackcandy/android/data/SystemInfoRepository.kt @@ -1,13 +1,11 @@ package org.blackcandy.android.data import org.blackcandy.android.api.BlackCandyService -import org.blackcandy.android.models.SystemInfo -import org.blackcandy.android.utils.TaskResult +import org.blackcandy.shared.models.SystemInfo +import org.blackcandy.shared.utils.TaskResult class SystemInfoRepository( private val service: BlackCandyService, ) { - suspend fun getSystemInfo(): TaskResult { - return service.getSystemInfo().asResult() - } + suspend fun getSystemInfo(): TaskResult = service.getSystemInfo().asResult() } diff --git a/app/src/main/java/org/blackcandy/android/data/UserRepository.kt b/app/src/main/java/org/blackcandy/android/data/UserRepository.kt index 960e8bc..e6dac9b 100644 --- a/app/src/main/java/org/blackcandy/android/data/UserRepository.kt +++ b/app/src/main/java/org/blackcandy/android/data/UserRepository.kt @@ -8,8 +8,8 @@ import io.ktor.client.plugins.auth.providers.BearerAuthProvider import io.ktor.client.plugins.plugin import kotlinx.coroutines.flow.Flow import org.blackcandy.android.api.BlackCandyService -import org.blackcandy.android.models.User -import org.blackcandy.android.utils.TaskResult +import org.blackcandy.shared.models.User +import org.blackcandy.shared.utils.TaskResult class UserRepository( private val httpClient: HttpClient, @@ -36,9 +36,12 @@ class UserRepository( encryptedPreferencesDataSource.updateApiToken(response.token) // Clear previous cached auth token in http client - httpClient.plugin(Auth).providers + httpClient + .plugin(Auth) + .providers .filterIsInstance() - .first().clearToken() + .first() + .clearToken() return TaskResult.Success(Unit) } catch (e: Exception) { @@ -53,7 +56,5 @@ class UserRepository( userDataStore.updateData { null } } - fun getCurrentUserFlow(): Flow { - return userDataStore.data - } + fun getCurrentUserFlow(): Flow = userDataStore.data } diff --git a/app/src/main/java/org/blackcandy/android/di/AppModule.kt b/app/src/main/java/org/blackcandy/android/di/AppModule.kt index aa68b01..7cdcd63 100644 --- a/app/src/main/java/org/blackcandy/android/di/AppModule.kt +++ b/app/src/main/java/org/blackcandy/android/di/AppModule.kt @@ -33,8 +33,6 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNamingStrategy import okhttp3.OkHttpClient -import org.blackcandy.android.api.ApiError -import org.blackcandy.android.api.ApiException import org.blackcandy.android.api.BlackCandyService import org.blackcandy.android.api.BlackCandyServiceImpl import org.blackcandy.android.data.CurrentPlaylistRepository @@ -45,8 +43,6 @@ import org.blackcandy.android.data.ServerAddressRepository import org.blackcandy.android.data.SystemInfoRepository import org.blackcandy.android.data.UserRepository import org.blackcandy.android.media.MusicServiceController -import org.blackcandy.android.models.User -import org.blackcandy.android.utils.BLACK_CANDY_USER_AGENT import org.blackcandy.android.viewmodels.AccountSheetViewModel import org.blackcandy.android.viewmodels.HomeViewModel import org.blackcandy.android.viewmodels.LoginViewModel @@ -55,6 +51,10 @@ import org.blackcandy.android.viewmodels.MiniPlayerViewModel import org.blackcandy.android.viewmodels.NavHostViewModel import org.blackcandy.android.viewmodels.PlayerViewModel import org.blackcandy.android.viewmodels.WebViewModel +import org.blackcandy.shared.api.ApiError +import org.blackcandy.shared.api.ApiException +import org.blackcandy.shared.models.User +import org.blackcandy.shared.utils.BLACK_CANDY_USER_AGENT import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.qualifier.named @@ -101,8 +101,8 @@ private fun provideHttpClient( json: Json, preferencesDataSource: PreferencesDataSource, encryptedPreferencesDataSource: EncryptedPreferencesDataSource, -): HttpClient { - return HttpClient { +): HttpClient = + HttpClient { expectSuccess = true install(UserAgent) { @@ -161,13 +161,11 @@ private fun provideHttpClient( } } } -} -private fun provideDataStore(appContext: Context): DataStore { - return PreferenceDataStoreFactory.create( +private fun provideDataStore(appContext: Context): DataStore = + PreferenceDataStoreFactory.create( produceFile = { appContext.preferencesDataStoreFile(DATASTORE_PREFERENCES_NAME) }, ) -} private fun provideUserDataStore(appContext: Context): DataStore { val serializer = @@ -175,8 +173,8 @@ private fun provideUserDataStore(appContext: Context): DataStore { override val defaultValue: User? get() = null - override suspend fun readFrom(input: InputStream): User? { - return try { + override suspend fun readFrom(input: InputStream): User? = + try { Json.decodeFromString( User.serializer(), input.readBytes().decodeToString(), @@ -184,7 +182,6 @@ private fun provideUserDataStore(appContext: Context): DataStore { } catch (e: Exception) { null } - } override suspend fun writeTo( t: User?, @@ -209,19 +206,16 @@ private fun provideUserDataStore(appContext: Context): DataStore { ) } -private fun provideCookieManager(): CookieManager { - return CookieManager.getInstance() -} +private fun provideCookieManager(): CookieManager = CookieManager.getInstance() -private fun provideEncryptedSharedPreferences(appContext: Context): SharedPreferences { - return EncryptedSharedPreferences.create( +private fun provideEncryptedSharedPreferences(appContext: Context): SharedPreferences = + EncryptedSharedPreferences.create( ENCRYPTED_SHARED_PREFERENCES_FILE_NAME, MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), appContext, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) -} @androidx.annotation.OptIn(UnstableApi::class) private fun provideDataSourceFactory(encryptedPreferencesDataSource: EncryptedPreferencesDataSource): DataSource.Factory { diff --git a/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt index 3baea0c..25c7162 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt @@ -15,10 +15,10 @@ import org.blackcandy.android.fragments.web.WebBottomSheetFragment import org.blackcandy.android.fragments.web.WebFragment import org.blackcandy.android.fragments.web.WebHomeFragment import org.blackcandy.android.fragments.web.WebLibraryFragment -import org.blackcandy.android.utils.BLACK_CANDY_USER_AGENT import org.blackcandy.android.utils.SnackbarUtil.Companion.showSnackbar -import org.blackcandy.android.utils.Theme import org.blackcandy.android.viewmodels.NavHostViewModel +import org.blackcandy.shared.utils.BLACK_CANDY_USER_AGENT +import org.blackcandy.shared.utils.Theme import org.koin.androidx.viewmodel.ext.android.viewModel import kotlin.reflect.KClass diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt index b0d88eb..9627151 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt @@ -18,9 +18,7 @@ open class WebFragment : TurboWebFragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View? { - return inflater.inflate(R.layout.fragment_web, container, false) - } + ): View? = inflater.inflate(R.layout.fragment_web, container, false) override fun onVisitErrorReceived( location: String, diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt index ddbcafc..c426912 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt @@ -38,7 +38,9 @@ class WebHomeFragment : WebFragment() { true } - else -> false + else -> { + false + } } } } diff --git a/app/src/main/java/org/blackcandy/android/media/MusicService.kt b/app/src/main/java/org/blackcandy/android/media/MusicService.kt index 512f157..e97c6b7 100644 --- a/app/src/main/java/org/blackcandy/android/media/MusicService.kt +++ b/app/src/main/java/org/blackcandy/android/media/MusicService.kt @@ -25,30 +25,32 @@ class MusicService : MediaSessionService() { val dataSourceFactory: DataSource.Factory = get() val audioAttributes = - AudioAttributes.Builder() + AudioAttributes + .Builder() .setContentType(AUDIO_CONTENT_TYPE_MUSIC) .setUsage(USAGE_MEDIA) .build() val player = - ExoPlayer.Builder(this) + ExoPlayer + .Builder(this) .setMediaSourceFactory( DefaultMediaSourceFactory(this).setDataSourceFactory(dataSourceFactory), - ) - .setTrackSelector( + ).setTrackSelector( DefaultTrackSelector(this).apply { setParameters( buildUponParameters().apply { setAudioOffloadPreferences( - TrackSelectionParameters.AudioOffloadPreferences.DEFAULT.buildUpon().apply { - setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) - }.build(), + TrackSelectionParameters.AudioOffloadPreferences.DEFAULT + .buildUpon() + .apply { + setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) + }.build(), ) }, ) }, - ) - .setAudioAttributes(audioAttributes, true) + ).setAudioAttributes(audioAttributes, true) .setHandleAudioBecomingNoisy(true) .build() diff --git a/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt b/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt index ca7309e..2cebf2e 100644 --- a/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt +++ b/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt @@ -38,10 +38,11 @@ class MusicServiceController( fun initMediaController(onInitialized: () -> Unit) { val controllerFuture = - MediaController.Builder( - appContext, - SessionToken(appContext, ComponentName(appContext, MusicService::class.java)), - ).buildAsync() + MediaController + .Builder( + appContext, + SessionToken(appContext, ComponentName(appContext, MusicService::class.java)), + ).buildAsync() controllerFuture.addListener({ controller = controllerFuture.get() @@ -77,54 +78,55 @@ class MusicServiceController( fun updatePlaylist(songs: List) { val mediaItems = songs.map { it.toMediaItem() } - DiffUtil.calculateDiff( - object : DiffUtil.Callback() { - override fun getOldListSize() = controller?.mediaItemCount ?: 0 - - override fun getNewListSize() = mediaItems.size - - override fun areItemsTheSame( - oldItemPosition: Int, - newItemPosition: Int, - ) = controller?.getMediaItemAt(oldItemPosition)?.mediaId == mediaItems[newItemPosition].mediaId - - override fun areContentsTheSame( - oldItemPosition: Int, - newItemPosition: Int, - ) = controller?.getMediaItemAt(oldItemPosition) == mediaItems[newItemPosition] - }, - ).dispatchUpdatesTo( - object : ListUpdateCallback { - override fun onInserted( - position: Int, - count: Int, - ) { - controller?.addMediaItems(position, mediaItems.subList(position, position + count)) - } + DiffUtil + .calculateDiff( + object : DiffUtil.Callback() { + override fun getOldListSize() = controller?.mediaItemCount ?: 0 - override fun onRemoved( - position: Int, - count: Int, - ) { - controller?.removeMediaItems(position, position + count) - } + override fun getNewListSize() = mediaItems.size - override fun onMoved( - fromPosition: Int, - toPosition: Int, - ) { - controller?.moveMediaItem(fromPosition, toPosition) - } + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ) = controller?.getMediaItemAt(oldItemPosition)?.mediaId == mediaItems[newItemPosition].mediaId - override fun onChanged( - position: Int, - count: Int, - payload: Any?, - ) { - controller?.replaceMediaItems(position, position + count, mediaItems.subList(position, position + count)) - } - }, - ) + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int, + ) = controller?.getMediaItemAt(oldItemPosition) == mediaItems[newItemPosition] + }, + ).dispatchUpdatesTo( + object : ListUpdateCallback { + override fun onInserted( + position: Int, + count: Int, + ) { + controller?.addMediaItems(position, mediaItems.subList(position, position + count)) + } + + override fun onRemoved( + position: Int, + count: Int, + ) { + controller?.removeMediaItems(position, position + count) + } + + override fun onMoved( + fromPosition: Int, + toPosition: Int, + ) { + controller?.moveMediaItem(fromPosition, toPosition) + } + + override fun onChanged( + position: Int, + count: Int, + payload: Any?, + ) { + controller?.replaceMediaItems(position, position + count, mediaItems.subList(position, position + count)) + } + }, + ) _musicState.update { it.copy(playlist = songs) } } @@ -167,7 +169,10 @@ class MusicServiceController( } fun deleteSongFromPlaylist(song: Song) { - val songs = musicState.value.playlist.toMutableList().apply { remove(song) } + val songs = + musicState.value.playlist + .toMutableList() + .apply { remove(song) } updatePlaylist(songs) } @@ -184,7 +189,10 @@ class MusicServiceController( from: Int, to: Int, ) { - val songs = musicState.value.playlist.toMutableList().apply { add(to, removeAt(from)) } + val songs = + musicState.value.playlist + .toMutableList() + .apply { add(to, removeAt(from)) } updatePlaylist(songs) } @@ -222,18 +230,20 @@ class MusicServiceController( _musicState.update { it.copy(playbackMode = playbackMode) } } - fun getSongIndex(songId: Int): Int { - return musicState.value.playlist.indexOfFirst { it.id == songId } - } + fun getSongIndex(songId: Int): Int = musicState.value.playlist.indexOfFirst { it.id == songId } fun addSongToNext(song: Song): Int { val currentSong = musicState.value.currentSong val songs = if (currentSong != null) { val index = musicState.value.playlist.indexOf(currentSong) - musicState.value.playlist.toMutableList().apply { add(index + 1, song) } + musicState.value.playlist + .toMutableList() + .apply { add(index + 1, song) } } else { - musicState.value.playlist.toMutableList().apply { add(0, song) } + musicState.value.playlist + .toMutableList() + .apply { add(0, song) } } updatePlaylist(songs) @@ -242,7 +252,10 @@ class MusicServiceController( } fun addSongToLast(song: Song) { - val songs = musicState.value.playlist.toMutableList().apply { add(song) } + val songs = + musicState.value.playlist + .toMutableList() + .apply { add(song) } updatePlaylist(songs) } diff --git a/app/src/main/java/org/blackcandy/android/models/AlertMessage.kt b/app/src/main/java/org/blackcandy/android/models/AlertMessage.kt index 0c50783..25e9ff0 100644 --- a/app/src/main/java/org/blackcandy/android/models/AlertMessage.kt +++ b/app/src/main/java/org/blackcandy/android/models/AlertMessage.kt @@ -3,7 +3,9 @@ package org.blackcandy.android.models import androidx.annotation.StringRes sealed class AlertMessage { - data class String(val value: kotlin.String?) : AlertMessage() + data class String( + val value: kotlin.String?, + ) : AlertMessage() data class StringResource( @StringRes val value: Int, diff --git a/app/src/main/java/org/blackcandy/android/models/Song.kt b/app/src/main/java/org/blackcandy/android/models/Song.kt index 1f53452..2858335 100644 --- a/app/src/main/java/org/blackcandy/android/models/Song.kt +++ b/app/src/main/java/org/blackcandy/android/models/Song.kt @@ -24,18 +24,18 @@ data class Song( val large: String, ) - fun toMediaItem(): MediaItem { - return MediaItem.Builder() + fun toMediaItem(): MediaItem = + MediaItem + .Builder() .setMediaId(id.toString()) .setUri(url) .setMediaMetadata( - MediaMetadata.Builder() + MediaMetadata + .Builder() .setTitle(name) .setArtist(artistName) .setAlbumTitle(albumName) .setArtworkUri(Uri.parse(albumImageUrl.large)) .build(), - ) - .build() - } + ).build() } diff --git a/app/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt b/app/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt index 48b32c8..390dd7f 100644 --- a/app/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt +++ b/app/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt @@ -43,14 +43,16 @@ class SnackbarUtil { is AlertMessage.StringResource -> rootView.context.getString(message.value) } ?: return - Snackbar.make(rootView, snackbarText, Snackbar.LENGTH_SHORT).addCallback( - object : Snackbar.Callback() { - override fun onShown(sb: Snackbar?) { - super.onShown(sb) - onShown() - } - }, - ).show() + Snackbar + .make(rootView, snackbarText, Snackbar.LENGTH_SHORT) + .addCallback( + object : Snackbar.Callback() { + override fun onShown(sb: Snackbar?) { + super.onShown(sb) + onShown() + } + }, + ).show() } } } diff --git a/app/src/main/java/org/blackcandy/android/utils/TaskResult.kt b/app/src/main/java/org/blackcandy/android/utils/TaskResult.kt deleted file mode 100644 index 8879bd6..0000000 --- a/app/src/main/java/org/blackcandy/android/utils/TaskResult.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.blackcandy.android.utils - -sealed interface TaskResult { - data class Success(val data: T) : TaskResult - - data class Failure(val message: String?) : TaskResult -} diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt index 2ed9c2e..13bf51f 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.blackcandy.android.data.ServerAddressRepository import org.blackcandy.android.data.UserRepository -import org.blackcandy.android.models.User +import org.blackcandy.shared.models.User data class AccountSheetUiState( val serverAddress: String? = null, diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt index efa7818..1bd29bd 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt @@ -14,8 +14,8 @@ import org.blackcandy.android.data.ServerAddressRepository import org.blackcandy.android.data.SystemInfoRepository import org.blackcandy.android.data.UserRepository import org.blackcandy.android.models.AlertMessage -import org.blackcandy.android.models.User -import org.blackcandy.android.utils.TaskResult +import org.blackcandy.shared.models.User +import org.blackcandy.shared.utils.TaskResult data class LoginUiState( val serverAddress: String? = null, @@ -100,7 +100,9 @@ class LoginViewModel( fun login() { viewModelScope.launch { when (val result = userRepository.login(uiState.value.email, uiState.value.password)) { - is TaskResult.Success -> Unit + is TaskResult.Success -> { + Unit + } is TaskResult.Failure -> { _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt index f6b72b5..65fee53 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt @@ -7,7 +7,7 @@ import org.blackcandy.android.data.CurrentPlaylistRepository import org.blackcandy.android.data.UserRepository import org.blackcandy.android.fragments.navs.LibraryNavHostFragment import org.blackcandy.android.media.MusicServiceController -import org.blackcandy.android.utils.TaskResult +import org.blackcandy.shared.utils.TaskResult class MainViewModel( userRepository: UserRepository, diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt index 201f5d9..77eef0f 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt @@ -14,8 +14,8 @@ import org.blackcandy.android.data.ServerAddressRepository import org.blackcandy.android.media.MusicServiceController import org.blackcandy.android.models.AlertMessage import org.blackcandy.android.models.Song -import org.blackcandy.android.utils.TaskResult -import org.blackcandy.android.utils.Theme +import org.blackcandy.shared.utils.TaskResult +import org.blackcandy.shared.utils.Theme data class NavHostUiState( val alertMessage: AlertMessage? = null, @@ -45,9 +45,11 @@ class NavHostViewModel( Theme.DARK -> { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) } + Theme.LIGHT -> { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) } + Theme.AUTO -> { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) } @@ -61,6 +63,7 @@ class NavHostViewModel( is TaskResult.Success -> { playSongs(result.data) } + is TaskResult.Failure -> { _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } } @@ -74,6 +77,7 @@ class NavHostViewModel( is TaskResult.Success -> { playSongs(result.data) } + is TaskResult.Failure -> { _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } } @@ -90,6 +94,7 @@ class NavHostViewModel( is TaskResult.Success -> { playSongsBeginWith(result.data, songId) } + is TaskResult.Failure -> { _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } } @@ -106,6 +111,7 @@ class NavHostViewModel( is TaskResult.Success -> { playSongsBeginWith(result.data, songId) } + is TaskResult.Failure -> { _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } } diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt index 0478a54..e6228e3 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt @@ -13,7 +13,7 @@ import org.blackcandy.android.data.FavoritePlaylistRepository import org.blackcandy.android.media.MusicServiceController import org.blackcandy.android.models.AlertMessage import org.blackcandy.android.models.MusicState -import org.blackcandy.android.utils.TaskResult +import org.blackcandy.shared.utils.TaskResult data class PlayerUiState( val musicState: MusicState = MusicState(), @@ -66,7 +66,9 @@ class PlayerViewModel( } fun playOn(songId: Int) { - val index = uiState.value.musicState.playlist.indexOfFirst { it.id == songId } + val index = + uiState.value.musicState.playlist + .indexOfFirst { it.id == songId } if (index != -1) { musicServiceController.playOn(index) @@ -79,7 +81,10 @@ class PlayerViewModel( viewModelScope.launch { when (val result = currentPlaylistRepository.removeAllSongs()) { - is TaskResult.Success -> Unit + is TaskResult.Success -> { + Unit + } + is TaskResult.Failure -> { _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } } @@ -88,13 +93,18 @@ class PlayerViewModel( } fun removeSongFromPlaylist(songId: Int) { - val song = uiState.value.musicState.playlist.firstOrNull { it.id == songId } ?: return + val song = + uiState.value.musicState.playlist + .firstOrNull { it.id == songId } ?: return musicServiceController.deleteSongFromPlaylist(song) viewModelScope.launch { when (val result = currentPlaylistRepository.removeSong(song.id)) { - is TaskResult.Success -> Unit + is TaskResult.Success -> { + Unit + } + is TaskResult.Failure -> { _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } } @@ -114,7 +124,10 @@ class PlayerViewModel( viewModelScope.launch { when (val result = currentPlaylistRepository.moveSong(songId, destinationSongId)) { - is TaskResult.Success -> Unit + is TaskResult.Success -> { + Unit + } + is TaskResult.Failure -> { _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } } @@ -133,7 +146,10 @@ class PlayerViewModel( viewModelScope.launch { when (val result = favoritePlaylistRepository.toggleSong(currentSong)) { - is TaskResult.Success -> Unit + is TaskResult.Success -> { + Unit + } + is TaskResult.Failure -> { // Rollback favorite state in previous operation musicServiceController.updateSongInPlaylist(currentSong) diff --git a/build.gradle.kts b/build.gradle.kts index 5ca1837..a2f53c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,4 +4,7 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.android.kotlin.multiplatform.library) apply false + alias(libs.plugins.android.lint) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5e7c18a..4cf84a7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,10 @@ reorderable = "1.3.3" turbo = "7.0.3" kotlin = "2.2.0" androidGradlePlugin = "8.13.1" +kotlinStdlib = "2.2.0" +kotlinTest = "2.2.0" +runner = "1.5.2" +core = "1.5.0" [libraries] androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } @@ -61,10 +65,17 @@ ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.r ktor-serialization-kotlinx-json = { group = "io.ktor", name ="ktor-serialization-kotlinx-json", version.ref = "ktor" } reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "reorderable" } turbo = { group = "dev.hotwire", name = "turbo", version.ref = "turbo" } +kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlinStdlib" } +kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinTest" } +androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } +androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "androidGradlePlugin" } +android-lint = { id = "com.android.lint", version.ref = "androidGradlePlugin" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index ac1c95d..4d56d81 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,3 +14,4 @@ dependencyResolutionManagement { } rootProject.name = "BlackCandy" include(":app") +include(":shared") diff --git a/shared/.gitignore b/shared/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/shared/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 0000000..b3c748a --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,101 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.android.lint) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + + // Target declarations - add or remove as needed below. These define + // which platforms this KMP module supports. + // See: https://kotlinlang.org/docs/multiplatform-discover-project.html#targets + androidLibrary { + namespace = "org.blackcandy.shared" + compileSdk = 36 + minSdk = 26 + + withHostTestBuilder { + } + + withDeviceTestBuilder { + sourceSetTreeName = "test" + }.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + + // For iOS targets, this is also where you should + // configure native binary output. For more information, see: + // https://kotlinlang.org/docs/multiplatform-build-native-binaries.html#build-xcframeworks + + // A step-by-step guide on how to include this library in an XCode + // project can be found here: + // https://developer.android.com/kotlin/multiplatform/migrate + val xcfName = "sharedKit" + + iosX64 { + binaries.framework { + baseName = xcfName + } + } + + iosArm64 { + binaries.framework { + baseName = xcfName + } + } + + iosSimulatorArm64 { + binaries.framework { + baseName = xcfName + } + } + + // Source set declarations. + // Declaring a target automatically creates a source set with the same name. By default, the + // Kotlin Gradle Plugin creates additional source sets that depend on each other, since it is + // common to share sources between related targets. + // See: https://kotlinlang.org/docs/multiplatform-hierarchy.html + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.serialization.json) + // Add KMP dependencies here + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + + androidMain { + dependencies { + // Add Android-specific dependencies here. Note that this source set depends on + // commonMain by default and will correctly pull the Android artifacts of any KMP + // dependencies declared in commonMain. + } + } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.runner) + implementation(libs.androidx.core) + implementation(libs.androidx.junit) + } + } + + iosMain { + dependencies { + // Add iOS-specific dependencies here. This a source set created by Kotlin Gradle + // Plugin (KGP) that each specific iOS target (e.g., iosX64) depends on as + // part of KMP’s default source set hierarchy. Note that this source set depends + // on common by default and will correctly pull the iOS artifacts of any + // KMP dependencies declared in commonMain. + } + } + } +} diff --git a/shared/src/androidDeviceTest/kotlin/org/blackcandy/shared/ExampleInstrumentedTest.kt b/shared/src/androidDeviceTest/kotlin/org/blackcandy/shared/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..0c101ec --- /dev/null +++ b/shared/src/androidDeviceTest/kotlin/org/blackcandy/shared/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package org.blackcandy.shared + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("org.blackcandy.shared.test", appContext.packageName) + } +} diff --git a/shared/src/androidHostTest/kotlin/org/blackcandy/shared/ExampleUnitTest.kt b/shared/src/androidHostTest/kotlin/org/blackcandy/shared/ExampleUnitTest.kt new file mode 100644 index 0000000..915d3a7 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/org/blackcandy/shared/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package org.blackcandy.shared + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/shared/src/androidMain/AndroidManifest.xml b/shared/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/shared/src/androidMain/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/java/org/blackcandy/android/api/ApiError.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/api/ApiError.kt similarity index 78% rename from app/src/main/java/org/blackcandy/android/api/ApiError.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/api/ApiError.kt index d719e48..35f68f1 100644 --- a/app/src/main/java/org/blackcandy/android/api/ApiError.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/api/ApiError.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.api +package org.blackcandy.shared.api import kotlinx.serialization.Serializable diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/api/ApiException.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/api/ApiException.kt new file mode 100644 index 0000000..f01aa2c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/api/ApiException.kt @@ -0,0 +1,6 @@ +package org.blackcandy.shared.api + +class ApiException( + val code: Int?, + message: String?, +) : Exception(message) diff --git a/app/src/main/java/org/blackcandy/android/api/ApiResponse.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/api/ApiResponse.kt similarity index 66% rename from app/src/main/java/org/blackcandy/android/api/ApiResponse.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/api/ApiResponse.kt index 0e8160f..75f270e 100644 --- a/app/src/main/java/org/blackcandy/android/api/ApiResponse.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/api/ApiResponse.kt @@ -1,11 +1,15 @@ -package org.blackcandy.android.api +package org.blackcandy.shared.api -import org.blackcandy.android.utils.TaskResult +import org.blackcandy.shared.utils.TaskResult sealed interface ApiResponse { - data class Success(val data: T) : ApiResponse + data class Success( + val data: T, + ) : ApiResponse - data class Failure(val exception: ApiException) : ApiResponse + data class Failure( + val exception: ApiException, + ) : ApiResponse fun orNull(): T? = when (this) { diff --git a/app/src/main/java/org/blackcandy/android/models/AuthenticationResponse.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/models/AuthenticationResponse.kt similarity index 74% rename from app/src/main/java/org/blackcandy/android/models/AuthenticationResponse.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/models/AuthenticationResponse.kt index 5037709..bbc31df 100644 --- a/app/src/main/java/org/blackcandy/android/models/AuthenticationResponse.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/models/AuthenticationResponse.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.models +package org.blackcandy.shared.models data class AuthenticationResponse( val token: String, diff --git a/app/src/main/java/org/blackcandy/android/models/SystemInfo.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/models/SystemInfo.kt similarity index 92% rename from app/src/main/java/org/blackcandy/android/models/SystemInfo.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/models/SystemInfo.kt index 2c6a079..4916d8e 100644 --- a/app/src/main/java/org/blackcandy/android/models/SystemInfo.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/models/SystemInfo.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.models +package org.blackcandy.shared.models import kotlinx.serialization.Serializable diff --git a/app/src/main/java/org/blackcandy/android/models/User.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/models/User.kt similarity index 79% rename from app/src/main/java/org/blackcandy/android/models/User.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/models/User.kt index ef868d9..7909e34 100644 --- a/app/src/main/java/org/blackcandy/android/models/User.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/models/User.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.models +package org.blackcandy.shared.models import kotlinx.serialization.Serializable diff --git a/app/src/main/java/org/blackcandy/android/utils/Constants.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Constants.kt similarity index 72% rename from app/src/main/java/org/blackcandy/android/utils/Constants.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Constants.kt index 0f67071..df319ae 100644 --- a/app/src/main/java/org/blackcandy/android/utils/Constants.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Constants.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.utils +package org.blackcandy.shared.utils const val BLACK_CANDY_USER_AGENT = "Black Candy Android" const val NONE_DURATION_TEXT = "--:--" diff --git a/app/src/main/java/org/blackcandy/android/utils/DurationFormatter.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/DurationFormatter.kt similarity index 89% rename from app/src/main/java/org/blackcandy/android/utils/DurationFormatter.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/utils/DurationFormatter.kt index 47c6b45..793878a 100644 --- a/app/src/main/java/org/blackcandy/android/utils/DurationFormatter.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/DurationFormatter.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.utils +package org.blackcandy.shared.utils import kotlin.time.Duration.Companion.seconds diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/TaskResult.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/TaskResult.kt new file mode 100644 index 0000000..e593829 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/TaskResult.kt @@ -0,0 +1,11 @@ +package org.blackcandy.shared.utils + +sealed interface TaskResult { + data class Success( + val data: T, + ) : TaskResult + + data class Failure( + val message: String?, + ) : TaskResult +} diff --git a/app/src/main/java/org/blackcandy/android/utils/Theme.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Theme.kt similarity index 58% rename from app/src/main/java/org/blackcandy/android/utils/Theme.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Theme.kt index b8d4548..308c1b8 100644 --- a/app/src/main/java/org/blackcandy/android/utils/Theme.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Theme.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.utils +package org.blackcandy.shared.utils enum class Theme { DARK, From e182f9113d9bc60ba85e5129af943b9eca7645be Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Tue, 25 Nov 2025 19:34:33 +0900 Subject: [PATCH 06/24] Upgrade Ktlint to 1.8.0 and fix lint issues --- .github/workflows/ci.yml | 2 +- .../android/fragments/sheets/AccountSheetFragment.kt | 3 +++ .../org/blackcandy/android/fragments/web/WebHomeFragment.kt | 3 +++ .../org/blackcandy/android/fragments/web/WebLibraryFragment.kt | 1 + .../kotlin/org/blackcandy/shared/ExampleInstrumentedTest.kt | 2 +- 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30a3d5f..76aad26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: distribution: 'zulu' java-version: '17' - run: | - curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.0.1/ktlint && chmod a+x ktlint && sudo mv ktlint /usr/local/bin/ + curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.8.0/ktlint && chmod a+x ktlint && sudo mv ktlint /usr/local/bin/ - name: Kotlin lint run: ktlint diff --git a/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt index b7db152..2d19cc2 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt @@ -21,7 +21,10 @@ import org.koin.androidx.viewmodel.ext.android.viewModel @TurboNavGraphDestination(uri = "turbo://fragment/sheets/account") class AccountSheetFragment : TurboBottomSheetDialogFragment() { private val viewModel: AccountSheetViewModel by viewModel() + + @Suppress("ktlint:standard:backing-property-naming") private var _binding: FragmentSheetAccountBinding? = null + private val binding get() = _binding!! override fun onCreateView( diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt index c426912..40ba9bc 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt @@ -13,7 +13,10 @@ import org.koin.androidx.viewmodel.ext.android.viewModel @TurboNavGraphDestination(uri = "turbo://fragment/web/home") class WebHomeFragment : WebFragment() { private val viewModel: HomeViewModel by viewModel() + + @Suppress("ktlint:standard:backing-property-naming") private var _binding: FragmentWebHomeBinding? = null + private val binding get() = _binding!! override fun onCreateView( diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt index fe6097a..603bdbc 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt @@ -9,6 +9,7 @@ import org.blackcandy.android.databinding.FragmentWebLibraryBinding @TurboNavGraphDestination(uri = "turbo://fragment/web/library") open class WebLibraryFragment : WebFragment() { + @Suppress("ktlint:standard:backing-property-naming") private var _binding: FragmentWebLibraryBinding? = null private val binding get() = _binding!! diff --git a/shared/src/androidDeviceTest/kotlin/org/blackcandy/shared/ExampleInstrumentedTest.kt b/shared/src/androidDeviceTest/kotlin/org/blackcandy/shared/ExampleInstrumentedTest.kt index 0c101ec..963a61a 100644 --- a/shared/src/androidDeviceTest/kotlin/org/blackcandy/shared/ExampleInstrumentedTest.kt +++ b/shared/src/androidDeviceTest/kotlin/org/blackcandy/shared/ExampleInstrumentedTest.kt @@ -2,7 +2,7 @@ package org.blackcandy.shared import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.* +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith From 447d30c2b72c7734272ae1031afee4c678b6d4c7 Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Wed, 3 Dec 2025 19:33:43 +0900 Subject: [PATCH 07/24] Move PreferencesDataSource and ServerAddressRepository to shared module --- .../main/java/org/blackcandy/android/data/UserRepository.kt | 1 + app/src/main/java/org/blackcandy/android/di/AppModule.kt | 4 ++-- .../blackcandy/android/viewmodels/AccountSheetViewModel.kt | 2 +- .../java/org/blackcandy/android/viewmodels/HomeViewModel.kt | 2 +- .../java/org/blackcandy/android/viewmodels/LoginViewModel.kt | 2 +- .../org/blackcandy/android/viewmodels/NavHostViewModel.kt | 2 +- shared/build.gradle.kts | 1 + .../org/blackcandy/shared}/data/PreferencesDataSource.kt | 2 +- .../org/blackcandy/shared}/data/ServerAddressRepository.kt | 2 +- 9 files changed, 10 insertions(+), 8 deletions(-) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/data/PreferencesDataSource.kt (95%) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/data/ServerAddressRepository.kt (92%) diff --git a/app/src/main/java/org/blackcandy/android/data/UserRepository.kt b/app/src/main/java/org/blackcandy/android/data/UserRepository.kt index e6dac9b..c47961f 100644 --- a/app/src/main/java/org/blackcandy/android/data/UserRepository.kt +++ b/app/src/main/java/org/blackcandy/android/data/UserRepository.kt @@ -8,6 +8,7 @@ import io.ktor.client.plugins.auth.providers.BearerAuthProvider import io.ktor.client.plugins.plugin import kotlinx.coroutines.flow.Flow import org.blackcandy.android.api.BlackCandyService +import org.blackcandy.shared.data.PreferencesDataSource import org.blackcandy.shared.models.User import org.blackcandy.shared.utils.TaskResult diff --git a/app/src/main/java/org/blackcandy/android/di/AppModule.kt b/app/src/main/java/org/blackcandy/android/di/AppModule.kt index 7cdcd63..497625e 100644 --- a/app/src/main/java/org/blackcandy/android/di/AppModule.kt +++ b/app/src/main/java/org/blackcandy/android/di/AppModule.kt @@ -38,8 +38,6 @@ import org.blackcandy.android.api.BlackCandyServiceImpl import org.blackcandy.android.data.CurrentPlaylistRepository import org.blackcandy.android.data.EncryptedPreferencesDataSource import org.blackcandy.android.data.FavoritePlaylistRepository -import org.blackcandy.android.data.PreferencesDataSource -import org.blackcandy.android.data.ServerAddressRepository import org.blackcandy.android.data.SystemInfoRepository import org.blackcandy.android.data.UserRepository import org.blackcandy.android.media.MusicServiceController @@ -53,6 +51,8 @@ import org.blackcandy.android.viewmodels.PlayerViewModel import org.blackcandy.android.viewmodels.WebViewModel import org.blackcandy.shared.api.ApiError import org.blackcandy.shared.api.ApiException +import org.blackcandy.shared.data.PreferencesDataSource +import org.blackcandy.shared.data.ServerAddressRepository import org.blackcandy.shared.models.User import org.blackcandy.shared.utils.BLACK_CANDY_USER_AGENT import org.koin.android.ext.koin.androidContext diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt index 13bf51f..e3fd03a 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt @@ -6,8 +6,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.blackcandy.android.data.ServerAddressRepository import org.blackcandy.android.data.UserRepository +import org.blackcandy.shared.data.ServerAddressRepository import org.blackcandy.shared.models.User data class AccountSheetUiState( diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/HomeViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/HomeViewModel.kt index 9c27e14..31d040c 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/HomeViewModel.kt @@ -2,7 +2,7 @@ package org.blackcandy.android.viewmodels import androidx.lifecycle.ViewModel import kotlinx.coroutines.runBlocking -import org.blackcandy.android.data.ServerAddressRepository +import org.blackcandy.shared.data.ServerAddressRepository class HomeViewModel( private val serverAddressRepository: ServerAddressRepository, diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt index 1bd29bd..9f4f29b 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt @@ -10,10 +10,10 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.blackcandy.android.R -import org.blackcandy.android.data.ServerAddressRepository import org.blackcandy.android.data.SystemInfoRepository import org.blackcandy.android.data.UserRepository import org.blackcandy.android.models.AlertMessage +import org.blackcandy.shared.data.ServerAddressRepository import org.blackcandy.shared.models.User import org.blackcandy.shared.utils.TaskResult diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt index 77eef0f..3a47ee0 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt @@ -10,10 +10,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.blackcandy.android.R import org.blackcandy.android.data.CurrentPlaylistRepository -import org.blackcandy.android.data.ServerAddressRepository import org.blackcandy.android.media.MusicServiceController import org.blackcandy.android.models.AlertMessage import org.blackcandy.android.models.Song +import org.blackcandy.shared.data.ServerAddressRepository import org.blackcandy.shared.utils.TaskResult import org.blackcandy.shared.utils.Theme diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index b3c748a..0060fe6 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -62,6 +62,7 @@ kotlin { dependencies { implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.datastore.preferences) // Add KMP dependencies here } } diff --git a/app/src/main/java/org/blackcandy/android/data/PreferencesDataSource.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/PreferencesDataSource.kt similarity index 95% rename from app/src/main/java/org/blackcandy/android/data/PreferencesDataSource.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/data/PreferencesDataSource.kt index 61d8f1d..4a182a7 100644 --- a/app/src/main/java/org/blackcandy/android/data/PreferencesDataSource.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/PreferencesDataSource.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.data +package org.blackcandy.shared.data import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences diff --git a/app/src/main/java/org/blackcandy/android/data/ServerAddressRepository.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/ServerAddressRepository.kt similarity index 92% rename from app/src/main/java/org/blackcandy/android/data/ServerAddressRepository.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/data/ServerAddressRepository.kt index cc87dbb..78a997e 100644 --- a/app/src/main/java/org/blackcandy/android/data/ServerAddressRepository.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/ServerAddressRepository.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.data +package org.blackcandy.shared.data import kotlinx.coroutines.flow.Flow From dc059f4e1cd8916f118309ac9adba4dd170ed38a Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Thu, 4 Dec 2025 20:35:13 +0900 Subject: [PATCH 08/24] Move APIService to shared module --- .../android/compose/player/FullPlayer.kt | 2 +- .../android/compose/player/PlayerInfo.kt | 2 +- .../android/compose/player/Playlist.kt | 2 +- .../android/compose/player/PlaylistItem.kt | 2 +- .../blackcandy/android/data/UserRepository.kt | 2 +- .../org/blackcandy/android/di/AppModule.kt | 10 ++--- .../android/media/MusicServiceController.kt | 22 +++++++++- .../blackcandy/android/models/MusicState.kt | 1 + .../org/blackcandy/android/models/Song.kt | 41 ------------------- .../android/viewmodels/LoginViewModel.kt | 2 +- .../android/viewmodels/MainViewModel.kt | 2 +- .../android/viewmodels/NavHostViewModel.kt | 4 +- .../android/viewmodels/PlayerViewModel.kt | 4 +- shared/build.gradle.kts | 5 +++ .../shared}/api/BlackCandyService.kt | 6 +-- .../shared}/data/CurrentPlaylistRepository.kt | 6 +-- .../data/FavoritePlaylistRepository.kt | 6 +-- .../shared}/data/SystemInfoRepository.kt | 4 +- .../org/blackcandy/shared/models/Song.kt | 23 +++++++++++ 19 files changed, 75 insertions(+), 71 deletions(-) delete mode 100644 app/src/main/java/org/blackcandy/android/models/Song.kt rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/api/BlackCandyService.kt (97%) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/data/CurrentPlaylistRepository.kt (90%) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/data/FavoritePlaylistRepository.kt (73%) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/data/SystemInfoRepository.kt (75%) create mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/models/Song.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt b/app/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt index 3f9396f..725d32b 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt +++ b/app/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.dimensionResource import org.blackcandy.android.R import org.blackcandy.android.models.PlaybackMode -import org.blackcandy.android.models.Song +import org.blackcandy.shared.models.Song @Composable fun FullPlayer( diff --git a/app/src/main/java/org/blackcandy/android/compose/player/PlayerInfo.kt b/app/src/main/java/org/blackcandy/android/compose/player/PlayerInfo.kt index 58a8b31..35eacfa 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/PlayerInfo.kt +++ b/app/src/main/java/org/blackcandy/android/compose/player/PlayerInfo.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import org.blackcandy.android.R -import org.blackcandy.android.models.Song +import org.blackcandy.shared.models.Song @OptIn(ExperimentalFoundationApi::class) @Composable diff --git a/app/src/main/java/org/blackcandy/android/compose/player/Playlist.kt b/app/src/main/java/org/blackcandy/android/compose/player/Playlist.kt index c8e5f7b..cd28af2 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/Playlist.kt +++ b/app/src/main/java/org/blackcandy/android/compose/player/Playlist.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import org.blackcandy.android.R -import org.blackcandy.android.models.Song +import org.blackcandy.shared.models.Song import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyColumnState diff --git a/app/src/main/java/org/blackcandy/android/compose/player/PlaylistItem.kt b/app/src/main/java/org/blackcandy/android/compose/player/PlaylistItem.kt index 2faba76..2b4a9b0 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/PlaylistItem.kt +++ b/app/src/main/java/org/blackcandy/android/compose/player/PlaylistItem.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import org.blackcandy.android.R -import org.blackcandy.android.models.Song +import org.blackcandy.shared.models.Song import sh.calvin.reorderable.ReorderableItemScope @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/org/blackcandy/android/data/UserRepository.kt b/app/src/main/java/org/blackcandy/android/data/UserRepository.kt index c47961f..fce9690 100644 --- a/app/src/main/java/org/blackcandy/android/data/UserRepository.kt +++ b/app/src/main/java/org/blackcandy/android/data/UserRepository.kt @@ -7,7 +7,7 @@ import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.providers.BearerAuthProvider import io.ktor.client.plugins.plugin import kotlinx.coroutines.flow.Flow -import org.blackcandy.android.api.BlackCandyService +import org.blackcandy.shared.api.BlackCandyService import org.blackcandy.shared.data.PreferencesDataSource import org.blackcandy.shared.models.User import org.blackcandy.shared.utils.TaskResult diff --git a/app/src/main/java/org/blackcandy/android/di/AppModule.kt b/app/src/main/java/org/blackcandy/android/di/AppModule.kt index 497625e..94bc4ce 100644 --- a/app/src/main/java/org/blackcandy/android/di/AppModule.kt +++ b/app/src/main/java/org/blackcandy/android/di/AppModule.kt @@ -33,12 +33,7 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNamingStrategy import okhttp3.OkHttpClient -import org.blackcandy.android.api.BlackCandyService -import org.blackcandy.android.api.BlackCandyServiceImpl -import org.blackcandy.android.data.CurrentPlaylistRepository import org.blackcandy.android.data.EncryptedPreferencesDataSource -import org.blackcandy.android.data.FavoritePlaylistRepository -import org.blackcandy.android.data.SystemInfoRepository import org.blackcandy.android.data.UserRepository import org.blackcandy.android.media.MusicServiceController import org.blackcandy.android.viewmodels.AccountSheetViewModel @@ -51,8 +46,13 @@ import org.blackcandy.android.viewmodels.PlayerViewModel import org.blackcandy.android.viewmodels.WebViewModel import org.blackcandy.shared.api.ApiError import org.blackcandy.shared.api.ApiException +import org.blackcandy.shared.api.BlackCandyService +import org.blackcandy.shared.api.BlackCandyServiceImpl +import org.blackcandy.shared.data.CurrentPlaylistRepository +import org.blackcandy.shared.data.FavoritePlaylistRepository import org.blackcandy.shared.data.PreferencesDataSource import org.blackcandy.shared.data.ServerAddressRepository +import org.blackcandy.shared.data.SystemInfoRepository import org.blackcandy.shared.models.User import org.blackcandy.shared.utils.BLACK_CANDY_USER_AGENT import org.koin.android.ext.koin.androidContext diff --git a/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt b/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt index 2cebf2e..141b913 100644 --- a/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt +++ b/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt @@ -2,6 +2,9 @@ package org.blackcandy.android.media import android.content.ComponentName import android.content.Context +import android.net.Uri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.session.MediaController import androidx.media3.session.SessionToken @@ -17,7 +20,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import org.blackcandy.android.models.MusicState import org.blackcandy.android.models.PlaybackMode -import org.blackcandy.android.models.Song +import org.blackcandy.shared.models.Song import kotlin.time.Duration.Companion.milliseconds class MusicServiceController( @@ -76,7 +79,7 @@ class MusicServiceController( } fun updatePlaylist(songs: List) { - val mediaItems = songs.map { it.toMediaItem() } + val mediaItems = songs.map { toMediaItem(it) } DiffUtil .calculateDiff( @@ -265,4 +268,19 @@ class MusicServiceController( _musicState.update { it.copy(currentSong = currentSong) } } + + private fun toMediaItem(song: Song): MediaItem = + MediaItem + .Builder() + .setMediaId(song.id.toString()) + .setUri(song.url) + .setMediaMetadata( + MediaMetadata + .Builder() + .setTitle(song.name) + .setArtist(song.artistName) + .setAlbumTitle(song.albumName) + .setArtworkUri(Uri.parse(song.albumImageUrl.large)) + .build(), + ).build() } diff --git a/app/src/main/java/org/blackcandy/android/models/MusicState.kt b/app/src/main/java/org/blackcandy/android/models/MusicState.kt index 7063ee5..1ab159e 100644 --- a/app/src/main/java/org/blackcandy/android/models/MusicState.kt +++ b/app/src/main/java/org/blackcandy/android/models/MusicState.kt @@ -1,6 +1,7 @@ package org.blackcandy.android.models import androidx.media3.common.Player +import org.blackcandy.shared.models.Song data class MusicState( val playlist: List = emptyList(), diff --git a/app/src/main/java/org/blackcandy/android/models/Song.kt b/app/src/main/java/org/blackcandy/android/models/Song.kt deleted file mode 100644 index 2858335..0000000 --- a/app/src/main/java/org/blackcandy/android/models/Song.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.blackcandy.android.models - -import android.net.Uri -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import kotlinx.serialization.Serializable - -@Serializable -data class Song( - val id: Int, - val name: String, - val duration: Double, - val url: String, - val albumName: String, - val artistName: String, - val format: String, - val albumImageUrl: ImageURL, - var isFavorited: Boolean, -) { - @Serializable - data class ImageURL( - val small: String, - val medium: String, - val large: String, - ) - - fun toMediaItem(): MediaItem = - MediaItem - .Builder() - .setMediaId(id.toString()) - .setUri(url) - .setMediaMetadata( - MediaMetadata - .Builder() - .setTitle(name) - .setArtist(artistName) - .setAlbumTitle(albumName) - .setArtworkUri(Uri.parse(albumImageUrl.large)) - .build(), - ).build() -} diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt index 9f4f29b..cec12d7 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt @@ -10,10 +10,10 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.blackcandy.android.R -import org.blackcandy.android.data.SystemInfoRepository import org.blackcandy.android.data.UserRepository import org.blackcandy.android.models.AlertMessage import org.blackcandy.shared.data.ServerAddressRepository +import org.blackcandy.shared.data.SystemInfoRepository import org.blackcandy.shared.models.User import org.blackcandy.shared.utils.TaskResult diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt index 65fee53..ce842db 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt @@ -3,10 +3,10 @@ package org.blackcandy.android.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.blackcandy.android.data.CurrentPlaylistRepository import org.blackcandy.android.data.UserRepository import org.blackcandy.android.fragments.navs.LibraryNavHostFragment import org.blackcandy.android.media.MusicServiceController +import org.blackcandy.shared.data.CurrentPlaylistRepository import org.blackcandy.shared.utils.TaskResult class MainViewModel( diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt index 3a47ee0..0a7ed66 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt @@ -9,11 +9,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.blackcandy.android.R -import org.blackcandy.android.data.CurrentPlaylistRepository import org.blackcandy.android.media.MusicServiceController import org.blackcandy.android.models.AlertMessage -import org.blackcandy.android.models.Song +import org.blackcandy.shared.data.CurrentPlaylistRepository import org.blackcandy.shared.data.ServerAddressRepository +import org.blackcandy.shared.models.Song import org.blackcandy.shared.utils.TaskResult import org.blackcandy.shared.utils.Theme diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt index e6228e3..207dffc 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt @@ -8,11 +8,11 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.blackcandy.android.data.CurrentPlaylistRepository -import org.blackcandy.android.data.FavoritePlaylistRepository import org.blackcandy.android.media.MusicServiceController import org.blackcandy.android.models.AlertMessage import org.blackcandy.android.models.MusicState +import org.blackcandy.shared.data.CurrentPlaylistRepository +import org.blackcandy.shared.data.FavoritePlaylistRepository import org.blackcandy.shared.utils.TaskResult data class PlayerUiState( diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 0060fe6..6039236 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -61,6 +61,11 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.auth) implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.datastore.preferences) // Add KMP dependencies here diff --git a/app/src/main/java/org/blackcandy/android/api/BlackCandyService.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/api/BlackCandyService.kt similarity index 97% rename from app/src/main/java/org/blackcandy/android/api/BlackCandyService.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/api/BlackCandyService.kt index 134e4fe..025610a 100644 --- a/app/src/main/java/org/blackcandy/android/api/BlackCandyService.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/api/BlackCandyService.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.api +package org.blackcandy.shared.api import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -19,10 +19,8 @@ import kotlinx.serialization.json.boolean import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import org.blackcandy.android.models.Song -import org.blackcandy.shared.api.ApiException -import org.blackcandy.shared.api.ApiResponse import org.blackcandy.shared.models.AuthenticationResponse +import org.blackcandy.shared.models.Song import org.blackcandy.shared.models.SystemInfo import org.blackcandy.shared.models.User diff --git a/app/src/main/java/org/blackcandy/android/data/CurrentPlaylistRepository.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/CurrentPlaylistRepository.kt similarity index 90% rename from app/src/main/java/org/blackcandy/android/data/CurrentPlaylistRepository.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/data/CurrentPlaylistRepository.kt index 02d6845..41d68e0 100644 --- a/app/src/main/java/org/blackcandy/android/data/CurrentPlaylistRepository.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/CurrentPlaylistRepository.kt @@ -1,7 +1,7 @@ -package org.blackcandy.android.data +package org.blackcandy.shared.data -import org.blackcandy.android.api.BlackCandyService -import org.blackcandy.android.models.Song +import org.blackcandy.shared.api.BlackCandyService +import org.blackcandy.shared.models.Song import org.blackcandy.shared.utils.TaskResult class CurrentPlaylistRepository( diff --git a/app/src/main/java/org/blackcandy/android/data/FavoritePlaylistRepository.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/FavoritePlaylistRepository.kt similarity index 73% rename from app/src/main/java/org/blackcandy/android/data/FavoritePlaylistRepository.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/data/FavoritePlaylistRepository.kt index 5b8ae1e..8361166 100644 --- a/app/src/main/java/org/blackcandy/android/data/FavoritePlaylistRepository.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/FavoritePlaylistRepository.kt @@ -1,7 +1,7 @@ -package org.blackcandy.android.data +package org.blackcandy.shared.data -import org.blackcandy.android.api.BlackCandyService -import org.blackcandy.android.models.Song +import org.blackcandy.shared.api.BlackCandyService +import org.blackcandy.shared.models.Song import org.blackcandy.shared.utils.TaskResult class FavoritePlaylistRepository( diff --git a/app/src/main/java/org/blackcandy/android/data/SystemInfoRepository.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/SystemInfoRepository.kt similarity index 75% rename from app/src/main/java/org/blackcandy/android/data/SystemInfoRepository.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/data/SystemInfoRepository.kt index 446d9d1..56ff092 100644 --- a/app/src/main/java/org/blackcandy/android/data/SystemInfoRepository.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/SystemInfoRepository.kt @@ -1,6 +1,6 @@ -package org.blackcandy.android.data +package org.blackcandy.shared.data -import org.blackcandy.android.api.BlackCandyService +import org.blackcandy.shared.api.BlackCandyService import org.blackcandy.shared.models.SystemInfo import org.blackcandy.shared.utils.TaskResult diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/models/Song.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/models/Song.kt new file mode 100644 index 0000000..91fb1a7 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/models/Song.kt @@ -0,0 +1,23 @@ +package org.blackcandy.shared.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Song( + val id: Int, + val name: String, + val duration: Double, + val url: String, + val albumName: String, + val artistName: String, + val format: String, + val albumImageUrl: ImageURL, + var isFavorited: Boolean, +) { + @Serializable + data class ImageURL( + val small: String, + val medium: String, + val large: String, + ) +} From 4c0132ce4ad2b404efdbc9c6ef5769d3398c576c Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Tue, 9 Dec 2025 19:10:14 +0900 Subject: [PATCH 09/24] Move UserRepository, EncryptedDataSource, and Cookies to shared module --- .../org/blackcandy/android/di/AppModule.kt | 19 +++++++--------- .../viewmodels/AccountSheetViewModel.kt | 2 +- .../android/viewmodels/LoginViewModel.kt | 2 +- .../android/viewmodels/MainViewModel.kt | 2 +- .../android/viewmodels/WebViewModel.kt | 2 +- .../shared/data/EncryptedDataSource.kt | 10 ++++----- .../org/blackcandy/shared/utils/Cookies.kt | 22 +++++++++++++++++++ .../shared/data/EncryptedDataSource.kt | 9 ++++++++ .../blackcandy/shared}/data/UserRepository.kt | 20 ++++++----------- .../org/blackcandy/shared/utils/Cookies.kt | 10 +++++++++ 10 files changed, 65 insertions(+), 33 deletions(-) rename app/src/main/java/org/blackcandy/android/data/EncryptedPreferencesDataSource.kt => shared/src/androidMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt (64%) create mode 100644 shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Cookies.kt create mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/data/UserRepository.kt (72%) create mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Cookies.kt diff --git a/app/src/main/java/org/blackcandy/android/di/AppModule.kt b/app/src/main/java/org/blackcandy/android/di/AppModule.kt index 94bc4ce..5dfe6b2 100644 --- a/app/src/main/java/org/blackcandy/android/di/AppModule.kt +++ b/app/src/main/java/org/blackcandy/android/di/AppModule.kt @@ -33,8 +33,6 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNamingStrategy import okhttp3.OkHttpClient -import org.blackcandy.android.data.EncryptedPreferencesDataSource -import org.blackcandy.android.data.UserRepository import org.blackcandy.android.media.MusicServiceController import org.blackcandy.android.viewmodels.AccountSheetViewModel import org.blackcandy.android.viewmodels.HomeViewModel @@ -49,10 +47,12 @@ import org.blackcandy.shared.api.ApiException import org.blackcandy.shared.api.BlackCandyService import org.blackcandy.shared.api.BlackCandyServiceImpl import org.blackcandy.shared.data.CurrentPlaylistRepository +import org.blackcandy.shared.data.EncryptedDataSource import org.blackcandy.shared.data.FavoritePlaylistRepository import org.blackcandy.shared.data.PreferencesDataSource import org.blackcandy.shared.data.ServerAddressRepository import org.blackcandy.shared.data.SystemInfoRepository +import org.blackcandy.shared.data.UserRepository import org.blackcandy.shared.models.User import org.blackcandy.shared.utils.BLACK_CANDY_USER_AGENT import org.koin.android.ext.koin.androidContext @@ -65,7 +65,6 @@ import java.io.OutputStream val appModule = module { single { provideJson() } - single { provideCookieManager() } single { provideEncryptedSharedPreferences(androidContext()) } single(named("PreferencesDataStore")) { provideDataStore(androidContext()) } single(named("UserDataStore")) { provideUserDataStore(androidContext()) } @@ -73,13 +72,13 @@ val appModule = single { provideDataSourceFactory(get()) } single { PreferencesDataSource(get(named("PreferencesDataStore"))) } - single { EncryptedPreferencesDataSource(get()) } + single { EncryptedDataSource(get()) } single { BlackCandyServiceImpl(get()) } single { MusicServiceController(androidContext()) } single { ServerAddressRepository(get()) } single { SystemInfoRepository(get()) } - single { UserRepository(get(), get(), get(), get(named("UserDataStore")), get(), get()) } + single { UserRepository(get(), get(), get(named("UserDataStore")), get(), get()) } single { CurrentPlaylistRepository(get()) } single { FavoritePlaylistRepository(get()) } @@ -100,7 +99,7 @@ private const val ENCRYPTED_SHARED_PREFERENCES_FILE_NAME = "encrypted_preference private fun provideHttpClient( json: Json, preferencesDataSource: PreferencesDataSource, - encryptedPreferencesDataSource: EncryptedPreferencesDataSource, + encryptedDataSource: EncryptedDataSource, ): HttpClient = HttpClient { expectSuccess = true @@ -116,7 +115,7 @@ private fun provideHttpClient( install(Auth) { bearer { loadTokens { - encryptedPreferencesDataSource.getApiToken()?.let { + encryptedDataSource.getApiToken()?.let { BearerTokens(it, "") } } @@ -206,8 +205,6 @@ private fun provideUserDataStore(appContext: Context): DataStore { ) } -private fun provideCookieManager(): CookieManager = CookieManager.getInstance() - private fun provideEncryptedSharedPreferences(appContext: Context): SharedPreferences = EncryptedSharedPreferences.create( ENCRYPTED_SHARED_PREFERENCES_FILE_NAME, @@ -218,9 +215,9 @@ private fun provideEncryptedSharedPreferences(appContext: Context): SharedPrefer ) @androidx.annotation.OptIn(UnstableApi::class) -private fun provideDataSourceFactory(encryptedPreferencesDataSource: EncryptedPreferencesDataSource): DataSource.Factory { +private fun provideDataSourceFactory(encryptedDataSource: EncryptedDataSource): DataSource.Factory { val httpClient = OkHttpClient().newBuilder().build() - val apiToken = encryptedPreferencesDataSource.getApiToken() + val apiToken = encryptedDataSource.getApiToken() return DataSource.Factory { val dataSource = diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt index e3fd03a..73fcf2c 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt @@ -6,8 +6,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.blackcandy.android.data.UserRepository import org.blackcandy.shared.data.ServerAddressRepository +import org.blackcandy.shared.data.UserRepository import org.blackcandy.shared.models.User data class AccountSheetUiState( diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt index cec12d7..52c025f 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt @@ -10,10 +10,10 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.blackcandy.android.R -import org.blackcandy.android.data.UserRepository import org.blackcandy.android.models.AlertMessage import org.blackcandy.shared.data.ServerAddressRepository import org.blackcandy.shared.data.SystemInfoRepository +import org.blackcandy.shared.data.UserRepository import org.blackcandy.shared.models.User import org.blackcandy.shared.utils.TaskResult diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt index ce842db..95917d5 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt @@ -3,10 +3,10 @@ package org.blackcandy.android.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.blackcandy.android.data.UserRepository import org.blackcandy.android.fragments.navs.LibraryNavHostFragment import org.blackcandy.android.media.MusicServiceController import org.blackcandy.shared.data.CurrentPlaylistRepository +import org.blackcandy.shared.data.UserRepository import org.blackcandy.shared.utils.TaskResult class MainViewModel( diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/WebViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/WebViewModel.kt index 23013f8..e869a51 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/WebViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/WebViewModel.kt @@ -3,7 +3,7 @@ package org.blackcandy.android.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.blackcandy.android.data.UserRepository +import org.blackcandy.shared.data.UserRepository class WebViewModel( val userRepository: UserRepository, diff --git a/app/src/main/java/org/blackcandy/android/data/EncryptedPreferencesDataSource.kt b/shared/src/androidMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt similarity index 64% rename from app/src/main/java/org/blackcandy/android/data/EncryptedPreferencesDataSource.kt rename to shared/src/androidMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt index 4ec58ad..3d65a33 100644 --- a/app/src/main/java/org/blackcandy/android/data/EncryptedPreferencesDataSource.kt +++ b/shared/src/androidMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt @@ -1,24 +1,24 @@ -package org.blackcandy.android.data +package org.blackcandy.shared.data import android.content.SharedPreferences -class EncryptedPreferencesDataSource( +actual class EncryptedDataSource( private val encryptedSharedPrefs: SharedPreferences, ) { companion object { private const val API_TOKEN_KEY = "api_token_key" } - fun getApiToken(): String? = encryptedSharedPrefs.getString(API_TOKEN_KEY, null) + actual fun getApiToken(): String? = encryptedSharedPrefs.getString(API_TOKEN_KEY, null) - fun updateApiToken(apiToken: String) { + actual fun updateApiToken(apiToken: String) { with(encryptedSharedPrefs.edit()) { putString(API_TOKEN_KEY, apiToken) apply() } } - fun removeApiToken() { + actual fun removeApiToken() { with(encryptedSharedPrefs.edit()) { remove(API_TOKEN_KEY) apply() diff --git a/shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Cookies.kt b/shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Cookies.kt new file mode 100644 index 0000000..a62c04a --- /dev/null +++ b/shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Cookies.kt @@ -0,0 +1,22 @@ +package org.blackcandy.shared.utils + +import android.webkit.CookieManager + +actual object Cookies { + val cookieManager: CookieManager = CookieManager.getInstance() + + actual fun update( + path: String, + cookies: List, + ) { + cookies.forEach { + cookieManager.setCookie(path, it) + } + + cookieManager.flush() + } + + actual fun clean() { + cookieManager.removeAllCookies(null) + } +} diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt new file mode 100644 index 0000000..4fc1d3b --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt @@ -0,0 +1,9 @@ +package org.blackcandy.shared.data + +expect class EncryptedDataSource { + fun getApiToken(): String? + + fun updateApiToken(apiToken: String) + + fun removeApiToken() +} diff --git a/app/src/main/java/org/blackcandy/android/data/UserRepository.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/UserRepository.kt similarity index 72% rename from app/src/main/java/org/blackcandy/android/data/UserRepository.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/data/UserRepository.kt index fce9690..54ad5bf 100644 --- a/app/src/main/java/org/blackcandy/android/data/UserRepository.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/UserRepository.kt @@ -1,6 +1,5 @@ -package org.blackcandy.android.data +package org.blackcandy.shared.data -import android.webkit.CookieManager import androidx.datastore.core.DataStore import io.ktor.client.HttpClient import io.ktor.client.plugins.auth.Auth @@ -8,17 +7,16 @@ import io.ktor.client.plugins.auth.providers.BearerAuthProvider import io.ktor.client.plugins.plugin import kotlinx.coroutines.flow.Flow import org.blackcandy.shared.api.BlackCandyService -import org.blackcandy.shared.data.PreferencesDataSource import org.blackcandy.shared.models.User +import org.blackcandy.shared.utils.Cookies import org.blackcandy.shared.utils.TaskResult class UserRepository( private val httpClient: HttpClient, private val service: BlackCandyService, - private val cookieManager: CookieManager, private val userDataStore: DataStore, private val preferencesDataSource: PreferencesDataSource, - private val encryptedPreferencesDataSource: EncryptedPreferencesDataSource, + private val encryptedDataSource: EncryptedDataSource, ) { suspend fun login( email: String, @@ -28,13 +26,9 @@ class UserRepository( val response = service.createAuthentication(email, password).orThrow() val serverAddress = preferencesDataSource.getServerAddress() - response.cookies.forEach { - cookieManager.setCookie(serverAddress, it) - } - - cookieManager.flush() + Cookies.update(serverAddress, response.cookies) userDataStore.updateData { response.user } - encryptedPreferencesDataSource.updateApiToken(response.token) + encryptedDataSource.updateApiToken(response.token) // Clear previous cached auth token in http client httpClient @@ -52,8 +46,8 @@ class UserRepository( suspend fun logout() { service.removeAuthentication() - encryptedPreferencesDataSource.removeApiToken() - cookieManager.removeAllCookies(null) + encryptedDataSource.removeApiToken() + Cookies.clean() userDataStore.updateData { null } } diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Cookies.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Cookies.kt new file mode 100644 index 0000000..1c7180a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Cookies.kt @@ -0,0 +1,10 @@ +package org.blackcandy.shared.utils + +expect object Cookies { + fun update( + path: String, + cookies: List, + ): Unit + + fun clean(): Unit +} From 1717d6c8291959bce88f160a4d070e682dccd2a0 Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Wed, 10 Dec 2025 19:08:43 +0900 Subject: [PATCH 10/24] Move AlertMessage and PlaybackMode to shared module --- .../android/compose/account/AccountMenu.kt | 2 +- .../android/compose/player/FullPlayer.kt | 2 +- .../android/compose/player/PlayerActions.kt | 31 ++++++++++++++--- .../fragments/sheets/AccountSheetFragment.kt | 2 +- .../android/media/MusicServiceController.kt | 2 +- .../blackcandy/android/models/AlertMessage.kt | 13 -------- .../blackcandy/android/models/MusicState.kt | 1 + .../blackcandy/android/models/PlaybackMode.kt | 33 ------------------- .../android/{models => ui}/MenuItem.kt | 2 +- .../blackcandy/android/utils/SnackbarUtil.kt | 13 ++++++-- .../android/viewmodels/LoginViewModel.kt | 11 ++++--- .../android/viewmodels/NavHostViewModel.kt | 13 +++++--- .../android/viewmodels/PlayerViewModel.kt | 2 +- .../blackcandy/shared/media/PlaybackMode.kt | 11 +++++++ .../blackcandy/shared/models/AlertMessage.kt | 17 ++++++++++ 15 files changed, 86 insertions(+), 69 deletions(-) delete mode 100644 app/src/main/java/org/blackcandy/android/models/AlertMessage.kt delete mode 100644 app/src/main/java/org/blackcandy/android/models/PlaybackMode.kt rename app/src/main/java/org/blackcandy/android/{models => ui}/MenuItem.kt (84%) create mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/media/PlaybackMode.kt create mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/models/AlertMessage.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/account/AccountMenu.kt b/app/src/main/java/org/blackcandy/android/compose/account/AccountMenu.kt index af89f7e..82fbae3 100644 --- a/app/src/main/java/org/blackcandy/android/compose/account/AccountMenu.kt +++ b/app/src/main/java/org/blackcandy/android/compose/account/AccountMenu.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import org.blackcandy.android.models.MenuItem +import org.blackcandy.android.ui.MenuItem @Composable fun AccountMenu(menuItems: List) { diff --git a/app/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt b/app/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt index 725d32b..9805bcb 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt +++ b/app/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.dimensionResource import org.blackcandy.android.R -import org.blackcandy.android.models.PlaybackMode +import org.blackcandy.shared.media.PlaybackMode import org.blackcandy.shared.models.Song @Composable diff --git a/app/src/main/java/org/blackcandy/android/compose/player/PlayerActions.kt b/app/src/main/java/org/blackcandy/android/compose/player/PlayerActions.kt index 2a15880..d7f9c8d 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/PlayerActions.kt +++ b/app/src/main/java/org/blackcandy/android/compose/player/PlayerActions.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import org.blackcandy.android.R -import org.blackcandy.android.models.PlaybackMode +import org.blackcandy.shared.media.PlaybackMode @Composable fun PlayerActions( @@ -33,10 +33,7 @@ fun PlayerActions( checked = playbackMode != PlaybackMode.NO_REPEAT, onCheckedChange = { _ -> onModeSwitchButtonClicked() }, ) { - Icon( - painter = painterResource(playbackMode.iconResourceId), - contentDescription = stringResource(playbackMode.titleResourceId), - ) + PlaybackModeIcon(playbackMode) } IconButton( @@ -66,3 +63,27 @@ fun PlayerActions( } } } + +@Composable +fun PlaybackModeIcon(playbackMode: PlaybackMode) { + val iconResourceId = + when (playbackMode) { + PlaybackMode.NO_REPEAT -> R.drawable.baseline_repeat_24 + PlaybackMode.REPEAT -> R.drawable.baseline_repeat_24 + PlaybackMode.REPEAT_ONE -> R.drawable.baseline_repeat_one_24 + PlaybackMode.SHUFFLE -> R.drawable.baseline_shuffle_24 + } + + val titleResourceId = + when (playbackMode) { + PlaybackMode.NO_REPEAT -> R.string.no_repeat_mode + PlaybackMode.REPEAT -> R.string.repeat_mode + PlaybackMode.REPEAT_ONE -> R.string.repeat_one_mode + PlaybackMode.SHUFFLE -> R.string.shuffle_mode + } + + Icon( + painter = painterResource(iconResourceId), + contentDescription = stringResource(titleResourceId), + ) +} diff --git a/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt index 2d19cc2..889caa2 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.launch import org.blackcandy.android.R import org.blackcandy.android.compose.account.AccountMenu import org.blackcandy.android.databinding.FragmentSheetAccountBinding -import org.blackcandy.android.models.MenuItem +import org.blackcandy.android.ui.MenuItem import org.blackcandy.android.viewmodels.AccountSheetViewModel import org.koin.androidx.viewmodel.ext.android.viewModel diff --git a/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt b/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt index 141b913..a6ffb28 100644 --- a/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt +++ b/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt @@ -19,7 +19,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import org.blackcandy.android.models.MusicState -import org.blackcandy.android.models.PlaybackMode +import org.blackcandy.shared.media.PlaybackMode import org.blackcandy.shared.models.Song import kotlin.time.Duration.Companion.milliseconds diff --git a/app/src/main/java/org/blackcandy/android/models/AlertMessage.kt b/app/src/main/java/org/blackcandy/android/models/AlertMessage.kt deleted file mode 100644 index 25e9ff0..0000000 --- a/app/src/main/java/org/blackcandy/android/models/AlertMessage.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.blackcandy.android.models - -import androidx.annotation.StringRes - -sealed class AlertMessage { - data class String( - val value: kotlin.String?, - ) : AlertMessage() - - data class StringResource( - @StringRes val value: Int, - ) : AlertMessage() -} diff --git a/app/src/main/java/org/blackcandy/android/models/MusicState.kt b/app/src/main/java/org/blackcandy/android/models/MusicState.kt index 1ab159e..87c147e 100644 --- a/app/src/main/java/org/blackcandy/android/models/MusicState.kt +++ b/app/src/main/java/org/blackcandy/android/models/MusicState.kt @@ -1,6 +1,7 @@ package org.blackcandy.android.models import androidx.media3.common.Player +import org.blackcandy.shared.media.PlaybackMode import org.blackcandy.shared.models.Song data class MusicState( diff --git a/app/src/main/java/org/blackcandy/android/models/PlaybackMode.kt b/app/src/main/java/org/blackcandy/android/models/PlaybackMode.kt deleted file mode 100644 index 0642510..0000000 --- a/app/src/main/java/org/blackcandy/android/models/PlaybackMode.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.blackcandy.android.models - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import org.blackcandy.android.R - -enum class PlaybackMode { - NO_REPEAT, - REPEAT, - REPEAT_ONE, - SHUFFLE, - ; - - @get:DrawableRes - val iconResourceId get() = - when (this) { - NO_REPEAT -> R.drawable.baseline_repeat_24 - REPEAT -> R.drawable.baseline_repeat_24 - REPEAT_ONE -> R.drawable.baseline_repeat_one_24 - SHUFFLE -> R.drawable.baseline_shuffle_24 - } - - @get:StringRes - val titleResourceId get() = - when (this) { - NO_REPEAT -> R.string.no_repeat_mode - REPEAT -> R.string.repeat_mode - REPEAT_ONE -> R.string.repeat_one_mode - SHUFFLE -> R.string.shuffle_mode - } - - val next get() = PlaybackMode.values()[(this.ordinal + 1) % PlaybackMode.values().size] -} diff --git a/app/src/main/java/org/blackcandy/android/models/MenuItem.kt b/app/src/main/java/org/blackcandy/android/ui/MenuItem.kt similarity index 84% rename from app/src/main/java/org/blackcandy/android/models/MenuItem.kt rename to app/src/main/java/org/blackcandy/android/ui/MenuItem.kt index 5f01898..79aae9f 100644 --- a/app/src/main/java/org/blackcandy/android/models/MenuItem.kt +++ b/app/src/main/java/org/blackcandy/android/ui/MenuItem.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.models +package org.blackcandy.android.ui import androidx.annotation.DrawableRes import androidx.annotation.StringRes diff --git a/app/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt b/app/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt index 390dd7f..8784d23 100644 --- a/app/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt +++ b/app/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.res.stringResource import com.google.android.material.snackbar.Snackbar import org.blackcandy.android.R -import org.blackcandy.android.models.AlertMessage +import org.blackcandy.shared.models.AlertMessage class SnackbarUtil { companion object { @@ -21,7 +21,7 @@ class SnackbarUtil { val snackbarText = when (message) { is AlertMessage.String -> message.value - is AlertMessage.StringResource -> stringResource(message.value) + is AlertMessage.LocalizedString -> stringResource(getLocalizedString(message.value)) } ?: return LaunchedEffect(state) { @@ -40,7 +40,7 @@ class SnackbarUtil { val snackbarText = when (message) { is AlertMessage.String -> message.value - is AlertMessage.StringResource -> rootView.context.getString(message.value) + is AlertMessage.LocalizedString -> rootView.context.getString(getLocalizedString(message.value)) } ?: return Snackbar @@ -54,5 +54,12 @@ class SnackbarUtil { }, ).show() } + + fun getLocalizedString(definedMessage: AlertMessage.DefinedMessages): Int = + when (definedMessage) { + AlertMessage.DefinedMessages.UNSUPPORTED_SERVER -> R.string.unsupported_server + AlertMessage.DefinedMessages.INVALID_SERVER_ADDRESS -> R.string.invalid_server_address + AlertMessage.DefinedMessages.ADDED_TO_PLAYLIST -> R.string.added_to_playlist + } } } diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt index 52c025f..2cfa920 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt @@ -9,11 +9,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.blackcandy.android.R -import org.blackcandy.android.models.AlertMessage import org.blackcandy.shared.data.ServerAddressRepository import org.blackcandy.shared.data.SystemInfoRepository import org.blackcandy.shared.data.UserRepository +import org.blackcandy.shared.models.AlertMessage import org.blackcandy.shared.models.User import org.blackcandy.shared.utils.TaskResult @@ -68,7 +67,7 @@ class LoginViewModel( } if (!Patterns.WEB_URL.matcher(serverAddress).matches()) { - _uiState.update { it.copy(alertMessage = AlertMessage.StringResource(R.string.invalid_server_address)) } + _uiState.update { it.copy(alertMessage = AlertMessage.LocalizedString(AlertMessage.DefinedMessages.INVALID_SERVER_ADDRESS)) } return } @@ -78,7 +77,11 @@ class LoginViewModel( when (val result = systemInfoRepository.getSystemInfo()) { is TaskResult.Success -> { if (!result.data.isSupported) { - _uiState.update { it.copy(alertMessage = AlertMessage.StringResource(R.string.unsupported_server)) } + _uiState.update { + it.copy( + alertMessage = AlertMessage.LocalizedString(AlertMessage.DefinedMessages.UNSUPPORTED_SERVER), + ) + } } else { val responseServerAddress = result.data.serverAddress diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt index 0a7ed66..cc6b23a 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt @@ -8,11 +8,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import org.blackcandy.android.R import org.blackcandy.android.media.MusicServiceController -import org.blackcandy.android.models.AlertMessage import org.blackcandy.shared.data.CurrentPlaylistRepository import org.blackcandy.shared.data.ServerAddressRepository +import org.blackcandy.shared.models.AlertMessage import org.blackcandy.shared.models.Song import org.blackcandy.shared.utils.TaskResult import org.blackcandy.shared.utils.Theme @@ -136,7 +135,11 @@ class NavHostViewModel( val songIndex = musicServiceController.addSongToNext(result.data) musicServiceController.playOn(songIndex) - _uiState.update { it.copy(alertMessage = AlertMessage.StringResource(R.string.added_to_playlist)) } + _uiState.update { + it.copy( + alertMessage = AlertMessage.LocalizedString(AlertMessage.DefinedMessages.ADDED_TO_PLAYLIST), + ) + } } is TaskResult.Failure -> { @@ -154,7 +157,7 @@ class NavHostViewModel( when (val result = currentPlaylistRepository.addSongToNext(songId, currentSong.id)) { is TaskResult.Success -> { musicServiceController.addSongToNext(result.data) - _uiState.update { it.copy(alertMessage = AlertMessage.StringResource(R.string.added_to_playlist)) } + _uiState.update { it.copy(alertMessage = AlertMessage.LocalizedString(AlertMessage.DefinedMessages.ADDED_TO_PLAYLIST)) } } is TaskResult.Failure -> { @@ -169,7 +172,7 @@ class NavHostViewModel( when (val result = currentPlaylistRepository.addSongToLast(songId)) { is TaskResult.Success -> { musicServiceController.addSongToLast(result.data) - _uiState.update { it.copy(alertMessage = AlertMessage.StringResource(R.string.added_to_playlist)) } + _uiState.update { it.copy(alertMessage = AlertMessage.LocalizedString(AlertMessage.DefinedMessages.ADDED_TO_PLAYLIST)) } } is TaskResult.Failure -> { diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt index 207dffc..497ba7e 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt @@ -9,10 +9,10 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.blackcandy.android.media.MusicServiceController -import org.blackcandy.android.models.AlertMessage import org.blackcandy.android.models.MusicState import org.blackcandy.shared.data.CurrentPlaylistRepository import org.blackcandy.shared.data.FavoritePlaylistRepository +import org.blackcandy.shared.models.AlertMessage import org.blackcandy.shared.utils.TaskResult data class PlayerUiState( diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/media/PlaybackMode.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/media/PlaybackMode.kt new file mode 100644 index 0000000..ac17761 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/media/PlaybackMode.kt @@ -0,0 +1,11 @@ +package org.blackcandy.shared.media + +enum class PlaybackMode { + NO_REPEAT, + REPEAT, + REPEAT_ONE, + SHUFFLE, + ; + + val next get() = values()[(this.ordinal + 1) % values().size] +} diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/models/AlertMessage.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/models/AlertMessage.kt new file mode 100644 index 0000000..2601334 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/models/AlertMessage.kt @@ -0,0 +1,17 @@ +package org.blackcandy.shared.models + +sealed class AlertMessage { + enum class DefinedMessages { + INVALID_SERVER_ADDRESS, + UNSUPPORTED_SERVER, + ADDED_TO_PLAYLIST, + } + + data class String( + val value: kotlin.String?, + ) : AlertMessage() + + data class LocalizedString( + val value: DefinedMessages, + ) : AlertMessage() +} From f295e5c32e6eed8d2198c69e06b9e2d63a94044c Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Thu, 11 Dec 2025 18:47:18 +0900 Subject: [PATCH 11/24] Move media services to share module --- app/src/main/AndroidManifest.xml | 2 +- .../org/blackcandy/android/di/AppModule.kt | 2 +- .../android/viewmodels/MainViewModel.kt | 2 +- .../android/viewmodels/MiniPlayerViewModel.kt | 2 +- .../android/viewmodels/NavHostViewModel.kt | 2 +- .../android/viewmodels/PlayerViewModel.kt | 4 +- gradle/libs.versions.toml | 2 + shared/build.gradle.kts | 5 ++ .../blackcandy/shared}/media/MusicService.kt | 2 +- .../shared}/media/MusicServiceController.kt | 53 +++++++++++-------- .../shared/media/MusicServiceController.kt | 45 ++++++++++++++++ .../blackcandy/shared/media}/MusicState.kt | 8 ++- .../blackcandy/shared/media/PlaybackState.kt | 8 +++ 13 files changed, 101 insertions(+), 36 deletions(-) rename {app/src/main/java/org/blackcandy/android => shared/src/androidMain/kotlin/org/blackcandy/shared}/media/MusicService.kt (98%) rename {app/src/main/java/org/blackcandy/android => shared/src/androidMain/kotlin/org/blackcandy/shared}/media/MusicServiceController.kt (86%) create mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt rename {app/src/main/java/org/blackcandy/android/models => shared/src/commonMain/kotlin/org/blackcandy/shared/media}/MusicState.kt (55%) create mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/media/PlaybackState.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 875cf81..dde126a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,7 +33,7 @@ diff --git a/app/src/main/java/org/blackcandy/android/di/AppModule.kt b/app/src/main/java/org/blackcandy/android/di/AppModule.kt index 5dfe6b2..813dd48 100644 --- a/app/src/main/java/org/blackcandy/android/di/AppModule.kt +++ b/app/src/main/java/org/blackcandy/android/di/AppModule.kt @@ -33,7 +33,6 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNamingStrategy import okhttp3.OkHttpClient -import org.blackcandy.android.media.MusicServiceController import org.blackcandy.android.viewmodels.AccountSheetViewModel import org.blackcandy.android.viewmodels.HomeViewModel import org.blackcandy.android.viewmodels.LoginViewModel @@ -53,6 +52,7 @@ import org.blackcandy.shared.data.PreferencesDataSource import org.blackcandy.shared.data.ServerAddressRepository import org.blackcandy.shared.data.SystemInfoRepository import org.blackcandy.shared.data.UserRepository +import org.blackcandy.shared.media.MusicServiceController import org.blackcandy.shared.models.User import org.blackcandy.shared.utils.BLACK_CANDY_USER_AGENT import org.koin.android.ext.koin.androidContext diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt index 95917d5..09a0b13 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt @@ -4,9 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch import org.blackcandy.android.fragments.navs.LibraryNavHostFragment -import org.blackcandy.android.media.MusicServiceController import org.blackcandy.shared.data.CurrentPlaylistRepository import org.blackcandy.shared.data.UserRepository +import org.blackcandy.shared.media.MusicServiceController import org.blackcandy.shared.utils.TaskResult class MainViewModel( diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/MiniPlayerViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/MiniPlayerViewModel.kt index 1ce6c01..bcac56b 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/MiniPlayerViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/MiniPlayerViewModel.kt @@ -1,7 +1,7 @@ package org.blackcandy.android.viewmodels import androidx.lifecycle.ViewModel -import org.blackcandy.android.media.MusicServiceController +import org.blackcandy.shared.media.MusicServiceController class MiniPlayerViewModel( private val musicServiceController: MusicServiceController, diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt index cc6b23a..db04006 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt @@ -8,9 +8,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import org.blackcandy.android.media.MusicServiceController import org.blackcandy.shared.data.CurrentPlaylistRepository import org.blackcandy.shared.data.ServerAddressRepository +import org.blackcandy.shared.media.MusicServiceController import org.blackcandy.shared.models.AlertMessage import org.blackcandy.shared.models.Song import org.blackcandy.shared.utils.TaskResult diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt index 497ba7e..5c3a75e 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt @@ -8,10 +8,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.blackcandy.android.media.MusicServiceController -import org.blackcandy.android.models.MusicState import org.blackcandy.shared.data.CurrentPlaylistRepository import org.blackcandy.shared.data.FavoritePlaylistRepository +import org.blackcandy.shared.media.MusicServiceController +import org.blackcandy.shared.media.MusicState import org.blackcandy.shared.models.AlertMessage import org.blackcandy.shared.utils.TaskResult diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4cf84a7..e354017 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ kotlinStdlib = "2.2.0" kotlinTest = "2.2.0" runner = "1.5.2" core = "1.5.0" +recyclerview = "1.3.0" [libraries] androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } @@ -69,6 +70,7 @@ kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", versio kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinTest" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } +androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 6039236..60f4984 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -67,6 +67,7 @@ kotlin { implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.client.auth) implementation(libs.kotlinx.serialization.json) + implementation(libs.koin.androidx.compose) implementation(libs.androidx.datastore.preferences) // Add KMP dependencies here } @@ -83,6 +84,10 @@ kotlin { // Add Android-specific dependencies here. Note that this source set depends on // commonMain by default and will correctly pull the Android artifacts of any KMP // dependencies declared in commonMain. + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.session) + implementation(libs.androidx.media3.datasource.okhttp) + implementation(libs.androidx.recyclerview) } } diff --git a/app/src/main/java/org/blackcandy/android/media/MusicService.kt b/shared/src/androidMain/kotlin/org/blackcandy/shared/media/MusicService.kt similarity index 98% rename from app/src/main/java/org/blackcandy/android/media/MusicService.kt rename to shared/src/androidMain/kotlin/org/blackcandy/shared/media/MusicService.kt index e97c6b7..2d05486 100644 --- a/app/src/main/java/org/blackcandy/android/media/MusicService.kt +++ b/shared/src/androidMain/kotlin/org/blackcandy/shared/media/MusicService.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.media +package org.blackcandy.shared.media import android.content.Intent import androidx.annotation.OptIn diff --git a/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt b/shared/src/androidMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt similarity index 86% rename from app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt rename to shared/src/androidMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt index a6ffb28..0454c3d 100644 --- a/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt +++ b/shared/src/androidMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.media +package org.blackcandy.shared.media import android.content.ComponentName import android.content.Context @@ -18,19 +18,17 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive -import org.blackcandy.android.models.MusicState -import org.blackcandy.shared.media.PlaybackMode import org.blackcandy.shared.models.Song import kotlin.time.Duration.Companion.milliseconds -class MusicServiceController( +actual class MusicServiceController( private val appContext: Context, ) { private var controller: MediaController? = null private val _musicState = MutableStateFlow(MusicState()) - val musicState = _musicState.asStateFlow() - val currentPosition = + actual val musicState = _musicState.asStateFlow() + actual val currentPosition = flow { while (currentCoroutineContext().isActive) { val currentPosition = (controller?.currentPosition ?: 0) / 1000.0 @@ -39,7 +37,7 @@ class MusicServiceController( } } - fun initMediaController(onInitialized: () -> Unit) { + actual fun initMediaController(onInitialized: () -> Unit) { val controllerFuture = MediaController .Builder( @@ -56,7 +54,7 @@ class MusicServiceController( events: Player.Events, ) { if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { - _musicState.update { it.copy(playbackState = player.playbackState) } + _musicState.update { it.copy(playbackState = toPlaybackState(player.playbackState)) } if (player.playbackState == Player.STATE_ENDED) { player.seekToDefaultPosition(0) @@ -78,7 +76,7 @@ class MusicServiceController( }, MoreExecutors.directExecutor()) } - fun updatePlaylist(songs: List) { + actual fun updatePlaylist(songs: List) { val mediaItems = songs.map { toMediaItem(it) } DiffUtil @@ -134,44 +132,44 @@ class MusicServiceController( _musicState.update { it.copy(playlist = songs) } } - fun play() { + actual fun play() { controller?.play() } - fun pause() { + actual fun pause() { controller?.pause() } - fun next() { + actual fun next() { controller?.run { seekToNext() play() } } - fun previous() { + actual fun previous() { controller?.run { seekToPrevious() play() } } - fun playOn(index: Int) { + actual fun playOn(index: Int) { controller?.run { seekToDefaultPosition(index) play() } } - fun seekTo(seconds: Double) { + actual fun seekTo(seconds: Double) { controller?.seekTo((seconds * 1000).toLong()) } - fun clearPlaylist() { + actual fun clearPlaylist() { updatePlaylist(emptyList()) } - fun deleteSongFromPlaylist(song: Song) { + actual fun deleteSongFromPlaylist(song: Song) { val songs = musicState.value.playlist .toMutableList() @@ -179,7 +177,7 @@ class MusicServiceController( updatePlaylist(songs) } - fun updateSongInPlaylist(song: Song) { + actual fun updateSongInPlaylist(song: Song) { val songs = musicState.value.playlist.map { if (it.id == song.id) song else it } updatePlaylist(songs) @@ -188,7 +186,7 @@ class MusicServiceController( } } - fun moveSongInPlaylist( + actual fun moveSongInPlaylist( from: Int, to: Int, ) { @@ -199,7 +197,7 @@ class MusicServiceController( updatePlaylist(songs) } - fun setPlaybackMode(playbackMode: PlaybackMode) { + actual fun setPlaybackMode(playbackMode: PlaybackMode) { when (playbackMode) { PlaybackMode.NO_REPEAT -> { controller?.run { @@ -233,9 +231,9 @@ class MusicServiceController( _musicState.update { it.copy(playbackMode = playbackMode) } } - fun getSongIndex(songId: Int): Int = musicState.value.playlist.indexOfFirst { it.id == songId } + actual fun getSongIndex(songId: Int): Int = musicState.value.playlist.indexOfFirst { it.id == songId } - fun addSongToNext(song: Song): Int { + actual fun addSongToNext(song: Song): Int { val currentSong = musicState.value.currentSong val songs = if (currentSong != null) { @@ -254,7 +252,7 @@ class MusicServiceController( return songs.indexOf(song) } - fun addSongToLast(song: Song) { + actual fun addSongToLast(song: Song) { val songs = musicState.value.playlist .toMutableList() @@ -283,4 +281,13 @@ class MusicServiceController( .setArtworkUri(Uri.parse(song.albumImageUrl.large)) .build(), ).build() + + private fun toPlaybackState(playerState: Int): PlaybackState = + when (playerState) { + Player.STATE_IDLE -> PlaybackState.IDLE + Player.STATE_BUFFERING -> PlaybackState.BUFFERING + Player.STATE_READY -> PlaybackState.READY + Player.STATE_ENDED -> PlaybackState.ENDED + else -> PlaybackState.IDLE + } } diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt new file mode 100644 index 0000000..7183d4d --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt @@ -0,0 +1,45 @@ +package org.blackcandy.shared.media + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.blackcandy.shared.models.Song + +expect class MusicServiceController { + val musicState: StateFlow + val currentPosition: Flow + + fun initMediaController(onInitialized: () -> Unit) + + fun updatePlaylist(songs: List) + + fun play() + + fun pause() + + fun next() + + fun previous() + + fun playOn(index: Int) + + fun seekTo(seconds: Double) + + fun clearPlaylist() + + fun deleteSongFromPlaylist(song: Song) + + fun updateSongInPlaylist(song: Song) + + fun moveSongInPlaylist( + from: Int, + to: Int, + ) + + fun setPlaybackMode(playbackMode: PlaybackMode) + + fun getSongIndex(songId: Int): Int + + fun addSongToNext(song: Song): Int + + fun addSongToLast(song: Song) +} diff --git a/app/src/main/java/org/blackcandy/android/models/MusicState.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/media/MusicState.kt similarity index 55% rename from app/src/main/java/org/blackcandy/android/models/MusicState.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/media/MusicState.kt index 87c147e..66f24ad 100644 --- a/app/src/main/java/org/blackcandy/android/models/MusicState.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/media/MusicState.kt @@ -1,16 +1,14 @@ -package org.blackcandy.android.models +package org.blackcandy.shared.media -import androidx.media3.common.Player -import org.blackcandy.shared.media.PlaybackMode import org.blackcandy.shared.models.Song data class MusicState( val playlist: List = emptyList(), - val playbackState: Int = Player.STATE_IDLE, + val playbackState: PlaybackState = PlaybackState.IDLE, val currentSong: Song? = null, val isPlaying: Boolean = false, val playbackMode: PlaybackMode = PlaybackMode.NO_REPEAT, ) { val hasCurrentSong: Boolean get() = currentSong != null - val isLoading: Boolean get() = playbackState == Player.STATE_BUFFERING + val isLoading: Boolean get() = playbackState == PlaybackState.BUFFERING } diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/media/PlaybackState.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/media/PlaybackState.kt new file mode 100644 index 0000000..b3065ff --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/media/PlaybackState.kt @@ -0,0 +1,8 @@ +package org.blackcandy.shared.media + +enum class PlaybackState { + IDLE, + BUFFERING, + READY, + ENDED, +} From 2a1499f2cc38fffadca42129bdcd9eb9d192887b Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Mon, 15 Dec 2025 18:52:12 +0900 Subject: [PATCH 12/24] Move view models to share module --- .../org/blackcandy/android/LoginActivity.kt | 2 +- .../org/blackcandy/android/MainActivity.kt | 8 ++++-- .../android/compose/login/LoginScreen.kt | 2 +- .../android/compose/player/MiniPlayer.kt | 2 +- .../android/compose/player/PlayerScreen.kt | 2 +- .../org/blackcandy/android/di/AppModule.kt | 19 +++++++------ .../fragments/navs/MainNavHostFragment.kt | 4 +-- .../fragments/sheets/AccountSheetFragment.kt | 2 +- .../android/fragments/web/WebFragment.kt | 2 +- .../android/fragments/web/WebHomeFragment.kt | 2 +- .../android/viewmodels/MainViewModel.kt | 22 --------------- gradle/libs.versions.toml | 5 ++-- shared/build.gradle.kts | 6 +++- .../org/blackcandy/shared/utils/Theme.kt | 19 +++++++++++++ .../org/blackcandy/shared/utils/Theme.kt | 2 ++ .../viewmodels/AccountSheetViewModel.kt | 2 +- .../shared}/viewmodels/HomeViewModel.kt | 2 +- .../shared}/viewmodels/LoginViewModel.kt | 10 +++++-- .../shared}/viewmodels/MiniPlayerViewModel.kt | 2 +- .../viewmodels/MusicServiceViewModel.kt | 28 +++++++++++++++++++ .../shared}/viewmodels/NavHostViewModel.kt | 18 ++---------- .../shared}/viewmodels/PlayerViewModel.kt | 2 +- .../shared}/viewmodels/WebViewModel.kt | 2 +- 23 files changed, 97 insertions(+), 68 deletions(-) create mode 100644 shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Theme.kt rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/viewmodels/AccountSheetViewModel.kt (96%) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/viewmodels/HomeViewModel.kt (89%) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/viewmodels/LoginViewModel.kt (92%) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/viewmodels/MiniPlayerViewModel.kt (92%) create mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MusicServiceViewModel.kt rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/viewmodels/NavHostViewModel.kt (91%) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/viewmodels/PlayerViewModel.kt (99%) rename {app/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/viewmodels/WebViewModel.kt (89%) diff --git a/app/src/main/java/org/blackcandy/android/LoginActivity.kt b/app/src/main/java/org/blackcandy/android/LoginActivity.kt index 117c5e2..12ff024 100644 --- a/app/src/main/java/org/blackcandy/android/LoginActivity.kt +++ b/app/src/main/java/org/blackcandy/android/LoginActivity.kt @@ -10,7 +10,7 @@ import androidx.lifecycle.repeatOnLifecycle import com.google.accompanist.themeadapter.material3.Mdc3Theme import kotlinx.coroutines.launch import org.blackcandy.android.compose.login.LoginScreen -import org.blackcandy.android.viewmodels.LoginViewModel +import org.blackcandy.shared.viewmodels.LoginViewModel import org.koin.androidx.viewmodel.ext.android.viewModel class LoginActivity : ComponentActivity() { diff --git a/app/src/main/java/org/blackcandy/android/MainActivity.kt b/app/src/main/java/org/blackcandy/android/MainActivity.kt index 1b2583a..8772426 100644 --- a/app/src/main/java/org/blackcandy/android/MainActivity.kt +++ b/app/src/main/java/org/blackcandy/android/MainActivity.kt @@ -33,6 +33,7 @@ import org.blackcandy.android.compose.player.MiniPlayer import org.blackcandy.android.compose.player.PlayerScreen import org.blackcandy.android.databinding.ActivityMainBinding import org.blackcandy.android.viewmodels.MainViewModel +import org.blackcandy.shared.viewmodels.MusicServiceViewModel import org.koin.androidx.viewmodel.ext.android.viewModel class MainActivity : @@ -44,6 +45,9 @@ class MainActivity : } private val viewModel: MainViewModel by viewModel() + + private val musicServiceViewModel: MusicServiceViewModel by viewModel() + private lateinit var binding: ActivityMainBinding private lateinit var playerBottomSheetBehavior: BottomSheetBehavior override lateinit var delegate: TurboActivityDelegate @@ -76,7 +80,7 @@ class MainActivity : binding = ActivityMainBinding.inflate(layoutInflater) delegate = TurboActivityDelegate(this, R.id.home_container) - viewModel.setupMusicServiceController() + musicServiceViewModel.setupMusicServiceController() setupLayout() setupNavListener() @@ -91,7 +95,7 @@ class MainActivity : override fun onRestart() { super.onRestart() - viewModel.getCurrentPlaylist() + musicServiceViewModel.getCurrentPlaylist() } override fun onDestroy() { diff --git a/app/src/main/java/org/blackcandy/android/compose/login/LoginScreen.kt b/app/src/main/java/org/blackcandy/android/compose/login/LoginScreen.kt index bb900c8..5d70453 100644 --- a/app/src/main/java/org/blackcandy/android/compose/login/LoginScreen.kt +++ b/app/src/main/java/org/blackcandy/android/compose/login/LoginScreen.kt @@ -33,7 +33,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import org.blackcandy.android.R import org.blackcandy.android.utils.SnackbarUtil.Companion.ShowSnackbar -import org.blackcandy.android.viewmodels.LoginViewModel +import org.blackcandy.shared.viewmodels.LoginViewModel import org.koin.androidx.compose.koinViewModel enum class LoginRoute( diff --git a/app/src/main/java/org/blackcandy/android/compose/player/MiniPlayer.kt b/app/src/main/java/org/blackcandy/android/compose/player/MiniPlayer.kt index 2539afb..6cef903 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/MiniPlayer.kt +++ b/app/src/main/java/org/blackcandy/android/compose/player/MiniPlayer.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import coil.compose.AsyncImage import org.blackcandy.android.R -import org.blackcandy.android.viewmodels.MiniPlayerViewModel +import org.blackcandy.shared.viewmodels.MiniPlayerViewModel import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalFoundationApi::class) diff --git a/app/src/main/java/org/blackcandy/android/compose/player/PlayerScreen.kt b/app/src/main/java/org/blackcandy/android/compose/player/PlayerScreen.kt index f15395a..7168480 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/PlayerScreen.kt +++ b/app/src/main/java/org/blackcandy/android/compose/player/PlayerScreen.kt @@ -33,7 +33,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import org.blackcandy.android.R import org.blackcandy.android.utils.SnackbarUtil.Companion.ShowSnackbar -import org.blackcandy.android.viewmodels.PlayerViewModel +import org.blackcandy.shared.viewmodels.PlayerViewModel import org.koin.androidx.compose.koinViewModel enum class PlayerRoute { diff --git a/app/src/main/java/org/blackcandy/android/di/AppModule.kt b/app/src/main/java/org/blackcandy/android/di/AppModule.kt index 813dd48..8888788 100644 --- a/app/src/main/java/org/blackcandy/android/di/AppModule.kt +++ b/app/src/main/java/org/blackcandy/android/di/AppModule.kt @@ -2,7 +2,6 @@ package org.blackcandy.android.di import android.content.Context import android.content.SharedPreferences -import android.webkit.CookieManager import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.Serializer @@ -33,14 +32,7 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNamingStrategy import okhttp3.OkHttpClient -import org.blackcandy.android.viewmodels.AccountSheetViewModel -import org.blackcandy.android.viewmodels.HomeViewModel -import org.blackcandy.android.viewmodels.LoginViewModel import org.blackcandy.android.viewmodels.MainViewModel -import org.blackcandy.android.viewmodels.MiniPlayerViewModel -import org.blackcandy.android.viewmodels.NavHostViewModel -import org.blackcandy.android.viewmodels.PlayerViewModel -import org.blackcandy.android.viewmodels.WebViewModel import org.blackcandy.shared.api.ApiError import org.blackcandy.shared.api.ApiException import org.blackcandy.shared.api.BlackCandyService @@ -55,6 +47,14 @@ import org.blackcandy.shared.data.UserRepository import org.blackcandy.shared.media.MusicServiceController import org.blackcandy.shared.models.User import org.blackcandy.shared.utils.BLACK_CANDY_USER_AGENT +import org.blackcandy.shared.viewmodels.AccountSheetViewModel +import org.blackcandy.shared.viewmodels.HomeViewModel +import org.blackcandy.shared.viewmodels.LoginViewModel +import org.blackcandy.shared.viewmodels.MiniPlayerViewModel +import org.blackcandy.shared.viewmodels.MusicServiceViewModel +import org.blackcandy.shared.viewmodels.NavHostViewModel +import org.blackcandy.shared.viewmodels.PlayerViewModel +import org.blackcandy.shared.viewmodels.WebViewModel import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.qualifier.named @@ -83,13 +83,14 @@ val appModule = single { FavoritePlaylistRepository(get()) } viewModel { LoginViewModel(get(), get(), get()) } - viewModel { MainViewModel(get(), get(), get()) } + viewModel { MainViewModel(get()) } viewModel { AccountSheetViewModel(get(), get()) } viewModel { NavHostViewModel(get(), get(), get()) } viewModel { HomeViewModel(get()) } viewModel { MiniPlayerViewModel(get()) } viewModel { PlayerViewModel(get(), get(), get()) } viewModel { WebViewModel(get()) } + viewModel { MusicServiceViewModel(get(), get()) } } private const val DATASTORE_PREFERENCES_NAME = "user_preferences" diff --git a/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt index 25c7162..cda9231 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt @@ -16,9 +16,9 @@ import org.blackcandy.android.fragments.web.WebFragment import org.blackcandy.android.fragments.web.WebHomeFragment import org.blackcandy.android.fragments.web.WebLibraryFragment import org.blackcandy.android.utils.SnackbarUtil.Companion.showSnackbar -import org.blackcandy.android.viewmodels.NavHostViewModel import org.blackcandy.shared.utils.BLACK_CANDY_USER_AGENT import org.blackcandy.shared.utils.Theme +import org.blackcandy.shared.viewmodels.NavHostViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import kotlin.reflect.KClass @@ -59,7 +59,7 @@ open class MainNavHostFragment : TurboSessionNavHostFragment() { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { if (it.alertMessage != null) { - showSnackbar(requireActivity(), it.alertMessage) { + showSnackbar(requireActivity(), it.alertMessage!!) { viewModel.alertMessageShown() } } diff --git a/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt index 889caa2..b2a8ed1 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt @@ -15,7 +15,7 @@ import org.blackcandy.android.R import org.blackcandy.android.compose.account.AccountMenu import org.blackcandy.android.databinding.FragmentSheetAccountBinding import org.blackcandy.android.ui.MenuItem -import org.blackcandy.android.viewmodels.AccountSheetViewModel +import org.blackcandy.shared.viewmodels.AccountSheetViewModel import org.koin.androidx.viewmodel.ext.android.viewModel @TurboNavGraphDestination(uri = "turbo://fragment/sheets/account") diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt index 9627151..b036349 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt @@ -7,7 +7,7 @@ import android.view.ViewGroup import dev.hotwire.turbo.fragments.TurboWebFragment import dev.hotwire.turbo.nav.TurboNavGraphDestination import org.blackcandy.android.R -import org.blackcandy.android.viewmodels.WebViewModel +import org.blackcandy.shared.viewmodels.WebViewModel import org.koin.androidx.viewmodel.ext.android.viewModel @TurboNavGraphDestination(uri = "turbo://fragment/web") diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt index 40ba9bc..3b5100a 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt @@ -7,7 +7,7 @@ import android.view.ViewGroup import dev.hotwire.turbo.nav.TurboNavGraphDestination import org.blackcandy.android.R import org.blackcandy.android.databinding.FragmentWebHomeBinding -import org.blackcandy.android.viewmodels.HomeViewModel +import org.blackcandy.shared.viewmodels.HomeViewModel import org.koin.androidx.viewmodel.ext.android.viewModel @TurboNavGraphDestination(uri = "turbo://fragment/web/home") diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt index 09a0b13..18f00ed 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt @@ -1,36 +1,14 @@ package org.blackcandy.android.viewmodels import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch import org.blackcandy.android.fragments.navs.LibraryNavHostFragment -import org.blackcandy.shared.data.CurrentPlaylistRepository import org.blackcandy.shared.data.UserRepository -import org.blackcandy.shared.media.MusicServiceController -import org.blackcandy.shared.utils.TaskResult class MainViewModel( userRepository: UserRepository, - private val currentPlaylistRepository: CurrentPlaylistRepository, - private val musicServiceController: MusicServiceController, ) : ViewModel() { val currentUserFlow = userRepository.getCurrentUserFlow() // Declare the library nav host fragment in view model to prevent it from being recreated when configuration changed. val libraryNav = LibraryNavHostFragment() - - fun setupMusicServiceController() { - musicServiceController.initMediaController { - getCurrentPlaylist() - } - } - - fun getCurrentPlaylist() { - viewModelScope.launch { - when (val result = currentPlaylistRepository.getSongs()) { - is TaskResult.Success -> musicServiceController.updatePlaylist(result.data) - is TaskResult.Failure -> Unit - } - } - } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e354017..9362c5a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ androidxActivity = "1.9.0" androidxWindow = "1.2.0" androidxNavigationCompose = "2.7.7" androidxComposeBom = "2024.05.00" -androidxLifecycleRuntimeKtx = "2.7.0" +androidxLifecycle = "2.8.0" androidxCore = "1.13.1" androidxAppcompat = "1.6.1" androidxConstraintlayout = "2.1.4" @@ -45,7 +45,8 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDatastorePreferences" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } -androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycleRuntimeKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } +androidx-lifecycle-viewmodel = {group = "androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidxLifecycle"} androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" } androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 60f4984..b2bdad5 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -67,8 +67,8 @@ kotlin { implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.client.auth) implementation(libs.kotlinx.serialization.json) - implementation(libs.koin.androidx.compose) implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.lifecycle.viewmodel) // Add KMP dependencies here } } @@ -88,6 +88,10 @@ kotlin { implementation(libs.androidx.media3.session) implementation(libs.androidx.media3.datasource.okhttp) implementation(libs.androidx.recyclerview) + + // TODO: may need to moved to koin to commonMain after migrated di to shared module, + // Right now because MusicService on androidMain is reply on koin, so added here. + implementation(libs.koin.androidx.compose) } } diff --git a/shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Theme.kt b/shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Theme.kt new file mode 100644 index 0000000..bd218cd --- /dev/null +++ b/shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Theme.kt @@ -0,0 +1,19 @@ +package org.blackcandy.shared.utils + +import androidx.appcompat.app.AppCompatDelegate + +actual fun updateAppTheme(theme: Theme) { + when (theme) { + Theme.DARK -> { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + } + + Theme.LIGHT -> { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + } + + Theme.AUTO -> { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + } + } +} diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Theme.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Theme.kt index 308c1b8..df07116 100644 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Theme.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Theme.kt @@ -5,3 +5,5 @@ enum class Theme { LIGHT, AUTO, } + +expect fun updateAppTheme(theme: Theme) diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/AccountSheetViewModel.kt similarity index 96% rename from app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/AccountSheetViewModel.kt index 73fcf2c..ace1b45 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/AccountSheetViewModel.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.viewmodels +package org.blackcandy.shared.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/HomeViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/HomeViewModel.kt similarity index 89% rename from app/src/main/java/org/blackcandy/android/viewmodels/HomeViewModel.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/HomeViewModel.kt index 31d040c..66ae00e 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/HomeViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/HomeViewModel.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.viewmodels +package org.blackcandy.shared.viewmodels import androidx.lifecycle.ViewModel import kotlinx.coroutines.runBlocking diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/LoginViewModel.kt similarity index 92% rename from app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/LoginViewModel.kt index 2cfa920..f5a19a6 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/LoginViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/LoginViewModel.kt @@ -1,6 +1,5 @@ -package org.blackcandy.android.viewmodels +package org.blackcandy.shared.viewmodels -import android.util.Patterns import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow @@ -66,7 +65,7 @@ class LoginViewModel( serverAddress = "http://$serverAddress" } - if (!Patterns.WEB_URL.matcher(serverAddress).matches()) { + if (!isValidUrl(serverAddress)) { _uiState.update { it.copy(alertMessage = AlertMessage.LocalizedString(AlertMessage.DefinedMessages.INVALID_SERVER_ADDRESS)) } return } @@ -117,4 +116,9 @@ class LoginViewModel( fun alertMessageShown() { _uiState.update { it.copy(alertMessage = null) } } + + private fun isValidUrl(url: String): Boolean { + val urlRegex = Regex("https?://(www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)") + return urlRegex.matches(url) + } } diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/MiniPlayerViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MiniPlayerViewModel.kt similarity index 92% rename from app/src/main/java/org/blackcandy/android/viewmodels/MiniPlayerViewModel.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MiniPlayerViewModel.kt index bcac56b..02d19e2 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/MiniPlayerViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MiniPlayerViewModel.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.viewmodels +package org.blackcandy.shared.viewmodels import androidx.lifecycle.ViewModel import org.blackcandy.shared.media.MusicServiceController diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MusicServiceViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MusicServiceViewModel.kt new file mode 100644 index 0000000..ef1cf73 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MusicServiceViewModel.kt @@ -0,0 +1,28 @@ +package org.blackcandy.shared.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.blackcandy.shared.data.CurrentPlaylistRepository +import org.blackcandy.shared.media.MusicServiceController +import org.blackcandy.shared.utils.TaskResult + +class MusicServiceViewModel( + private val currentPlaylistRepository: CurrentPlaylistRepository, + private val musicServiceController: MusicServiceController, +) : ViewModel() { + fun setupMusicServiceController() { + musicServiceController.initMediaController { + getCurrentPlaylist() + } + } + + fun getCurrentPlaylist() { + viewModelScope.launch { + when (val result = currentPlaylistRepository.getSongs()) { + is TaskResult.Success -> musicServiceController.updatePlaylist(result.data) + is TaskResult.Failure -> Unit + } + } + } +} diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/NavHostViewModel.kt similarity index 91% rename from app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/NavHostViewModel.kt index db04006..b7431c0 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/NavHostViewModel.kt @@ -1,6 +1,5 @@ -package org.blackcandy.android.viewmodels +package org.blackcandy.shared.viewmodels -import androidx.appcompat.app.AppCompatDelegate import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow @@ -15,6 +14,7 @@ import org.blackcandy.shared.models.AlertMessage import org.blackcandy.shared.models.Song import org.blackcandy.shared.utils.TaskResult import org.blackcandy.shared.utils.Theme +import org.blackcandy.shared.utils.updateAppTheme data class NavHostUiState( val alertMessage: AlertMessage? = null, @@ -40,19 +40,7 @@ class NavHostViewModel( fun updateTheme(theme: Theme) { viewModelScope.launch { - when (theme) { - Theme.DARK -> { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - } - - Theme.LIGHT -> { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - } - - Theme.AUTO -> { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - } - } + updateAppTheme(theme) } } diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/PlayerViewModel.kt similarity index 99% rename from app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/PlayerViewModel.kt index 5c3a75e..0c10df5 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/PlayerViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/PlayerViewModel.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.viewmodels +package org.blackcandy.shared.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/WebViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/WebViewModel.kt similarity index 89% rename from app/src/main/java/org/blackcandy/android/viewmodels/WebViewModel.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/WebViewModel.kt index e869a51..e38d69a 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/WebViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/WebViewModel.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.viewmodels +package org.blackcandy.shared.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope From 30e809acd3a8dcb5afe6e3aaa9b7d21fb1bdca1f Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Tue, 16 Dec 2025 22:23:27 +0900 Subject: [PATCH 13/24] Move di to shared module --- .../org/blackcandy/android/MainApplication.kt | 5 +- .../blackcandy/android/di/AndroidModule.kt | 16 +++ gradle/libs.versions.toml | 9 +- shared/build.gradle.kts | 10 +- .../blackcandy/shared/di/PlatformModule.kt | 111 ++++++++++++++++++ .../org/blackcandy/shared/di/AppModule.kt | 3 + .../org/blackcandy/shared/di/CommonModule.kt | 109 +---------------- .../blackcandy/shared/di/PlatformModule.kt | 5 + 8 files changed, 154 insertions(+), 114 deletions(-) create mode 100644 app/src/main/java/org/blackcandy/android/di/AndroidModule.kt create mode 100644 shared/src/androidMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt create mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/di/AppModule.kt rename app/src/main/java/org/blackcandy/android/di/AppModule.kt => shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt (54%) create mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt diff --git a/app/src/main/java/org/blackcandy/android/MainApplication.kt b/app/src/main/java/org/blackcandy/android/MainApplication.kt index edcbc7a..f6f1141 100644 --- a/app/src/main/java/org/blackcandy/android/MainApplication.kt +++ b/app/src/main/java/org/blackcandy/android/MainApplication.kt @@ -1,7 +1,8 @@ package org.blackcandy.android import android.app.Application -import org.blackcandy.android.di.appModule +import org.blackcandy.android.di.androidModule +import org.blackcandy.shared.di.appModule import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin @@ -13,7 +14,7 @@ class MainApplication : Application() { startKoin { androidLogger() androidContext(this@MainApplication) - modules(appModule) + modules(appModule() + androidModule) } } } diff --git a/app/src/main/java/org/blackcandy/android/di/AndroidModule.kt b/app/src/main/java/org/blackcandy/android/di/AndroidModule.kt new file mode 100644 index 0000000..2a594e1 --- /dev/null +++ b/app/src/main/java/org/blackcandy/android/di/AndroidModule.kt @@ -0,0 +1,16 @@ +package org.blackcandy.android.di + +import org.blackcandy.android.viewmodels.MainViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val androidModule = + module { + // TODO: rename MainViewModel and remove it the shared module. + // Tha main purpose of MainViewModel is to get current user + // and declare the library nav host fragment in view model to prevent it from being recreated when configuration changed. + // After we migrate to use turbo native. it already provide a way to declare bottom tag. so we don't need to declare the library nav host fragment + // in viewmodel. and only purpose of left for MainViewModel will be get current user. should rename a proper name. + // After refactoring. we may also don't need this androidModule anymore. + viewModel { MainViewModel(get()) } + } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9362c5a..d590da3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ coilCompose = "2.5.0" googleAccompanistThemeadapterMaterial3 = "0.30.1" googleMaterial = "1.12.0" junit = "4.13.2" -koinAndroidxCompose = "3.5.0" +koin = "4.0.0" kotlinxSerializationJson = "1.5.1" ktor = "2.3.4" media3 = "1.3.1" @@ -58,7 +58,12 @@ coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coi google-accompanist-themeadapter-material3 = { group = "com.google.accompanist", name = "accompanist-themeadapter-material3", version.ref = "googleAccompanistThemeadapterMaterial3" } google-material = { group = "com.google.android.material", name = "material", version.ref = "googleMaterial" } junit = { group = "junit", name = "junit", version.ref = "junit" } -koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koinAndroidxCompose" } +koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } +koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } +koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } +koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } +koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } +koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" } ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index b2bdad5..cccdb68 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -69,6 +69,9 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.koin.core) + implementation(libs.koin.compose) + implementation(libs.koin.compose.viewmodel) // Add KMP dependencies here } } @@ -76,6 +79,7 @@ kotlin { commonTest { dependencies { implementation(libs.kotlin.test) + implementation(libs.koin.test) } } @@ -88,10 +92,8 @@ kotlin { implementation(libs.androidx.media3.session) implementation(libs.androidx.media3.datasource.okhttp) implementation(libs.androidx.recyclerview) - - // TODO: may need to moved to koin to commonMain after migrated di to shared module, - // Right now because MusicService on androidMain is reply on koin, so added here. - implementation(libs.koin.androidx.compose) + implementation(libs.koin.android) + implementation(libs.androidx.security.crypto) } } diff --git a/shared/src/androidMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt b/shared/src/androidMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt new file mode 100644 index 0000000..cd9e989 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt @@ -0,0 +1,111 @@ +package org.blackcandy.shared.di + +import android.content.Context +import android.content.SharedPreferences +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.Serializer +import androidx.datastore.dataStoreFile +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import org.blackcandy.shared.data.EncryptedDataSource +import org.blackcandy.shared.media.MusicServiceController +import org.blackcandy.shared.models.User +import org.koin.android.ext.koin.androidContext +import org.koin.core.qualifier.named +import org.koin.dsl.module +import java.io.InputStream +import java.io.OutputStream + +actual val platformModule = + module { + single { provideEncryptedSharedPreferences(androidContext()) } + single(named("PreferencesDataStore")) { provideDataStore(androidContext()) } + single(named("UserDataStore")) { provideUserDataStore(androidContext()) } + single { provideDataSourceFactory(get()) } + + single { EncryptedDataSource(get()) } + single { MusicServiceController(androidContext()) } + } + +private const val DATASTORE_PREFERENCES_NAME = "user_preferences" +private const val USER_DATASTORE_FILE_NAME = "user.json" +private const val ENCRYPTED_SHARED_PREFERENCES_FILE_NAME = "encrypted_preferences.txt" + +private fun provideDataStore(appContext: Context): DataStore = + PreferenceDataStoreFactory.create( + produceFile = { appContext.preferencesDataStoreFile(DATASTORE_PREFERENCES_NAME) }, + ) + +private fun provideUserDataStore(appContext: Context): DataStore { + val serializer = + object : Serializer { + override val defaultValue: User? + get() = null + + override suspend fun readFrom(input: InputStream): User? = + try { + Json.decodeFromString( + User.serializer(), + input.readBytes().decodeToString(), + ) + } catch (e: Exception) { + null + } + + override suspend fun writeTo( + t: User?, + output: OutputStream, + ) { + val data = + if (t == null) { + "{}".encodeToByteArray() + } else { + Json.encodeToString(User.serializer(), t).encodeToByteArray() + } + + withContext(Dispatchers.IO) { + output.write(data) + } + } + } + + return DataStoreFactory.create( + serializer = serializer, + produceFile = { appContext.dataStoreFile(USER_DATASTORE_FILE_NAME) }, + ) +} + +private fun provideEncryptedSharedPreferences(appContext: Context): SharedPreferences = + EncryptedSharedPreferences.create( + ENCRYPTED_SHARED_PREFERENCES_FILE_NAME, + MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), + appContext, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + +@androidx.annotation.OptIn(UnstableApi::class) +private fun provideDataSourceFactory(encryptedDataSource: EncryptedDataSource): DataSource.Factory { + val httpClient = OkHttpClient().newBuilder().build() + val apiToken = encryptedDataSource.getApiToken() + + return DataSource.Factory { + val dataSource = + OkHttpDataSource.Factory(httpClient).createDataSource() + + dataSource.setRequestProperty("Authorization", "Token $apiToken") + + dataSource + } +} diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/di/AppModule.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/di/AppModule.kt new file mode 100644 index 0000000..d322973 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/di/AppModule.kt @@ -0,0 +1,3 @@ +package org.blackcandy.shared.di + +fun appModule() = listOf(commonModule, platformModule) diff --git a/app/src/main/java/org/blackcandy/android/di/AppModule.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt similarity index 54% rename from app/src/main/java/org/blackcandy/android/di/AppModule.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt index 8888788..78cc9d0 100644 --- a/app/src/main/java/org/blackcandy/android/di/AppModule.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt @@ -1,19 +1,5 @@ -package org.blackcandy.android.di +package org.blackcandy.shared.di -import android.content.Context -import android.content.SharedPreferences -import androidx.datastore.core.DataStore -import androidx.datastore.core.DataStoreFactory -import androidx.datastore.core.Serializer -import androidx.datastore.dataStoreFile -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStoreFile -import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.okhttp.OkHttpDataSource -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys import io.ktor.client.HttpClient import io.ktor.client.plugins.ClientRequestException import io.ktor.client.plugins.HttpResponseValidator @@ -25,14 +11,10 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.statement.bodyAsText import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNamingStrategy -import okhttp3.OkHttpClient -import org.blackcandy.android.viewmodels.MainViewModel import org.blackcandy.shared.api.ApiError import org.blackcandy.shared.api.ApiException import org.blackcandy.shared.api.BlackCandyService @@ -44,8 +26,6 @@ import org.blackcandy.shared.data.PreferencesDataSource import org.blackcandy.shared.data.ServerAddressRepository import org.blackcandy.shared.data.SystemInfoRepository import org.blackcandy.shared.data.UserRepository -import org.blackcandy.shared.media.MusicServiceController -import org.blackcandy.shared.models.User import org.blackcandy.shared.utils.BLACK_CANDY_USER_AGENT import org.blackcandy.shared.viewmodels.AccountSheetViewModel import org.blackcandy.shared.viewmodels.HomeViewModel @@ -55,27 +35,17 @@ import org.blackcandy.shared.viewmodels.MusicServiceViewModel import org.blackcandy.shared.viewmodels.NavHostViewModel import org.blackcandy.shared.viewmodels.PlayerViewModel import org.blackcandy.shared.viewmodels.WebViewModel -import org.koin.android.ext.koin.androidContext -import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.module.dsl.viewModel import org.koin.core.qualifier.named import org.koin.dsl.module -import java.io.InputStream -import java.io.OutputStream -val appModule = +val commonModule = module { single { provideJson() } - single { provideEncryptedSharedPreferences(androidContext()) } - single(named("PreferencesDataStore")) { provideDataStore(androidContext()) } - single(named("UserDataStore")) { provideUserDataStore(androidContext()) } single { provideHttpClient(get(), get(), get()) } - single { provideDataSourceFactory(get()) } single { PreferencesDataSource(get(named("PreferencesDataStore"))) } - single { EncryptedDataSource(get()) } - single { BlackCandyServiceImpl(get()) } - single { MusicServiceController(androidContext()) } single { ServerAddressRepository(get()) } single { SystemInfoRepository(get()) } single { UserRepository(get(), get(), get(named("UserDataStore")), get(), get()) } @@ -83,7 +53,6 @@ val appModule = single { FavoritePlaylistRepository(get()) } viewModel { LoginViewModel(get(), get(), get()) } - viewModel { MainViewModel(get()) } viewModel { AccountSheetViewModel(get(), get()) } viewModel { NavHostViewModel(get(), get(), get()) } viewModel { HomeViewModel(get()) } @@ -93,10 +62,6 @@ val appModule = viewModel { MusicServiceViewModel(get(), get()) } } -private const val DATASTORE_PREFERENCES_NAME = "user_preferences" -private const val USER_DATASTORE_FILE_NAME = "user.json" -private const val ENCRYPTED_SHARED_PREFERENCES_FILE_NAME = "encrypted_preferences.txt" - private fun provideHttpClient( json: Json, preferencesDataSource: PreferencesDataSource, @@ -162,74 +127,6 @@ private fun provideHttpClient( } } -private fun provideDataStore(appContext: Context): DataStore = - PreferenceDataStoreFactory.create( - produceFile = { appContext.preferencesDataStoreFile(DATASTORE_PREFERENCES_NAME) }, - ) - -private fun provideUserDataStore(appContext: Context): DataStore { - val serializer = - object : Serializer { - override val defaultValue: User? - get() = null - - override suspend fun readFrom(input: InputStream): User? = - try { - Json.decodeFromString( - User.serializer(), - input.readBytes().decodeToString(), - ) - } catch (e: Exception) { - null - } - - override suspend fun writeTo( - t: User?, - output: OutputStream, - ) { - val data = - if (t == null) { - "{}".encodeToByteArray() - } else { - Json.encodeToString(User.serializer(), t).encodeToByteArray() - } - - withContext(Dispatchers.IO) { - output.write(data) - } - } - } - - return DataStoreFactory.create( - serializer = serializer, - produceFile = { appContext.dataStoreFile(USER_DATASTORE_FILE_NAME) }, - ) -} - -private fun provideEncryptedSharedPreferences(appContext: Context): SharedPreferences = - EncryptedSharedPreferences.create( - ENCRYPTED_SHARED_PREFERENCES_FILE_NAME, - MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), - appContext, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - -@androidx.annotation.OptIn(UnstableApi::class) -private fun provideDataSourceFactory(encryptedDataSource: EncryptedDataSource): DataSource.Factory { - val httpClient = OkHttpClient().newBuilder().build() - val apiToken = encryptedDataSource.getApiToken() - - return DataSource.Factory { - val dataSource = - OkHttpDataSource.Factory(httpClient).createDataSource() - - dataSource.setRequestProperty("Authorization", "Token $apiToken") - - dataSource - } -} - @OptIn(ExperimentalSerializationApi::class) private fun provideJson() = Json { diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt new file mode 100644 index 0000000..0d7c63e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt @@ -0,0 +1,5 @@ +package org.blackcandy.shared.di + +import org.koin.core.module.Module + +expect val platformModule: Module From 99a91faecdf89d83b6b9c98fb972e9f7b3bbe1cd Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Thu, 22 Jan 2026 19:39:45 +0900 Subject: [PATCH 14/24] Migrate to hotwire native --- app/build.gradle.kts | 6 +- app/src/main/assets/json/configuration.json | 17 +-- .../org/blackcandy/android/MainActivity.kt | 95 ++++---------- .../org/blackcandy/android/MainApplication.kt | 42 +++++++ .../java/org/blackcandy/android/MainTabs.kt | 28 +++++ .../android/bridge/AccountComponent.kt | 116 ++++++++++++++++++ .../android/bridge/SearchComponent.kt | 51 ++++++++ .../blackcandy/android/di/AndroidModule.kt | 2 +- .../fragments/navs/MainNavHostFragment.kt | 2 - .../fragments/sheets/AccountSheetFragment.kt | 93 -------------- .../fragments/web/WebBottomSheetFragment.kt | 8 +- .../android/fragments/web/WebFragment.kt | 16 +-- .../android/fragments/web/WebHomeFragment.kt | 25 +--- .../fragments/web/WebLibraryFragment.kt | 24 +--- .../org/blackcandy/android/ui/MenuItem.kt | 3 +- .../android/viewmodels/MainViewModel.kt | 12 +- app/src/main/res/layout/activity_main.xml | 5 +- .../res/layout/fragment_sheet_account.xml | 29 ----- app/src/main/res/layout/fragment_web.xml | 2 +- app/src/main/res/layout/fragment_web_home.xml | 2 +- .../main/res/layout/fragment_web_library.xml | 2 +- gradle/libs.versions.toml | 3 + shared/build.gradle.kts | 2 +- 23 files changed, 306 insertions(+), 279 deletions(-) create mode 100644 app/src/main/java/org/blackcandy/android/MainTabs.kt create mode 100644 app/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt create mode 100644 app/src/main/java/org/blackcandy/android/bridge/SearchComponent.kt delete mode 100644 app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt delete mode 100644 app/src/main/res/layout/fragment_sheet_account.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8c75bde..1446620 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,11 +7,11 @@ plugins { android { namespace = "org.blackcandy.android" - compileSdk = 34 + compileSdk = 36 defaultConfig { applicationId = "org.blackcandy.android" - minSdk = 26 + minSdk = 28 targetSdk = 34 versionCode = 3 versionName = "1.0.0" @@ -82,6 +82,8 @@ dependencies { implementation(libs.ktor.client.auth) implementation(libs.coil.compose) implementation(libs.reorderable) + implementation(libs.hotwire.core) + implementation(libs.hotwire.navigation.fragments) implementation(project(":shared")) diff --git a/app/src/main/assets/json/configuration.json b/app/src/main/assets/json/configuration.json index b73a29d..4ea7714 100644 --- a/app/src/main/assets/json/configuration.json +++ b/app/src/main/assets/json/configuration.json @@ -9,7 +9,7 @@ ], "properties": { "context": "default", - "uri": "turbo://fragment/web", + "uri": "hotwire://fragment/web", "pull_to_refresh_enabled": true } }, @@ -19,7 +19,7 @@ "^/$" ], "properties": { - "uri": "turbo://fragment/web/home", + "uri": "hotwire://fragment/web/home", "presentation": "replace_root" } }, @@ -28,26 +28,17 @@ "^/library$" ], "properties": { - "uri": "turbo://fragment/web/library", + "uri": "hotwire://fragment/web/library", "presentation": "replace_root" } }, - { - "patterns": [ - "^/account$" - ], - "properties": { - "context": "modal", - "uri": "turbo://fragment/sheets/account" - } - }, { "patterns": [ "^/dialog/*" ], "properties": { "context": "modal", - "uri": "turbo://fragment/web/bottom_sheet", + "uri": "hotwire://fragment/web/bottom_sheet", "pull_to_refresh_enabled": false } } diff --git a/app/src/main/java/org/blackcandy/android/MainActivity.kt b/app/src/main/java/org/blackcandy/android/MainActivity.kt index 8772426..23401f5 100644 --- a/app/src/main/java/org/blackcandy/android/MainActivity.kt +++ b/app/src/main/java/org/blackcandy/android/MainActivity.kt @@ -3,12 +3,10 @@ package org.blackcandy.android import android.content.Intent import android.os.Build import android.os.Bundle -import android.view.MenuItem import android.view.View import android.view.ViewGroup.MarginLayoutParams import android.view.WindowManager import android.widget.FrameLayout -import androidx.appcompat.app.AppCompatActivity import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.core.view.ViewCompat @@ -17,15 +15,17 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.updateLayoutParams import androidx.core.view.updateMargins -import androidx.fragment.app.commitNow import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.accompanist.themeadapter.material3.Mdc3Theme +import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.navigation.NavigationBarView.OnItemSelectedListener -import dev.hotwire.turbo.activities.TurboActivity -import dev.hotwire.turbo.delegates.TurboActivityDelegate +import dev.hotwire.navigation.activities.HotwireActivity +import dev.hotwire.navigation.navigator.NavigatorConfiguration +import dev.hotwire.navigation.tabs.HotwireBottomNavigationController +import dev.hotwire.navigation.tabs.HotwireBottomTab +import dev.hotwire.navigation.tabs.navigatorConfigurations import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -36,13 +36,8 @@ import org.blackcandy.android.viewmodels.MainViewModel import org.blackcandy.shared.viewmodels.MusicServiceViewModel import org.koin.androidx.viewmodel.ext.android.viewModel -class MainActivity : - AppCompatActivity(), - TurboActivity, - OnItemSelectedListener { - companion object { - private const val SELECTED_NAV_ITEM_ID_KEY = "selected_nav_item_id" - } +class MainActivity : HotwireActivity() { + private lateinit var bottomNavigationController: HotwireBottomNavigationController private val viewModel: MainViewModel by viewModel() @@ -50,7 +45,7 @@ class MainActivity : private lateinit var binding: ActivityMainBinding private lateinit var playerBottomSheetBehavior: BottomSheetBehavior - override lateinit var delegate: TurboActivityDelegate + private lateinit var mainTabs: List private val playerBottomSheetCallback by lazy { object : BottomSheetBehavior.BottomSheetCallback() { @@ -78,18 +73,16 @@ class MainActivity : } binding = ActivityMainBinding.inflate(layoutInflater) - delegate = TurboActivityDelegate(this, R.id.home_container) musicServiceViewModel.setupMusicServiceController() + setContentView(binding.root) + setupLayout() - setupNavListener() + setupBottomTabs() setupPlayerBottomSheet() setupMiniPlayer() setupPlayerScreen() - restoreSavedState(savedInstanceState) - - setContentView(binding.root) } override fun onRestart() { @@ -116,26 +109,11 @@ class MainActivity : } } - override fun onSaveInstanceState(outState: Bundle) { - // Save the selected nav item id to restore it when configuration changed. - binding.bottomNav?.let { outState.putInt(SELECTED_NAV_ITEM_ID_KEY, it.selectedItemId) } - binding.railNav?.let { outState.putInt(SELECTED_NAV_ITEM_ID_KEY, it.selectedItemId) } - - super.onSaveInstanceState(outState) + override fun navigatorConfigurations(): List { + mainTabs = buildMainTabs(viewModel.serverAddress) + return mainTabs.navigatorConfigurations } - override fun onNavigationItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - R.id.nav_menu_home, R.id.nav_menu_library -> { - showSelectedNavItem(item.itemId) - true - } - - else -> { - false - } - } - private fun requireLogin(): Boolean { runBlocking { viewModel.currentUserFlow.first() } ?: return true @@ -152,11 +130,6 @@ class MainActivity : return false } - private fun setupNavListener() { - binding.bottomNav?.setOnItemSelectedListener(this) - binding.railNav?.setOnItemSelectedListener(this) - } - private fun setupLayout() { // Displaying edge-to-edge WindowCompat.setDecorFitsSystemWindows(window, false) @@ -268,9 +241,13 @@ class MainActivity : binding.playerScreenComposeView.alpha = (slideOffset - transitionOffsetThreshold) / transitionOffsetThreshold } - private fun restoreSavedState(savedInstanceState: Bundle?) { - if (savedInstanceState != null) { - showSelectedNavItem(savedInstanceState.getInt(SELECTED_NAV_ITEM_ID_KEY, R.id.nav_menu_home)) + private fun setupBottomTabs() { + val bottomNavigationView = findViewById(R.id.bottom_nav) + + bottomNavigationController = HotwireBottomNavigationController(this, bottomNavigationView) + bottomNavigationController.load(mainTabs, viewModel.selectedTabIndex) + bottomNavigationController.setOnTabSelectedListener { index, _ -> + viewModel.selectedTabIndex = index } } @@ -280,32 +257,4 @@ class MainActivity : startActivity(intent) } - - private fun showSelectedNavItem(itemId: Int) { - when (itemId) { - R.id.nav_menu_home -> { - binding.homeContainer.isGone = false - binding.libraryContainer.isGone = true - delegate.currentNavHostFragmentId = R.id.home_container - } - - R.id.nav_menu_library -> { - val libraryNavFragment = - supportFragmentManager.findFragmentById(viewModel.libraryNav.id) - - // Lazily add the library nav host fragment. - if (libraryNavFragment == null) { - supportFragmentManager.commitNow { - add(R.id.library_container, viewModel.libraryNav) - } - - delegate.registerNavHostFragment(R.id.library_container) - } - - binding.homeContainer.isGone = true - binding.libraryContainer.isGone = false - delegate.currentNavHostFragmentId = R.id.library_container - } - } - } } diff --git a/app/src/main/java/org/blackcandy/android/MainApplication.kt b/app/src/main/java/org/blackcandy/android/MainApplication.kt index f6f1141..c914136 100644 --- a/app/src/main/java/org/blackcandy/android/MainApplication.kt +++ b/app/src/main/java/org/blackcandy/android/MainApplication.kt @@ -1,8 +1,22 @@ package org.blackcandy.android import android.app.Application +import dev.hotwire.core.bridge.BridgeComponentFactory +import dev.hotwire.core.bridge.KotlinXJsonConverter +import dev.hotwire.core.config.Hotwire +import dev.hotwire.core.turbo.config.PathConfiguration +import dev.hotwire.navigation.config.defaultFragmentDestination +import dev.hotwire.navigation.config.registerBridgeComponents +import dev.hotwire.navigation.config.registerFragmentDestinations +import org.blackcandy.android.bridge.AccountComponent +import org.blackcandy.android.bridge.SearchComponent import org.blackcandy.android.di.androidModule +import org.blackcandy.android.fragments.web.WebBottomSheetFragment +import org.blackcandy.android.fragments.web.WebFragment +import org.blackcandy.android.fragments.web.WebHomeFragment +import org.blackcandy.android.fragments.web.WebLibraryFragment import org.blackcandy.shared.di.appModule +import org.blackcandy.shared.utils.BLACK_CANDY_USER_AGENT import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin @@ -11,10 +25,38 @@ class MainApplication : Application() { override fun onCreate() { super.onCreate() + configureApp() + startKoin { androidLogger() androidContext(this@MainApplication) modules(appModule() + androidModule) } } + + private fun configureApp() { + Hotwire.loadPathConfiguration( + context = this, + location = + PathConfiguration.Location( + assetFilePath = "json/configuration.json", + ), + ) + + Hotwire.config.applicationUserAgentPrefix = "${BLACK_CANDY_USER_AGENT};" + Hotwire.defaultFragmentDestination = WebFragment::class + Hotwire.registerFragmentDestinations( + WebFragment::class, + WebHomeFragment::class, + WebLibraryFragment::class, + WebBottomSheetFragment::class, + ) + + Hotwire.registerBridgeComponents( + BridgeComponentFactory("account", ::AccountComponent), + BridgeComponentFactory("search", ::SearchComponent), + ) + + Hotwire.config.jsonConverter = KotlinXJsonConverter() + } } diff --git a/app/src/main/java/org/blackcandy/android/MainTabs.kt b/app/src/main/java/org/blackcandy/android/MainTabs.kt new file mode 100644 index 0000000..22d60df --- /dev/null +++ b/app/src/main/java/org/blackcandy/android/MainTabs.kt @@ -0,0 +1,28 @@ +package org.blackcandy.android + +import dev.hotwire.navigation.navigator.NavigatorConfiguration +import dev.hotwire.navigation.tabs.HotwireBottomTab + +fun buildMainTabs(serverAddress: String): List = + listOf( + HotwireBottomTab( + title = "Home", + iconResId = R.drawable.baseline_home_24, + configuration = + NavigatorConfiguration( + name = "home", + startLocation = serverAddress, + navigatorHostId = R.id.home_container, + ), + ), + HotwireBottomTab( + title = "Library", + iconResId = R.drawable.baseline_library_music_24, + configuration = + NavigatorConfiguration( + name = "library", + startLocation = "$serverAddress/library", + navigatorHostId = R.id.library_container, + ), + ), + ) diff --git a/app/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt b/app/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt new file mode 100644 index 0000000..ea4685b --- /dev/null +++ b/app/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt @@ -0,0 +1,116 @@ +package org.blackcandy.android.bridge + +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import com.google.accompanist.themeadapter.material3.Mdc3Theme +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.bottomsheet.BottomSheetDialog +import dev.hotwire.core.bridge.BridgeComponent +import dev.hotwire.core.bridge.BridgeDelegate +import dev.hotwire.core.bridge.Message +import dev.hotwire.navigation.destinations.HotwireDestination +import org.blackcandy.android.R +import org.blackcandy.android.compose.account.AccountMenu +import org.blackcandy.android.ui.MenuItem +import org.blackcandy.shared.viewmodels.WebViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel +import kotlin.getValue + +class AccountComponent( + name: String, + private val delegate: BridgeDelegate, +) : BridgeComponent(name, delegate) { + private val viewModel: WebViewModel by fragment.viewModel() + + private val fragment: Fragment + get() = delegate.destination.fragment + + private val toolbar: MaterialToolbar? + get() = fragment.view?.findViewById(R.id.toolbar) + + private val menuItems: MutableList = mutableListOf() + + private lateinit var bottomSheet: BottomSheetDialog + + override fun onReceive(message: Message) { + when (message.event) { + "connect" -> handleConnectEvent(message) + "menuItemConnected:settings" -> handleMenuItemConnectedEvent("settings") + "menuItemConnected:manage_users" -> handleMenuItemConnectedEvent("manage_users") + "menuItemConnected:update_profile" -> handleMenuItemConnectedEvent("update_profile") + "menuItemConnected:logout" -> handleMenuItemConnectedEvent("logout") + } + } + + private fun handleConnectEvent(message: Message) { + val view = fragment.view?.rootView ?: return + bottomSheet = BottomSheetDialog(view.context) + + val composeView = + ComposeView(view.context).apply { + setContent { + Mdc3Theme { + AccountMenu(menuItems) + } + } + } + + bottomSheet.setContentView(composeView) + + toolbar?.setOnMenuItemClickListener { + when (it.itemId) { + R.id.top_bar_account -> { + bottomSheet.show() + true + } + + else -> { + false + } + } + } + } + + private fun handleMenuItemConnectedEvent(id: String) { + if (menuItems.any { it.id == id }) { + return + } + + when (id) { + "settings" -> { + menuItems.add( + MenuItem("settings", R.string.settings, R.drawable.baseline_settings_24, { + replyTo("menuItemConnected:settings") + bottomSheet.dismiss() + }), + ) + } + + "manage_users" -> { + menuItems.add( + MenuItem("manage_users", R.string.manage_users, R.drawable.baseline_people_24, { + replyTo("menuItemConnected:manage_users") + bottomSheet.dismiss() + }), + ) + } + + "update_profile" -> { + menuItems.add( + MenuItem("update_profile", R.string.update_profile, R.drawable.baseline_face_24, { + replyTo("menuItemConnected:update_profile") + bottomSheet.dismiss() + }), + ) + } + + "logout" -> { + menuItems.add( + MenuItem("logout", R.string.logout, R.drawable.baseline_exit_to_app_24, { + viewModel.logout() + }), + ) + } + } + } +} diff --git a/app/src/main/java/org/blackcandy/android/bridge/SearchComponent.kt b/app/src/main/java/org/blackcandy/android/bridge/SearchComponent.kt new file mode 100644 index 0000000..6c0176d --- /dev/null +++ b/app/src/main/java/org/blackcandy/android/bridge/SearchComponent.kt @@ -0,0 +1,51 @@ +package org.blackcandy.android.bridge + +import androidx.fragment.app.Fragment +import com.google.android.material.search.SearchBar +import com.google.android.material.search.SearchView +import dev.hotwire.core.bridge.BridgeComponent +import dev.hotwire.core.bridge.BridgeDelegate +import dev.hotwire.core.bridge.Message +import dev.hotwire.navigation.destinations.HotwireDestination +import kotlinx.serialization.Serializable +import org.blackcandy.android.R + +class SearchComponent( + name: String, + private val delegate: BridgeDelegate, +) : BridgeComponent(name, delegate) { + private val fragment: Fragment + get() = delegate.destination.fragment + + private val searchBar: SearchBar? + get() = fragment.view?.findViewById(R.id.search_bar) + + private val searchView: SearchView? + get() = fragment.view?.findViewById(R.id.search_view) + + override fun onReceive(message: Message) { + when (message.event) { + "connect" -> handleConnectEvent(message) + } + } + + private fun handleConnectEvent(message: Message) { + searchView?.setupWithSearchBar(searchBar) + searchView?.editText?.setOnEditorActionListener { _, _, _ -> + val searchText = searchView?.text.toString() + + if (searchText.isEmpty()) return@setOnEditorActionListener false + + searchView?.hide() + searchBar?.setText(searchText) + replyTo("connect", SearchData(query = searchText)) + + true + } + } + + @Serializable + data class SearchData( + val query: String, + ) +} diff --git a/app/src/main/java/org/blackcandy/android/di/AndroidModule.kt b/app/src/main/java/org/blackcandy/android/di/AndroidModule.kt index 2a594e1..bfc1b73 100644 --- a/app/src/main/java/org/blackcandy/android/di/AndroidModule.kt +++ b/app/src/main/java/org/blackcandy/android/di/AndroidModule.kt @@ -12,5 +12,5 @@ val androidModule = // After we migrate to use turbo native. it already provide a way to declare bottom tag. so we don't need to declare the library nav host fragment // in viewmodel. and only purpose of left for MainViewModel will be get current user. should rename a proper name. // After refactoring. we may also don't need this androidModule anymore. - viewModel { MainViewModel(get()) } + viewModel { MainViewModel(get(), get()) } } diff --git a/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt index cda9231..8fc9c39 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt @@ -10,7 +10,6 @@ import androidx.lifecycle.repeatOnLifecycle import dev.hotwire.turbo.config.TurboPathConfiguration import dev.hotwire.turbo.session.TurboSessionNavHostFragment import kotlinx.coroutines.launch -import org.blackcandy.android.fragments.sheets.AccountSheetFragment import org.blackcandy.android.fragments.web.WebBottomSheetFragment import org.blackcandy.android.fragments.web.WebFragment import org.blackcandy.android.fragments.web.WebHomeFragment @@ -42,7 +41,6 @@ open class MainNavHostFragment : TurboSessionNavHostFragment() { WebHomeFragment::class, WebLibraryFragment::class, WebBottomSheetFragment::class, - AccountSheetFragment::class, // And any other TurboFragments in your app ) diff --git a/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt deleted file mode 100644 index b2a8ed1..0000000 --- a/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt +++ /dev/null @@ -1,93 +0,0 @@ -package org.blackcandy.android.fragments.sheets - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.google.accompanist.themeadapter.material3.Mdc3Theme -import dev.hotwire.turbo.fragments.TurboBottomSheetDialogFragment -import dev.hotwire.turbo.nav.TurboNavGraphDestination -import kotlinx.coroutines.launch -import org.blackcandy.android.R -import org.blackcandy.android.compose.account.AccountMenu -import org.blackcandy.android.databinding.FragmentSheetAccountBinding -import org.blackcandy.android.ui.MenuItem -import org.blackcandy.shared.viewmodels.AccountSheetViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -@TurboNavGraphDestination(uri = "turbo://fragment/sheets/account") -class AccountSheetFragment : TurboBottomSheetDialogFragment() { - private val viewModel: AccountSheetViewModel by viewModel() - - @Suppress("ktlint:standard:backing-property-naming") - private var _binding: FragmentSheetAccountBinding? = null - - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _binding = FragmentSheetAccountBinding.inflate(inflater, container, false) - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect { - val currentUser = it.currentUser ?: return@collect - val serverAddress = it.serverAddress ?: return@collect - - val menuItems = - buildList { - add( - MenuItem( - R.string.settings, - R.drawable.baseline_settings_24, - { navigate("$serverAddress/setting") }, - ), - ) - - if (currentUser.isAdmin) { - add( - MenuItem( - R.string.manage_users, - R.drawable.baseline_people_24, - { navigate("$serverAddress/users") }, - ), - ) - } - - add( - MenuItem( - R.string.update_profile, - R.drawable.baseline_face_24, - { navigate("$serverAddress/users/${currentUser.id}/edit") }, - ), - ) - - add( - MenuItem( - R.string.logout, - R.drawable.baseline_exit_to_app_24, - { viewModel.logout() }, - ), - ) - } - - binding.composeView.apply { - setContent { - Mdc3Theme { - AccountMenu(menuItems) - } - } - } - } - } - } - - return binding.root - } -} diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebBottomSheetFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/web/WebBottomSheetFragment.kt index 03809b5..ad42f2b 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebBottomSheetFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/web/WebBottomSheetFragment.kt @@ -1,7 +1,7 @@ package org.blackcandy.android.fragments.web -import dev.hotwire.turbo.fragments.TurboWebBottomSheetDialogFragment -import dev.hotwire.turbo.nav.TurboNavGraphDestination +import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink +import dev.hotwire.navigation.fragments.HotwireWebBottomSheetFragment -@TurboNavGraphDestination(uri = "turbo://fragment/web/bottom_sheet") -class WebBottomSheetFragment : TurboWebBottomSheetDialogFragment() +@HotwireDestinationDeepLink(uri = "hotwire://fragment/web/bottom_sheet") +class WebBottomSheetFragment : HotwireWebBottomSheetFragment() diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt index b036349..6ef5c2b 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt @@ -4,14 +4,16 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import dev.hotwire.turbo.fragments.TurboWebFragment -import dev.hotwire.turbo.nav.TurboNavGraphDestination +import dev.hotwire.core.turbo.errors.HttpError +import dev.hotwire.core.turbo.errors.VisitError +import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink +import dev.hotwire.navigation.fragments.HotwireWebFragment import org.blackcandy.android.R import org.blackcandy.shared.viewmodels.WebViewModel import org.koin.androidx.viewmodel.ext.android.viewModel -@TurboNavGraphDestination(uri = "turbo://fragment/web") -open class WebFragment : TurboWebFragment() { +@HotwireDestinationDeepLink(uri = "hotwire://fragment/web") +open class WebFragment : HotwireWebFragment() { private val viewModel: WebViewModel by viewModel() override fun onCreateView( @@ -22,12 +24,12 @@ open class WebFragment : TurboWebFragment() { override fun onVisitErrorReceived( location: String, - errorCode: Int, + error: VisitError, ) { - if (errorCode == 401) { + if (error is HttpError.ClientError.Unauthorized) { viewModel.logout() } else { - super.onVisitErrorReceived(location, errorCode) + super.onVisitErrorReceived(location, error) } } } diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt index 3b5100a..13e35af 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt @@ -4,13 +4,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import dev.hotwire.turbo.nav.TurboNavGraphDestination -import org.blackcandy.android.R +import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink import org.blackcandy.android.databinding.FragmentWebHomeBinding import org.blackcandy.shared.viewmodels.HomeViewModel import org.koin.androidx.viewmodel.ext.android.viewModel -@TurboNavGraphDestination(uri = "turbo://fragment/web/home") +@HotwireDestinationDeepLink(uri = "hotwire://fragment/web/home") class WebHomeFragment : WebFragment() { private val viewModel: HomeViewModel by viewModel() @@ -27,24 +26,4 @@ class WebHomeFragment : WebFragment() { _binding = FragmentWebHomeBinding.inflate(inflater, container, false) return binding.root } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - binding.toolbar.setOnMenuItemClickListener { - when (it.itemId) { - R.id.top_bar_account -> { - navigate("${viewModel.serverAddress}/account") - true - } - - else -> { - false - } - } - } - } } diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt index 603bdbc..3b7bb49 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt @@ -4,10 +4,10 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import dev.hotwire.turbo.nav.TurboNavGraphDestination +import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink import org.blackcandy.android.databinding.FragmentWebLibraryBinding -@TurboNavGraphDestination(uri = "turbo://fragment/web/library") +@HotwireDestinationDeepLink(uri = "hotwire://fragment/web/library") open class WebLibraryFragment : WebFragment() { @Suppress("ktlint:standard:backing-property-naming") private var _binding: FragmentWebLibraryBinding? = null @@ -21,24 +21,4 @@ open class WebLibraryFragment : WebFragment() { _binding = FragmentWebLibraryBinding.inflate(inflater, container, false) return binding.root } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - binding.searchView.setupWithSearchBar(binding.searchBar) - binding.searchView.editText.setOnEditorActionListener { _, _, _ -> - val searchText = binding.searchView.text.toString() - - if (searchText.isEmpty()) return@setOnEditorActionListener false - - binding.searchView.hide() - binding.searchBar.setText(searchText) - session.webView.evaluateJavascript("App.nativeBridge.search('$searchText')", null) - - true - } - } } diff --git a/app/src/main/java/org/blackcandy/android/ui/MenuItem.kt b/app/src/main/java/org/blackcandy/android/ui/MenuItem.kt index 79aae9f..5e16396 100644 --- a/app/src/main/java/org/blackcandy/android/ui/MenuItem.kt +++ b/app/src/main/java/org/blackcandy/android/ui/MenuItem.kt @@ -4,7 +4,8 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes data class MenuItem( + val id: String, @StringRes val titleResourceId: Int, @DrawableRes val iconResourceId: Int, - val action: () -> Unit, + val action: () -> Unit = {}, ) diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt index 18f00ed..e7afa0e 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt +++ b/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt @@ -1,14 +1,20 @@ package org.blackcandy.android.viewmodels import androidx.lifecycle.ViewModel -import org.blackcandy.android.fragments.navs.LibraryNavHostFragment +import kotlinx.coroutines.runBlocking +import org.blackcandy.shared.data.ServerAddressRepository import org.blackcandy.shared.data.UserRepository class MainViewModel( userRepository: UserRepository, + private val serverAddressRepository: ServerAddressRepository, ) : ViewModel() { val currentUserFlow = userRepository.getCurrentUserFlow() - // Declare the library nav host fragment in view model to prevent it from being recreated when configuration changed. - val libraryNav = LibraryNavHostFragment() + var selectedTabIndex = 0 + + val serverAddress = + runBlocking { + serverAddressRepository.getServerAddress() + } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 15a86d5..d1dbbc4 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -11,12 +11,13 @@ android:layout_height="match_parent"> @@ -50,5 +51,5 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" - app:menu="@menu/nav" /> + /> diff --git a/app/src/main/res/layout/fragment_sheet_account.xml b/app/src/main/res/layout/fragment_sheet_account.xml deleted file mode 100644 index 30b9a71..0000000 --- a/app/src/main/res/layout/fragment_sheet_account.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_web.xml b/app/src/main/res/layout/fragment_web.xml index 4b41c93..2e21581 100644 --- a/app/src/main/res/layout/fragment_web.xml +++ b/app/src/main/res/layout/fragment_web.xml @@ -22,7 +22,7 @@ Date: Mon, 26 Jan 2026 19:35:15 +0900 Subject: [PATCH 15/24] Adopt hotwire bridge components --- .../org/blackcandy/android/MainApplication.kt | 18 +- .../android/bridge/AccountComponent.kt | 13 +- .../android/bridge/AlbumComponent.kt | 43 ++++ .../android/bridge/FlashComponent.kt | 36 ++++ .../android/bridge/PlaylistComponent.kt | 43 ++++ .../android/bridge/SongsComponent.kt | 48 +++++ .../android/bridge/ThemeComponent.kt | 39 ++++ .../fragments/navs/HomeNavHostFragment.kt | 5 - .../fragments/navs/LibraryNavHostFragment.kt | 6 - .../fragments/navs/MainNavHostFragment.kt | 131 ------------ .../android/fragments/web/WebFragment.kt | 23 +- .../android/fragments/web/WebHomeFragment.kt | 4 - .../res/layout-sw600dp-land/activity_main.xml | 2 +- .../main/res/layout-w600dp/activity_main.xml | 2 +- .../org/blackcandy/shared/di/CommonModule.kt | 8 +- .../viewmodels/AccountSheetViewModel.kt | 42 ---- .../shared/viewmodels/HomeViewModel.kt | 14 -- .../shared/viewmodels/NavHostViewModel.kt | 196 ------------------ .../shared/viewmodels/WebViewModel.kt | 182 +++++++++++++++- 19 files changed, 435 insertions(+), 420 deletions(-) create mode 100644 app/src/main/java/org/blackcandy/android/bridge/AlbumComponent.kt create mode 100644 app/src/main/java/org/blackcandy/android/bridge/FlashComponent.kt create mode 100644 app/src/main/java/org/blackcandy/android/bridge/PlaylistComponent.kt create mode 100644 app/src/main/java/org/blackcandy/android/bridge/SongsComponent.kt create mode 100644 app/src/main/java/org/blackcandy/android/bridge/ThemeComponent.kt delete mode 100644 app/src/main/java/org/blackcandy/android/fragments/navs/HomeNavHostFragment.kt delete mode 100644 app/src/main/java/org/blackcandy/android/fragments/navs/LibraryNavHostFragment.kt delete mode 100644 app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt delete mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/AccountSheetViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/HomeViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/NavHostViewModel.kt diff --git a/app/src/main/java/org/blackcandy/android/MainApplication.kt b/app/src/main/java/org/blackcandy/android/MainApplication.kt index c914136..a8af7eb 100644 --- a/app/src/main/java/org/blackcandy/android/MainApplication.kt +++ b/app/src/main/java/org/blackcandy/android/MainApplication.kt @@ -9,7 +9,12 @@ import dev.hotwire.navigation.config.defaultFragmentDestination import dev.hotwire.navigation.config.registerBridgeComponents import dev.hotwire.navigation.config.registerFragmentDestinations import org.blackcandy.android.bridge.AccountComponent +import org.blackcandy.android.bridge.AlbumComponent +import org.blackcandy.android.bridge.FlashComponent +import org.blackcandy.android.bridge.PlaylistComponent import org.blackcandy.android.bridge.SearchComponent +import org.blackcandy.android.bridge.SongsComponent +import org.blackcandy.android.bridge.ThemeComponent import org.blackcandy.android.di.androidModule import org.blackcandy.android.fragments.web.WebBottomSheetFragment import org.blackcandy.android.fragments.web.WebFragment @@ -35,6 +40,10 @@ class MainApplication : Application() { } private fun configureApp() { + Hotwire.config.applicationUserAgentPrefix = "${BLACK_CANDY_USER_AGENT};" + Hotwire.config.jsonConverter = KotlinXJsonConverter() + Hotwire.defaultFragmentDestination = WebFragment::class + Hotwire.loadPathConfiguration( context = this, location = @@ -43,8 +52,6 @@ class MainApplication : Application() { ), ) - Hotwire.config.applicationUserAgentPrefix = "${BLACK_CANDY_USER_AGENT};" - Hotwire.defaultFragmentDestination = WebFragment::class Hotwire.registerFragmentDestinations( WebFragment::class, WebHomeFragment::class, @@ -55,8 +62,11 @@ class MainApplication : Application() { Hotwire.registerBridgeComponents( BridgeComponentFactory("account", ::AccountComponent), BridgeComponentFactory("search", ::SearchComponent), + BridgeComponentFactory("album", ::AlbumComponent), + BridgeComponentFactory("flash", ::FlashComponent), + BridgeComponentFactory("playlist", ::PlaylistComponent), + BridgeComponentFactory("songs", ::SongsComponent), + BridgeComponentFactory("theme", ::ThemeComponent), ) - - Hotwire.config.jsonConverter = KotlinXJsonConverter() } } diff --git a/app/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt b/app/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt index ea4685b..4b3308c 100644 --- a/app/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt +++ b/app/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt @@ -1,7 +1,6 @@ package org.blackcandy.android.bridge import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment import com.google.accompanist.themeadapter.material3.Mdc3Theme import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.bottomsheet.BottomSheetDialog @@ -11,25 +10,25 @@ import dev.hotwire.core.bridge.Message import dev.hotwire.navigation.destinations.HotwireDestination import org.blackcandy.android.R import org.blackcandy.android.compose.account.AccountMenu +import org.blackcandy.android.fragments.web.WebFragment import org.blackcandy.android.ui.MenuItem import org.blackcandy.shared.viewmodels.WebViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel -import kotlin.getValue class AccountComponent( name: String, private val delegate: BridgeDelegate, ) : BridgeComponent(name, delegate) { - private val viewModel: WebViewModel by fragment.viewModel() - - private val fragment: Fragment - get() = delegate.destination.fragment + private val fragment: WebFragment + get() = delegate.destination.fragment as WebFragment private val toolbar: MaterialToolbar? get() = fragment.view?.findViewById(R.id.toolbar) private val menuItems: MutableList = mutableListOf() + private val viewModel: WebViewModel + get() = fragment.viewModel + private lateinit var bottomSheet: BottomSheetDialog override fun onReceive(message: Message) { diff --git a/app/src/main/java/org/blackcandy/android/bridge/AlbumComponent.kt b/app/src/main/java/org/blackcandy/android/bridge/AlbumComponent.kt new file mode 100644 index 0000000..b96a976 --- /dev/null +++ b/app/src/main/java/org/blackcandy/android/bridge/AlbumComponent.kt @@ -0,0 +1,43 @@ +package org.blackcandy.android.bridge + +import dev.hotwire.core.bridge.BridgeComponent +import dev.hotwire.core.bridge.BridgeDelegate +import dev.hotwire.core.bridge.Message +import dev.hotwire.navigation.destinations.HotwireDestination +import kotlinx.serialization.Serializable +import org.blackcandy.android.fragments.web.WebFragment +import org.blackcandy.shared.viewmodels.WebViewModel + +class AlbumComponent( + name: String, + private val delegate: BridgeDelegate, +) : BridgeComponent(name, delegate) { + private val viewModel: WebViewModel + get() { + val fragment = delegate.destination.fragment as WebFragment + return fragment.viewModel + } + + override fun onReceive(message: Message) { + when (message.event) { + "play" -> handlePlayEvent(message) + "playBeginWith" -> handlePlayBeginWithEvent(message) + } + } + + private fun handlePlayEvent(message: Message) { + val data = message.data() ?: return + viewModel.playAlbum(data.albumId) + } + + private fun handlePlayBeginWithEvent(message: Message) { + val data = message.data() ?: return + viewModel.playAlbumBeginWith(data.albumId, data.songId!!) + } + + @Serializable + data class AlbumData( + val albumId: Int, + val songId: Int?, + ) +} diff --git a/app/src/main/java/org/blackcandy/android/bridge/FlashComponent.kt b/app/src/main/java/org/blackcandy/android/bridge/FlashComponent.kt new file mode 100644 index 0000000..517a746 --- /dev/null +++ b/app/src/main/java/org/blackcandy/android/bridge/FlashComponent.kt @@ -0,0 +1,36 @@ +package org.blackcandy.android.bridge + +import dev.hotwire.core.bridge.BridgeComponent +import dev.hotwire.core.bridge.BridgeDelegate +import dev.hotwire.core.bridge.Message +import dev.hotwire.navigation.destinations.HotwireDestination +import kotlinx.serialization.Serializable +import org.blackcandy.android.fragments.web.WebFragment +import org.blackcandy.shared.viewmodels.WebViewModel + +class FlashComponent( + name: String, + private val delegate: BridgeDelegate, +) : BridgeComponent(name, delegate) { + private val viewModel: WebViewModel + get() { + val fragment = delegate.destination.fragment as WebFragment + return fragment.viewModel + } + + override fun onReceive(message: Message) { + when (message.event) { + "connect" -> handleConnectEvent(message) + } + } + + private fun handleConnectEvent(message: Message) { + val data = message.data() ?: return + viewModel.showFlashMessage(data.message) + } + + @Serializable + data class MessageData( + val message: String, + ) +} diff --git a/app/src/main/java/org/blackcandy/android/bridge/PlaylistComponent.kt b/app/src/main/java/org/blackcandy/android/bridge/PlaylistComponent.kt new file mode 100644 index 0000000..ac54dfc --- /dev/null +++ b/app/src/main/java/org/blackcandy/android/bridge/PlaylistComponent.kt @@ -0,0 +1,43 @@ +package org.blackcandy.android.bridge + +import dev.hotwire.core.bridge.BridgeComponent +import dev.hotwire.core.bridge.BridgeDelegate +import dev.hotwire.core.bridge.Message +import dev.hotwire.navigation.destinations.HotwireDestination +import kotlinx.serialization.Serializable +import org.blackcandy.android.fragments.web.WebFragment +import org.blackcandy.shared.viewmodels.WebViewModel + +class PlaylistComponent( + name: String, + private val delegate: BridgeDelegate, +) : BridgeComponent(name, delegate) { + private val viewModel: WebViewModel + get() { + val fragment = delegate.destination.fragment as WebFragment + return fragment.viewModel + } + + override fun onReceive(message: Message) { + when (message.event) { + "play" -> handlePlayEvent(message) + "playBeginWith" -> handlePlayBeginWithEvent(message) + } + } + + private fun handlePlayEvent(message: Message) { + val data = message.data() ?: return + viewModel.playPlaylist(data.playlistId) + } + + private fun handlePlayBeginWithEvent(message: Message) { + val data = message.data() ?: return + viewModel.playPlaylistBeginWith(data.playlistId, data.songId!!) + } + + @Serializable + data class PlaylistData( + val playlistId: Int, + val songId: Int?, + ) +} diff --git a/app/src/main/java/org/blackcandy/android/bridge/SongsComponent.kt b/app/src/main/java/org/blackcandy/android/bridge/SongsComponent.kt new file mode 100644 index 0000000..d6db6f0 --- /dev/null +++ b/app/src/main/java/org/blackcandy/android/bridge/SongsComponent.kt @@ -0,0 +1,48 @@ +package org.blackcandy.android.bridge + +import dev.hotwire.core.bridge.BridgeComponent +import dev.hotwire.core.bridge.BridgeDelegate +import dev.hotwire.core.bridge.Message +import dev.hotwire.navigation.destinations.HotwireDestination +import kotlinx.serialization.Serializable +import org.blackcandy.android.fragments.web.WebFragment +import org.blackcandy.shared.viewmodels.WebViewModel + +class SongsComponent( + name: String, + private val delegate: BridgeDelegate, +) : BridgeComponent(name, delegate) { + private val viewModel: WebViewModel + get() { + val fragment = delegate.destination.fragment as WebFragment + return fragment.viewModel + } + + override fun onReceive(message: Message) { + when (message.event) { + "playNow" -> handlePlayNowEvent(message) + "playNext" -> handlePlayNextEvent(message) + "playLast" -> handlePlayLastEvent(message) + } + } + + private fun handlePlayNowEvent(message: Message) { + val data = message.data() ?: return + viewModel.playNow(data.songId) + } + + private fun handlePlayNextEvent(message: Message) { + val data = message.data() ?: return + viewModel.playNext(data.songId) + } + + private fun handlePlayLastEvent(message: Message) { + val data = message.data() ?: return + viewModel.playLast(data.songId) + } + + @Serializable + data class SongsData( + val songId: Int, + ) +} diff --git a/app/src/main/java/org/blackcandy/android/bridge/ThemeComponent.kt b/app/src/main/java/org/blackcandy/android/bridge/ThemeComponent.kt new file mode 100644 index 0000000..4dba64d --- /dev/null +++ b/app/src/main/java/org/blackcandy/android/bridge/ThemeComponent.kt @@ -0,0 +1,39 @@ +package org.blackcandy.android.bridge + +import dev.hotwire.core.bridge.BridgeComponent +import dev.hotwire.core.bridge.BridgeDelegate +import dev.hotwire.core.bridge.Message +import dev.hotwire.navigation.destinations.HotwireDestination +import kotlinx.serialization.Serializable +import org.blackcandy.android.fragments.web.WebFragment +import org.blackcandy.shared.utils.Theme +import org.blackcandy.shared.viewmodels.WebViewModel + +class ThemeComponent( + name: String, + private val delegate: BridgeDelegate, +) : BridgeComponent(name, delegate) { + private val viewModel: WebViewModel + get() { + val fragment = delegate.destination.fragment as WebFragment + return fragment.viewModel + } + + override fun onReceive(message: Message) { + when (message.event) { + "initialize" -> handleInitializeEvent(message) + } + } + + private fun handleInitializeEvent(message: Message) { + val data = message.data() ?: return + val themeValue = Theme.values().find { it.name == data.theme.uppercase() } ?: return + + viewModel.updateTheme(themeValue) + } + + @Serializable + data class ThemeData( + val theme: String, + ) +} diff --git a/app/src/main/java/org/blackcandy/android/fragments/navs/HomeNavHostFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/navs/HomeNavHostFragment.kt deleted file mode 100644 index ab5b577..0000000 --- a/app/src/main/java/org/blackcandy/android/fragments/navs/HomeNavHostFragment.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.blackcandy.android.fragments.navs - -class HomeNavHostFragment : MainNavHostFragment() { - override val sessionName = "home" -} diff --git a/app/src/main/java/org/blackcandy/android/fragments/navs/LibraryNavHostFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/navs/LibraryNavHostFragment.kt deleted file mode 100644 index 1fdcd3d..0000000 --- a/app/src/main/java/org/blackcandy/android/fragments/navs/LibraryNavHostFragment.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.blackcandy.android.fragments.navs - -class LibraryNavHostFragment : MainNavHostFragment() { - override val sessionName = "library" - override val startLocation get() = "${super.startLocation}/library" -} diff --git a/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt deleted file mode 100644 index 8fc9c39..0000000 --- a/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt +++ /dev/null @@ -1,131 +0,0 @@ -package org.blackcandy.android.fragments.navs - -import android.os.Bundle -import android.webkit.JavascriptInterface -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import dev.hotwire.turbo.config.TurboPathConfiguration -import dev.hotwire.turbo.session.TurboSessionNavHostFragment -import kotlinx.coroutines.launch -import org.blackcandy.android.fragments.web.WebBottomSheetFragment -import org.blackcandy.android.fragments.web.WebFragment -import org.blackcandy.android.fragments.web.WebHomeFragment -import org.blackcandy.android.fragments.web.WebLibraryFragment -import org.blackcandy.android.utils.SnackbarUtil.Companion.showSnackbar -import org.blackcandy.shared.utils.BLACK_CANDY_USER_AGENT -import org.blackcandy.shared.utils.Theme -import org.blackcandy.shared.viewmodels.NavHostViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel -import kotlin.reflect.KClass - -open class MainNavHostFragment : TurboSessionNavHostFragment() { - private val viewModel: NavHostViewModel by viewModel() - - override val sessionName = "main" - override val startLocation get() = viewModel.serverAddress - - override val registeredActivities: List> - get() = - listOf( - // Leave empty unless you have more - // than one TurboActivity in your app - ) - - override val registeredFragments: List> - get() = - listOf( - WebFragment::class, - WebHomeFragment::class, - WebLibraryFragment::class, - WebBottomSheetFragment::class, - // And any other TurboFragments in your app - ) - - override val pathConfigurationLocation: TurboPathConfiguration.Location - get() = - TurboPathConfiguration.Location( - assetFilePath = "json/configuration.json", - ) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect { - if (it.alertMessage != null) { - showSnackbar(requireActivity(), it.alertMessage!!) { - viewModel.alertMessageShown() - } - } - } - } - } - } - - override fun onSessionCreated() { - super.onSessionCreated() - - session.webView.settings.userAgentString = BLACK_CANDY_USER_AGENT - - session.webView.addJavascriptInterface( - object { - @JavascriptInterface - fun updateTheme(theme: String) { - val themeValue = Theme.values().find { it.name == theme.uppercase() } ?: return - viewModel.updateTheme(themeValue) - } - - @JavascriptInterface - fun playAlbum(albumId: Int) { - viewModel.playAlbum(albumId) - } - - @JavascriptInterface - fun playPlaylist(playlistId: Int) { - viewModel.playPlaylist(playlistId) - } - - @JavascriptInterface - fun playAlbumBeginWith( - albumId: Int, - songId: Int, - ) { - viewModel.playAlbumBeginWith(albumId, songId) - } - - @JavascriptInterface - fun playPlaylistBeginWith( - playlistId: Int, - songId: Int, - ) { - viewModel.playPlaylistBeginWith(playlistId, songId) - } - - @JavascriptInterface - fun playNow(songId: Int) { - viewModel.playNow(songId) - } - - @JavascriptInterface - fun playNext(songId: Int) { - viewModel.playNext(songId) - } - - @JavascriptInterface - fun playLast(songId: Int) { - viewModel.playLast(songId) - } - - @JavascriptInterface - fun showFlashMessage(message: String) { - viewModel.showFlashMessage(message) - } - }, - "NativeBridge", - ) - } -} diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt index 6ef5c2b..313c03a 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt @@ -4,17 +4,38 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import dev.hotwire.core.turbo.errors.HttpError import dev.hotwire.core.turbo.errors.VisitError import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink import dev.hotwire.navigation.fragments.HotwireWebFragment +import kotlinx.coroutines.launch import org.blackcandy.android.R +import org.blackcandy.android.utils.SnackbarUtil.Companion.showSnackbar import org.blackcandy.shared.viewmodels.WebViewModel import org.koin.androidx.viewmodel.ext.android.viewModel @HotwireDestinationDeepLink(uri = "hotwire://fragment/web") open class WebFragment : HotwireWebFragment() { - private val viewModel: WebViewModel by viewModel() + val viewModel: WebViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { + if (it.alertMessage != null) { + showSnackbar(requireActivity(), it.alertMessage!!) { + viewModel.alertMessageShown() + } + } + } + } + } + } override fun onCreateView( inflater: LayoutInflater, diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt index 13e35af..f702e2c 100644 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt +++ b/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt @@ -6,13 +6,9 @@ import android.view.View import android.view.ViewGroup import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink import org.blackcandy.android.databinding.FragmentWebHomeBinding -import org.blackcandy.shared.viewmodels.HomeViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel @HotwireDestinationDeepLink(uri = "hotwire://fragment/web/home") class WebHomeFragment : WebFragment() { - private val viewModel: HomeViewModel by viewModel() - @Suppress("ktlint:standard:backing-property-naming") private var _binding: FragmentWebHomeBinding? = null diff --git a/app/src/main/res/layout-sw600dp-land/activity_main.xml b/app/src/main/res/layout-sw600dp-land/activity_main.xml index 3d7a885..40fdf18 100644 --- a/app/src/main/res/layout-sw600dp-land/activity_main.xml +++ b/app/src/main/res/layout-sw600dp-land/activity_main.xml @@ -26,7 +26,7 @@ diff --git a/app/src/main/res/layout-w600dp/activity_main.xml b/app/src/main/res/layout-w600dp/activity_main.xml index 9ba24a1..37fbee6 100644 --- a/app/src/main/res/layout-w600dp/activity_main.xml +++ b/app/src/main/res/layout-w600dp/activity_main.xml @@ -19,7 +19,7 @@ diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt index 78cc9d0..29dc211 100644 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt @@ -27,12 +27,9 @@ import org.blackcandy.shared.data.ServerAddressRepository import org.blackcandy.shared.data.SystemInfoRepository import org.blackcandy.shared.data.UserRepository import org.blackcandy.shared.utils.BLACK_CANDY_USER_AGENT -import org.blackcandy.shared.viewmodels.AccountSheetViewModel -import org.blackcandy.shared.viewmodels.HomeViewModel import org.blackcandy.shared.viewmodels.LoginViewModel import org.blackcandy.shared.viewmodels.MiniPlayerViewModel import org.blackcandy.shared.viewmodels.MusicServiceViewModel -import org.blackcandy.shared.viewmodels.NavHostViewModel import org.blackcandy.shared.viewmodels.PlayerViewModel import org.blackcandy.shared.viewmodels.WebViewModel import org.koin.core.module.dsl.viewModel @@ -53,12 +50,9 @@ val commonModule = single { FavoritePlaylistRepository(get()) } viewModel { LoginViewModel(get(), get(), get()) } - viewModel { AccountSheetViewModel(get(), get()) } - viewModel { NavHostViewModel(get(), get(), get()) } - viewModel { HomeViewModel(get()) } viewModel { MiniPlayerViewModel(get()) } viewModel { PlayerViewModel(get(), get(), get()) } - viewModel { WebViewModel(get()) } + viewModel { WebViewModel(get(), get(), get()) } viewModel { MusicServiceViewModel(get(), get()) } } diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/AccountSheetViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/AccountSheetViewModel.kt deleted file mode 100644 index ace1b45..0000000 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/AccountSheetViewModel.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.blackcandy.shared.viewmodels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import org.blackcandy.shared.data.ServerAddressRepository -import org.blackcandy.shared.data.UserRepository -import org.blackcandy.shared.models.User - -data class AccountSheetUiState( - val serverAddress: String? = null, - val currentUser: User? = null, -) - -class AccountSheetViewModel( - private val userRepository: UserRepository, - private val serverAddressRepository: ServerAddressRepository, -) : ViewModel() { - val uiState = - combine( - serverAddressRepository.getServerAddressFlow(), - userRepository.getCurrentUserFlow(), - ) { serverAddress, currentUser -> - AccountSheetUiState( - serverAddress = serverAddress, - currentUser = currentUser, - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = AccountSheetUiState(), - ) - - fun logout() { - viewModelScope.launch { - userRepository.logout() - } - } -} diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/HomeViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/HomeViewModel.kt deleted file mode 100644 index 66ae00e..0000000 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/HomeViewModel.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.blackcandy.shared.viewmodels - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.runBlocking -import org.blackcandy.shared.data.ServerAddressRepository - -class HomeViewModel( - private val serverAddressRepository: ServerAddressRepository, -) : ViewModel() { - val serverAddress = - runBlocking { - serverAddressRepository.getServerAddress() - } -} diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/NavHostViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/NavHostViewModel.kt deleted file mode 100644 index b7431c0..0000000 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/NavHostViewModel.kt +++ /dev/null @@ -1,196 +0,0 @@ -package org.blackcandy.shared.viewmodels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import org.blackcandy.shared.data.CurrentPlaylistRepository -import org.blackcandy.shared.data.ServerAddressRepository -import org.blackcandy.shared.media.MusicServiceController -import org.blackcandy.shared.models.AlertMessage -import org.blackcandy.shared.models.Song -import org.blackcandy.shared.utils.TaskResult -import org.blackcandy.shared.utils.Theme -import org.blackcandy.shared.utils.updateAppTheme - -data class NavHostUiState( - val alertMessage: AlertMessage? = null, -) - -class NavHostViewModel( - private val serverAddressRepository: ServerAddressRepository, - private val currentPlaylistRepository: CurrentPlaylistRepository, - private val musicServiceController: MusicServiceController, -) : ViewModel() { - private val _uiState = MutableStateFlow(NavHostUiState()) - - val uiState = _uiState.asStateFlow() - - val serverAddress = - runBlocking { - serverAddressRepository.getServerAddress() - } - - fun alertMessageShown() { - _uiState.update { it.copy(alertMessage = null) } - } - - fun updateTheme(theme: Theme) { - viewModelScope.launch { - updateAppTheme(theme) - } - } - - fun playAlbum(albumId: Int) { - viewModelScope.launch { - when (val result = currentPlaylistRepository.replaceWithAlbumSongs(albumId)) { - is TaskResult.Success -> { - playSongs(result.data) - } - - is TaskResult.Failure -> { - _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } - } - } - } - } - - fun playPlaylist(playlistId: Int) { - viewModelScope.launch { - when (val result = currentPlaylistRepository.replaceWithPlaylistSongs(playlistId)) { - is TaskResult.Success -> { - playSongs(result.data) - } - - is TaskResult.Failure -> { - _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } - } - } - } - } - - fun playAlbumBeginWith( - albumId: Int, - songId: Int, - ) { - viewModelScope.launch { - when (val result = currentPlaylistRepository.replaceWithAlbumSongs(albumId)) { - is TaskResult.Success -> { - playSongsBeginWith(result.data, songId) - } - - is TaskResult.Failure -> { - _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } - } - } - } - } - - fun playPlaylistBeginWith( - playlistId: Int, - songId: Int, - ) { - viewModelScope.launch { - when (val result = currentPlaylistRepository.replaceWithPlaylistSongs(playlistId)) { - is TaskResult.Success -> { - playSongsBeginWith(result.data, songId) - } - - is TaskResult.Failure -> { - _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } - } - } - } - } - - fun playNow(songId: Int) { - viewModelScope.launch { - val index = musicServiceController.getSongIndex(songId) - - if (index != -1) { - musicServiceController.playOn(index) - } else { - val currentSong = musicServiceController.musicState.value.currentSong ?: return@launch - - when ( - val result = - currentPlaylistRepository.addSongToNext(songId, currentSong.id) - ) { - is TaskResult.Success -> { - val songIndex = musicServiceController.addSongToNext(result.data) - musicServiceController.playOn(songIndex) - - _uiState.update { - it.copy( - alertMessage = AlertMessage.LocalizedString(AlertMessage.DefinedMessages.ADDED_TO_PLAYLIST), - ) - } - } - - is TaskResult.Failure -> { - _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } - } - } - } - } - } - - fun playNext(songId: Int) { - viewModelScope.launch { - val currentSong = musicServiceController.musicState.value.currentSong ?: return@launch - - when (val result = currentPlaylistRepository.addSongToNext(songId, currentSong.id)) { - is TaskResult.Success -> { - musicServiceController.addSongToNext(result.data) - _uiState.update { it.copy(alertMessage = AlertMessage.LocalizedString(AlertMessage.DefinedMessages.ADDED_TO_PLAYLIST)) } - } - - is TaskResult.Failure -> { - _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } - } - } - } - } - - fun playLast(songId: Int) { - viewModelScope.launch { - when (val result = currentPlaylistRepository.addSongToLast(songId)) { - is TaskResult.Success -> { - musicServiceController.addSongToLast(result.data) - _uiState.update { it.copy(alertMessage = AlertMessage.LocalizedString(AlertMessage.DefinedMessages.ADDED_TO_PLAYLIST)) } - } - - is TaskResult.Failure -> { - _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } - } - } - } - } - - fun showFlashMessage(message: String) { - _uiState.update { it.copy(alertMessage = AlertMessage.String(message)) } - } - - private fun playSongs(songs: List) { - musicServiceController.updatePlaylist(songs) - musicServiceController.playOn(0) - } - - private fun playSongsBeginWith( - songs: List, - songId: Int, - ) { - musicServiceController.updatePlaylist(songs) - - val index = musicServiceController.getSongIndex(songId) - - if (index != -1) { - musicServiceController.playOn(index) - } else { - musicServiceController.playOn(0) - } - } -} diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/WebViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/WebViewModel.kt index e38d69a..82b3f54 100644 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/WebViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/WebViewModel.kt @@ -2,15 +2,195 @@ package org.blackcandy.shared.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.blackcandy.shared.data.CurrentPlaylistRepository import org.blackcandy.shared.data.UserRepository +import org.blackcandy.shared.media.MusicServiceController +import org.blackcandy.shared.models.AlertMessage +import org.blackcandy.shared.models.Song +import org.blackcandy.shared.utils.TaskResult +import org.blackcandy.shared.utils.Theme +import org.blackcandy.shared.utils.updateAppTheme + +data class WebUiState( + val alertMessage: AlertMessage? = null, +) class WebViewModel( - val userRepository: UserRepository, + private val userRepository: UserRepository, + private val currentPlaylistRepository: CurrentPlaylistRepository, + private val musicServiceController: MusicServiceController, ) : ViewModel() { + private val _uiState = MutableStateFlow(WebUiState()) + + val uiState = _uiState.asStateFlow() + fun logout() { viewModelScope.launch { userRepository.logout() } } + + fun showFlashMessage(message: String) { + _uiState.update { it.copy(alertMessage = AlertMessage.String(message)) } + } + + fun alertMessageShown() { + _uiState.update { it.copy(alertMessage = null) } + } + + fun updateTheme(theme: Theme) { + viewModelScope.launch { + updateAppTheme(theme) + } + } + + fun playAlbum(albumId: Int) { + viewModelScope.launch { + when (val result = currentPlaylistRepository.replaceWithAlbumSongs(albumId)) { + is TaskResult.Success -> { + playSongs(result.data) + } + + is TaskResult.Failure -> { + _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } + } + } + } + } + + fun playAlbumBeginWith( + albumId: Int, + songId: Int, + ) { + viewModelScope.launch { + when (val result = currentPlaylistRepository.replaceWithAlbumSongs(albumId)) { + is TaskResult.Success -> { + playSongsBeginWith(result.data, songId) + } + + is TaskResult.Failure -> { + _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } + } + } + } + } + + fun playPlaylist(playlistId: Int) { + viewModelScope.launch { + when (val result = currentPlaylistRepository.replaceWithPlaylistSongs(playlistId)) { + is TaskResult.Success -> { + playSongs(result.data) + } + + is TaskResult.Failure -> { + _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } + } + } + } + } + + fun playPlaylistBeginWith( + playlistId: Int, + songId: Int, + ) { + viewModelScope.launch { + when (val result = currentPlaylistRepository.replaceWithPlaylistSongs(playlistId)) { + is TaskResult.Success -> { + playSongsBeginWith(result.data, songId) + } + + is TaskResult.Failure -> { + _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } + } + } + } + } + + fun playNow(songId: Int) { + viewModelScope.launch { + val index = musicServiceController.getSongIndex(songId) + + if (index != -1) { + musicServiceController.playOn(index) + } else { + val currentSong = musicServiceController.musicState.value.currentSong ?: return@launch + + when ( + val result = + currentPlaylistRepository.addSongToNext(songId, currentSong.id) + ) { + is TaskResult.Success -> { + val songIndex = musicServiceController.addSongToNext(result.data) + musicServiceController.playOn(songIndex) + + _uiState.update { + it.copy( + alertMessage = AlertMessage.LocalizedString(AlertMessage.DefinedMessages.ADDED_TO_PLAYLIST), + ) + } + } + + is TaskResult.Failure -> { + _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } + } + } + } + } + } + + fun playNext(songId: Int) { + viewModelScope.launch { + val currentSong = musicServiceController.musicState.value.currentSong ?: return@launch + + when (val result = currentPlaylistRepository.addSongToNext(songId, currentSong.id)) { + is TaskResult.Success -> { + musicServiceController.addSongToNext(result.data) + _uiState.update { it.copy(alertMessage = AlertMessage.LocalizedString(AlertMessage.DefinedMessages.ADDED_TO_PLAYLIST)) } + } + + is TaskResult.Failure -> { + _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } + } + } + } + } + + fun playLast(songId: Int) { + viewModelScope.launch { + when (val result = currentPlaylistRepository.addSongToLast(songId)) { + is TaskResult.Success -> { + musicServiceController.addSongToLast(result.data) + _uiState.update { it.copy(alertMessage = AlertMessage.LocalizedString(AlertMessage.DefinedMessages.ADDED_TO_PLAYLIST)) } + } + + is TaskResult.Failure -> { + _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } + } + } + } + } + + private fun playSongs(songs: List) { + musicServiceController.updatePlaylist(songs) + musicServiceController.playOn(0) + } + + private fun playSongsBeginWith( + songs: List, + songId: Int, + ) { + musicServiceController.updatePlaylist(songs) + + val index = musicServiceController.getSongIndex(songId) + + if (index != -1) { + musicServiceController.playOn(index) + } else { + musicServiceController.playOn(0) + } + } } From bfec480814e3e5250e1b365f7201323545a88a8a Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Tue, 27 Jan 2026 19:42:08 +0900 Subject: [PATCH 16/24] Remove turbo native lib --- app/build.gradle.kts | 1 - .../main/java/org/blackcandy/android/di/AndroidModule.kt | 8 +------- gradle/libs.versions.toml | 2 -- 3 files changed, 1 insertion(+), 10 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1446620..7a5d59a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,7 +72,6 @@ dependencies { implementation(libs.google.material) implementation(libs.google.accompanist.themeadapter.material3) - implementation(libs.turbo) implementation(libs.kotlinx.serialization.json) implementation(libs.koin.androidx.compose) implementation(libs.ktor.client.core) diff --git a/app/src/main/java/org/blackcandy/android/di/AndroidModule.kt b/app/src/main/java/org/blackcandy/android/di/AndroidModule.kt index bfc1b73..4b785d5 100644 --- a/app/src/main/java/org/blackcandy/android/di/AndroidModule.kt +++ b/app/src/main/java/org/blackcandy/android/di/AndroidModule.kt @@ -1,16 +1,10 @@ package org.blackcandy.android.di import org.blackcandy.android.viewmodels.MainViewModel -import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.module.dsl.viewModel import org.koin.dsl.module val androidModule = module { - // TODO: rename MainViewModel and remove it the shared module. - // Tha main purpose of MainViewModel is to get current user - // and declare the library nav host fragment in view model to prevent it from being recreated when configuration changed. - // After we migrate to use turbo native. it already provide a way to declare bottom tag. so we don't need to declare the library nav host fragment - // in viewmodel. and only purpose of left for MainViewModel will be get current user. should rename a proper name. - // After refactoring. we may also don't need this androidModule anymore. viewModel { MainViewModel(get(), get()) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc32103..27a65ab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,6 @@ kotlinxSerializationJson = "1.5.1" ktor = "2.3.4" media3 = "1.3.1" reorderable = "1.3.3" -turbo = "7.0.3" kotlin = "2.2.0" androidGradlePlugin = "8.13.1" kotlinStdlib = "2.2.0" @@ -72,7 +71,6 @@ ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { group = "io.ktor", name ="ktor-serialization-kotlinx-json", version.ref = "ktor" } reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "reorderable" } -turbo = { group = "dev.hotwire", name = "turbo", version.ref = "turbo" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlinStdlib" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinTest" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } From 924a958b0f136b344ab85c2c7ff8d0ead98244ec Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Wed, 28 Jan 2026 19:59:34 +0900 Subject: [PATCH 17/24] Initialize iOS app --- .gitignore | 6 +- {app => androidApp}/.gitignore | 0 {app => androidApp}/build.gradle.kts | 0 {app => androidApp}/proguard-rules.pro | 0 .../android/ExampleInstrumentedTest.kt | 0 .../src/main/AndroidManifest.xml | 0 .../src/main/assets/json/configuration.json | 0 .../src/main/ic_launcher-playstore.png | Bin .../org/blackcandy/android/LoginActivity.kt | 0 .../org/blackcandy/android/MainActivity.kt | 0 .../org/blackcandy/android/MainApplication.kt | 0 .../java/org/blackcandy/android/MainTabs.kt | 0 .../android/bridge/AccountComponent.kt | 0 .../android/bridge/AlbumComponent.kt | 0 .../android/bridge/FlashComponent.kt | 0 .../android/bridge/PlaylistComponent.kt | 0 .../android/bridge/SearchComponent.kt | 0 .../android/bridge/SongsComponent.kt | 0 .../android/bridge/ThemeComponent.kt | 0 .../android/compose/account/AccountMenu.kt | 0 .../compose/login/LoginAuthenticationForm.kt | 0 .../compose/login/LoginConnectionForm.kt | 0 .../android/compose/login/LoginScreen.kt | 0 .../android/compose/player/FullPlayer.kt | 0 .../android/compose/player/MiniPlayer.kt | 0 .../android/compose/player/PlayerActions.kt | 0 .../android/compose/player/PlayerArt.kt | 0 .../android/compose/player/PlayerControl.kt | 0 .../android/compose/player/PlayerInfo.kt | 0 .../android/compose/player/PlayerScreen.kt | 0 .../android/compose/player/Playlist.kt | 0 .../android/compose/player/PlaylistItem.kt | 0 .../blackcandy/android/di/AndroidModule.kt | 0 .../fragments/web/WebBottomSheetFragment.kt | 0 .../android/fragments/web/WebFragment.kt | 0 .../android/fragments/web/WebHomeFragment.kt | 0 .../fragments/web/WebLibraryFragment.kt | 0 .../org/blackcandy/android/ui/MenuItem.kt | 0 .../blackcandy/android/utils/SnackbarUtil.kt | 0 .../android/viewmodels/MainViewModel.kt | 0 .../res/drawable/baseline_clear_all_24.xml | 0 .../main/res/drawable/baseline_delete_24.xml | 0 .../res/drawable/baseline_drag_handle_24.xml | 0 .../main/res/drawable/baseline_edit_24.xml | 0 .../res/drawable/baseline_exit_to_app_24.xml | 0 .../main/res/drawable/baseline_face_24.xml | 0 .../res/drawable/baseline_favorite_24.xml | 0 .../drawable/baseline_favorite_border_24.xml | 0 .../baseline_format_list_bulleted_24.xml | 0 .../main/res/drawable/baseline_home_24.xml | 0 .../drawable/baseline_library_music_24.xml | 0 .../main/res/drawable/baseline_pause_24.xml | 0 .../main/res/drawable/baseline_people_24.xml | 0 .../res/drawable/baseline_play_arrow_24.xml | 0 .../main/res/drawable/baseline_repeat_24.xml | 0 .../res/drawable/baseline_repeat_one_24.xml | 0 .../res/drawable/baseline_settings_24.xml | 0 .../main/res/drawable/baseline_shuffle_24.xml | 0 .../res/drawable/baseline_skip_next_24.xml | 0 .../drawable/baseline_skip_previous_24.xml | 0 .../main/res/drawable/black_candy_logo.xml | 0 .../res/drawable/ic_launcher_background.xml | 0 .../res/drawable/ic_launcher_foreground.xml | 0 .../drawable/ic_launcher_foreground_mono.xml | 0 .../drawable/outline_account_circle_30.xml | 0 .../res/layout-sw600dp-land/activity_main.xml | 0 .../main/res/layout-w600dp/activity_main.xml | 0 .../src/main/res/layout/activity_main.xml | 0 .../src/main/res/layout/fragment_web.xml | 0 .../src/main/res/layout/fragment_web_home.xml | 0 .../main/res/layout/fragment_web_library.xml | 0 .../src/main/res/menu/home_top_bar.xml | 0 {app => androidApp}/src/main/res/menu/nav.xml | 0 .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../mipmap-anydpi-v26/ic_launcher_round.xml | 0 .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin .../res/mipmap-hdpi/ic_launcher_round.webp | Bin .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin .../res/mipmap-mdpi/ic_launcher_round.webp | Bin .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin .../src/main/res/values-night/colors.xml | 0 .../src/main/res/values-night/themes.xml | 0 .../src/main/res/values/colors.xml | 0 .../src/main/res/values/dimens.xml | 0 .../src/main/res/values/strings.xml | 0 .../src/main/res/values/styles.xml | 0 .../src/main/res/values/themes.xml | 0 .../src/main/res/xml/backup_rules.xml | 0 .../main/res/xml/data_extraction_rules.xml | 0 .../org/blackcandy/android/ExampleUnitTest.kt | 0 gradle/libs.versions.toml | 1 + iosApp/iosApp.xcodeproj/project.pbxproj | 363 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + iosApp/iosApp/AppDelegate.swift | 36 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ iosApp/iosApp/Assets.xcassets/Contents.json | 6 + .../iosApp/Base.lproj/LaunchScreen.storyboard | 25 ++ iosApp/iosApp/Base.lproj/Main.storyboard | 24 ++ iosApp/iosApp/Info.plist | 25 ++ iosApp/iosApp/SceneDelegate.swift | 52 +++ iosApp/iosApp/ViewController.swift | 19 + settings.gradle.kts | 2 +- shared/build.gradle.kts | 3 +- .../shared/utils/DurationFormatter.kt | 5 +- .../shared/data/EncryptedDataSource.kt | 13 + .../blackcandy/shared/di/PlatformModule.kt | 8 + .../shared/media/MusicServiceController.kt | 63 +++ .../org/blackcandy/shared/utils/Cookies.kt | 9 + .../org/blackcandy/shared/utils/Theme.kt | 5 + 115 files changed, 714 insertions(+), 4 deletions(-) rename {app => androidApp}/.gitignore (100%) rename {app => androidApp}/build.gradle.kts (100%) rename {app => androidApp}/proguard-rules.pro (100%) rename {app => androidApp}/src/androidTest/java/org/blackcandy/android/ExampleInstrumentedTest.kt (100%) rename {app => androidApp}/src/main/AndroidManifest.xml (100%) rename {app => androidApp}/src/main/assets/json/configuration.json (100%) rename {app => androidApp}/src/main/ic_launcher-playstore.png (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/LoginActivity.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/MainActivity.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/MainApplication.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/MainTabs.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/bridge/AlbumComponent.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/bridge/FlashComponent.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/bridge/PlaylistComponent.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/bridge/SearchComponent.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/bridge/SongsComponent.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/bridge/ThemeComponent.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/compose/account/AccountMenu.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/compose/login/LoginAuthenticationForm.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/compose/login/LoginConnectionForm.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/compose/login/LoginScreen.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/compose/player/MiniPlayer.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/compose/player/PlayerActions.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/compose/player/PlayerArt.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/compose/player/PlayerControl.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/compose/player/PlayerInfo.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/compose/player/PlayerScreen.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/compose/player/Playlist.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/compose/player/PlaylistItem.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/di/AndroidModule.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/fragments/web/WebBottomSheetFragment.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/ui/MenuItem.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt (100%) rename {app => androidApp}/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt (100%) rename {app => androidApp}/src/main/res/drawable/baseline_clear_all_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_delete_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_drag_handle_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_edit_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_exit_to_app_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_face_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_favorite_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_favorite_border_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_format_list_bulleted_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_home_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_library_music_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_pause_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_people_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_play_arrow_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_repeat_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_repeat_one_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_settings_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_shuffle_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_skip_next_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/baseline_skip_previous_24.xml (100%) rename {app => androidApp}/src/main/res/drawable/black_candy_logo.xml (100%) rename {app => androidApp}/src/main/res/drawable/ic_launcher_background.xml (100%) rename {app => androidApp}/src/main/res/drawable/ic_launcher_foreground.xml (100%) rename {app => androidApp}/src/main/res/drawable/ic_launcher_foreground_mono.xml (100%) rename {app => androidApp}/src/main/res/drawable/outline_account_circle_30.xml (100%) rename {app => androidApp}/src/main/res/layout-sw600dp-land/activity_main.xml (100%) rename {app => androidApp}/src/main/res/layout-w600dp/activity_main.xml (100%) rename {app => androidApp}/src/main/res/layout/activity_main.xml (100%) rename {app => androidApp}/src/main/res/layout/fragment_web.xml (100%) rename {app => androidApp}/src/main/res/layout/fragment_web_home.xml (100%) rename {app => androidApp}/src/main/res/layout/fragment_web_library.xml (100%) rename {app => androidApp}/src/main/res/menu/home_top_bar.xml (100%) rename {app => androidApp}/src/main/res/menu/nav.xml (100%) rename {app => androidApp}/src/main/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename {app => androidApp}/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml (100%) rename {app => androidApp}/src/main/res/mipmap-hdpi/ic_launcher.webp (100%) rename {app => androidApp}/src/main/res/mipmap-hdpi/ic_launcher_round.webp (100%) rename {app => androidApp}/src/main/res/mipmap-mdpi/ic_launcher.webp (100%) rename {app => androidApp}/src/main/res/mipmap-mdpi/ic_launcher_round.webp (100%) rename {app => androidApp}/src/main/res/mipmap-xhdpi/ic_launcher.webp (100%) rename {app => androidApp}/src/main/res/mipmap-xhdpi/ic_launcher_round.webp (100%) rename {app => androidApp}/src/main/res/mipmap-xxhdpi/ic_launcher.webp (100%) rename {app => androidApp}/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp (100%) rename {app => androidApp}/src/main/res/mipmap-xxxhdpi/ic_launcher.webp (100%) rename {app => androidApp}/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp (100%) rename {app => androidApp}/src/main/res/values-night/colors.xml (100%) rename {app => androidApp}/src/main/res/values-night/themes.xml (100%) rename {app => androidApp}/src/main/res/values/colors.xml (100%) rename {app => androidApp}/src/main/res/values/dimens.xml (100%) rename {app => androidApp}/src/main/res/values/strings.xml (100%) rename {app => androidApp}/src/main/res/values/styles.xml (100%) rename {app => androidApp}/src/main/res/values/themes.xml (100%) rename {app => androidApp}/src/main/res/xml/backup_rules.xml (100%) rename {app => androidApp}/src/main/res/xml/data_extraction_rules.xml (100%) rename {app => androidApp}/src/test/java/org/blackcandy/android/ExampleUnitTest.kt (100%) create mode 100644 iosApp/iosApp.xcodeproj/project.pbxproj create mode 100644 iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 iosApp/iosApp/AppDelegate.swift create mode 100644 iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 iosApp/iosApp/Assets.xcassets/Contents.json create mode 100644 iosApp/iosApp/Base.lproj/LaunchScreen.storyboard create mode 100644 iosApp/iosApp/Base.lproj/Main.storyboard create mode 100644 iosApp/iosApp/Info.plist create mode 100644 iosApp/iosApp/SceneDelegate.swift create mode 100644 iosApp/iosApp/ViewController.swift create mode 100644 shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt create mode 100644 shared/src/iosMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt create mode 100644 shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt create mode 100644 shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Cookies.kt create mode 100644 shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Theme.kt diff --git a/.gitignore b/.gitignore index 0896ce2..776deb9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,8 @@ local.properties .idea /app/release/ /fastlane/report.xml -.kotlin \ No newline at end of file +.kotlin +xcuserdata/ +iosApp.xcconfig +*.zip +*.ipa \ No newline at end of file diff --git a/app/.gitignore b/androidApp/.gitignore similarity index 100% rename from app/.gitignore rename to androidApp/.gitignore diff --git a/app/build.gradle.kts b/androidApp/build.gradle.kts similarity index 100% rename from app/build.gradle.kts rename to androidApp/build.gradle.kts diff --git a/app/proguard-rules.pro b/androidApp/proguard-rules.pro similarity index 100% rename from app/proguard-rules.pro rename to androidApp/proguard-rules.pro diff --git a/app/src/androidTest/java/org/blackcandy/android/ExampleInstrumentedTest.kt b/androidApp/src/androidTest/java/org/blackcandy/android/ExampleInstrumentedTest.kt similarity index 100% rename from app/src/androidTest/java/org/blackcandy/android/ExampleInstrumentedTest.kt rename to androidApp/src/androidTest/java/org/blackcandy/android/ExampleInstrumentedTest.kt diff --git a/app/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml similarity index 100% rename from app/src/main/AndroidManifest.xml rename to androidApp/src/main/AndroidManifest.xml diff --git a/app/src/main/assets/json/configuration.json b/androidApp/src/main/assets/json/configuration.json similarity index 100% rename from app/src/main/assets/json/configuration.json rename to androidApp/src/main/assets/json/configuration.json diff --git a/app/src/main/ic_launcher-playstore.png b/androidApp/src/main/ic_launcher-playstore.png similarity index 100% rename from app/src/main/ic_launcher-playstore.png rename to androidApp/src/main/ic_launcher-playstore.png diff --git a/app/src/main/java/org/blackcandy/android/LoginActivity.kt b/androidApp/src/main/java/org/blackcandy/android/LoginActivity.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/LoginActivity.kt rename to androidApp/src/main/java/org/blackcandy/android/LoginActivity.kt diff --git a/app/src/main/java/org/blackcandy/android/MainActivity.kt b/androidApp/src/main/java/org/blackcandy/android/MainActivity.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/MainActivity.kt rename to androidApp/src/main/java/org/blackcandy/android/MainActivity.kt diff --git a/app/src/main/java/org/blackcandy/android/MainApplication.kt b/androidApp/src/main/java/org/blackcandy/android/MainApplication.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/MainApplication.kt rename to androidApp/src/main/java/org/blackcandy/android/MainApplication.kt diff --git a/app/src/main/java/org/blackcandy/android/MainTabs.kt b/androidApp/src/main/java/org/blackcandy/android/MainTabs.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/MainTabs.kt rename to androidApp/src/main/java/org/blackcandy/android/MainTabs.kt diff --git a/app/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt rename to androidApp/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt diff --git a/app/src/main/java/org/blackcandy/android/bridge/AlbumComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/AlbumComponent.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/bridge/AlbumComponent.kt rename to androidApp/src/main/java/org/blackcandy/android/bridge/AlbumComponent.kt diff --git a/app/src/main/java/org/blackcandy/android/bridge/FlashComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/FlashComponent.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/bridge/FlashComponent.kt rename to androidApp/src/main/java/org/blackcandy/android/bridge/FlashComponent.kt diff --git a/app/src/main/java/org/blackcandy/android/bridge/PlaylistComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/PlaylistComponent.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/bridge/PlaylistComponent.kt rename to androidApp/src/main/java/org/blackcandy/android/bridge/PlaylistComponent.kt diff --git a/app/src/main/java/org/blackcandy/android/bridge/SearchComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/SearchComponent.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/bridge/SearchComponent.kt rename to androidApp/src/main/java/org/blackcandy/android/bridge/SearchComponent.kt diff --git a/app/src/main/java/org/blackcandy/android/bridge/SongsComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/SongsComponent.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/bridge/SongsComponent.kt rename to androidApp/src/main/java/org/blackcandy/android/bridge/SongsComponent.kt diff --git a/app/src/main/java/org/blackcandy/android/bridge/ThemeComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/ThemeComponent.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/bridge/ThemeComponent.kt rename to androidApp/src/main/java/org/blackcandy/android/bridge/ThemeComponent.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/account/AccountMenu.kt b/androidApp/src/main/java/org/blackcandy/android/compose/account/AccountMenu.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/compose/account/AccountMenu.kt rename to androidApp/src/main/java/org/blackcandy/android/compose/account/AccountMenu.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/login/LoginAuthenticationForm.kt b/androidApp/src/main/java/org/blackcandy/android/compose/login/LoginAuthenticationForm.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/compose/login/LoginAuthenticationForm.kt rename to androidApp/src/main/java/org/blackcandy/android/compose/login/LoginAuthenticationForm.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/login/LoginConnectionForm.kt b/androidApp/src/main/java/org/blackcandy/android/compose/login/LoginConnectionForm.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/compose/login/LoginConnectionForm.kt rename to androidApp/src/main/java/org/blackcandy/android/compose/login/LoginConnectionForm.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/login/LoginScreen.kt b/androidApp/src/main/java/org/blackcandy/android/compose/login/LoginScreen.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/compose/login/LoginScreen.kt rename to androidApp/src/main/java/org/blackcandy/android/compose/login/LoginScreen.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt b/androidApp/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt rename to androidApp/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/player/MiniPlayer.kt b/androidApp/src/main/java/org/blackcandy/android/compose/player/MiniPlayer.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/compose/player/MiniPlayer.kt rename to androidApp/src/main/java/org/blackcandy/android/compose/player/MiniPlayer.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/player/PlayerActions.kt b/androidApp/src/main/java/org/blackcandy/android/compose/player/PlayerActions.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/compose/player/PlayerActions.kt rename to androidApp/src/main/java/org/blackcandy/android/compose/player/PlayerActions.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/player/PlayerArt.kt b/androidApp/src/main/java/org/blackcandy/android/compose/player/PlayerArt.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/compose/player/PlayerArt.kt rename to androidApp/src/main/java/org/blackcandy/android/compose/player/PlayerArt.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/player/PlayerControl.kt b/androidApp/src/main/java/org/blackcandy/android/compose/player/PlayerControl.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/compose/player/PlayerControl.kt rename to androidApp/src/main/java/org/blackcandy/android/compose/player/PlayerControl.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/player/PlayerInfo.kt b/androidApp/src/main/java/org/blackcandy/android/compose/player/PlayerInfo.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/compose/player/PlayerInfo.kt rename to androidApp/src/main/java/org/blackcandy/android/compose/player/PlayerInfo.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/player/PlayerScreen.kt b/androidApp/src/main/java/org/blackcandy/android/compose/player/PlayerScreen.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/compose/player/PlayerScreen.kt rename to androidApp/src/main/java/org/blackcandy/android/compose/player/PlayerScreen.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/player/Playlist.kt b/androidApp/src/main/java/org/blackcandy/android/compose/player/Playlist.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/compose/player/Playlist.kt rename to androidApp/src/main/java/org/blackcandy/android/compose/player/Playlist.kt diff --git a/app/src/main/java/org/blackcandy/android/compose/player/PlaylistItem.kt b/androidApp/src/main/java/org/blackcandy/android/compose/player/PlaylistItem.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/compose/player/PlaylistItem.kt rename to androidApp/src/main/java/org/blackcandy/android/compose/player/PlaylistItem.kt diff --git a/app/src/main/java/org/blackcandy/android/di/AndroidModule.kt b/androidApp/src/main/java/org/blackcandy/android/di/AndroidModule.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/di/AndroidModule.kt rename to androidApp/src/main/java/org/blackcandy/android/di/AndroidModule.kt diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebBottomSheetFragment.kt b/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebBottomSheetFragment.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/fragments/web/WebBottomSheetFragment.kt rename to androidApp/src/main/java/org/blackcandy/android/fragments/web/WebBottomSheetFragment.kt diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt b/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt rename to androidApp/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt b/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt rename to androidApp/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt diff --git a/app/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt b/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt rename to androidApp/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt diff --git a/app/src/main/java/org/blackcandy/android/ui/MenuItem.kt b/androidApp/src/main/java/org/blackcandy/android/ui/MenuItem.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/ui/MenuItem.kt rename to androidApp/src/main/java/org/blackcandy/android/ui/MenuItem.kt diff --git a/app/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt b/androidApp/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt rename to androidApp/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt b/androidApp/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt similarity index 100% rename from app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt rename to androidApp/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt diff --git a/app/src/main/res/drawable/baseline_clear_all_24.xml b/androidApp/src/main/res/drawable/baseline_clear_all_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_clear_all_24.xml rename to androidApp/src/main/res/drawable/baseline_clear_all_24.xml diff --git a/app/src/main/res/drawable/baseline_delete_24.xml b/androidApp/src/main/res/drawable/baseline_delete_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_delete_24.xml rename to androidApp/src/main/res/drawable/baseline_delete_24.xml diff --git a/app/src/main/res/drawable/baseline_drag_handle_24.xml b/androidApp/src/main/res/drawable/baseline_drag_handle_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_drag_handle_24.xml rename to androidApp/src/main/res/drawable/baseline_drag_handle_24.xml diff --git a/app/src/main/res/drawable/baseline_edit_24.xml b/androidApp/src/main/res/drawable/baseline_edit_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_edit_24.xml rename to androidApp/src/main/res/drawable/baseline_edit_24.xml diff --git a/app/src/main/res/drawable/baseline_exit_to_app_24.xml b/androidApp/src/main/res/drawable/baseline_exit_to_app_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_exit_to_app_24.xml rename to androidApp/src/main/res/drawable/baseline_exit_to_app_24.xml diff --git a/app/src/main/res/drawable/baseline_face_24.xml b/androidApp/src/main/res/drawable/baseline_face_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_face_24.xml rename to androidApp/src/main/res/drawable/baseline_face_24.xml diff --git a/app/src/main/res/drawable/baseline_favorite_24.xml b/androidApp/src/main/res/drawable/baseline_favorite_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_favorite_24.xml rename to androidApp/src/main/res/drawable/baseline_favorite_24.xml diff --git a/app/src/main/res/drawable/baseline_favorite_border_24.xml b/androidApp/src/main/res/drawable/baseline_favorite_border_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_favorite_border_24.xml rename to androidApp/src/main/res/drawable/baseline_favorite_border_24.xml diff --git a/app/src/main/res/drawable/baseline_format_list_bulleted_24.xml b/androidApp/src/main/res/drawable/baseline_format_list_bulleted_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_format_list_bulleted_24.xml rename to androidApp/src/main/res/drawable/baseline_format_list_bulleted_24.xml diff --git a/app/src/main/res/drawable/baseline_home_24.xml b/androidApp/src/main/res/drawable/baseline_home_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_home_24.xml rename to androidApp/src/main/res/drawable/baseline_home_24.xml diff --git a/app/src/main/res/drawable/baseline_library_music_24.xml b/androidApp/src/main/res/drawable/baseline_library_music_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_library_music_24.xml rename to androidApp/src/main/res/drawable/baseline_library_music_24.xml diff --git a/app/src/main/res/drawable/baseline_pause_24.xml b/androidApp/src/main/res/drawable/baseline_pause_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_pause_24.xml rename to androidApp/src/main/res/drawable/baseline_pause_24.xml diff --git a/app/src/main/res/drawable/baseline_people_24.xml b/androidApp/src/main/res/drawable/baseline_people_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_people_24.xml rename to androidApp/src/main/res/drawable/baseline_people_24.xml diff --git a/app/src/main/res/drawable/baseline_play_arrow_24.xml b/androidApp/src/main/res/drawable/baseline_play_arrow_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_play_arrow_24.xml rename to androidApp/src/main/res/drawable/baseline_play_arrow_24.xml diff --git a/app/src/main/res/drawable/baseline_repeat_24.xml b/androidApp/src/main/res/drawable/baseline_repeat_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_repeat_24.xml rename to androidApp/src/main/res/drawable/baseline_repeat_24.xml diff --git a/app/src/main/res/drawable/baseline_repeat_one_24.xml b/androidApp/src/main/res/drawable/baseline_repeat_one_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_repeat_one_24.xml rename to androidApp/src/main/res/drawable/baseline_repeat_one_24.xml diff --git a/app/src/main/res/drawable/baseline_settings_24.xml b/androidApp/src/main/res/drawable/baseline_settings_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_settings_24.xml rename to androidApp/src/main/res/drawable/baseline_settings_24.xml diff --git a/app/src/main/res/drawable/baseline_shuffle_24.xml b/androidApp/src/main/res/drawable/baseline_shuffle_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_shuffle_24.xml rename to androidApp/src/main/res/drawable/baseline_shuffle_24.xml diff --git a/app/src/main/res/drawable/baseline_skip_next_24.xml b/androidApp/src/main/res/drawable/baseline_skip_next_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_skip_next_24.xml rename to androidApp/src/main/res/drawable/baseline_skip_next_24.xml diff --git a/app/src/main/res/drawable/baseline_skip_previous_24.xml b/androidApp/src/main/res/drawable/baseline_skip_previous_24.xml similarity index 100% rename from app/src/main/res/drawable/baseline_skip_previous_24.xml rename to androidApp/src/main/res/drawable/baseline_skip_previous_24.xml diff --git a/app/src/main/res/drawable/black_candy_logo.xml b/androidApp/src/main/res/drawable/black_candy_logo.xml similarity index 100% rename from app/src/main/res/drawable/black_candy_logo.xml rename to androidApp/src/main/res/drawable/black_candy_logo.xml diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/androidApp/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from app/src/main/res/drawable/ic_launcher_background.xml rename to androidApp/src/main/res/drawable/ic_launcher_background.xml diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/androidApp/src/main/res/drawable/ic_launcher_foreground.xml similarity index 100% rename from app/src/main/res/drawable/ic_launcher_foreground.xml rename to androidApp/src/main/res/drawable/ic_launcher_foreground.xml diff --git a/app/src/main/res/drawable/ic_launcher_foreground_mono.xml b/androidApp/src/main/res/drawable/ic_launcher_foreground_mono.xml similarity index 100% rename from app/src/main/res/drawable/ic_launcher_foreground_mono.xml rename to androidApp/src/main/res/drawable/ic_launcher_foreground_mono.xml diff --git a/app/src/main/res/drawable/outline_account_circle_30.xml b/androidApp/src/main/res/drawable/outline_account_circle_30.xml similarity index 100% rename from app/src/main/res/drawable/outline_account_circle_30.xml rename to androidApp/src/main/res/drawable/outline_account_circle_30.xml diff --git a/app/src/main/res/layout-sw600dp-land/activity_main.xml b/androidApp/src/main/res/layout-sw600dp-land/activity_main.xml similarity index 100% rename from app/src/main/res/layout-sw600dp-land/activity_main.xml rename to androidApp/src/main/res/layout-sw600dp-land/activity_main.xml diff --git a/app/src/main/res/layout-w600dp/activity_main.xml b/androidApp/src/main/res/layout-w600dp/activity_main.xml similarity index 100% rename from app/src/main/res/layout-w600dp/activity_main.xml rename to androidApp/src/main/res/layout-w600dp/activity_main.xml diff --git a/app/src/main/res/layout/activity_main.xml b/androidApp/src/main/res/layout/activity_main.xml similarity index 100% rename from app/src/main/res/layout/activity_main.xml rename to androidApp/src/main/res/layout/activity_main.xml diff --git a/app/src/main/res/layout/fragment_web.xml b/androidApp/src/main/res/layout/fragment_web.xml similarity index 100% rename from app/src/main/res/layout/fragment_web.xml rename to androidApp/src/main/res/layout/fragment_web.xml diff --git a/app/src/main/res/layout/fragment_web_home.xml b/androidApp/src/main/res/layout/fragment_web_home.xml similarity index 100% rename from app/src/main/res/layout/fragment_web_home.xml rename to androidApp/src/main/res/layout/fragment_web_home.xml diff --git a/app/src/main/res/layout/fragment_web_library.xml b/androidApp/src/main/res/layout/fragment_web_library.xml similarity index 100% rename from app/src/main/res/layout/fragment_web_library.xml rename to androidApp/src/main/res/layout/fragment_web_library.xml diff --git a/app/src/main/res/menu/home_top_bar.xml b/androidApp/src/main/res/menu/home_top_bar.xml similarity index 100% rename from app/src/main/res/menu/home_top_bar.xml rename to androidApp/src/main/res/menu/home_top_bar.xml diff --git a/app/src/main/res/menu/nav.xml b/androidApp/src/main/res/menu/nav.xml similarity index 100% rename from app/src/main/res/menu/nav.xml rename to androidApp/src/main/res/menu/nav.xml diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-hdpi/ic_launcher.webp rename to androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-hdpi/ic_launcher_round.webp rename to androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-mdpi/ic_launcher.webp rename to androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-mdpi/ic_launcher_round.webp rename to androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-xhdpi/ic_launcher.webp rename to androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp rename to androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-xxhdpi/ic_launcher.webp rename to androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp rename to androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp rename to androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp rename to androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp diff --git a/app/src/main/res/values-night/colors.xml b/androidApp/src/main/res/values-night/colors.xml similarity index 100% rename from app/src/main/res/values-night/colors.xml rename to androidApp/src/main/res/values-night/colors.xml diff --git a/app/src/main/res/values-night/themes.xml b/androidApp/src/main/res/values-night/themes.xml similarity index 100% rename from app/src/main/res/values-night/themes.xml rename to androidApp/src/main/res/values-night/themes.xml diff --git a/app/src/main/res/values/colors.xml b/androidApp/src/main/res/values/colors.xml similarity index 100% rename from app/src/main/res/values/colors.xml rename to androidApp/src/main/res/values/colors.xml diff --git a/app/src/main/res/values/dimens.xml b/androidApp/src/main/res/values/dimens.xml similarity index 100% rename from app/src/main/res/values/dimens.xml rename to androidApp/src/main/res/values/dimens.xml diff --git a/app/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml similarity index 100% rename from app/src/main/res/values/strings.xml rename to androidApp/src/main/res/values/strings.xml diff --git a/app/src/main/res/values/styles.xml b/androidApp/src/main/res/values/styles.xml similarity index 100% rename from app/src/main/res/values/styles.xml rename to androidApp/src/main/res/values/styles.xml diff --git a/app/src/main/res/values/themes.xml b/androidApp/src/main/res/values/themes.xml similarity index 100% rename from app/src/main/res/values/themes.xml rename to androidApp/src/main/res/values/themes.xml diff --git a/app/src/main/res/xml/backup_rules.xml b/androidApp/src/main/res/xml/backup_rules.xml similarity index 100% rename from app/src/main/res/xml/backup_rules.xml rename to androidApp/src/main/res/xml/backup_rules.xml diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/androidApp/src/main/res/xml/data_extraction_rules.xml similarity index 100% rename from app/src/main/res/xml/data_extraction_rules.xml rename to androidApp/src/main/res/xml/data_extraction_rules.xml diff --git a/app/src/test/java/org/blackcandy/android/ExampleUnitTest.kt b/androidApp/src/test/java/org/blackcandy/android/ExampleUnitTest.kt similarity index 100% rename from app/src/test/java/org/blackcandy/android/ExampleUnitTest.kt rename to androidApp/src/test/java/org/blackcandy/android/ExampleUnitTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27a65ab..c21892c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" } ktor-serialization-kotlinx-json = { group = "io.ktor", name ="ktor-serialization-kotlinx-json", version.ref = "ktor" } reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "reorderable" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlinStdlib" } diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6ace57f --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,363 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 64ADE25F2F29ACA5002615D0 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 64ADE2712F29ACA6002615D0 /* Exceptions for "iosApp" folder in "iosApp" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 64ADE25E2F29ACA5002615D0 /* iosApp */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 64ADE2612F29ACA5002615D0 /* iosApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 64ADE2712F29ACA6002615D0 /* Exceptions for "iosApp" folder in "iosApp" target */, + ); + path = iosApp; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 64ADE25C2F29ACA5002615D0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 64ADE2562F29ACA5002615D0 = { + isa = PBXGroup; + children = ( + 64ADE2612F29ACA5002615D0 /* iosApp */, + 64ADE2602F29ACA5002615D0 /* Products */, + ); + sourceTree = ""; + }; + 64ADE2602F29ACA5002615D0 /* Products */ = { + isa = PBXGroup; + children = ( + 64ADE25F2F29ACA5002615D0 /* iosApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 64ADE25E2F29ACA5002615D0 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 64ADE2722F29ACA6002615D0 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + 643D96262F29AD410056B9CE /* Run Script */, + 64ADE25B2F29ACA5002615D0 /* Sources */, + 64ADE25C2F29ACA5002615D0 /* Frameworks */, + 64ADE25D2F29ACA5002615D0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 64ADE2612F29ACA5002615D0 /* iosApp */, + ); + name = iosApp; + packageProductDependencies = ( + ); + productName = iosApp; + productReference = 64ADE25F2F29ACA5002615D0 /* iosApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 64ADE2572F29ACA5002615D0 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1640; + LastUpgradeCheck = 1640; + TargetAttributes = { + 64ADE25E2F29ACA5002615D0 = { + CreatedOnToolsVersion = 16.4; + }; + }; + }; + buildConfigurationList = 64ADE25A2F29ACA5002615D0 /* Build configuration list for PBXProject "iosApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 64ADE2562F29ACA5002615D0; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 64ADE2602F29ACA5002615D0 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 64ADE25E2F29ACA5002615D0 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 64ADE25D2F29ACA5002615D0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 643D96262F29AD410056B9CE /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\nexport JAVA_HOME=\"/Applications/Android Studio.app/Contents/jbr/Contents/Home\"\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 64ADE25B2F29ACA5002615D0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 64ADE2732F29ACA6002615D0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.blackcandy.iosApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 64ADE2742F29ACA6002615D0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.blackcandy.iosApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 64ADE2752F29ACA6002615D0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 64ADE2762F29ACA6002615D0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 64ADE25A2F29ACA5002615D0 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 64ADE2752F29ACA6002615D0 /* Debug */, + 64ADE2762F29ACA6002615D0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 64ADE2722F29ACA6002615D0 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 64ADE2732F29ACA6002615D0 /* Debug */, + 64ADE2742F29ACA6002615D0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 64ADE2572F29ACA5002615D0 /* Project object */; +} diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift new file mode 100644 index 0000000..cd3bfbe --- /dev/null +++ b/iosApp/iosApp/AppDelegate.swift @@ -0,0 +1,36 @@ +// +// AppDelegate.swift +// iosApp +// +// Created by Ed on 2026/01/28. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/Contents.json b/iosApp/iosApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Base.lproj/LaunchScreen.storyboard b/iosApp/iosApp/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/iosApp/iosApp/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iosApp/iosApp/Base.lproj/Main.storyboard b/iosApp/iosApp/Base.lproj/Main.storyboard new file mode 100644 index 0000000..25a7638 --- /dev/null +++ b/iosApp/iosApp/Base.lproj/Main.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist new file mode 100644 index 0000000..dd3c9af --- /dev/null +++ b/iosApp/iosApp/Info.plist @@ -0,0 +1,25 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + + diff --git a/iosApp/iosApp/SceneDelegate.swift b/iosApp/iosApp/SceneDelegate.swift new file mode 100644 index 0000000..d7f35a5 --- /dev/null +++ b/iosApp/iosApp/SceneDelegate.swift @@ -0,0 +1,52 @@ +// +// SceneDelegate.swift +// iosApp +// +// Created by Ed on 2026/01/28. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + guard let _ = (scene as? UIWindowScene) else { return } + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } + + +} + diff --git a/iosApp/iosApp/ViewController.swift b/iosApp/iosApp/ViewController.swift new file mode 100644 index 0000000..34f1067 --- /dev/null +++ b/iosApp/iosApp/ViewController.swift @@ -0,0 +1,19 @@ +// +// ViewController.swift +// iosApp +// +// Created by Ed on 2026/01/28. +// + +import UIKit + +class ViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + // Do any additional setup after loading the view. + } + + +} + diff --git a/settings.gradle.kts b/settings.gradle.kts index 4d56d81..de2afa6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,5 +13,5 @@ dependencyResolutionManagement { } } rootProject.name = "BlackCandy" -include(":app") +include(":androidApp") include(":shared") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 465fb53..060377e 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -62,7 +62,6 @@ kotlin { dependencies { implementation(libs.kotlin.stdlib) implementation(libs.ktor.client.core) - implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.client.auth) @@ -88,6 +87,7 @@ kotlin { // Add Android-specific dependencies here. Note that this source set depends on // commonMain by default and will correctly pull the Android artifacts of any KMP // dependencies declared in commonMain. + implementation(libs.ktor.client.okhttp) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.session) implementation(libs.androidx.media3.datasource.okhttp) @@ -107,6 +107,7 @@ kotlin { iosMain { dependencies { + implementation(libs.ktor.client.darwin) // Add iOS-specific dependencies here. This a source set created by Kotlin Gradle // Plugin (KGP) that each specific iOS target (e.g., iosX64) depends on as // part of KMP’s default source set hierarchy. Note that this source set depends diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/DurationFormatter.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/DurationFormatter.kt index 793878a..e658da9 100644 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/DurationFormatter.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/DurationFormatter.kt @@ -6,7 +6,10 @@ class DurationFormatter { companion object { fun string(duration: Double): String { duration.seconds.toComponents { minutes, seconds, _ -> - return "%02d:%02d".format(minutes, seconds) + val mm = minutes.toString().padStart(2, '0') + val ss = seconds.toString().padStart(2, '0') + + return "$mm:$ss" } } } diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt new file mode 100644 index 0000000..084f29b --- /dev/null +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt @@ -0,0 +1,13 @@ +package org.blackcandy.shared.data + +actual class EncryptedDataSource { + actual fun getApiToken(): String? { + TODO("Not yet implemented") + } + + actual fun updateApiToken(apiToken: String) { + } + + actual fun removeApiToken() { + } +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt new file mode 100644 index 0000000..3e50cf7 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt @@ -0,0 +1,8 @@ +package org.blackcandy.shared.di + +import org.koin.dsl.module + +actual val platformModule = + module { + + } \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt new file mode 100644 index 0000000..3db6c0d --- /dev/null +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt @@ -0,0 +1,63 @@ +package org.blackcandy.shared.media + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.blackcandy.shared.models.Song + +actual class MusicServiceController { + actual val musicState: StateFlow + get() = TODO("Not yet implemented") + actual val currentPosition: Flow + get() = TODO("Not yet implemented") + + actual fun initMediaController(onInitialized: () -> Unit) { + } + + actual fun updatePlaylist(songs: List) { + } + + actual fun play() { + } + + actual fun pause() { + } + + actual fun next() { + } + + actual fun previous() { + } + + actual fun playOn(index: Int) { + } + + actual fun seekTo(seconds: Double) { + } + + actual fun clearPlaylist() { + } + + actual fun deleteSongFromPlaylist(song: Song) { + } + + actual fun updateSongInPlaylist(song: Song) { + } + + actual fun moveSongInPlaylist(from: Int, to: Int) { + } + + actual fun setPlaybackMode(playbackMode: PlaybackMode) { + } + + actual fun getSongIndex(songId: Int): Int { + TODO("Not yet implemented") + } + + actual fun addSongToNext(song: Song): Int { + TODO("Not yet implemented") + } + + actual fun addSongToLast(song: Song) { + } + +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Cookies.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Cookies.kt new file mode 100644 index 0000000..8535b83 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Cookies.kt @@ -0,0 +1,9 @@ +package org.blackcandy.shared.utils + +actual object Cookies { + actual fun update(path: String, cookies: List) { + } + + actual fun clean() { + } +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Theme.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Theme.kt new file mode 100644 index 0000000..a8e086e --- /dev/null +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Theme.kt @@ -0,0 +1,5 @@ +package org.blackcandy.shared.utils + +actual fun updateAppTheme(theme: Theme) { + +} \ No newline at end of file From a7588ff7c431d095677624a4d7dbc491a4589e14 Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Thu, 29 Jan 2026 20:41:18 +0900 Subject: [PATCH 18/24] Move main view model to shared module --- .github/workflows/ci.yml | 11 +++- .swiftlint.yml | 4 ++ .../org/blackcandy/android/MainActivity.kt | 4 +- .../org/blackcandy/android/MainApplication.kt | 3 +- .../blackcandy/android/di/AndroidModule.kt | 10 ---- gradle/libs.versions.toml | 4 +- iosApp/iosApp.xcodeproj/project.pbxproj | 32 ++++++++++-- .../xcshareddata/swiftpm/Package.resolved | 15 ++++++ iosApp/iosApp/AppDelegate.swift | 16 ++---- .../iosApp/Base.lproj/LaunchScreen.storyboard | 25 ---------- iosApp/iosApp/Base.lproj/Main.storyboard | 24 --------- iosApp/iosApp/Info.plist | 10 +++- iosApp/iosApp/SceneDelegate.swift | 31 +++++++----- iosApp/iosApp/ViewController.swift | 19 ------- .../ViewControllers/LoginViewController.swift | 12 +++++ .../ViewControllers/MainViewController.swift | 4 ++ iosApp/iosApp/Views/LoginView.swift | 7 +++ shared/build.gradle.kts | 1 + .../blackcandy/shared/di/PlatformModule.kt | 50 ------------------- .../shared/data/PreferencesDataSource.kt | 13 +++++ .../blackcandy/shared/data/UserRepository.kt | 9 ++-- .../org/blackcandy/shared/di/CommonModule.kt | 4 +- .../shared}/viewmodels/MainViewModel.kt | 12 +++-- .../org/blackcandy/shared/KoinHelper.kt | 20 ++++++++ .../shared/data/EncryptedDataSource.kt | 2 +- .../blackcandy/shared/di/PlatformModule.kt | 33 +++++++++++- .../shared/media/MusicServiceController.kt | 8 +-- .../org/blackcandy/shared/utils/Cookies.kt | 7 ++- .../org/blackcandy/shared/utils/Theme.kt | 3 +- 29 files changed, 211 insertions(+), 182 deletions(-) create mode 100644 .swiftlint.yml delete mode 100644 androidApp/src/main/java/org/blackcandy/android/di/AndroidModule.kt create mode 100644 iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 iosApp/iosApp/Base.lproj/LaunchScreen.storyboard delete mode 100644 iosApp/iosApp/Base.lproj/Main.storyboard delete mode 100644 iosApp/iosApp/ViewController.swift create mode 100644 iosApp/iosApp/ViewControllers/LoginViewController.swift create mode 100644 iosApp/iosApp/ViewControllers/MainViewController.swift create mode 100644 iosApp/iosApp/Views/LoginView.swift rename {androidApp/src/main/java/org/blackcandy/android => shared/src/commonMain/kotlin/org/blackcandy/shared}/viewmodels/MainViewModel.kt (72%) create mode 100644 shared/src/iosMain/kotlin/org/blackcandy/shared/KoinHelper.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76aad26..8e7f09a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: [push, pull_request] jobs: - test_lint: + android_lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -19,3 +19,12 @@ jobs: - name: Android Lint run: ./gradlew lint + + ios_lint: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v3 + - name: Lint + run: | + swiftlint \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..c8bc8cd --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,4 @@ +disabled_rules: + - line_length +excluded: + - shared/build/ \ No newline at end of file diff --git a/androidApp/src/main/java/org/blackcandy/android/MainActivity.kt b/androidApp/src/main/java/org/blackcandy/android/MainActivity.kt index 23401f5..94914bf 100644 --- a/androidApp/src/main/java/org/blackcandy/android/MainActivity.kt +++ b/androidApp/src/main/java/org/blackcandy/android/MainActivity.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.runBlocking import org.blackcandy.android.compose.player.MiniPlayer import org.blackcandy.android.compose.player.PlayerScreen import org.blackcandy.android.databinding.ActivityMainBinding -import org.blackcandy.android.viewmodels.MainViewModel +import org.blackcandy.shared.viewmodels.MainViewModel import org.blackcandy.shared.viewmodels.MusicServiceViewModel import org.koin.androidx.viewmodel.ext.android.viewModel @@ -115,7 +115,7 @@ class MainActivity : HotwireActivity() { } private fun requireLogin(): Boolean { - runBlocking { viewModel.currentUserFlow.first() } ?: return true + viewModel.currentUser ?: return true lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { diff --git a/androidApp/src/main/java/org/blackcandy/android/MainApplication.kt b/androidApp/src/main/java/org/blackcandy/android/MainApplication.kt index a8af7eb..cd86c45 100644 --- a/androidApp/src/main/java/org/blackcandy/android/MainApplication.kt +++ b/androidApp/src/main/java/org/blackcandy/android/MainApplication.kt @@ -15,7 +15,6 @@ import org.blackcandy.android.bridge.PlaylistComponent import org.blackcandy.android.bridge.SearchComponent import org.blackcandy.android.bridge.SongsComponent import org.blackcandy.android.bridge.ThemeComponent -import org.blackcandy.android.di.androidModule import org.blackcandy.android.fragments.web.WebBottomSheetFragment import org.blackcandy.android.fragments.web.WebFragment import org.blackcandy.android.fragments.web.WebHomeFragment @@ -35,7 +34,7 @@ class MainApplication : Application() { startKoin { androidLogger() androidContext(this@MainApplication) - modules(appModule() + androidModule) + modules(appModule()) } } diff --git a/androidApp/src/main/java/org/blackcandy/android/di/AndroidModule.kt b/androidApp/src/main/java/org/blackcandy/android/di/AndroidModule.kt deleted file mode 100644 index 4b785d5..0000000 --- a/androidApp/src/main/java/org/blackcandy/android/di/AndroidModule.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.blackcandy.android.di - -import org.blackcandy.android.viewmodels.MainViewModel -import org.koin.core.module.dsl.viewModel -import org.koin.dsl.module - -val androidModule = - module { - viewModel { MainViewModel(get(), get()) } - } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c21892c..a5728eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ runner = "1.5.2" core = "1.5.0" recyclerview = "1.3.0" hotwire="1.2.4" +skie="0.10.6" [libraries] androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } @@ -88,4 +89,5 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "androidGradlePlugin" } -android-lint = { id = "com.android.lint", version.ref = "androidGradlePlugin" } \ No newline at end of file +android-lint = { id = "com.android.lint", version.ref = "androidGradlePlugin" } +skie = { id = "co.touchlab.skie", version.ref = "skie" } \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 6ace57f..c87c400 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 64D93BEB2F2AFACF00B13EAA /* HotwireNative in Frameworks */ = {isa = PBXBuildFile; productRef = 64D93BEA2F2AFACF00B13EAA /* HotwireNative */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ 64ADE25F2F29ACA5002615D0 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -36,6 +40,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 64D93BEB2F2AFACF00B13EAA /* HotwireNative in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -79,6 +84,7 @@ ); name = iosApp; packageProductDependencies = ( + 64D93BEA2F2AFACF00B13EAA /* HotwireNative */, ); productName = iosApp; productReference = 64ADE25F2F29ACA5002615D0 /* iosApp.app */; @@ -108,6 +114,9 @@ ); mainGroup = 64ADE2562F29ACA5002615D0; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 64D93BE92F2AFACF00B13EAA /* XCRemoteSwiftPackageReference "hotwire-native-ios" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 64ADE2602F29ACA5002615D0 /* Products */; projectDirPath = ""; @@ -172,8 +181,6 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( @@ -200,8 +207,6 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( @@ -358,6 +363,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 64D93BE92F2AFACF00B13EAA /* XCRemoteSwiftPackageReference "hotwire-native-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/hotwired/hotwire-native-ios"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.2; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 64D93BEA2F2AFACF00B13EAA /* HotwireNative */ = { + isa = XCSwiftPackageProductDependency; + package = 64D93BE92F2AFACF00B13EAA /* XCRemoteSwiftPackageReference "hotwire-native-ios" */; + productName = HotwireNative; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 64ADE2572F29ACA5002615D0 /* Project object */; } diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..6de117b --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "e043e35d89fc9c175f4169f4aa9709becdfb87b915ef76ee944456cb593c96b1", + "pins" : [ + { + "identity" : "hotwire-native-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hotwired/hotwire-native-ios", + "state" : { + "revision" : "595dba37c4918afd0e2d470109bc0c356d6e9afe", + "version" : "1.2.2" + } + } + ], + "version" : 3 +} diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index cd3bfbe..690cf1f 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -1,19 +1,12 @@ -// -// AppDelegate.swift -// iosApp -// -// Created by Ed on 2026/01/28. -// - import UIKit +import sharedKit @main class AppDelegate: UIResponder, UIApplicationDelegate { - - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. + KoinHelperKt.doInitKoin() + return true } @@ -30,7 +23,4 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } - - } - diff --git a/iosApp/iosApp/Base.lproj/LaunchScreen.storyboard b/iosApp/iosApp/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index 865e932..0000000 --- a/iosApp/iosApp/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iosApp/iosApp/Base.lproj/Main.storyboard b/iosApp/iosApp/Base.lproj/Main.storyboard deleted file mode 100644 index 25a7638..0000000 --- a/iosApp/iosApp/Base.lproj/Main.storyboard +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index dd3c9af..12769e2 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -15,11 +15,17 @@ Default Configuration UISceneDelegateClassName $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main + UILaunchScreen + + UITabBar + + UIImageName + + + diff --git a/iosApp/iosApp/SceneDelegate.swift b/iosApp/iosApp/SceneDelegate.swift index d7f35a5..4a11f6d 100644 --- a/iosApp/iosApp/SceneDelegate.swift +++ b/iosApp/iosApp/SceneDelegate.swift @@ -1,22 +1,32 @@ -// -// SceneDelegate.swift -// iosApp -// -// Created by Ed on 2026/01/28. -// - import UIKit +import sharedKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? + private let viewModel: MainViewModel = KoinHelper().getMainViewModel() + + private var isLoggedIn: Bool { + return viewModel.currentUser != nil + } func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let _ = (scene as? UIWindowScene) else { return } + guard let windowScene = scene as? UIWindowScene else { return } + + let window = UIWindow(windowScene: windowScene) + + if isLoggedIn { + window.rootViewController = MainViewController() + } else { + window.rootViewController = LoginViewController() + } + + self.window = window + + window.makeKeyAndVisible() } func sceneDidDisconnect(_ scene: UIScene) { @@ -46,7 +56,4 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Use this method to save data, release shared resources, and store enough scene-specific state information // to restore the scene back to its current state. } - - } - diff --git a/iosApp/iosApp/ViewController.swift b/iosApp/iosApp/ViewController.swift deleted file mode 100644 index 34f1067..0000000 --- a/iosApp/iosApp/ViewController.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// ViewController.swift -// iosApp -// -// Created by Ed on 2026/01/28. -// - -import UIKit - -class ViewController: UIViewController { - - override func viewDidLoad() { - super.viewDidLoad() - // Do any additional setup after loading the view. - } - - -} - diff --git a/iosApp/iosApp/ViewControllers/LoginViewController.swift b/iosApp/iosApp/ViewControllers/LoginViewController.swift new file mode 100644 index 0000000..148e6de --- /dev/null +++ b/iosApp/iosApp/ViewControllers/LoginViewController.swift @@ -0,0 +1,12 @@ +import Foundation +import SwiftUI + +class LoginViewController: UIHostingController { + init() { + super.init(rootView: LoginView()) + } + + @MainActor required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/iosApp/iosApp/ViewControllers/MainViewController.swift b/iosApp/iosApp/ViewControllers/MainViewController.swift new file mode 100644 index 0000000..94a50b1 --- /dev/null +++ b/iosApp/iosApp/ViewControllers/MainViewController.swift @@ -0,0 +1,4 @@ +import UIKit + +class MainViewController: UISplitViewController, UISplitViewControllerDelegate { +} diff --git a/iosApp/iosApp/Views/LoginView.swift b/iosApp/iosApp/Views/LoginView.swift new file mode 100644 index 0000000..f486956 --- /dev/null +++ b/iosApp/iosApp/Views/LoginView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct LoginView: View { + var body: some View { + + } +} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 060377e..cfe7bb4 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.android.kotlin.multiplatform.library) alias(libs.plugins.android.lint) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.skie) } kotlin { diff --git a/shared/src/androidMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt b/shared/src/androidMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt index cd9e989..b57147c 100644 --- a/shared/src/androidMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt +++ b/shared/src/androidMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt @@ -3,9 +3,6 @@ package org.blackcandy.shared.di import android.content.Context import android.content.SharedPreferences import androidx.datastore.core.DataStore -import androidx.datastore.core.DataStoreFactory -import androidx.datastore.core.Serializer -import androidx.datastore.dataStoreFile import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStoreFile @@ -14,24 +11,17 @@ import androidx.media3.datasource.DataSource import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKeys -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import org.blackcandy.shared.data.EncryptedDataSource import org.blackcandy.shared.media.MusicServiceController -import org.blackcandy.shared.models.User import org.koin.android.ext.koin.androidContext import org.koin.core.qualifier.named import org.koin.dsl.module -import java.io.InputStream -import java.io.OutputStream actual val platformModule = module { single { provideEncryptedSharedPreferences(androidContext()) } single(named("PreferencesDataStore")) { provideDataStore(androidContext()) } - single(named("UserDataStore")) { provideUserDataStore(androidContext()) } single { provideDataSourceFactory(get()) } single { EncryptedDataSource(get()) } @@ -39,7 +29,6 @@ actual val platformModule = } private const val DATASTORE_PREFERENCES_NAME = "user_preferences" -private const val USER_DATASTORE_FILE_NAME = "user.json" private const val ENCRYPTED_SHARED_PREFERENCES_FILE_NAME = "encrypted_preferences.txt" private fun provideDataStore(appContext: Context): DataStore = @@ -47,45 +36,6 @@ private fun provideDataStore(appContext: Context): DataStore = produceFile = { appContext.preferencesDataStoreFile(DATASTORE_PREFERENCES_NAME) }, ) -private fun provideUserDataStore(appContext: Context): DataStore { - val serializer = - object : Serializer { - override val defaultValue: User? - get() = null - - override suspend fun readFrom(input: InputStream): User? = - try { - Json.decodeFromString( - User.serializer(), - input.readBytes().decodeToString(), - ) - } catch (e: Exception) { - null - } - - override suspend fun writeTo( - t: User?, - output: OutputStream, - ) { - val data = - if (t == null) { - "{}".encodeToByteArray() - } else { - Json.encodeToString(User.serializer(), t).encodeToByteArray() - } - - withContext(Dispatchers.IO) { - output.write(data) - } - } - } - - return DataStoreFactory.create( - serializer = serializer, - produceFile = { appContext.dataStoreFile(USER_DATASTORE_FILE_NAME) }, - ) -} - private fun provideEncryptedSharedPreferences(appContext: Context): SharedPreferences = EncryptedSharedPreferences.create( ENCRYPTED_SHARED_PREFERENCES_FILE_NAME, diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/data/PreferencesDataSource.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/PreferencesDataSource.kt index 4a182a7..980122a 100644 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/data/PreferencesDataSource.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/PreferencesDataSource.kt @@ -7,19 +7,32 @@ import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.blackcandy.shared.models.User class PreferencesDataSource( private val dataStore: DataStore, ) { companion object { private val SERVER_ADDRESS_KEY = stringPreferencesKey("server_address") + private val CURRENT_USER_KEY = stringPreferencesKey("current_user") } suspend fun getServerAddress(): String = dataStore.data.first()[SERVER_ADDRESS_KEY] ?: "" + suspend fun getCurrentUser(): User? = dataStore.data.first()[CURRENT_USER_KEY]?.let { Json.decodeFromString(it) } + + suspend fun updateServerAddress(serverAddress: String) { dataStore.edit { it[SERVER_ADDRESS_KEY] = serverAddress } } + suspend fun updateCurrentUser(user: User?) { + dataStore.edit { it[CURRENT_USER_KEY] = Json.encodeToString(user) } + } + fun getServerAddressFlow(): Flow = dataStore.data.map { it[SERVER_ADDRESS_KEY] ?: "" } + + fun getCurrentUserFlow(): Flow = dataStore.data.map { it[CURRENT_USER_KEY]?.let { Json.decodeFromString(it) } } } diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/data/UserRepository.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/UserRepository.kt index 54ad5bf..1fe5997 100644 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/data/UserRepository.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/UserRepository.kt @@ -14,7 +14,6 @@ import org.blackcandy.shared.utils.TaskResult class UserRepository( private val httpClient: HttpClient, private val service: BlackCandyService, - private val userDataStore: DataStore, private val preferencesDataSource: PreferencesDataSource, private val encryptedDataSource: EncryptedDataSource, ) { @@ -27,7 +26,7 @@ class UserRepository( val serverAddress = preferencesDataSource.getServerAddress() Cookies.update(serverAddress, response.cookies) - userDataStore.updateData { response.user } + preferencesDataSource.updateCurrentUser(response.user) encryptedDataSource.updateApiToken(response.token) // Clear previous cached auth token in http client @@ -48,8 +47,10 @@ class UserRepository( service.removeAuthentication() encryptedDataSource.removeApiToken() Cookies.clean() - userDataStore.updateData { null } + preferencesDataSource.updateCurrentUser(null) } - fun getCurrentUserFlow(): Flow = userDataStore.data + suspend fun getCurrentUser(): User? = preferencesDataSource.getCurrentUser() + + fun getCurrentUserFlow(): Flow = preferencesDataSource.getCurrentUserFlow() } diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt index 29dc211..4ec74e1 100644 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt @@ -28,6 +28,7 @@ import org.blackcandy.shared.data.SystemInfoRepository import org.blackcandy.shared.data.UserRepository import org.blackcandy.shared.utils.BLACK_CANDY_USER_AGENT import org.blackcandy.shared.viewmodels.LoginViewModel +import org.blackcandy.shared.viewmodels.MainViewModel import org.blackcandy.shared.viewmodels.MiniPlayerViewModel import org.blackcandy.shared.viewmodels.MusicServiceViewModel import org.blackcandy.shared.viewmodels.PlayerViewModel @@ -45,10 +46,11 @@ val commonModule = single { BlackCandyServiceImpl(get()) } single { ServerAddressRepository(get()) } single { SystemInfoRepository(get()) } - single { UserRepository(get(), get(), get(named("UserDataStore")), get(), get()) } + single { UserRepository(get(), get(), get(), get()) } single { CurrentPlaylistRepository(get()) } single { FavoritePlaylistRepository(get()) } + viewModel { MainViewModel(get(), get()) } viewModel { LoginViewModel(get(), get(), get()) } viewModel { MiniPlayerViewModel(get()) } viewModel { PlayerViewModel(get(), get(), get()) } diff --git a/androidApp/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MainViewModel.kt similarity index 72% rename from androidApp/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MainViewModel.kt index e7afa0e..9a85c96 100644 --- a/androidApp/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MainViewModel.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.viewmodels +package org.blackcandy.shared.viewmodels import androidx.lifecycle.ViewModel import kotlinx.coroutines.runBlocking @@ -6,12 +6,18 @@ import org.blackcandy.shared.data.ServerAddressRepository import org.blackcandy.shared.data.UserRepository class MainViewModel( - userRepository: UserRepository, + private val userRepository: UserRepository, private val serverAddressRepository: ServerAddressRepository, ) : ViewModel() { + var selectedTabIndex = 0 + val currentUserFlow = userRepository.getCurrentUserFlow() - var selectedTabIndex = 0 + val currentUser = + runBlocking { + userRepository.getCurrentUser() + } + val serverAddress = runBlocking { diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/KoinHelper.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/KoinHelper.kt new file mode 100644 index 0000000..e0b0b50 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/KoinHelper.kt @@ -0,0 +1,20 @@ +package org.blackcandy.shared + +import org.blackcandy.shared.di.appModule +import org.blackcandy.shared.viewmodels.LoginViewModel +import org.blackcandy.shared.viewmodels.MainViewModel +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.context.startKoin + +fun initKoin() { + startKoin { + modules(appModule()) + } +} + +class KoinHelper : KoinComponent { + fun getMainViewModel(): MainViewModel = get() + + fun getLoginViewModel(): LoginViewModel = get() +} diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt index 084f29b..ad1989e 100644 --- a/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt @@ -10,4 +10,4 @@ actual class EncryptedDataSource { actual fun removeApiToken() { } -} \ No newline at end of file +} diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt index 3e50cf7..aefc060 100644 --- a/shared/src/iosMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt @@ -1,8 +1,39 @@ package org.blackcandy.shared.di +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import kotlinx.cinterop.ExperimentalForeignApi +import okio.Path.Companion.toPath +import org.blackcandy.shared.data.EncryptedDataSource +import org.koin.core.qualifier.named import org.koin.dsl.module +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask actual val platformModule = module { + single(named("PreferencesDataStore")) { provideDataStore() } + single { EncryptedDataSource() } + } - } \ No newline at end of file +private const val DATASTORE_PREFERENCES_NAME = "user.preferences_pb" + +@OptIn(ExperimentalForeignApi::class) +private fun provideDataStore(): DataStore = + PreferenceDataStoreFactory.createWithPath( + produceFile = { + val documentDirectory: NSURL? = + NSFileManager.defaultManager.URLForDirectory( + directory = NSDocumentDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null, + ) + + (requireNotNull(documentDirectory).path + "/$DATASTORE_PREFERENCES_NAME").toPath() + }, + ) diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt index 3db6c0d..c11a1bc 100644 --- a/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt @@ -43,7 +43,10 @@ actual class MusicServiceController { actual fun updateSongInPlaylist(song: Song) { } - actual fun moveSongInPlaylist(from: Int, to: Int) { + actual fun moveSongInPlaylist( + from: Int, + to: Int, + ) { } actual fun setPlaybackMode(playbackMode: PlaybackMode) { @@ -59,5 +62,4 @@ actual class MusicServiceController { actual fun addSongToLast(song: Song) { } - -} \ No newline at end of file +} diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Cookies.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Cookies.kt index 8535b83..bad4e0f 100644 --- a/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Cookies.kt +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Cookies.kt @@ -1,9 +1,12 @@ package org.blackcandy.shared.utils actual object Cookies { - actual fun update(path: String, cookies: List) { + actual fun update( + path: String, + cookies: List, + ) { } actual fun clean() { } -} \ No newline at end of file +} diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Theme.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Theme.kt index a8e086e..3a26b40 100644 --- a/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Theme.kt +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Theme.kt @@ -1,5 +1,4 @@ package org.blackcandy.shared.utils actual fun updateAppTheme(theme: Theme) { - -} \ No newline at end of file +} From cb6a1f37bfb0c3e5c9f059d4eb03c479807155c2 Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Mon, 2 Feb 2026 22:45:17 +0900 Subject: [PATCH 19/24] Implement login screen --- iosApp/iosApp.xcodeproj/project.pbxproj | 2 + iosApp/iosApp/Info.plist | 5 + iosApp/iosApp/Localizable.xcstrings | 306 ++++++++++++++++++ iosApp/iosApp/Utils/Alert.swift | 42 +++ .../ViewControllers/LoginViewController.swift | 4 +- .../Views/Login/LoginAuthenticationForm.swift | 28 ++ .../Views/Login/LoginConnectionForm.swift | 35 ++ iosApp/iosApp/Views/LoginScreen.swift | 46 +++ iosApp/iosApp/Views/LoginView.swift | 7 - shared/build.gradle.kts | 6 + .../shared/data/PreferencesDataSource.kt | 1 - .../shared/viewmodels/MainViewModel.kt | 1 - .../shared/data/EncryptedDataSource.kt | 4 +- 13 files changed, 473 insertions(+), 14 deletions(-) create mode 100644 iosApp/iosApp/Localizable.xcstrings create mode 100644 iosApp/iosApp/Utils/Alert.swift create mode 100644 iosApp/iosApp/Views/Login/LoginAuthenticationForm.swift create mode 100644 iosApp/iosApp/Views/Login/LoginConnectionForm.swift create mode 100644 iosApp/iosApp/Views/LoginScreen.swift delete mode 100644 iosApp/iosApp/Views/LoginView.swift diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index c87c400..b59695a 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -281,6 +281,7 @@ ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; @@ -337,6 +338,7 @@ MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; VALIDATE_PRODUCT = YES; }; name = Release; diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 12769e2..cdd5d70 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings new file mode 100644 index 0000000..e394fc3 --- /dev/null +++ b/iosApp/iosApp/Localizable.xcstrings @@ -0,0 +1,306 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "label.account" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Account" + } + } + } + }, + "label.connect" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect" + } + } + } + }, + "label.done" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + } + } + }, + "label.email" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Email" + } + } + } + }, + "label.home" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Home" + } + } + } + }, + "label.library" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Library" + } + } + } + }, + "label.login" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Login" + } + } + } + }, + "label.logout" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logout" + } + } + } + }, + "label.manage_users" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage Users" + } + } + } + }, + "label.no_items" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No items" + } + } + } + }, + "label.not_playing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Playing" + } + } + } + }, + "label.ok" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } + }, + "label.password" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Password" + } + } + } + }, + "label.server_address" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server Address" + } + } + } + }, + "label.settings" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "label.tracks(%lld)" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld tracks" + } + } + } + }, + "label.update_profile" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update Profile" + } + } + } + }, + "OK" : { + + }, + "text.added_to_playlist" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Added to Playlist" + } + } + } + }, + "text.bad_request" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bad Request" + } + } + } + }, + "text.connect_to_bc" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect to Black Candy" + } + } + } + }, + "text.invalid_request" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid Request" + } + } + } + }, + "text.invalid_response" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid Response" + } + } + } + }, + "text.invalid_server_address" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid Server Address" + } + } + } + }, + "text.invalid_user_credential" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wrong email or password" + } + } + } + }, + "text.login_to_bc" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Login to Black Candy" + } + } + } + }, + "text.unknown_network_error" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown Network Error" + } + } + } + }, + "text.unsupported_server" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unsupported Black Candy Server" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/iosApp/iosApp/Utils/Alert.swift b/iosApp/iosApp/Utils/Alert.swift new file mode 100644 index 0000000..50bda76 --- /dev/null +++ b/iosApp/iosApp/Utils/Alert.swift @@ -0,0 +1,42 @@ +import SwiftUI +import sharedKit + +struct AlertMessageCover { + static func toString(_ message: AlertMessage?) -> String { + guard let message = message else { + return "" + } + + switch onEnum(of: message) { + case .string(let string): + return string.value ?? "" + case .localizedString(let string): + return getLocalizedString(definedMessage: string.value) + } + } + + private static func getLocalizedString(definedMessage: AlertMessage.DefinedMessages) -> String { + switch definedMessage { + case .unsupportedServer: + return String(localized: "text.unsupported_server") + case .invalidServerAddress: + return String(localized: "text.invalid_server_address") + case .addedToPlaylist: + return String(localized: "text.added_to_playlist") + } + } +} + +extension View { + @ViewBuilder func alertMessage(_ message: AlertMessage?, isPresented: Binding, onShown: @escaping () -> Void) -> some View { + alert( + AlertMessageCover.toString(message), + isPresented: isPresented + ) { + Button("label.ok", role: .cancel) { + onShown() + } + } + } + +} diff --git a/iosApp/iosApp/ViewControllers/LoginViewController.swift b/iosApp/iosApp/ViewControllers/LoginViewController.swift index 148e6de..618f954 100644 --- a/iosApp/iosApp/ViewControllers/LoginViewController.swift +++ b/iosApp/iosApp/ViewControllers/LoginViewController.swift @@ -1,9 +1,9 @@ import Foundation import SwiftUI -class LoginViewController: UIHostingController { +class LoginViewController: UIHostingController { init() { - super.init(rootView: LoginView()) + super.init(rootView: LoginScreen()) } @MainActor required dynamic init?(coder aDecoder: NSCoder) { diff --git a/iosApp/iosApp/Views/Login/LoginAuthenticationForm.swift b/iosApp/iosApp/Views/Login/LoginAuthenticationForm.swift new file mode 100644 index 0000000..7b9633a --- /dev/null +++ b/iosApp/iosApp/Views/Login/LoginAuthenticationForm.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct LoginAuthenticationForm: View { + @State var email = "" + @State var password = "" + + var body: some View { + Form { + Section { + TextField("label.email", text: $email) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.emailAddress) + + SecureField("label.password", text: $password) + } + + Button(action: { + }, label: { + Text("label.login") + }) + .frame(maxWidth: .infinity) + .disabled(email.isEmpty || password.isEmpty) + } + .navigationTitle("text.login_to_bc") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/iosApp/iosApp/Views/Login/LoginConnectionForm.swift b/iosApp/iosApp/Views/Login/LoginConnectionForm.swift new file mode 100644 index 0000000..8773fe7 --- /dev/null +++ b/iosApp/iosApp/Views/Login/LoginConnectionForm.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct LoginConnectionForm: View { + let serverAddress: String + let onConnectButtonClicked: (() -> Void) + let onServerAddressChanged: ((String) -> Void) + + var body: some View { + Form { + Section(content: { + TextField("label.server_address", text: Binding( + get: { serverAddress }, + set: { serverAddress in onServerAddressChanged(serverAddress) } + )) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.URL) + }, header: { + Image("BlackCandyLogo") + .frame(maxWidth: .infinity) + .padding(.bottom) + }) + + Button(action: { + onConnectButtonClicked() + }, label: { + Text("label.connect") + }) + .frame(maxWidth: .infinity) + .disabled(serverAddress.isEmpty) + } + .navigationTitle("text.connect_to_bc") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/iosApp/iosApp/Views/LoginScreen.swift b/iosApp/iosApp/Views/LoginScreen.swift new file mode 100644 index 0000000..5f1d204 --- /dev/null +++ b/iosApp/iosApp/Views/LoginScreen.swift @@ -0,0 +1,46 @@ +import SwiftUI +import sharedKit + +struct LoginScreen: View { + @State var isAuthenticationFormVisible: Bool = false + @State var serverAddress: String? + @State var showingAlert = false + @State var alertMessage: AlertMessage? + + private let viewModel: LoginViewModel = KoinHelper().getLoginViewModel() + + var body: some View { + NavigationView { + VStack { + LoginConnectionForm( + serverAddress: serverAddress ?? "", + onConnectButtonClicked: { + viewModel.checkSystemInfo(onSuccess: { isAuthenticationFormVisible = true }) + }, + onServerAddressChanged: { serverAddress in + viewModel.updateServerAddress(serverAddress: serverAddress) + } + ) + + NavigationLink( + destination: LoginAuthenticationForm(), + isActive: $isAuthenticationFormVisible, + label: { EmptyView() } + ) + .hidden() + } + } + .navigationViewStyle(.stack) + .alertMessage(alertMessage, isPresented: $showingAlert, onShown: { + viewModel.alertMessageShown() + }) + .collect(flow: viewModel.uiState) { state in + serverAddress = state.serverAddress + + if let message = state.alertMessage { + alertMessage = message + showingAlert = true + } + } + } +} diff --git a/iosApp/iosApp/Views/LoginView.swift b/iosApp/iosApp/Views/LoginView.swift deleted file mode 100644 index f486956..0000000 --- a/iosApp/iosApp/Views/LoginView.swift +++ /dev/null @@ -1,7 +0,0 @@ -import SwiftUI - -struct LoginView: View { - var body: some View { - - } -} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index cfe7bb4..e5f0bf0 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -118,3 +118,9 @@ kotlin { } } } + +skie { + features { + enableSwiftUIObservingPreview = true + } +} diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/data/PreferencesDataSource.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/PreferencesDataSource.kt index 980122a..c42ca7e 100644 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/data/PreferencesDataSource.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/PreferencesDataSource.kt @@ -23,7 +23,6 @@ class PreferencesDataSource( suspend fun getCurrentUser(): User? = dataStore.data.first()[CURRENT_USER_KEY]?.let { Json.decodeFromString(it) } - suspend fun updateServerAddress(serverAddress: String) { dataStore.edit { it[SERVER_ADDRESS_KEY] = serverAddress } } diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MainViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MainViewModel.kt index 9a85c96..a465281 100644 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MainViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MainViewModel.kt @@ -18,7 +18,6 @@ class MainViewModel( userRepository.getCurrentUser() } - val serverAddress = runBlocking { serverAddressRepository.getServerAddress() diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt index ad1989e..5fcc550 100644 --- a/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt @@ -1,9 +1,7 @@ package org.blackcandy.shared.data actual class EncryptedDataSource { - actual fun getApiToken(): String? { - TODO("Not yet implemented") - } + actual fun getApiToken(): String? = null actual fun updateApiToken(apiToken: String) { } From 87b560d25fbbcfe2878a6b91b6ca2c896194fae3 Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Tue, 3 Feb 2026 20:59:49 +0900 Subject: [PATCH 20/24] Integrate Hotwire Native and implement iOS cookie management --- iosApp/iosApp/AppDelegate.swift | 7 +++++ .../BlackCandyLogo.imageset/Contents.json | 23 +++++++++++++++ .../BlackCandyLogo.imageset/logo.png | Bin 0 -> 5411 bytes .../BlackCandyLogo.imageset/logo@2x.png | Bin 0 -> 14468 bytes .../BlackCandyLogo.imageset/logo@3x.png | Bin 0 -> 24379 bytes iosApp/iosApp/Localizable.xcstrings | 3 -- iosApp/iosApp/MainTabs.swift | 9 ++++++ iosApp/iosApp/SceneDelegate.swift | 2 +- iosApp/iosApp/Utils/Window.swift | 6 ++++ .../ViewControllers/MainViewController.swift | 27 ++++++++++++++++++ .../Views/Login/LoginAuthenticationForm.swift | 24 +++++++++++----- iosApp/iosApp/Views/LoginScreen.swift | 20 ++++++++++++- .../org/blackcandy/shared/utils/Constants.kt | 3 ++ .../blackcandy/shared/data/UserRepository.kt | 4 +-- .../org/blackcandy/shared/utils/Constants.kt | 2 +- .../shared/viewmodels/LoginViewModel.kt | 4 +-- .../org/blackcandy/shared/utils/Constants.kt | 3 ++ .../org/blackcandy/shared/utils/Cookies.kt | 23 +++++++++++++++ 18 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/Contents.json create mode 100644 iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/logo.png create mode 100644 iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/logo@2x.png create mode 100644 iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/logo@3x.png create mode 100644 iosApp/iosApp/MainTabs.swift create mode 100644 iosApp/iosApp/Utils/Window.swift create mode 100644 shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Constants.kt create mode 100644 shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Constants.kt diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index 690cf1f..9baa17e 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -1,4 +1,5 @@ import UIKit +import HotwireNative import sharedKit @main @@ -7,6 +8,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Override point for customization after application launch. KoinHelperKt.doInitKoin() + configureHotwire() + return true } @@ -23,4 +26,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } + + private func configureHotwire() { + Hotwire.config.applicationUserAgentPrefix = "\(BLACK_CANDY_USER_AGENT);" + } } diff --git a/iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/Contents.json new file mode 100644 index 0000000..b54d4d9 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "logo@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/logo.png b/iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7f35a2a40899478c53c635d58ae236c0c35cf433 GIT binary patch literal 5411 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 N002ovPDHLkV1mnDbu<6~ literal 0 HcmV?d00001 diff --git a/iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/logo@2x.png b/iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/logo@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b7944a7a708260872b60fd63e2e23d924fe4b6c8 GIT binary patch literal 14468 zcmV-~ID5y5P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91jGzMm1ONa40RR91i~s-t02pS0CjbC9?ny*JRCod9eF>Bv)ph2pzq{3~ zrL~|139$(w1jGadvjmVq5}O2$aj+pE787RT@sN|rvB|NWoZ~ahWM(|}#FLXV$BDzt zWC4u9*d*SNutCDCW;NIoKo%09B|=C7Nv*Bb-Tyz|ckjDZuU`FsSO2|e^OLIXt^4lw z?!EQu{q@&how#3I9ZT(GjW zcjw--t4VEP;%a9(m(0aD=b%73Tn(6w+Hk^p4OQe&DUMKDB;XHGK>N_W8v0j(`Q=J2 zS=l;%^ot80U)bvfj~HN)IIIU$!xZt8bANKCtM)9yKo-@K+C?Y~%p96)!g?)1AwU-O zpkYA5QA?mnRY4-Ek61^co17slCH* zf7lhw^7EIUTIuI!F29V*8b`9sAY@#B%p9~oQwpzkxNjXP?tI%B|V zka$Lj@>8}Zi3_gCuM1-HC$3LhK-(oNUPEQYUcbQAUS$*Xel3~Q@{KEge8uiVDlu^8 zkSdrLKmB6c`>(wBS%mZR2>Rs0j1Gsb1|*QV6BK>_U{;>9a(Z{N_iG5&(l`Kxgi8RCn3&NY7x|<6Dl(x`T8CZa*PcfD8<FDk=sOm^u<+f~;6Xq%atFb1?b=$)^0y!UL)?G=b62g7R~O=t$BtRDEv04rk$lWv zrY^n)m8_8>YsJHYeK=q`g*#@5R3vVvt5v?V_{7CuPaaJAqTT~?NDmKqd+hwjX7=>$ z`xf%?k(}|8c%=)NM)guw06Akgi239GGJj(Jvg4uJ^YU=($+nh`8!mbFl8t31hr9oP z7tHc=mM^X)efMA>Q?d(yxsi`(=?M<@@B}rSuwEZYH! ztoBE0|L}Pa9}C_0BVZSXHsS!(`;-v!$M{g?i~6#&ntjDHy#IoY^_3kD)&Eq!l04nf z;jUl!_>~*VY!0=5#0%!f7yNi$UtjMsI6B?#IS|%ug;NS#MiYVP94}-v&tJT8mbYrE zQ*TQ2<)wKP`I9=U8e{t-JpOlETicdg{>e@d)B<}Q?6%+Tak>7YyKg=d&wKtX4nUQ<_r}EIy z&_C~>^~aDVa`s3Fn)R6+yNo8d=asR;+r*7$%$Hq=*pE=NkPoZ+W#=#Zchze3uW^m7 z*t&!U3@2nuufaYDalbGHyE6W`KM?gO?Q+O!Kf?zXhT-;i81+>Vi`$nxyX0?+F&G+U zaFOcACW_@xG4TIJmN-2zCBDYTYGZjzwK*O47gwo)6F_5og#@v1Z5<>0naA`k<}WMI zf`3s&Vt;5OY_WXP1lWBf@=@|h)158q5PYz4#X zX0@;OAYxYW@rmQjQy($B8U0R70>!?Dq* z)^P7{!QnE0OgACbL65n_W5Wrh)wfrzRFZ2^G!J>MApsl8kd!Q&zidIRiUn@9)}985 z8sh4LE zOHZxur#6>f=Xn-c8!wIadzhj_{!G!|=Ny6wCd%6v-W2IeQpT{9|GzxrnMkNrv<*&FBU# zNd}n^E)orfxDe~@lpTWTq%AS3P;ih9in!OGIxKS7d3ovPq2H`n2xa4FiTi)#JCw%{ zSEtvy-Tlm6z3>f$Ht2%cy}$dvpja-H;4(21xD1jcN%K(nxWoT6BOw!U_8IiSC(RO) zCI>vkr7y?tXtlI zq*SNKqQgUp0S;408TNTtPhChOE@MsHg?o`ZdNG&=HO;r>^OrBi?~os^;#+5)`J9T` z47b@Jo}$Yw(H)CGJ#On$d-XU%+R*(8wUv)===KiG_@(M9$(2_<)%>FoO&82#iyoV~ zx4Zil7@ZQQuWY#a@Z(5I9$E<5Sa?QLN`4<3C^!HH(@{rXkzI@33OtqdCqU{;1OE#8 zI0S5dGt|ok=mneiMlhv$iuvmOlSAzMA$-VU zk`k@69t_7E;wdj&5S$QOIOY%^%=mG_@9pmS7IR9?18q$e%)^_O{~@lS>0^~~faUrM z%L>c)3;?+_8X0bq9EA20%kZo}lL>WMzouC=h!q6ZAoIW;gNmy@&Ny;;flY&yV>1}y zr0Hh12+RM4K8LtTs*TJd=|)q0sN=7GggF(Z1Kdg74utnd3sz3A_3XpzEZlf=T!M+| zn@}3ZL)6A-xOIar3G)3>w}cp}5=ec96H1E&ifg1igV{3oR7vB#>BHJgY{&LFZlh7l zkuZKlY^Q7On0wXZ1N$|?z&~E~c7F}glIL11ffiuGv;}BZJwD;SB&?zse&Px(0Sc*4 zmm?@3($}&xHT&Ll@9x{=-tBqUy|;g}+ugU@ zb>lPVzP^3pQG2D`byP;XF)gFrl-8+kM*9rej_)|ZozyWKPe!(gUMm_gC`MC^2C=CR zO0}`-fDYF9B@T25@u?-rc-Oo4YdE@5ee8d4ne3PAhZiqjSnI2%fO%dhPExqE8+6YiC+pS#}L$h{2enzvS)+S>#R*rYemMw10 znl-uYwbx#AJw3&rJCAM|;}%W2#9c9Yv7693F`F#uXT27WC+3O9R~BE6-Eu-zhNt<7 z^cVP($`^j;$;Drd*m8&c77X8_(7m_z4ps_wdbpYgL9588~T{ZWD8@?b?L{n%N| zc#QEz1fixozXVCIfM$!3?L}O6zWv?%f2xkny( z#Qo?;KXT7L`z(GEp8pYIYtrT}oqUUjJ1?QZ$<qnQ8Xqfad6BDij&4|UtaOIEk{?OyX13T8%K1`rDsijf(u zkxWQ8UP89`3{YzS`xWm$>h9lspW9z;?j1Z6CQNWIzW8GQ%A4rrC5i?3^{;>3eel5t zIY};#KRxqyH>qt>jy@dY4;2<>Fa*?SAzyzu_&2qTojvQyWpnBVwBe1r>YlZ?;TktX zDu4;K(lQ8=T?z;VN1s9R(^-RpQ;k&z<*~653XVR5nO)T(_JJDQU)%5Q+W0r_?oEH+ zbg|IptXZ=NhOKe^^QljL%02ejWA66bZ+9K|Wtp|tcfaPowC0Oge7`(wPx-F^$ z9LoviA>%9k9+IEtl;*<^f<%}d+%l$#y-9WLeQ)3UsuHuk=gJ7gesJ+4Ph)DjfM>Iv z)ubD4$&35R(_br-FYDj9Z-e`bcfRbl_iXRaOjCU)o_L}=?X=U}m@#8qOG}H4k≀ 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

)ifL!f9p& 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 [HotwireTab] { + [ + .init(title: String(localized: "label.home"), image: .init(systemName: "house")!, url: URL(string: serverAddress)!), + .init(title: String(localized: "label.library"), image: .init(systemName: "square.stack")!, url: URL(string: "\(serverAddress)/library")!) + ] +} diff --git a/iosApp/iosApp/SceneDelegate.swift b/iosApp/iosApp/SceneDelegate.swift index 4a11f6d..762a2fc 100644 --- a/iosApp/iosApp/SceneDelegate.swift +++ b/iosApp/iosApp/SceneDelegate.swift @@ -19,7 +19,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let window = UIWindow(windowScene: windowScene) if isLoggedIn { - window.rootViewController = MainViewController() + window.rootViewController = MainViewController(serverAddress: viewModel.serverAddress) } else { window.rootViewController = LoginViewController() } diff --git a/iosApp/iosApp/Utils/Window.swift b/iosApp/iosApp/Utils/Window.swift new file mode 100644 index 0000000..c843076 --- /dev/null +++ b/iosApp/iosApp/Utils/Window.swift @@ -0,0 +1,6 @@ +import UIKit + +func changeRootViewController(viewController: UIViewController) { + let sceneDelegate = UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate + sceneDelegate?.window?.rootViewController = viewController +} diff --git a/iosApp/iosApp/ViewControllers/MainViewController.swift b/iosApp/iosApp/ViewControllers/MainViewController.swift index 94a50b1..bad6cb3 100644 --- a/iosApp/iosApp/ViewControllers/MainViewController.swift +++ b/iosApp/iosApp/ViewControllers/MainViewController.swift @@ -1,4 +1,31 @@ import UIKit +import HotwireNative class MainViewController: UISplitViewController, UISplitViewControllerDelegate { + init(serverAddress: String) { + super.init(style: .doubleColumn) + + preferredDisplayMode = .oneBesideSecondary + preferredSplitBehavior = .tile + presentsWithGesture = false + delegate = self + + let tabBarController = HotwireTabBarController(navigatorDelegate: self) + let tabs = buildMainTabs(serverAddress: serverAddress) + + tabBarController.load(tabs) + + setViewController(tabBarController, for: .secondary) + setViewController(tabBarController, for: .compact) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension MainViewController: NavigatorDelegate { + func handle(proposal: HotwireNative.VisitProposal, from navigator: HotwireNative.Navigator) -> HotwireNative.ProposalResult { + return .accept + } } diff --git a/iosApp/iosApp/Views/Login/LoginAuthenticationForm.swift b/iosApp/iosApp/Views/Login/LoginAuthenticationForm.swift index 7b9633a..deb7342 100644 --- a/iosApp/iosApp/Views/Login/LoginAuthenticationForm.swift +++ b/iosApp/iosApp/Views/Login/LoginAuthenticationForm.swift @@ -1,21 +1,31 @@ import SwiftUI struct LoginAuthenticationForm: View { - @State var email = "" - @State var password = "" + let email: String + let password: String + let onLoginButtonClicked: (() -> Void) + let onEmailChanged: ((String) -> Void) + let onPasswordChanged: ((String) -> Void) var body: some View { Form { Section { - TextField("label.email", text: $email) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .keyboardType(.emailAddress) + TextField("label.email", text: Binding( + get: { email }, + set: { email in onEmailChanged(email) } + )) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.emailAddress) - SecureField("label.password", text: $password) + SecureField("label.password", text: Binding( + get: { password }, + set: { password in onPasswordChanged(password) } + )) } Button(action: { + onLoginButtonClicked() }, label: { Text("label.login") }) diff --git a/iosApp/iosApp/Views/LoginScreen.swift b/iosApp/iosApp/Views/LoginScreen.swift index 5f1d204..2171520 100644 --- a/iosApp/iosApp/Views/LoginScreen.swift +++ b/iosApp/iosApp/Views/LoginScreen.swift @@ -4,6 +4,8 @@ import sharedKit struct LoginScreen: View { @State var isAuthenticationFormVisible: Bool = false @State var serverAddress: String? + @State var email: String = "" + @State var password: String = "" @State var showingAlert = false @State var alertMessage: AlertMessage? @@ -23,7 +25,21 @@ struct LoginScreen: View { ) NavigationLink( - destination: LoginAuthenticationForm(), + destination: LoginAuthenticationForm( + email: email, + password: password, + onLoginButtonClicked: { + viewModel.login(onSuccess: { serverAddress in + changeRootViewController(viewController: MainViewController(serverAddress: serverAddress)) + }) + }, + onEmailChanged: { email in + viewModel.updateEmail(email: email) + }, + onPasswordChanged: { password in + viewModel.updatePassword(password: password) + } + ), isActive: $isAuthenticationFormVisible, label: { EmptyView() } ) @@ -36,6 +52,8 @@ struct LoginScreen: View { }) .collect(flow: viewModel.uiState) { state in serverAddress = state.serverAddress + email = state.email + password = state.password if let message = state.alertMessage { alertMessage = message diff --git a/shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Constants.kt b/shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Constants.kt new file mode 100644 index 0000000..7521e0b --- /dev/null +++ b/shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Constants.kt @@ -0,0 +1,3 @@ +package org.blackcandy.shared.utils + +actual val BLACK_CANDY_USER_AGENT: String = "Black Candy Android" diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/data/UserRepository.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/UserRepository.kt index 1fe5997..b722de4 100644 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/data/UserRepository.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/UserRepository.kt @@ -20,7 +20,7 @@ class UserRepository( suspend fun login( email: String, password: String, - ): TaskResult { + ): TaskResult { try { val response = service.createAuthentication(email, password).orThrow() val serverAddress = preferencesDataSource.getServerAddress() @@ -37,7 +37,7 @@ class UserRepository( .first() .clearToken() - return TaskResult.Success(Unit) + return TaskResult.Success(serverAddress) } catch (e: Exception) { return TaskResult.Failure(e.message) } diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Constants.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Constants.kt index df319ae..36f58ca 100644 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Constants.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Constants.kt @@ -1,4 +1,4 @@ package org.blackcandy.shared.utils -const val BLACK_CANDY_USER_AGENT = "Black Candy Android" +expect val BLACK_CANDY_USER_AGENT: String const val NONE_DURATION_TEXT = "--:--" diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/LoginViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/LoginViewModel.kt index f5a19a6..6d11c3c 100644 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/LoginViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/LoginViewModel.kt @@ -99,11 +99,11 @@ class LoginViewModel( } } - fun login() { + fun login(onSuccess: (String) -> Unit) { viewModelScope.launch { when (val result = userRepository.login(uiState.value.email, uiState.value.password)) { is TaskResult.Success -> { - Unit + onSuccess(result.data) } is TaskResult.Failure -> { diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Constants.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Constants.kt new file mode 100644 index 0000000..d1ff9a7 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Constants.kt @@ -0,0 +1,3 @@ +package org.blackcandy.shared.utils + +actual val BLACK_CANDY_USER_AGENT: String = "Black Candy iOS" diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Cookies.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Cookies.kt index bad4e0f..7bef4f4 100644 --- a/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Cookies.kt +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Cookies.kt @@ -1,12 +1,35 @@ package org.blackcandy.shared.utils +import platform.Foundation.NSHTTPCookie +import platform.Foundation.NSURL +import platform.WebKit.WKWebsiteDataStore + actual object Cookies { + val dataStore: WKWebsiteDataStore = WKWebsiteDataStore.defaultDataStore() + actual fun update( path: String, cookies: List, ) { + val cookieStore = dataStore.httpCookieStore + val url = NSURL.URLWithString(path) ?: return + + cookies.forEach { cookieString -> + val headerFields = mapOf("Set-Cookie" to cookieString) + val nsCookies = NSHTTPCookie.cookiesWithResponseHeaderFields(headerFields, url) + nsCookies.forEach { cookie -> + cookieStore.setCookie(cookie as NSHTTPCookie, completionHandler = null) + } + } } actual fun clean() { + val cookieStore = dataStore.httpCookieStore + + cookieStore.getAllCookies { cookies -> + cookies?.forEach { cookie -> + cookieStore.deleteCookie(cookie as NSHTTPCookie, completionHandler = null) + } + } } } From 4a110da06e2baa4c3dde4ab17b68e29c98fdd207 Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Wed, 4 Feb 2026 21:07:36 +0900 Subject: [PATCH 21/24] Add account bridge component and Keychain support --- iosApp/iosApp/AppDelegate.swift | 8 ++ iosApp/iosApp/Bridge/AccountComponent.swift | 107 ++++++++++++++++++ iosApp/iosApp/UI/MenuItem.swift | 24 ++++ iosApp/iosApp/Views/Account/AccountMenu.swift | 29 +++++ iosApp/iosApp/Web/WebViewController.swift | 7 ++ .../shared/viewmodels/WebViewModel.kt | 3 +- .../org/blackcandy/shared/KoinHelper.kt | 3 + .../shared/data/EncryptedDataSource.kt | 82 +++++++++++++- .../blackcandy/shared/di/PlatformModule.kt | 2 + .../shared/media/MusicServiceController.kt | 15 +-- 10 files changed, 268 insertions(+), 12 deletions(-) create mode 100644 iosApp/iosApp/Bridge/AccountComponent.swift create mode 100644 iosApp/iosApp/UI/MenuItem.swift create mode 100644 iosApp/iosApp/Views/Account/AccountMenu.swift create mode 100644 iosApp/iosApp/Web/WebViewController.swift diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index 9baa17e..638237e 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -29,5 +29,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private func configureHotwire() { Hotwire.config.applicationUserAgentPrefix = "\(BLACK_CANDY_USER_AGENT);" + + Hotwire.config.defaultViewController = { url in + WebViewController(url: url) + } + + Hotwire.registerBridgeComponents([ + AccountComponent.self + ]) } } diff --git a/iosApp/iosApp/Bridge/AccountComponent.swift b/iosApp/iosApp/Bridge/AccountComponent.swift new file mode 100644 index 0000000..2129dec --- /dev/null +++ b/iosApp/iosApp/Bridge/AccountComponent.swift @@ -0,0 +1,107 @@ +import Foundation +import SwiftUI +import UIKit +import HotwireNative +import sharedKit + +class AccountComponent: BridgeComponent { + private var menuItems: [MenuItem] = [] + + private var viewController: WebViewController? { + delegate?.destination as? WebViewController + } + + private var viewModel: WebViewModel? { + viewController?.viewModel + } + + override class var name: String { "account" } + + override func onReceive(message: Message) { + switch message.event { + case "connect": + handleConnectEvent() + case "menuItemConnected:settings": + handleMenuItemConnectedEvent("settings") + case "menuItemConnected:manage_users": + handleMenuItemConnectedEvent("manage_users") + case "menuItemConnected:update_profile": + handleMenuItemConnectedEvent("update_profile") + case "menuItemConnected:logout": + handleMenuItemConnectedEvent("logout") + default: + break + } + + } + + private func handleConnectEvent() { + guard let viewController else { return } + + let action = UIAction { [unowned self] _ in + viewController.present( + UIHostingController( + rootView: AccountMenu(menuItems: menuItems) + ), + animated: true + ) + } + + let item = UIBarButtonItem(title: String(localized: "label.account"), primaryAction: action) + item.image = .init(systemName: "person.circle") + + viewController.navigationItem.rightBarButtonItem = item + } + + private func handleMenuItemConnectedEvent(_ id: String) { + if menuItems.contains(where: { $0.id == id }) { return } + + switch id { + case "settings": + menuItems.append( + .init( + id: "settings", + title: String(localized: "label.settings"), + action: { + self.reply(to: "menuItemConnected:settings") + } + ) + ) + case "manage_users": + menuItems.append( + .init( + id: "manage_users", + title: String(localized: "label.manage_users"), + action: { + self.reply(to: "menuItemConnected:manage_users") + } + ) + ) + case "update_profile": + menuItems.append( + .init( + id: "update_profile", + title: String(localized: "label.update_profile"), + action: { + self.reply(to: "menuItemConnected:update_profile") + } + ) + ) + case "logout": + menuItems.append( + .init( + id: "logout", + type: .destructive, + title: String(localized: "label.logout"), + action: { + self.viewModel?.logout(onSuccess: { + changeRootViewController(viewController: LoginViewController()) + }) + } + ) + ) + default: + break + } + } +} diff --git a/iosApp/iosApp/UI/MenuItem.swift b/iosApp/iosApp/UI/MenuItem.swift new file mode 100644 index 0000000..0911209 --- /dev/null +++ b/iosApp/iosApp/UI/MenuItem.swift @@ -0,0 +1,24 @@ +import Foundation +import UIKit + +struct MenuItem: Identifiable { + let id: String + let type: ItemType + let title: String + let action: (() -> Void) + + init(id: String, type: ItemType = .normal, title: String, action: @escaping () -> Void) { + self.id = id + self.title = title + self.action = action + self.type = type + } +} + + +extension MenuItem { + enum ItemType { + case normal + case destructive + } +} diff --git a/iosApp/iosApp/Views/Account/AccountMenu.swift b/iosApp/iosApp/Views/Account/AccountMenu.swift new file mode 100644 index 0000000..512aa49 --- /dev/null +++ b/iosApp/iosApp/Views/Account/AccountMenu.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct AccountMenu: View { + let menuItems: [MenuItem] + + var body: some View { + List(menuItems) { item in + if item.type == .destructive { + Section { + Button( + role: .destructive, + action: { + item.action() + }, + label: { + Text(item.title) + } + ) + .frame(maxWidth: .infinity) + } + } else { + Button(item.title) { + item.action() + } + } + } + .listStyle(.insetGrouped) + } +} diff --git a/iosApp/iosApp/Web/WebViewController.swift b/iosApp/iosApp/Web/WebViewController.swift new file mode 100644 index 0000000..6fd673a --- /dev/null +++ b/iosApp/iosApp/Web/WebViewController.swift @@ -0,0 +1,7 @@ +import Foundation +import HotwireNative +import sharedKit + +class WebViewController: HotwireWebViewController { + let viewModel: WebViewModel = KoinHelper().getWebViewModel() +} diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/WebViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/WebViewModel.kt index 82b3f54..7d92a0d 100644 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/WebViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/WebViewModel.kt @@ -28,9 +28,10 @@ class WebViewModel( val uiState = _uiState.asStateFlow() - fun logout() { + fun logout(onSuccess: () -> Unit = {}) { viewModelScope.launch { userRepository.logout() + onSuccess() } } diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/KoinHelper.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/KoinHelper.kt index e0b0b50..1c164c2 100644 --- a/shared/src/iosMain/kotlin/org/blackcandy/shared/KoinHelper.kt +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/KoinHelper.kt @@ -3,6 +3,7 @@ package org.blackcandy.shared import org.blackcandy.shared.di.appModule import org.blackcandy.shared.viewmodels.LoginViewModel import org.blackcandy.shared.viewmodels.MainViewModel +import org.blackcandy.shared.viewmodels.WebViewModel import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.core.context.startKoin @@ -17,4 +18,6 @@ class KoinHelper : KoinComponent { fun getMainViewModel(): MainViewModel = get() fun getLoginViewModel(): LoginViewModel = get() + + fun getWebViewModel(): WebViewModel = get() } diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt index 5fcc550..cf18296 100644 --- a/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt @@ -1,11 +1,91 @@ package org.blackcandy.shared.data +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.interpretObjCPointer +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value +import platform.CoreFoundation.CFDictionaryRef +import platform.CoreFoundation.CFTypeRef +import platform.CoreFoundation.CFTypeRefVar +import platform.CoreFoundation.kCFBooleanTrue +import platform.Foundation.CFBridgingRetain +import platform.Foundation.NSData +import platform.Foundation.NSDictionary +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create +import platform.Foundation.dataUsingEncoding +import platform.Security.SecItemAdd +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.errSecSuccess +import platform.Security.kSecAttrAccount +import platform.Security.kSecClass +import platform.Security.kSecClassGenericPassword +import platform.Security.kSecMatchLimit +import platform.Security.kSecMatchLimitOne +import platform.Security.kSecReturnData +import platform.Security.kSecValueData + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) actual class EncryptedDataSource { - actual fun getApiToken(): String? = null + companion object { + private const val API_TOKEN_KEY = "org.blackcandy.api_token_key" + } + + private fun CFTypeRef?.asNSObject(): Any? = this?.let { interpretObjCPointer(it.rawValue) } + + actual fun getApiToken(): String? { + val query = + mapOf( + kSecClass.asNSObject() to kSecClassGenericPassword.asNSObject(), + kSecAttrAccount.asNSObject() to API_TOKEN_KEY, + kSecReturnData.asNSObject() to kCFBooleanTrue.asNSObject(), + kSecMatchLimit.asNSObject() to kSecMatchLimitOne.asNSObject(), + ) as NSDictionary + + return memScoped { + val result = alloc() + val cfQuery = CFBridgingRetain(query) as CFDictionaryRef + val status = SecItemCopyMatching(cfQuery, result.ptr) + + if (status == errSecSuccess) { + val data = result.value?.let { interpretObjCPointer(it.rawValue) } + data?.let { + NSString.create(it, NSUTF8StringEncoding)?.toString() + } + } else { + null + } + } + } actual fun updateApiToken(apiToken: String) { + removeApiToken() + + val apiTokenData = (apiToken as Any as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return + val query = + mapOf( + kSecClass.asNSObject() to kSecClassGenericPassword.asNSObject(), + kSecAttrAccount.asNSObject() to API_TOKEN_KEY, + kSecValueData.asNSObject() to apiTokenData, + ) as NSDictionary + + val cfQuery = CFBridgingRetain(query) as CFDictionaryRef + SecItemAdd(cfQuery, null) } actual fun removeApiToken() { + val query = + mapOf( + kSecClass.asNSObject() to kSecClassGenericPassword.asNSObject(), + kSecAttrAccount.asNSObject() to API_TOKEN_KEY, + ) as NSDictionary + + val cfQuery = CFBridgingRetain(query) as CFDictionaryRef + SecItemDelete(cfQuery) } } diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt index aefc060..aefb5b0 100644 --- a/shared/src/iosMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences import kotlinx.cinterop.ExperimentalForeignApi import okio.Path.Companion.toPath import org.blackcandy.shared.data.EncryptedDataSource +import org.blackcandy.shared.media.MusicServiceController import org.koin.core.qualifier.named import org.koin.dsl.module import platform.Foundation.NSDocumentDirectory @@ -17,6 +18,7 @@ actual val platformModule = module { single(named("PreferencesDataStore")) { provideDataStore() } single { EncryptedDataSource() } + single { MusicServiceController() } } private const val DATASTORE_PREFERENCES_NAME = "user.preferences_pb" diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt index c11a1bc..7f1ec84 100644 --- a/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt @@ -1,14 +1,13 @@ package org.blackcandy.shared.media import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.blackcandy.shared.models.Song actual class MusicServiceController { - actual val musicState: StateFlow - get() = TODO("Not yet implemented") - actual val currentPosition: Flow - get() = TODO("Not yet implemented") + actual val musicState: StateFlow = MutableStateFlow(MusicState()) + actual val currentPosition: Flow = MutableStateFlow(0.0) actual fun initMediaController(onInitialized: () -> Unit) { } @@ -52,13 +51,9 @@ actual class MusicServiceController { actual fun setPlaybackMode(playbackMode: PlaybackMode) { } - actual fun getSongIndex(songId: Int): Int { - TODO("Not yet implemented") - } + actual fun getSongIndex(songId: Int): Int = 0 - actual fun addSongToNext(song: Song): Int { - TODO("Not yet implemented") - } + actual fun addSongToNext(song: Song): Int = 0 actual fun addSongToLast(song: Song) { } From 8f7dbe084017aa4ef7cc455d5503d71a9123032b Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Thu, 5 Feb 2026 20:14:33 +0900 Subject: [PATCH 22/24] Add search bar --- iosApp/iosApp/AppDelegate.swift | 7 ++- iosApp/iosApp/Bridge/SearchComponent.swift | 57 +++++++++++++++++++ iosApp/iosApp/UI/MenuItem.swift | 1 - iosApp/iosApp/configuration.json | 12 ++++ .../{Constants.kt => Constants.android.kt} | 0 .../shared/viewmodels/LoginViewModel.kt | 2 +- 6 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 iosApp/iosApp/Bridge/SearchComponent.swift create mode 100644 iosApp/iosApp/configuration.json rename shared/src/androidMain/kotlin/org/blackcandy/shared/utils/{Constants.kt => Constants.android.kt} (100%) diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index 638237e..16a0a21 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -30,12 +30,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private func configureHotwire() { Hotwire.config.applicationUserAgentPrefix = "\(BLACK_CANDY_USER_AGENT);" + Hotwire.loadPathConfiguration(from: [ + .file(Bundle.main.url(forResource: "configuration", withExtension: "json")!) + ]) + Hotwire.config.defaultViewController = { url in WebViewController(url: url) } Hotwire.registerBridgeComponents([ - AccountComponent.self + AccountComponent.self, + SearchComponent.self ]) } } diff --git a/iosApp/iosApp/Bridge/SearchComponent.swift b/iosApp/iosApp/Bridge/SearchComponent.swift new file mode 100644 index 0000000..a173791 --- /dev/null +++ b/iosApp/iosApp/Bridge/SearchComponent.swift @@ -0,0 +1,57 @@ +import HotwireNative +import UIKit + +class SearchComponent: BridgeComponent { + private var viewController: UIViewController? { + delegate?.destination as? UIViewController + } + + private lazy var searchBarDelegator = SearchBarDelegator(component: self) + + override class var name: String { "search" } + + override func onReceive(message: Message) { + switch message.event { + case "connect": + handleConnectEvent(message) + default: + break + } + } + + private func updateSearchResults(with query: String) { + let data = SearchData(query: query) + reply(to: "connect", with: data) + } + + private func handleConnectEvent(_ message: Message) { + guard let viewController else { return } + + let searchController = UISearchController(searchResultsController: nil) + + searchController.searchBar.delegate = searchBarDelegator + + viewController.navigationItem.searchController = searchController + viewController.navigationItem.hidesSearchBarWhenScrolling = false + } + +} + +extension SearchComponent { + struct SearchData: Encodable { + let query: String + } + + private class SearchBarDelegator: NSObject, UISearchBarDelegate { + private weak var component: SearchComponent? + + init(component: SearchComponent) { + self.component = component + } + + func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + guard let query = searchBar.searchTextField.text, !query.isEmpty else { return } + component?.updateSearchResults(with: query) + } + } +} diff --git a/iosApp/iosApp/UI/MenuItem.swift b/iosApp/iosApp/UI/MenuItem.swift index 0911209..4e1fe42 100644 --- a/iosApp/iosApp/UI/MenuItem.swift +++ b/iosApp/iosApp/UI/MenuItem.swift @@ -15,7 +15,6 @@ struct MenuItem: Identifiable { } } - extension MenuItem { enum ItemType { case normal diff --git a/iosApp/iosApp/configuration.json b/iosApp/iosApp/configuration.json new file mode 100644 index 0000000..af140f5 --- /dev/null +++ b/iosApp/iosApp/configuration.json @@ -0,0 +1,12 @@ +{ + "rules": [ + { + "patterns": [ + "^/dialog/*" + ], + "properties": { + "context": "modal" + } + } + ] +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Constants.kt b/shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Constants.android.kt similarity index 100% rename from shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Constants.kt rename to shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Constants.android.kt diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/LoginViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/LoginViewModel.kt index 6d11c3c..ade35f9 100644 --- a/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/LoginViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/LoginViewModel.kt @@ -99,7 +99,7 @@ class LoginViewModel( } } - fun login(onSuccess: (String) -> Unit) { + fun login(onSuccess: (String) -> Unit = {}) { viewModelScope.launch { when (val result = userRepository.login(uiState.value.email, uiState.value.password)) { is TaskResult.Success -> { From 632a808e4d00eb813fd30056674ca4e9f219631a Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Mon, 9 Feb 2026 20:13:16 +0900 Subject: [PATCH 23/24] Add mini player --- .../android/bridge/AccountComponent.kt | 2 +- .../android/compose/account/AccountMenu.kt | 2 +- .../android/{ui => utils}/MenuItem.kt | 2 +- iosApp/iosApp.xcodeproj/project.pbxproj | 17 +++ .../xcshareddata/swiftpm/Package.resolved | 29 ++++- iosApp/iosApp/Utils/Alert.swift | 20 ++-- iosApp/iosApp/Utils/CustomStyle.swift | 74 +++++++++++++ iosApp/iosApp/{UI => Utils}/MenuItem.swift | 0 .../ViewControllers/MainViewController.swift | 9 ++ .../WebViewController.swift | 0 iosApp/iosApp/Views/LoginScreen.swift | 83 ++++++--------- iosApp/iosApp/Views/Player/FullPlayer.swift | 14 +++ iosApp/iosApp/Views/Player/PlayerArt.swift | 15 +++ .../iosApp/Views/Player/PlayerControl.swift | 20 ++++ iosApp/iosApp/Views/Player/PlayerInfo.swift | 15 +++ iosApp/iosApp/Views/PlayerScreen.swift | 73 +++++++++++++ .../org/blackcandy/shared/KoinHelper.kt | 6 ++ .../shared/data/EncryptedDataSource.kt | 100 ++++++++++-------- .../shared/media/MusicServiceController.kt | 11 +- 19 files changed, 386 insertions(+), 106 deletions(-) rename androidApp/src/main/java/org/blackcandy/android/{ui => utils}/MenuItem.kt (86%) create mode 100644 iosApp/iosApp/Utils/CustomStyle.swift rename iosApp/iosApp/{UI => Utils}/MenuItem.swift (100%) rename iosApp/iosApp/{Web => ViewControllers}/WebViewController.swift (100%) create mode 100644 iosApp/iosApp/Views/Player/FullPlayer.swift create mode 100644 iosApp/iosApp/Views/Player/PlayerArt.swift create mode 100644 iosApp/iosApp/Views/Player/PlayerControl.swift create mode 100644 iosApp/iosApp/Views/Player/PlayerInfo.swift create mode 100644 iosApp/iosApp/Views/PlayerScreen.swift diff --git a/androidApp/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt index 4b3308c..4d3a773 100644 --- a/androidApp/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt +++ b/androidApp/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt @@ -11,7 +11,7 @@ import dev.hotwire.navigation.destinations.HotwireDestination import org.blackcandy.android.R import org.blackcandy.android.compose.account.AccountMenu import org.blackcandy.android.fragments.web.WebFragment -import org.blackcandy.android.ui.MenuItem +import org.blackcandy.android.utils.MenuItem import org.blackcandy.shared.viewmodels.WebViewModel class AccountComponent( diff --git a/androidApp/src/main/java/org/blackcandy/android/compose/account/AccountMenu.kt b/androidApp/src/main/java/org/blackcandy/android/compose/account/AccountMenu.kt index 82fbae3..6962b8a 100644 --- a/androidApp/src/main/java/org/blackcandy/android/compose/account/AccountMenu.kt +++ b/androidApp/src/main/java/org/blackcandy/android/compose/account/AccountMenu.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import org.blackcandy.android.ui.MenuItem +import org.blackcandy.android.utils.MenuItem @Composable fun AccountMenu(menuItems: List) { diff --git a/androidApp/src/main/java/org/blackcandy/android/ui/MenuItem.kt b/androidApp/src/main/java/org/blackcandy/android/utils/MenuItem.kt similarity index 86% rename from androidApp/src/main/java/org/blackcandy/android/ui/MenuItem.kt rename to androidApp/src/main/java/org/blackcandy/android/utils/MenuItem.kt index 5e16396..0cc19ad 100644 --- a/androidApp/src/main/java/org/blackcandy/android/ui/MenuItem.kt +++ b/androidApp/src/main/java/org/blackcandy/android/utils/MenuItem.kt @@ -1,4 +1,4 @@ -package org.blackcandy.android.ui +package org.blackcandy.android.utils import androidx.annotation.DrawableRes import androidx.annotation.StringRes diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index b59695a..bd3be63 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 646F4A7B2F35C74300A4FAFB /* LNPopupUI in Frameworks */ = {isa = PBXBuildFile; productRef = 646F4A7A2F35C74300A4FAFB /* LNPopupUI */; }; 64D93BEB2F2AFACF00B13EAA /* HotwireNative in Frameworks */ = {isa = PBXBuildFile; productRef = 64D93BEA2F2AFACF00B13EAA /* HotwireNative */; }; /* End PBXBuildFile section */ @@ -41,6 +42,7 @@ buildActionMask = 2147483647; files = ( 64D93BEB2F2AFACF00B13EAA /* HotwireNative in Frameworks */, + 646F4A7B2F35C74300A4FAFB /* LNPopupUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -85,6 +87,7 @@ name = iosApp; packageProductDependencies = ( 64D93BEA2F2AFACF00B13EAA /* HotwireNative */, + 646F4A7A2F35C74300A4FAFB /* LNPopupUI */, ); productName = iosApp; productReference = 64ADE25F2F29ACA5002615D0 /* iosApp.app */; @@ -116,6 +119,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 64D93BE92F2AFACF00B13EAA /* XCRemoteSwiftPackageReference "hotwire-native-ios" */, + 646F4A792F35C74300A4FAFB /* XCRemoteSwiftPackageReference "LNPopupUI" */, ); preferredProjectObjectVersion = 77; productRefGroup = 64ADE2602F29ACA5002615D0 /* Products */; @@ -367,6 +371,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 646F4A792F35C74300A4FAFB /* XCRemoteSwiftPackageReference "LNPopupUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LeoNatan/LNPopupUI"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.0; + }; + }; 64D93BE92F2AFACF00B13EAA /* XCRemoteSwiftPackageReference "hotwire-native-ios" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/hotwired/hotwire-native-ios"; @@ -378,6 +390,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 646F4A7A2F35C74300A4FAFB /* LNPopupUI */ = { + isa = XCSwiftPackageProductDependency; + package = 646F4A792F35C74300A4FAFB /* XCRemoteSwiftPackageReference "LNPopupUI" */; + productName = LNPopupUI; + }; 64D93BEA2F2AFACF00B13EAA /* HotwireNative */ = { isa = XCSwiftPackageProductDependency; package = 64D93BE92F2AFACF00B13EAA /* XCRemoteSwiftPackageReference "hotwire-native-ios" */; diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6de117b..dc3d15f 100644 --- a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e043e35d89fc9c175f4169f4aa9709becdfb87b915ef76ee944456cb593c96b1", + "originHash" : "d188413bb6cdb3bcd359b60173898d2ad2fd8332a285dda182d506d02306bb23", "pins" : [ { "identity" : "hotwire-native-ios", @@ -9,6 +9,33 @@ "revision" : "595dba37c4918afd0e2d470109bc0c356d6e9afe", "version" : "1.2.2" } + }, + { + "identity" : "lnpopupcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LeoNatan/LNPopupController.git", + "state" : { + "revision" : "1609e4c6473c6f854b9e24ca6547af33da9014c8", + "version" : "4.3.7" + } + }, + { + "identity" : "lnpopupui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LeoNatan/LNPopupUI", + "state" : { + "revision" : "28a129a09e6057f9d07ef4e748d858acf427c161", + "version" : "3.0.0" + } + }, + { + "identity" : "lnswiftuiutils", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LeoNatan/LNSwiftUIUtils.git", + "state" : { + "revision" : "181a95b992f84d99a14c562c86e8ebef3cbcdca9", + "version" : "1.1.5" + } } ], "version" : 3 diff --git a/iosApp/iosApp/Utils/Alert.swift b/iosApp/iosApp/Utils/Alert.swift index 50bda76..89eb81b 100644 --- a/iosApp/iosApp/Utils/Alert.swift +++ b/iosApp/iosApp/Utils/Alert.swift @@ -28,15 +28,19 @@ struct AlertMessageCover { } extension View { - @ViewBuilder func alertMessage(_ message: AlertMessage?, isPresented: Binding, onShown: @escaping () -> Void) -> some View { + @ViewBuilder func alertMessage(_ message: AlertMessage?, onShown: @escaping () -> Void) -> some View { + let alertMessage = AlertMessageCover.toString(message) + alert( - AlertMessageCover.toString(message), - isPresented: isPresented - ) { - Button("label.ok", role: .cancel) { - onShown() - } - } + alertMessage, + isPresented: Binding( + get: { !alertMessage.isEmpty }, + set: { presented in + print(presented) + if !presented { onShown() } + } + ) + ) {} } } diff --git a/iosApp/iosApp/Utils/CustomStyle.swift b/iosApp/iosApp/Utils/CustomStyle.swift new file mode 100644 index 0000000..5223353 --- /dev/null +++ b/iosApp/iosApp/Utils/CustomStyle.swift @@ -0,0 +1,74 @@ +import Foundation +import SwiftUI + +struct CustomStyle { + enum Spacing: CGFloat { + case tiny = 4 + case narrow = 8 + case small = 12 + case medium = 16 + case large = 20 + case wide = 24 + case extraWide = 30 + case ultraWide = 60 + case ultraWide2x = 120 + } + + enum CornerRadius: CGFloat { + case small = 2 + case medium = 4 + case large = 8 + } + + enum FontSize: CGFloat { + case small = 12 + case medium = 16 + case large = 20 + } + + enum Style { + case largeSymbol + case extraLargeSymbol + case smallFont + case mediumFont + case playerProgressLoader + } + + static let playerImageSize: CGFloat = 200 + static let playerMaxWidth: CGFloat = 350 + static let sideBarPlayerHeight: CGFloat = 550 + + static func spacing(_ spacing: Spacing) -> CGFloat { + spacing.rawValue + } + + static func cornerRadius(_ radius: CornerRadius) -> CGFloat { + radius.rawValue + } + + static func fontSize(_ fontSize: FontSize) -> CGFloat { + fontSize.rawValue + } +} + +extension View { + @ViewBuilder func customStyle(_ style: CustomStyle.Style) -> some View { + switch style { + case .largeSymbol: + font(.system(size: CustomStyle.spacing(.wide))) + + case .extraLargeSymbol: + font(.system(size: CustomStyle.spacing(.extraWide))) + + case .smallFont: + font(.system(size: CustomStyle.fontSize(.small))) + + case .mediumFont: + font(.system(size: CustomStyle.fontSize(.medium))) + + case .playerProgressLoader: + scaleEffect(0.6, anchor: .center) + .frame(width: 10, height: 10) + } + } +} diff --git a/iosApp/iosApp/UI/MenuItem.swift b/iosApp/iosApp/Utils/MenuItem.swift similarity index 100% rename from iosApp/iosApp/UI/MenuItem.swift rename to iosApp/iosApp/Utils/MenuItem.swift diff --git a/iosApp/iosApp/ViewControllers/MainViewController.swift b/iosApp/iosApp/ViewControllers/MainViewController.swift index bad6cb3..877eefa 100644 --- a/iosApp/iosApp/ViewControllers/MainViewController.swift +++ b/iosApp/iosApp/ViewControllers/MainViewController.swift @@ -1,7 +1,11 @@ import UIKit import HotwireNative +import LNPopupUI +import sharedKit class MainViewController: UISplitViewController, UISplitViewControllerDelegate { + private let musicServiceViewModel: MusicServiceViewModel = KoinHelper().getMusicServiceViewModel() + init(serverAddress: String) { super.init(style: .doubleColumn) @@ -14,9 +18,14 @@ class MainViewController: UISplitViewController, UISplitViewControllerDelegate { let tabs = buildMainTabs(serverAddress: serverAddress) tabBarController.load(tabs) + tabBarController.presentPopupBar { + PlayerScreen() + } setViewController(tabBarController, for: .secondary) setViewController(tabBarController, for: .compact) + + musicServiceViewModel.setupMusicServiceController() } required init?(coder: NSCoder) { diff --git a/iosApp/iosApp/Web/WebViewController.swift b/iosApp/iosApp/ViewControllers/WebViewController.swift similarity index 100% rename from iosApp/iosApp/Web/WebViewController.swift rename to iosApp/iosApp/ViewControllers/WebViewController.swift diff --git a/iosApp/iosApp/Views/LoginScreen.swift b/iosApp/iosApp/Views/LoginScreen.swift index 2171520..0056704 100644 --- a/iosApp/iosApp/Views/LoginScreen.swift +++ b/iosApp/iosApp/Views/LoginScreen.swift @@ -3,62 +3,49 @@ import sharedKit struct LoginScreen: View { @State var isAuthenticationFormVisible: Bool = false - @State var serverAddress: String? - @State var email: String = "" - @State var password: String = "" - @State var showingAlert = false - @State var alertMessage: AlertMessage? private let viewModel: LoginViewModel = KoinHelper().getLoginViewModel() var body: some View { - NavigationView { - VStack { - LoginConnectionForm( - serverAddress: serverAddress ?? "", - onConnectButtonClicked: { - viewModel.checkSystemInfo(onSuccess: { isAuthenticationFormVisible = true }) - }, - onServerAddressChanged: { serverAddress in - viewModel.updateServerAddress(serverAddress: serverAddress) - } - ) - - NavigationLink( - destination: LoginAuthenticationForm( - email: email, - password: password, - onLoginButtonClicked: { - viewModel.login(onSuccess: { serverAddress in - changeRootViewController(viewController: MainViewController(serverAddress: serverAddress)) - }) - }, - onEmailChanged: { email in - viewModel.updateEmail(email: email) + Observing(viewModel.uiState) { uiState in + NavigationView { + VStack { + LoginConnectionForm( + serverAddress: uiState.serverAddress ?? "", + onConnectButtonClicked: { + viewModel.checkSystemInfo(onSuccess: { isAuthenticationFormVisible = true }) }, - onPasswordChanged: { password in - viewModel.updatePassword(password: password) + onServerAddressChanged: { serverAddress in + viewModel.updateServerAddress(serverAddress: serverAddress) } - ), - isActive: $isAuthenticationFormVisible, - label: { EmptyView() } - ) - .hidden() - } - } - .navigationViewStyle(.stack) - .alertMessage(alertMessage, isPresented: $showingAlert, onShown: { - viewModel.alertMessageShown() - }) - .collect(flow: viewModel.uiState) { state in - serverAddress = state.serverAddress - email = state.email - password = state.password + ) - if let message = state.alertMessage { - alertMessage = message - showingAlert = true + NavigationLink( + destination: LoginAuthenticationForm( + email: uiState.email, + password: uiState.password, + onLoginButtonClicked: { + viewModel.login(onSuccess: { serverAddress in + changeRootViewController(viewController: MainViewController(serverAddress: serverAddress)) + }) + }, + onEmailChanged: { email in + viewModel.updateEmail(email: email) + }, + onPasswordChanged: { password in + viewModel.updatePassword(password: password) + } + ), + isActive: $isAuthenticationFormVisible, + label: { EmptyView() } + ) + .hidden() + } } + .navigationViewStyle(.stack) + .alertMessage(uiState.alertMessage, onShown: { + viewModel.alertMessageShown() + }) } } } diff --git a/iosApp/iosApp/Views/Player/FullPlayer.swift b/iosApp/iosApp/Views/Player/FullPlayer.swift new file mode 100644 index 0000000..03f0b52 --- /dev/null +++ b/iosApp/iosApp/Views/Player/FullPlayer.swift @@ -0,0 +1,14 @@ +import SwiftUI +import sharedKit + +struct FullPlayer: View { + let currentSong: Song? + + var body: some View { + VStack { + PlayerArt(imageURL: currentSong?.albumImageUrl.large) + .padding(.bottom, CustomStyle.spacing(.extraWide)) + PlayerInfo(currentSong: currentSong) + } + } +} diff --git a/iosApp/iosApp/Views/Player/PlayerArt.swift b/iosApp/iosApp/Views/Player/PlayerArt.swift new file mode 100644 index 0000000..a9dd9cb --- /dev/null +++ b/iosApp/iosApp/Views/Player/PlayerArt.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct PlayerArt: View { + let imageURL: String? + + var body: some View { + AsyncImage(url: .init(string: imageURL ?? "")) { image in + image.resizable() + } placeholder: { + Color.secondary + } + .cornerRadius(CustomStyle.cornerRadius(.medium)) + .frame(width: CustomStyle.playerImageSize, height: CustomStyle.playerImageSize) + } +} diff --git a/iosApp/iosApp/Views/Player/PlayerControl.swift b/iosApp/iosApp/Views/Player/PlayerControl.swift new file mode 100644 index 0000000..f0cdc7b --- /dev/null +++ b/iosApp/iosApp/Views/Player/PlayerControl.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct PlayerControl: View { + let isPlaying = false + let isLoading = false + let currentPosition: Double + let duration: Double + let enabled = true + let onPreviousButtonClicked: (() -> Void) + let onNextButtonClicked: (() -> Void) + let onPlayButtonClicked: (() -> Void) + let onPauseButtonClicked: (() -> Void) + let onSeek: ((Double) -> Void) + + var body: some View { + VStack { + + } + } +} diff --git a/iosApp/iosApp/Views/Player/PlayerInfo.swift b/iosApp/iosApp/Views/Player/PlayerInfo.swift new file mode 100644 index 0000000..694df9a --- /dev/null +++ b/iosApp/iosApp/Views/Player/PlayerInfo.swift @@ -0,0 +1,15 @@ +import SwiftUI +import sharedKit + +struct PlayerInfo: View { + let currentSong: Song? + + var body: some View { + VStack(spacing: CustomStyle.spacing(.tiny)) { + Text(currentSong?.name ?? String(localized: "label.not_playing")) + .font(.headline) + Text(currentSong?.artistName ?? "") + .font(.caption) + } + } +} diff --git a/iosApp/iosApp/Views/PlayerScreen.swift b/iosApp/iosApp/Views/PlayerScreen.swift new file mode 100644 index 0000000..4ac6952 --- /dev/null +++ b/iosApp/iosApp/Views/PlayerScreen.swift @@ -0,0 +1,73 @@ +import SwiftUI +import LNPopupUI +import sharedKit + +struct PlayerScreen: View { + private let viewModel: PlayerViewModel = KoinHelper().getPlayerViewModel() + + @State private var albumImage: UIImage? + @State private var currentSong: Song? + @State private var isPlaying = false + + var body: some View { + Observing(viewModel.uiState) { uiState in + FullPlayer( + currentSong: uiState.musicState.currentSong + ) + } + .popupTitle(currentSong?.name ?? String(localized: "label.not_playing")) + .popupImage(albumImage != nil ? Image(uiImage: albumImage!) : nil) + .popupBarButtons { + ToolbarItemGroup(placement: .popupBar) { + Button( + action: { + if isPlaying { + viewModel.pause() + } else { + viewModel.play() + } + }, + label: { + if isPlaying { + Image(systemName: "pause.fill") + .tint(.primary) + } else { + Image(systemName: "play.fill") + .tint(.primary) + } + } + ) + + Button( + action: { + viewModel.next() + }, + label: { + Image(systemName: "forward.fill") + .tint(.primary) + } + ) + } + } + .task(id: currentSong?.id) { + guard let urlString = currentSong?.albumImageUrl.small, + let url = URL(string: urlString) else { + albumImage = nil + return + } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + if let image = UIImage(data: data) { + albumImage = image + } + } catch { + albumImage = nil + } + } + .collect(flow: viewModel.uiState) { state in + currentSong = state.musicState.currentSong + isPlaying = state.musicState.isPlaying + } + } +} diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/KoinHelper.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/KoinHelper.kt index 1c164c2..2cd7222 100644 --- a/shared/src/iosMain/kotlin/org/blackcandy/shared/KoinHelper.kt +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/KoinHelper.kt @@ -3,6 +3,8 @@ package org.blackcandy.shared import org.blackcandy.shared.di.appModule import org.blackcandy.shared.viewmodels.LoginViewModel import org.blackcandy.shared.viewmodels.MainViewModel +import org.blackcandy.shared.viewmodels.MusicServiceViewModel +import org.blackcandy.shared.viewmodels.PlayerViewModel import org.blackcandy.shared.viewmodels.WebViewModel import org.koin.core.component.KoinComponent import org.koin.core.component.get @@ -20,4 +22,8 @@ class KoinHelper : KoinComponent { fun getLoginViewModel(): LoginViewModel = get() fun getWebViewModel(): WebViewModel = get() + + fun getPlayerViewModel(): PlayerViewModel = get() + + fun getMusicServiceViewModel(): MusicServiceViewModel = get() } diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt index cf18296..5ed13c3 100644 --- a/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt @@ -7,13 +7,15 @@ import kotlinx.cinterop.interpretObjCPointer import kotlinx.cinterop.memScoped import kotlinx.cinterop.ptr import kotlinx.cinterop.value -import platform.CoreFoundation.CFDictionaryRef -import platform.CoreFoundation.CFTypeRef +import platform.CoreFoundation.CFDictionaryAddValue +import platform.CoreFoundation.CFDictionaryCreateMutable +import platform.CoreFoundation.CFRelease import platform.CoreFoundation.CFTypeRefVar import platform.CoreFoundation.kCFBooleanTrue +import platform.CoreFoundation.kCFTypeDictionaryKeyCallBacks +import platform.CoreFoundation.kCFTypeDictionaryValueCallBacks import platform.Foundation.CFBridgingRetain import platform.Foundation.NSData -import platform.Foundation.NSDictionary import platform.Foundation.NSString import platform.Foundation.NSUTF8StringEncoding import platform.Foundation.create @@ -36,56 +38,66 @@ actual class EncryptedDataSource { private const val API_TOKEN_KEY = "org.blackcandy.api_token_key" } - private fun CFTypeRef?.asNSObject(): Any? = this?.let { interpretObjCPointer(it.rawValue) } - actual fun getApiToken(): String? { - val query = - mapOf( - kSecClass.asNSObject() to kSecClassGenericPassword.asNSObject(), - kSecAttrAccount.asNSObject() to API_TOKEN_KEY, - kSecReturnData.asNSObject() to kCFBooleanTrue.asNSObject(), - kSecMatchLimit.asNSObject() to kSecMatchLimitOne.asNSObject(), - ) as NSDictionary - - return memScoped { - val result = alloc() - val cfQuery = CFBridgingRetain(query) as CFDictionaryRef - val status = SecItemCopyMatching(cfQuery, result.ptr) - - if (status == errSecSuccess) { - val data = result.value?.let { interpretObjCPointer(it.rawValue) } - data?.let { - NSString.create(it, NSUTF8StringEncoding)?.toString() + val query = CFDictionaryCreateMutable(null, 0, kCFTypeDictionaryKeyCallBacks.ptr, kCFTypeDictionaryValueCallBacks.ptr) + val keyRef = CFBridgingRetain(API_TOKEN_KEY as Any as NSString) + + CFDictionaryAddValue(query, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(query, kSecAttrAccount, keyRef) + CFDictionaryAddValue(query, kSecReturnData, kCFBooleanTrue) + CFDictionaryAddValue(query, kSecMatchLimit, kSecMatchLimitOne) + + val result = + memScoped { + val resultVar = alloc() + val status = SecItemCopyMatching(query, resultVar.ptr) + + if (status == errSecSuccess) { + val data = resultVar.value?.let { interpretObjCPointer(it.rawValue) } + data?.let { + NSString.create(it, NSUTF8StringEncoding)?.toString() + } + } else { + null } - } else { - null } - } + + CFRelease(query) + CFRelease(keyRef) + + return result } actual fun updateApiToken(apiToken: String) { - removeApiToken() - val apiTokenData = (apiToken as Any as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return - val query = - mapOf( - kSecClass.asNSObject() to kSecClassGenericPassword.asNSObject(), - kSecAttrAccount.asNSObject() to API_TOKEN_KEY, - kSecValueData.asNSObject() to apiTokenData, - ) as NSDictionary - - val cfQuery = CFBridgingRetain(query) as CFDictionaryRef - SecItemAdd(cfQuery, null) + + val query = CFDictionaryCreateMutable(null, 0, kCFTypeDictionaryKeyCallBacks.ptr, kCFTypeDictionaryValueCallBacks.ptr) + val keyRef = CFBridgingRetain(API_TOKEN_KEY as Any as NSString) + val valueRef = CFBridgingRetain(apiTokenData) + + CFDictionaryAddValue(query, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(query, kSecAttrAccount, keyRef) + + SecItemDelete(query) + + CFDictionaryAddValue(query, kSecValueData, valueRef) + SecItemAdd(query, null) + + CFRelease(query) + CFRelease(keyRef) + CFRelease(valueRef) } actual fun removeApiToken() { - val query = - mapOf( - kSecClass.asNSObject() to kSecClassGenericPassword.asNSObject(), - kSecAttrAccount.asNSObject() to API_TOKEN_KEY, - ) as NSDictionary - - val cfQuery = CFBridgingRetain(query) as CFDictionaryRef - SecItemDelete(cfQuery) + val query = CFDictionaryCreateMutable(null, 0, kCFTypeDictionaryKeyCallBacks.ptr, kCFTypeDictionaryValueCallBacks.ptr) + val keyRef = CFBridgingRetain(API_TOKEN_KEY as Any as NSString) + + CFDictionaryAddValue(query, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(query, kSecAttrAccount, keyRef) + + SecItemDelete(query) + + CFRelease(query) + CFRelease(keyRef) } } diff --git a/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt index 7f1ec84..90e21db 100644 --- a/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt @@ -2,17 +2,24 @@ package org.blackcandy.shared.media import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import org.blackcandy.shared.models.Song actual class MusicServiceController { - actual val musicState: StateFlow = MutableStateFlow(MusicState()) + private val _musicState = MutableStateFlow(MusicState()) + + actual val musicState = _musicState.asStateFlow() + actual val currentPosition: Flow = MutableStateFlow(0.0) actual fun initMediaController(onInitialized: () -> Unit) { + onInitialized() } actual fun updatePlaylist(songs: List) { + _musicState.update { it.copy(playlist = songs) } + _musicState.update { it.copy(currentSong = songs.firstOrNull()) } } actual fun play() { From c9605fcca2bc04fe7886ba9108cc926c21ef4a97 Mon Sep 17 00:00:00 2001 From: Ed Chao Date: Tue, 10 Feb 2026 21:27:55 +0900 Subject: [PATCH 24/24] Add static player screen --- iosApp/iosApp.xcodeproj/project.pbxproj | 2 + iosApp/iosApp/Models/Song.swift | 3 + iosApp/iosApp/Views/LoginScreen.swift | 45 ++++++----- iosApp/iosApp/Views/Player/FullPlayer.swift | 29 +++++++ .../iosApp/Views/Player/PlayerActions.swift | 74 ++++++++++++++++++ .../iosApp/Views/Player/PlayerControl.swift | 76 +++++++++++++++++++ .../iosApp/Views/Player/PlayerPlaylist.swift | 30 ++++++++ iosApp/iosApp/Views/Player/PlayerSlider.swift | 46 +++++++++++ iosApp/iosApp/Views/Player/PlaylistItem.swift | 29 +++++++ iosApp/iosApp/Views/PlayerScreen.swift | 30 +++++++- 10 files changed, 340 insertions(+), 24 deletions(-) create mode 100644 iosApp/iosApp/Models/Song.swift create mode 100644 iosApp/iosApp/Views/Player/PlayerActions.swift create mode 100644 iosApp/iosApp/Views/Player/PlayerPlaylist.swift create mode 100644 iosApp/iosApp/Views/Player/PlayerSlider.swift create mode 100644 iosApp/iosApp/Views/Player/PlaylistItem.swift diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index bd3be63..144afbd 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -187,6 +187,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -213,6 +214,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/iosApp/iosApp/Models/Song.swift b/iosApp/iosApp/Models/Song.swift new file mode 100644 index 0000000..af8d1d1 --- /dev/null +++ b/iosApp/iosApp/Models/Song.swift @@ -0,0 +1,3 @@ +import sharedKit + +extension Song: Identifiable {} diff --git a/iosApp/iosApp/Views/LoginScreen.swift b/iosApp/iosApp/Views/LoginScreen.swift index 0056704..2113486 100644 --- a/iosApp/iosApp/Views/LoginScreen.swift +++ b/iosApp/iosApp/Views/LoginScreen.swift @@ -2,26 +2,26 @@ import SwiftUI import sharedKit struct LoginScreen: View { - @State var isAuthenticationFormVisible: Bool = false - private let viewModel: LoginViewModel = KoinHelper().getLoginViewModel() + @State private var path = NavigationPath() + var body: some View { Observing(viewModel.uiState) { uiState in - NavigationView { - VStack { - LoginConnectionForm( - serverAddress: uiState.serverAddress ?? "", - onConnectButtonClicked: { - viewModel.checkSystemInfo(onSuccess: { isAuthenticationFormVisible = true }) - }, - onServerAddressChanged: { serverAddress in - viewModel.updateServerAddress(serverAddress: serverAddress) - } - ) - - NavigationLink( - destination: LoginAuthenticationForm( + NavigationStack(path: $path) { + LoginConnectionForm( + serverAddress: uiState.serverAddress ?? "", + onConnectButtonClicked: { + viewModel.checkSystemInfo(onSuccess: { path.append(Route.authentication) }) + }, + onServerAddressChanged: { serverAddress in + viewModel.updateServerAddress(serverAddress: serverAddress) + } + ) + .navigationDestination(for: Route.self) { route in + switch route { + case .authentication: + LoginAuthenticationForm( email: uiState.email, password: uiState.password, onLoginButtonClicked: { @@ -35,11 +35,8 @@ struct LoginScreen: View { onPasswordChanged: { password in viewModel.updatePassword(password: password) } - ), - isActive: $isAuthenticationFormVisible, - label: { EmptyView() } - ) - .hidden() + ) + } } } .navigationViewStyle(.stack) @@ -49,3 +46,9 @@ struct LoginScreen: View { } } } + +extension LoginScreen { + enum Route: Hashable { + case authentication + } +} diff --git a/iosApp/iosApp/Views/Player/FullPlayer.swift b/iosApp/iosApp/Views/Player/FullPlayer.swift index 03f0b52..0c86e0c 100644 --- a/iosApp/iosApp/Views/Player/FullPlayer.swift +++ b/iosApp/iosApp/Views/Player/FullPlayer.swift @@ -3,12 +3,41 @@ import sharedKit struct FullPlayer: View { let currentSong: Song? + let currentPosition: Double + let playbackMode: PlaybackMode + let onPlaylistButtonClicked: (() -> Void) var body: some View { VStack { + Spacer() + PlayerArt(imageURL: currentSong?.albumImageUrl.large) .padding(.bottom, CustomStyle.spacing(.extraWide)) + PlayerInfo(currentSong: currentSong) + + PlayerControl( + currentPosition: currentPosition, + duration: currentSong?.duration ?? 0, + onPreviousButtonClicked: {}, + onNextButtonClicked: {}, + onPlayButtonClicked: {}, + onPauseButtonClicked: {}, + onSeek: { _ in } + ) + .padding(.horizontal, CustomStyle.spacing(.large)) + + Spacer() + + PlayerActions( + playbackMode: playbackMode, + isFavorited: currentSong?.isFavorited ?? false, + onModeSwitchButtonClicked: {}, + onFavoriteButtonClicked: {}, + onPlaylistButtonClicked: onPlaylistButtonClicked + ) + .padding(.vertical, CustomStyle.spacing(.medium)) + .padding(.horizontal, CustomStyle.spacing(.large)) } } } diff --git a/iosApp/iosApp/Views/Player/PlayerActions.swift b/iosApp/iosApp/Views/Player/PlayerActions.swift new file mode 100644 index 0000000..7f9c54b --- /dev/null +++ b/iosApp/iosApp/Views/Player/PlayerActions.swift @@ -0,0 +1,74 @@ +import SwiftUI +import sharedKit + +struct PlayerActions: View { + let playbackMode: PlaybackMode + let isFavorited: Bool + let onModeSwitchButtonClicked: (() -> Void) + let onFavoriteButtonClicked: (() -> Void) + let onPlaylistButtonClicked: (() -> Void) + + var body: some View { + HStack { + Button( + action: { + onModeSwitchButtonClicked() + }, + label: { + playbackModeIcon(playbackMode) + } + ) + .padding(CustomStyle.spacing(.narrow)) + .background(playbackMode == .noRepeat ? .clear : .accentColor) + .cornerRadius(CustomStyle.cornerRadius(.medium)) + + Spacer() + + Button( + action: { + onFavoriteButtonClicked() + }, + label: { + if isFavorited { + Image(systemName: "heart.fill") + .tint(.red) + } else { + Image(systemName: "heart") + .tint(.primary) + } + } + ) + .padding(CustomStyle.spacing(.narrow)) + + Spacer() + + Button( + action: { + onPlaylistButtonClicked() + }, + label: { + Image(systemName: "list.bullet") + // .tint(viewStore.isPlaylistVisible ? .white : .primary) + } + ) + .padding(CustomStyle.spacing(.narrow)) + // .background(viewStore.isPlaylistVisible ? Color.accentColor : .clear) + .cornerRadius(CustomStyle.cornerRadius(.medium)) + } + } + + func playbackModeIcon(_ mode: PlaybackMode) -> some View { + let iconName = switch mode { + case .noRepeat: + "repeat" + case .repeat: + "repeat" + case .repeatOne: + "repeat.1" + case .shuffle: + "shuffle" + } + + return Image(systemName: iconName) + } +} diff --git a/iosApp/iosApp/Views/Player/PlayerControl.swift b/iosApp/iosApp/Views/Player/PlayerControl.swift index f0cdc7b..90ba665 100644 --- a/iosApp/iosApp/Views/Player/PlayerControl.swift +++ b/iosApp/iosApp/Views/Player/PlayerControl.swift @@ -1,4 +1,5 @@ import SwiftUI +import sharedKit struct PlayerControl: View { let isPlaying = false @@ -13,8 +14,83 @@ struct PlayerControl: View { let onSeek: ((Double) -> Void) var body: some View { + let progressValue = duration > 0 ? (currentPosition / duration) : 0 + let currentPositionText = enabled ? DurationFormatter.companion.string(duration: currentPosition) : NONE_DURATION_TEXT + let durationText = enabled ? DurationFormatter.companion.string(duration: currentPosition) : NONE_DURATION_TEXT + VStack { + PlayerSliderView(value: Binding( + get: { progressValue }, + set: { value in onSeek(value) } + )) + + HStack { + if isLoading { + ProgressView() + .customStyle(.playerProgressLoader) + } else { + Text(currentPositionText) + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + + Text(durationText) + .font(.caption2) + .foregroundColor(.secondary) + } + } + + HStack { + Button( + action: { + onPreviousButtonClicked() + }, + label: { + Image(systemName: "backward.fill") + .tint(.primary) + .customStyle(.largeSymbol) + } + ) + + Spacer() + + Button( + action: { + if isPlaying { + onPauseButtonClicked() + } else { + onPlayButtonClicked() + } + }, + label: { + if isPlaying { + Image(systemName: "pause.fill") + .tint(.primary) + .customStyle(.extraLargeSymbol) + } else { + Image(systemName: "play.fill") + .tint(.primary) + .customStyle(.extraLargeSymbol) + } + } + ) + + Spacer() + + Button( + action: { + onNextButtonClicked() + }, + label: { + Image(systemName: "forward.fill") + .tint(.primary) + .customStyle(.largeSymbol) + } + ) } + .padding(CustomStyle.spacing(.large)) } } diff --git a/iosApp/iosApp/Views/Player/PlayerPlaylist.swift b/iosApp/iosApp/Views/Player/PlayerPlaylist.swift new file mode 100644 index 0000000..132e4d0 --- /dev/null +++ b/iosApp/iosApp/Views/Player/PlayerPlaylist.swift @@ -0,0 +1,30 @@ +import SwiftUI +import sharedKit + +struct PlayerPlaylist: View { + let playlist: [Song] + let currentSong: Song? + let onItemClicked: ((Int) -> Void) + let onItemSweepToDismiss: ((Int) -> Void) + let onItemMoved: ((Int, Int) -> Void) + + var body: some View { + List { + ForEach(playlist) { song in + PlaylistItem( + song: song, + isCurrent: song == currentSong, + onClicked: { _ in } + ) + } + .onDelete { _ in + // onItemSweepToDismiss() + } + .onMove { _, _ in + // onItemMoved() + } + .listRowBackground(Color.clear) + } + .listStyle(.plain) + } +} diff --git a/iosApp/iosApp/Views/Player/PlayerSlider.swift b/iosApp/iosApp/Views/Player/PlayerSlider.swift new file mode 100644 index 0000000..d2bc24d --- /dev/null +++ b/iosApp/iosApp/Views/Player/PlayerSlider.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct PlayerSliderView: UIViewRepresentable { + @Binding var value: Double + + class Coordinator { + @Binding var value: Double + + init(value: Binding) { + _value = value + } + + @objc func valueChanged(_ sender: UISlider) { + value = Double(sender.value) + } + } + + func makeUIView(context: Context) -> UISlider { + let slider = UISlider() + + slider.isContinuous = false + slider.setThumbImage( + .init( + systemName: "circle.fill", + withConfiguration: UIImage.SymbolConfiguration(pointSize: CustomStyle.fontSize(.small)) + ), + for: .normal + ) + + slider.addTarget( + context.coordinator, + action: #selector(Coordinator.valueChanged(_:)), + for: .valueChanged + ) + + return slider + } + + func updateUIView(_ slider: UISlider, context: Context) { + slider.value = Float(value) + } + + func makeCoordinator() -> Coordinator { + .init(value: $value) + } +} diff --git a/iosApp/iosApp/Views/Player/PlaylistItem.swift b/iosApp/iosApp/Views/Player/PlaylistItem.swift new file mode 100644 index 0000000..440e97c --- /dev/null +++ b/iosApp/iosApp/Views/Player/PlaylistItem.swift @@ -0,0 +1,29 @@ +import SwiftUI +import sharedKit + +struct PlaylistItem: View { + let song: Song + let isCurrent: Bool + let onClicked: ((Int32) -> Void) + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: CustomStyle.spacing(.small)) { + Text(song.name) + .customStyle(.mediumFont) + + Text(song.artistName) + .customStyle(.smallFont) + } + + Spacer() + + Text(DurationFormatter.companion.string(duration: song.duration)) + .customStyle(.smallFont) + } + .foregroundColor(isCurrent ? .accentColor : .primary) + .onTapGesture { + onClicked(song.id) + } + } +} diff --git a/iosApp/iosApp/Views/PlayerScreen.swift b/iosApp/iosApp/Views/PlayerScreen.swift index 4ac6952..e919160 100644 --- a/iosApp/iosApp/Views/PlayerScreen.swift +++ b/iosApp/iosApp/Views/PlayerScreen.swift @@ -5,15 +5,33 @@ import sharedKit struct PlayerScreen: View { private let viewModel: PlayerViewModel = KoinHelper().getPlayerViewModel() + @State private var path = NavigationPath() @State private var albumImage: UIImage? @State private var currentSong: Song? @State private var isPlaying = false var body: some View { Observing(viewModel.uiState) { uiState in - FullPlayer( - currentSong: uiState.musicState.currentSong - ) + NavigationStack(path: $path) { + FullPlayer( + currentSong: uiState.musicState.currentSong, + currentPosition: uiState.currentPosition, + playbackMode: uiState.musicState.playbackMode, + onPlaylistButtonClicked: { path.append(Route.playlist) } + ) + .navigationDestination(for: Route.self) { route in + switch route { + case .playlist: + PlayerPlaylist( + playlist: uiState.musicState.playlist, + currentSong: uiState.musicState.currentSong, + onItemClicked: { _ in }, + onItemSweepToDismiss: { _ in }, + onItemMoved: {_, _ in } + ) + } + } + } } .popupTitle(currentSong?.name ?? String(localized: "label.not_playing")) .popupImage(albumImage != nil ? Image(uiImage: albumImage!) : nil) @@ -71,3 +89,9 @@ struct PlayerScreen: View { } } } + +extension PlayerScreen { + enum Route: Hashable { + case playlist + } +}