diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30a3d5f..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 @@ -12,10 +12,19 @@ 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 - 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/.gitignore b/.gitignore index 832f622..776deb9 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,9 @@ local.properties .env .idea /app/release/ -/fastlane/report.xml \ No newline at end of file +/fastlane/report.xml +.kotlin +xcuserdata/ +iosApp.xcconfig +*.zip +*.ipa \ 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/app/.gitignore b/androidApp/.gitignore similarity index 100% rename from app/.gitignore rename to androidApp/.gitignore diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts new file mode 100644 index 0000000..7a5d59a --- /dev/null +++ b/androidApp/build.gradle.kts @@ -0,0 +1,92 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "org.blackcandy.android" + compileSdk = 36 + + defaultConfig { + applicationId = "org.blackcandy.android" + minSdk = 28 + 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 { + 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(libs.androidx.annotation.experimental) + + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.session) + implementation(libs.androidx.media3.datasource.okhttp) + + implementation(libs.google.material) + implementation(libs.google.accompanist.themeadapter.material3) + + 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(libs.hotwire.core) + implementation(libs.hotwire.navigation.fragments) + + implementation(project(":shared")) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/app/proguard-rules.pro b/androidApp/proguard-rules.pro similarity index 94% rename from app/proguard-rules.pro rename to androidApp/proguard-rules.pro index 3ed4389..9f5f6c5 100644 --- a/app/proguard-rules.pro +++ b/androidApp/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/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 96% rename from app/src/main/AndroidManifest.xml rename to androidApp/src/main/AndroidManifest.xml index 875cf81..dde126a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -33,7 +33,7 @@ diff --git a/app/src/main/assets/json/configuration.json b/androidApp/src/main/assets/json/configuration.json similarity index 65% rename from app/src/main/assets/json/configuration.json rename to androidApp/src/main/assets/json/configuration.json index b73a29d..4ea7714 100644 --- a/app/src/main/assets/json/configuration.json +++ b/androidApp/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/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 95% rename from app/src/main/java/org/blackcandy/android/LoginActivity.kt rename to androidApp/src/main/java/org/blackcandy/android/LoginActivity.kt index 117c5e2..12ff024 100644 --- a/app/src/main/java/org/blackcandy/android/LoginActivity.kt +++ b/androidApp/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/androidApp/src/main/java/org/blackcandy/android/MainActivity.kt similarity index 73% rename from app/src/main/java/org/blackcandy/android/MainActivity.kt rename to androidApp/src/main/java/org/blackcandy/android/MainActivity.kt index 7182e73..94914bf 100644 --- a/app/src/main/java/org/blackcandy/android/MainActivity.kt +++ b/androidApp/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,33 +15,37 @@ 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 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 -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() + + private val musicServiceViewModel: MusicServiceViewModel by viewModel() + 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() { @@ -71,24 +73,22 @@ class MainActivity : AppCompatActivity(), TurboActivity, OnItemSelectedListener } binding = ActivityMainBinding.inflate(layoutInflater) - delegate = TurboActivityDelegate(this, R.id.home_container) - viewModel.setupMusicServiceController() + musicServiceViewModel.setupMusicServiceController() + + setContentView(binding.root) setupLayout() - setupNavListener() + setupBottomTabs() setupPlayerBottomSheet() setupMiniPlayer() setupPlayerScreen() - restoreSavedState(savedInstanceState) - - setContentView(binding.root) } override fun onRestart() { super.onRestart() - viewModel.getCurrentPlaylist() + musicServiceViewModel.getCurrentPlaylist() } override fun onDestroy() { @@ -109,27 +109,13 @@ class MainActivity : AppCompatActivity(), TurboActivity, OnItemSelectedListener } } - 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 onNavigationItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - R.id.nav_menu_home, R.id.nav_menu_library -> { - showSelectedNavItem(item.itemId) - true - } - - else -> false - } + override fun navigatorConfigurations(): List { + mainTabs = buildMainTabs(viewModel.serverAddress) + return mainTabs.navigatorConfigurations } private fun requireLogin(): Boolean { - runBlocking { viewModel.currentUserFlow.first() } ?: return true + viewModel.currentUser ?: return true lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -144,11 +130,6 @@ class MainActivity : AppCompatActivity(), TurboActivity, OnItemSelectedListener return false } - private fun setupNavListener() { - binding.bottomNav?.setOnItemSelectedListener(this) - binding.railNav?.setOnItemSelectedListener(this) - } - private fun setupLayout() { // Displaying edge-to-edge WindowCompat.setDecorFitsSystemWindows(window, false) @@ -260,9 +241,13 @@ class MainActivity : AppCompatActivity(), TurboActivity, OnItemSelectedListener 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 } } @@ -272,32 +257,4 @@ class MainActivity : AppCompatActivity(), TurboActivity, OnItemSelectedListener 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/androidApp/src/main/java/org/blackcandy/android/MainApplication.kt b/androidApp/src/main/java/org/blackcandy/android/MainApplication.kt new file mode 100644 index 0000000..cd86c45 --- /dev/null +++ b/androidApp/src/main/java/org/blackcandy/android/MainApplication.kt @@ -0,0 +1,71 @@ +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.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.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 + +class MainApplication : Application() { + override fun onCreate() { + super.onCreate() + + configureApp() + + startKoin { + androidLogger() + androidContext(this@MainApplication) + modules(appModule()) + } + } + + private fun configureApp() { + Hotwire.config.applicationUserAgentPrefix = "${BLACK_CANDY_USER_AGENT};" + Hotwire.config.jsonConverter = KotlinXJsonConverter() + Hotwire.defaultFragmentDestination = WebFragment::class + + Hotwire.loadPathConfiguration( + context = this, + location = + PathConfiguration.Location( + assetFilePath = "json/configuration.json", + ), + ) + + Hotwire.registerFragmentDestinations( + WebFragment::class, + WebHomeFragment::class, + WebLibraryFragment::class, + WebBottomSheetFragment::class, + ) + + Hotwire.registerBridgeComponents( + BridgeComponentFactory("account", ::AccountComponent), + BridgeComponentFactory("search", ::SearchComponent), + BridgeComponentFactory("album", ::AlbumComponent), + BridgeComponentFactory("flash", ::FlashComponent), + BridgeComponentFactory("playlist", ::PlaylistComponent), + BridgeComponentFactory("songs", ::SongsComponent), + BridgeComponentFactory("theme", ::ThemeComponent), + ) + } +} diff --git a/androidApp/src/main/java/org/blackcandy/android/MainTabs.kt b/androidApp/src/main/java/org/blackcandy/android/MainTabs.kt new file mode 100644 index 0000000..22d60df --- /dev/null +++ b/androidApp/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/androidApp/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt new file mode 100644 index 0000000..4d3a773 --- /dev/null +++ b/androidApp/src/main/java/org/blackcandy/android/bridge/AccountComponent.kt @@ -0,0 +1,115 @@ +package org.blackcandy.android.bridge + +import androidx.compose.ui.platform.ComposeView +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.fragments.web.WebFragment +import org.blackcandy.android.utils.MenuItem +import org.blackcandy.shared.viewmodels.WebViewModel + +class AccountComponent( + name: String, + private val delegate: BridgeDelegate, +) : BridgeComponent(name, delegate) { + 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) { + 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/androidApp/src/main/java/org/blackcandy/android/bridge/AlbumComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/AlbumComponent.kt new file mode 100644 index 0000000..b96a976 --- /dev/null +++ b/androidApp/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/androidApp/src/main/java/org/blackcandy/android/bridge/FlashComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/FlashComponent.kt new file mode 100644 index 0000000..517a746 --- /dev/null +++ b/androidApp/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/androidApp/src/main/java/org/blackcandy/android/bridge/PlaylistComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/PlaylistComponent.kt new file mode 100644 index 0000000..ac54dfc --- /dev/null +++ b/androidApp/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/androidApp/src/main/java/org/blackcandy/android/bridge/SearchComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/SearchComponent.kt new file mode 100644 index 0000000..6c0176d --- /dev/null +++ b/androidApp/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/androidApp/src/main/java/org/blackcandy/android/bridge/SongsComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/SongsComponent.kt new file mode 100644 index 0000000..d6db6f0 --- /dev/null +++ b/androidApp/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/androidApp/src/main/java/org/blackcandy/android/bridge/ThemeComponent.kt b/androidApp/src/main/java/org/blackcandy/android/bridge/ThemeComponent.kt new file mode 100644 index 0000000..4dba64d --- /dev/null +++ b/androidApp/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/compose/account/AccountMenu.kt b/androidApp/src/main/java/org/blackcandy/android/compose/account/AccountMenu.kt similarity index 96% 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 index af89f7e..6962b8a 100644 --- a/app/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.models.MenuItem +import org.blackcandy.android.utils.MenuItem @Composable fun AccountMenu(menuItems: List) { 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 98% 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 index bb900c8..5d70453 100644 --- a/app/src/main/java/org/blackcandy/android/compose/login/LoginScreen.kt +++ b/androidApp/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/FullPlayer.kt b/androidApp/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt similarity index 98% 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 index 3f9396f..9805bcb 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt +++ b/androidApp/src/main/java/org/blackcandy/android/compose/player/FullPlayer.kt @@ -15,8 +15,8 @@ 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.android.models.Song +import org.blackcandy.shared.media.PlaybackMode +import org.blackcandy.shared.models.Song @Composable fun FullPlayer( 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 98% 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 index 2539afb..6cef903 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/MiniPlayer.kt +++ b/androidApp/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/PlayerActions.kt b/androidApp/src/main/java/org/blackcandy/android/compose/player/PlayerActions.kt similarity index 68% 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 index 2a15880..d7f9c8d 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/PlayerActions.kt +++ b/androidApp/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/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 97% 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 index 0d57a66..7b7d682 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/PlayerControl.kt +++ b/androidApp/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/compose/player/PlayerInfo.kt b/androidApp/src/main/java/org/blackcandy/android/compose/player/PlayerInfo.kt similarity index 97% 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 index 58a8b31..35eacfa 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/PlayerInfo.kt +++ b/androidApp/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/PlayerScreen.kt b/androidApp/src/main/java/org/blackcandy/android/compose/player/PlayerScreen.kt similarity index 99% 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 index f15395a..7168480 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/PlayerScreen.kt +++ b/androidApp/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/compose/player/Playlist.kt b/androidApp/src/main/java/org/blackcandy/android/compose/player/Playlist.kt similarity index 98% 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 index c8e5f7b..cd28af2 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/Playlist.kt +++ b/androidApp/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/androidApp/src/main/java/org/blackcandy/android/compose/player/PlaylistItem.kt similarity index 99% 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 index 2faba76..2b4a9b0 100644 --- a/app/src/main/java/org/blackcandy/android/compose/player/PlaylistItem.kt +++ b/androidApp/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/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebBottomSheetFragment.kt b/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebBottomSheetFragment.kt new file mode 100644 index 0000000..ad42f2b --- /dev/null +++ b/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebBottomSheetFragment.kt @@ -0,0 +1,7 @@ +package org.blackcandy.android.fragments.web + +import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink +import dev.hotwire.navigation.fragments.HotwireWebBottomSheetFragment + +@HotwireDestinationDeepLink(uri = "hotwire://fragment/web/bottom_sheet") +class WebBottomSheetFragment : HotwireWebBottomSheetFragment() diff --git a/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt b/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt new file mode 100644 index 0000000..313c03a --- /dev/null +++ b/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt @@ -0,0 +1,56 @@ +package org.blackcandy.android.fragments.web + +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() { + 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, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? = inflater.inflate(R.layout.fragment_web, container, false) + + override fun onVisitErrorReceived( + location: String, + error: VisitError, + ) { + if (error is HttpError.ClientError.Unauthorized) { + viewModel.logout() + } else { + super.onVisitErrorReceived(location, error) + } + } +} diff --git a/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt b/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt new file mode 100644 index 0000000..f702e2c --- /dev/null +++ b/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt @@ -0,0 +1,25 @@ +package org.blackcandy.android.fragments.web + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink +import org.blackcandy.android.databinding.FragmentWebHomeBinding + +@HotwireDestinationDeepLink(uri = "hotwire://fragment/web/home") +class WebHomeFragment : WebFragment() { + @Suppress("ktlint:standard:backing-property-naming") + private var _binding: FragmentWebHomeBinding? = null + + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentWebHomeBinding.inflate(inflater, container, false) + return binding.root + } +} diff --git a/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt b/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt new file mode 100644 index 0000000..3b7bb49 --- /dev/null +++ b/androidApp/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt @@ -0,0 +1,24 @@ +package org.blackcandy.android.fragments.web + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink +import org.blackcandy.android.databinding.FragmentWebLibraryBinding + +@HotwireDestinationDeepLink(uri = "hotwire://fragment/web/library") +open class WebLibraryFragment : WebFragment() { + @Suppress("ktlint:standard:backing-property-naming") + private var _binding: FragmentWebLibraryBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + _binding = FragmentWebLibraryBinding.inflate(inflater, container, false) + return binding.root + } +} diff --git a/app/src/main/java/org/blackcandy/android/models/MenuItem.kt b/androidApp/src/main/java/org/blackcandy/android/utils/MenuItem.kt similarity index 67% rename from app/src/main/java/org/blackcandy/android/models/MenuItem.kt rename to androidApp/src/main/java/org/blackcandy/android/utils/MenuItem.kt index 5f01898..0cc19ad 100644 --- a/app/src/main/java/org/blackcandy/android/models/MenuItem.kt +++ b/androidApp/src/main/java/org/blackcandy/android/utils/MenuItem.kt @@ -1,10 +1,11 @@ -package org.blackcandy.android.models +package org.blackcandy.android.utils 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/utils/SnackbarUtil.kt b/androidApp/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt similarity index 53% rename from app/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt rename to androidApp/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt index 48b32c8..8784d23 100644 --- a/app/src/main/java/org/blackcandy/android/utils/SnackbarUtil.kt +++ b/androidApp/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,17 +40,26 @@ 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.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() } + + 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/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 95% rename from app/src/main/res/layout-sw600dp-land/activity_main.xml rename to androidApp/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/androidApp/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/androidApp/src/main/res/layout-w600dp/activity_main.xml similarity index 96% rename from app/src/main/res/layout-w600dp/activity_main.xml rename to androidApp/src/main/res/layout-w600dp/activity_main.xml index 9ba24a1..37fbee6 100644 --- a/app/src/main/res/layout-w600dp/activity_main.xml +++ b/androidApp/src/main/res/layout-w600dp/activity_main.xml @@ -19,7 +19,7 @@ diff --git a/app/src/main/res/layout/activity_main.xml b/androidApp/src/main/res/layout/activity_main.xml similarity index 93% rename from app/src/main/res/layout/activity_main.xml rename to androidApp/src/main/res/layout/activity_main.xml index 15a86d5..d1dbbc4 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/androidApp/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_web.xml b/androidApp/src/main/res/layout/fragment_web.xml similarity index 96% rename from app/src/main/res/layout/fragment_web.xml rename to androidApp/src/main/res/layout/fragment_web.xml index 4b41c93..2e21581 100644 --- a/app/src/main/res/layout/fragment_web.xml +++ b/androidApp/src/main/res/layout/fragment_web.xml @@ -22,7 +22,7 @@ > { - return service.getSongsFromCurrentPlaylist().asResult() - } - - suspend fun removeAllSongs(): TaskResult { - return service.removeAllSongsFromCurrentPlaylist().asResult() - } - - suspend fun removeSong(songId: Int): TaskResult { - return service.removeSongFromCurrentPlaylist(songId).asResult() - } - - suspend fun moveSong( - songId: Int, - destinationSongId: Int, - ): TaskResult { - return service.moveSongInCurrentPlaylist(songId, destinationSongId).asResult() - } - - suspend fun replaceWithAlbumSongs(albumId: Int): TaskResult> { - return service.replaceCurrentPlaylistWithAlbumSongs(albumId).asResult() - } - - suspend fun replaceWithPlaylistSongs(playlistId: Int): TaskResult> { - return service.replaceCurrentPlaylistWithPlaylistSongs(playlistId).asResult() - } - - suspend fun addSongToNext( - songId: Int, - currentSongId: Int, - ): TaskResult { - return service.addSongToCurrentPlaylist(songId, currentSongId, null).asResult() - } - - suspend fun addSongToLast(songId: Int): TaskResult { - return service.addSongToCurrentPlaylist(songId, null, "last").asResult() - } -} diff --git a/app/src/main/java/org/blackcandy/android/data/PreferencesDataSource.kt b/app/src/main/java/org/blackcandy/android/data/PreferencesDataSource.kt deleted file mode 100644 index 2c5ffdf..0000000 --- a/app/src/main/java/org/blackcandy/android/data/PreferencesDataSource.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.blackcandy.android.data - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map - -class PreferencesDataSource( - private val dataStore: DataStore, -) { - companion object { - private val SERVER_ADDRESS_KEY = stringPreferencesKey("server_address") - } - - suspend fun getServerAddress(): String { - return 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] ?: "" } - } -} diff --git a/app/src/main/java/org/blackcandy/android/data/SystemInfoRepository.kt b/app/src/main/java/org/blackcandy/android/data/SystemInfoRepository.kt deleted file mode 100644 index 4da85fe..0000000 --- a/app/src/main/java/org/blackcandy/android/data/SystemInfoRepository.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.blackcandy.android.data - -import org.blackcandy.android.api.BlackCandyService -import org.blackcandy.android.models.SystemInfo -import org.blackcandy.android.utils.TaskResult - -class SystemInfoRepository( - private val service: BlackCandyService, -) { - suspend fun getSystemInfo(): TaskResult { - return 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 deleted file mode 100644 index 960e8bc..0000000 --- a/app/src/main/java/org/blackcandy/android/data/UserRepository.kt +++ /dev/null @@ -1,59 +0,0 @@ -package org.blackcandy.android.data - -import android.webkit.CookieManager -import androidx.datastore.core.DataStore -import io.ktor.client.HttpClient -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.android.models.User -import org.blackcandy.android.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, -) { - suspend fun login( - email: String, - password: String, - ): TaskResult { - try { - val response = service.createAuthentication(email, password).orThrow() - val serverAddress = preferencesDataSource.getServerAddress() - - response.cookies.forEach { - cookieManager.setCookie(serverAddress, it) - } - - cookieManager.flush() - userDataStore.updateData { response.user } - encryptedPreferencesDataSource.updateApiToken(response.token) - - // Clear previous cached auth token in http client - httpClient.plugin(Auth).providers - .filterIsInstance() - .first().clearToken() - - return TaskResult.Success(Unit) - } catch (e: Exception) { - return TaskResult.Failure(e.message) - } - } - - suspend fun logout() { - service.removeAuthentication() - encryptedPreferencesDataSource.removeApiToken() - cookieManager.removeAllCookies(null) - userDataStore.updateData { null } - } - - fun getCurrentUserFlow(): Flow { - return 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 deleted file mode 100644 index aa68b01..0000000 --- a/app/src/main/java/org/blackcandy/android/di/AppModule.kt +++ /dev/null @@ -1,248 +0,0 @@ -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 -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 -import io.ktor.client.plugins.UserAgent -import io.ktor.client.plugins.auth.Auth -import io.ktor.client.plugins.auth.providers.BearerTokens -import io.ktor.client.plugins.auth.providers.bearer -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.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 -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 -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 -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.koin.android.ext.koin.androidContext -import org.koin.androidx.viewmodel.dsl.viewModel -import org.koin.core.qualifier.named -import org.koin.dsl.module -import java.io.InputStream -import java.io.OutputStream - -val appModule = - module { - single { provideJson() } - single { provideCookieManager() } - 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 { EncryptedPreferencesDataSource(get()) } - - single { BlackCandyServiceImpl(get()) } - single { MusicServiceController(androidContext()) } - single { ServerAddressRepository(get()) } - single { SystemInfoRepository(get()) } - single { UserRepository(get(), get(), get(), get(named("UserDataStore")), get(), get()) } - single { CurrentPlaylistRepository(get()) } - single { FavoritePlaylistRepository(get()) } - - viewModel { LoginViewModel(get(), get(), get()) } - viewModel { MainViewModel(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()) } - } - -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, - encryptedPreferencesDataSource: EncryptedPreferencesDataSource, -): HttpClient { - return HttpClient { - expectSuccess = true - - install(UserAgent) { - agent = BLACK_CANDY_USER_AGENT - } - - install(ContentNegotiation) { - json(json) - } - - install(Auth) { - bearer { - loadTokens { - encryptedPreferencesDataSource.getApiToken()?.let { - BearerTokens(it, "") - } - } - } - } - - defaultRequest { - val serverAddress = - runBlocking { - preferencesDataSource.getServerAddress() - } - - url("$serverAddress/api/v1/") - } - - HttpResponseValidator { - handleResponseExceptionWithRequest { exception, _ -> - when (exception) { - is ClientRequestException -> { - val response = exception.response - - val apiError = - try { - json.decodeFromString(response.bodyAsText()) - } catch (e: Exception) { - null - } - - throw ApiException( - code = response.status.value, - message = apiError?.message ?: exception.message, - ) - } - - else -> { - throw ApiException( - code = null, - message = exception.message, - ) - } - } - } - } - } -} - -private fun provideDataStore(appContext: Context): DataStore { - return 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? { - return 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 provideCookieManager(): CookieManager { - return CookieManager.getInstance() -} - -private fun provideEncryptedSharedPreferences(appContext: Context): SharedPreferences { - return 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 { - val httpClient = OkHttpClient().newBuilder().build() - val apiToken = encryptedPreferencesDataSource.getApiToken() - - return DataSource.Factory { - val dataSource = - OkHttpDataSource.Factory(httpClient).createDataSource() - - dataSource.setRequestProperty("Authorization", "Token $apiToken") - - dataSource - } -} - -@OptIn(ExperimentalSerializationApi::class) -private fun provideJson() = - Json { - isLenient = true - ignoreUnknownKeys = true - namingStrategy = JsonNamingStrategy.SnakeCase - useAlternativeNames = false - } 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 3baea0c..0000000 --- a/app/src/main/java/org/blackcandy/android/fragments/navs/MainNavHostFragment.kt +++ /dev/null @@ -1,133 +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.sheets.AccountSheetFragment -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.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, - AccountSheetFragment::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/sheets/AccountSheetFragment.kt b/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt deleted file mode 100644 index b7db152..0000000 --- a/app/src/main/java/org/blackcandy/android/fragments/sheets/AccountSheetFragment.kt +++ /dev/null @@ -1,90 +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.models.MenuItem -import org.blackcandy.android.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() - 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 deleted file mode 100644 index 03809b5..0000000 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebBottomSheetFragment.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.blackcandy.android.fragments.web - -import dev.hotwire.turbo.fragments.TurboWebBottomSheetDialogFragment -import dev.hotwire.turbo.nav.TurboNavGraphDestination - -@TurboNavGraphDestination(uri = "turbo://fragment/web/bottom_sheet") -class WebBottomSheetFragment : TurboWebBottomSheetDialogFragment() 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 deleted file mode 100644 index b0d88eb..0000000 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebFragment.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.blackcandy.android.fragments.web - -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 org.blackcandy.android.R -import org.blackcandy.android.viewmodels.WebViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -@TurboNavGraphDestination(uri = "turbo://fragment/web") -open class WebFragment : TurboWebFragment() { - private val viewModel: WebViewModel by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - return inflater.inflate(R.layout.fragment_web, container, false) - } - - override fun onVisitErrorReceived( - location: String, - errorCode: Int, - ) { - if (errorCode == 401) { - viewModel.logout() - } else { - super.onVisitErrorReceived(location, errorCode) - } - } -} 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 deleted file mode 100644 index ddbcafc..0000000 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebHomeFragment.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.blackcandy.android.fragments.web - -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 org.blackcandy.android.databinding.FragmentWebHomeBinding -import org.blackcandy.android.viewmodels.HomeViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -@TurboNavGraphDestination(uri = "turbo://fragment/web/home") -class WebHomeFragment : WebFragment() { - private val viewModel: HomeViewModel by viewModel() - private var _binding: FragmentWebHomeBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _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 deleted file mode 100644 index fe6097a..0000000 --- a/app/src/main/java/org/blackcandy/android/fragments/web/WebLibraryFragment.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.blackcandy.android.fragments.web - -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.databinding.FragmentWebLibraryBinding - -@TurboNavGraphDestination(uri = "turbo://fragment/web/library") -open class WebLibraryFragment : WebFragment() { - private var _binding: FragmentWebLibraryBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - _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/media/MusicServiceController.kt b/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt deleted file mode 100644 index ca7309e..0000000 --- a/app/src/main/java/org/blackcandy/android/media/MusicServiceController.kt +++ /dev/null @@ -1,255 +0,0 @@ -package org.blackcandy.android.media - -import android.content.ComponentName -import android.content.Context -import androidx.media3.common.Player -import androidx.media3.session.MediaController -import androidx.media3.session.SessionToken -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListUpdateCallback -import com.google.common.util.concurrent.MoreExecutors -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -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.android.models.PlaybackMode -import org.blackcandy.android.models.Song -import kotlin.time.Duration.Companion.milliseconds - -class MusicServiceController( - private val appContext: Context, -) { - private var controller: MediaController? = null - private val _musicState = MutableStateFlow(MusicState()) - - val musicState = _musicState.asStateFlow() - val currentPosition = - flow { - while (currentCoroutineContext().isActive) { - val currentPosition = (controller?.currentPosition ?: 0) / 1000.0 - emit(currentPosition) - delay(1.milliseconds) - } - } - - fun initMediaController(onInitialized: () -> Unit) { - val controllerFuture = - MediaController.Builder( - appContext, - SessionToken(appContext, ComponentName(appContext, MusicService::class.java)), - ).buildAsync() - - controllerFuture.addListener({ - controller = controllerFuture.get() - controller?.addListener( - object : Player.Listener { - override fun onEvents( - player: Player, - events: Player.Events, - ) { - if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { - _musicState.update { it.copy(playbackState = player.playbackState) } - - if (player.playbackState == Player.STATE_ENDED) { - player.seekToDefaultPosition(0) - player.stop() - } - } - - if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)) { - updateCurrentSong() - } - - if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) { - _musicState.update { it.copy(isPlaying = player.isPlaying) } - } - } - }, - ) - onInitialized() - }, MoreExecutors.directExecutor()) - } - - 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)) - } - - 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) } - } - - fun play() { - controller?.play() - } - - fun pause() { - controller?.pause() - } - - fun next() { - controller?.run { - seekToNext() - play() - } - } - - fun previous() { - controller?.run { - seekToPrevious() - play() - } - } - - fun playOn(index: Int) { - controller?.run { - seekToDefaultPosition(index) - play() - } - } - - fun seekTo(seconds: Double) { - controller?.seekTo((seconds * 1000).toLong()) - } - - fun clearPlaylist() { - updatePlaylist(emptyList()) - } - - fun deleteSongFromPlaylist(song: Song) { - val songs = musicState.value.playlist.toMutableList().apply { remove(song) } - updatePlaylist(songs) - } - - fun updateSongInPlaylist(song: Song) { - val songs = musicState.value.playlist.map { if (it.id == song.id) song else it } - updatePlaylist(songs) - - if (song.id.toString() == controller?.currentMediaItem?.mediaId) { - updateCurrentSong() - } - } - - fun moveSongInPlaylist( - from: Int, - to: Int, - ) { - val songs = musicState.value.playlist.toMutableList().apply { add(to, removeAt(from)) } - updatePlaylist(songs) - } - - fun setPlaybackMode(playbackMode: PlaybackMode) { - when (playbackMode) { - PlaybackMode.NO_REPEAT -> { - controller?.run { - setRepeatMode(Player.REPEAT_MODE_OFF) - setShuffleModeEnabled(false) - } - } - - PlaybackMode.REPEAT -> { - controller?.run { - setRepeatMode(Player.REPEAT_MODE_ALL) - setShuffleModeEnabled(false) - } - } - - PlaybackMode.REPEAT_ONE -> { - controller?.run { - setRepeatMode(Player.REPEAT_MODE_ONE) - setShuffleModeEnabled(false) - } - } - - PlaybackMode.SHUFFLE -> { - controller?.run { - setRepeatMode(Player.REPEAT_MODE_OFF) - setShuffleModeEnabled(true) - } - } - } - - _musicState.update { it.copy(playbackMode = playbackMode) } - } - - fun getSongIndex(songId: Int): Int { - return 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) } - } else { - musicState.value.playlist.toMutableList().apply { add(0, song) } - } - - updatePlaylist(songs) - - return songs.indexOf(song) - } - - fun addSongToLast(song: Song) { - val songs = musicState.value.playlist.toMutableList().apply { add(song) } - updatePlaylist(songs) - } - - private fun updateCurrentSong() { - val currentMediaId = controller?.currentMediaItem?.mediaId - val currentSong = musicState.value.playlist.find { it.id.toString() == currentMediaId } - - _musicState.update { it.copy(currentSong = currentSong) } - } -} 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 0c50783..0000000 --- a/app/src/main/java/org/blackcandy/android/models/AlertMessage.kt +++ /dev/null @@ -1,11 +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/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/Song.kt b/app/src/main/java/org/blackcandy/android/models/Song.kt deleted file mode 100644 index 1f53452..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 { - return 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/utils/Constants.kt b/app/src/main/java/org/blackcandy/android/utils/Constants.kt deleted file mode 100644 index 0f67071..0000000 --- a/app/src/main/java/org/blackcandy/android/utils/Constants.kt +++ /dev/null @@ -1,4 +0,0 @@ -package org.blackcandy.android.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/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/utils/Theme.kt b/app/src/main/java/org/blackcandy/android/utils/Theme.kt deleted file mode 100644 index b8d4548..0000000 --- a/app/src/main/java/org/blackcandy/android/utils/Theme.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.blackcandy.android.utils - -enum class Theme { - DARK, - LIGHT, - AUTO, -} diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt deleted file mode 100644 index 2ed9c2e..0000000 --- a/app/src/main/java/org/blackcandy/android/viewmodels/AccountSheetViewModel.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.blackcandy.android.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.android.data.ServerAddressRepository -import org.blackcandy.android.data.UserRepository -import org.blackcandy.android.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/app/src/main/java/org/blackcandy/android/viewmodels/HomeViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/HomeViewModel.kt deleted file mode 100644 index 9c27e14..0000000 --- a/app/src/main/java/org/blackcandy/android/viewmodels/HomeViewModel.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.blackcandy.android.viewmodels - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.runBlocking -import org.blackcandy.android.data.ServerAddressRepository - -class HomeViewModel( - private val serverAddressRepository: ServerAddressRepository, -) : ViewModel() { - val serverAddress = - runBlocking { - serverAddressRepository.getServerAddress() - } -} diff --git a/app/src/main/java/org/blackcandy/android/viewmodels/WebViewModel.kt b/app/src/main/java/org/blackcandy/android/viewmodels/WebViewModel.kt deleted file mode 100644 index 23013f8..0000000 --- a/app/src/main/java/org/blackcandy/android/viewmodels/WebViewModel.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.blackcandy.android.viewmodels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch -import org.blackcandy.android.data.UserRepository - -class WebViewModel( - val userRepository: UserRepository, -) : ViewModel() { - fun logout() { - viewModelScope.launch { - userRepository.logout() - } - } -} 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/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..a2f53c8 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,10 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + 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 + 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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..a5728eb --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,93 @@ +[versions] +androidxActivity = "1.9.0" +androidxWindow = "1.2.0" +androidxNavigationCompose = "2.7.7" +androidxComposeBom = "2024.05.00" +androidxLifecycle = "2.8.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" +annotationExperimental = "1.5.1" +coilCompose = "2.5.0" +googleAccompanistThemeadapterMaterial3 = "0.30.1" +googleMaterial = "1.12.0" +junit = "4.13.2" +koin = "4.0.0" +kotlinxSerializationJson = "1.5.1" +ktor = "2.3.4" +media3 = "1.3.1" +reorderable = "1.3.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" +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" } +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" } +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 = "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" } +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 = "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" } +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" } +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" } +hotwire-core = { module = "dev.hotwire:core", version.ref = "hotwire" } +hotwire-navigation-fragments = { module = "dev.hotwire:navigation-fragments", version.ref = "hotwire" } + +[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" } +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" } +skie = { id = "co.touchlab.skie", version.ref = "skie" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d67a4cd..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.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..144afbd --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,408 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + 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 */ + +/* 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 = ( + 64D93BEB2F2AFACF00B13EAA /* HotwireNative in Frameworks */, + 646F4A7B2F35C74300A4FAFB /* LNPopupUI in Frameworks */, + ); + 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 = ( + 64D93BEA2F2AFACF00B13EAA /* HotwireNative */, + 646F4A7A2F35C74300A4FAFB /* LNPopupUI */, + ); + 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; + packageReferences = ( + 64D93BE92F2AFACF00B13EAA /* XCRemoteSwiftPackageReference "hotwire-native-ios" */, + 646F4A792F35C74300A4FAFB /* XCRemoteSwiftPackageReference "LNPopupUI" */, + ); + 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_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", + ); + 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_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", + ); + 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_EMIT_LOC_STRINGS = YES; + 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; + SWIFT_EMIT_LOC_STRINGS = YES; + 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 */ + +/* 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"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.2; + }; + }; +/* 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" */; + productName = HotwireNative; + }; +/* End XCSwiftPackageProductDependency 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.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..dc3d15f --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "d188413bb6cdb3bcd359b60173898d2ad2fd8332a285dda182d506d02306bb23", + "pins" : [ + { + "identity" : "hotwire-native-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hotwired/hotwire-native-ios", + "state" : { + "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/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift new file mode 100644 index 0000000..16a0a21 --- /dev/null +++ b/iosApp/iosApp/AppDelegate.swift @@ -0,0 +1,46 @@ +import UIKit +import HotwireNative +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() + + configureHotwire() + + 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. + } + + 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, + SearchComponent.self + ]) + } +} 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/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 0000000..7f35a2a Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/logo.png differ 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 0000000..b7944a7 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/logo@2x.png differ diff --git a/iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/logo@3x.png b/iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/logo@3x.png new file mode 100644 index 0000000..4637301 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/BlackCandyLogo.imageset/logo@3x.png differ 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/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/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/Info.plist b/iosApp/iosApp/Info.plist new file mode 100644 index 0000000..cdd5d70 --- /dev/null +++ b/iosApp/iosApp/Info.plist @@ -0,0 +1,36 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UILaunchScreen + + UITabBar + + UIImageName + + + + + diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings new file mode 100644 index 0000000..54329eb --- /dev/null +++ b/iosApp/iosApp/Localizable.xcstrings @@ -0,0 +1,303 @@ +{ + "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" + } + } + } + }, + "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/MainTabs.swift b/iosApp/iosApp/MainTabs.swift new file mode 100644 index 0000000..b6de590 --- /dev/null +++ b/iosApp/iosApp/MainTabs.swift @@ -0,0 +1,9 @@ +import Foundation +import HotwireNative + +func buildMainTabs(serverAddress: String) -> [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/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/SceneDelegate.swift b/iosApp/iosApp/SceneDelegate.swift new file mode 100644 index 0000000..762a2fc --- /dev/null +++ b/iosApp/iosApp/SceneDelegate.swift @@ -0,0 +1,59 @@ +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 windowScene = scene as? UIWindowScene else { return } + + let window = UIWindow(windowScene: windowScene) + + if isLoggedIn { + window.rootViewController = MainViewController(serverAddress: viewModel.serverAddress) + } else { + window.rootViewController = LoginViewController() + } + + self.window = window + + window.makeKeyAndVisible() + } + + 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/Utils/Alert.swift b/iosApp/iosApp/Utils/Alert.swift new file mode 100644 index 0000000..89eb81b --- /dev/null +++ b/iosApp/iosApp/Utils/Alert.swift @@ -0,0 +1,46 @@ +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?, onShown: @escaping () -> Void) -> some View { + let alertMessage = AlertMessageCover.toString(message) + + alert( + 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/Utils/MenuItem.swift b/iosApp/iosApp/Utils/MenuItem.swift new file mode 100644 index 0000000..4e1fe42 --- /dev/null +++ b/iosApp/iosApp/Utils/MenuItem.swift @@ -0,0 +1,23 @@ +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/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/LoginViewController.swift b/iosApp/iosApp/ViewControllers/LoginViewController.swift new file mode 100644 index 0000000..618f954 --- /dev/null +++ b/iosApp/iosApp/ViewControllers/LoginViewController.swift @@ -0,0 +1,12 @@ +import Foundation +import SwiftUI + +class LoginViewController: UIHostingController { + init() { + super.init(rootView: LoginScreen()) + } + + @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..877eefa --- /dev/null +++ b/iosApp/iosApp/ViewControllers/MainViewController.swift @@ -0,0 +1,40 @@ +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) + + preferredDisplayMode = .oneBesideSecondary + preferredSplitBehavior = .tile + presentsWithGesture = false + delegate = self + + let tabBarController = HotwireTabBarController(navigatorDelegate: self) + 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) { + 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/ViewControllers/WebViewController.swift b/iosApp/iosApp/ViewControllers/WebViewController.swift new file mode 100644 index 0000000..6fd673a --- /dev/null +++ b/iosApp/iosApp/ViewControllers/WebViewController.swift @@ -0,0 +1,7 @@ +import Foundation +import HotwireNative +import sharedKit + +class WebViewController: HotwireWebViewController { + let viewModel: WebViewModel = KoinHelper().getWebViewModel() +} 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/Views/Login/LoginAuthenticationForm.swift b/iosApp/iosApp/Views/Login/LoginAuthenticationForm.swift new file mode 100644 index 0000000..deb7342 --- /dev/null +++ b/iosApp/iosApp/Views/Login/LoginAuthenticationForm.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct LoginAuthenticationForm: View { + 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: Binding( + get: { email }, + set: { email in onEmailChanged(email) } + )) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.emailAddress) + + SecureField("label.password", text: Binding( + get: { password }, + set: { password in onPasswordChanged(password) } + )) + } + + Button(action: { + onLoginButtonClicked() + }, 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..2113486 --- /dev/null +++ b/iosApp/iosApp/Views/LoginScreen.swift @@ -0,0 +1,54 @@ +import SwiftUI +import sharedKit + +struct LoginScreen: View { + private let viewModel: LoginViewModel = KoinHelper().getLoginViewModel() + + @State private var path = NavigationPath() + + var body: some View { + Observing(viewModel.uiState) { uiState in + 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: { + viewModel.login(onSuccess: { serverAddress in + changeRootViewController(viewController: MainViewController(serverAddress: serverAddress)) + }) + }, + onEmailChanged: { email in + viewModel.updateEmail(email: email) + }, + onPasswordChanged: { password in + viewModel.updatePassword(password: password) + } + ) + } + } + } + .navigationViewStyle(.stack) + .alertMessage(uiState.alertMessage, onShown: { + viewModel.alertMessageShown() + }) + } + } +} + +extension LoginScreen { + enum Route: Hashable { + case authentication + } +} diff --git a/iosApp/iosApp/Views/Player/FullPlayer.swift b/iosApp/iosApp/Views/Player/FullPlayer.swift new file mode 100644 index 0000000..0c86e0c --- /dev/null +++ b/iosApp/iosApp/Views/Player/FullPlayer.swift @@ -0,0 +1,43 @@ +import SwiftUI +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/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..90ba665 --- /dev/null +++ b/iosApp/iosApp/Views/Player/PlayerControl.swift @@ -0,0 +1,96 @@ +import SwiftUI +import sharedKit + +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 { + 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/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/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 new file mode 100644 index 0000000..e919160 --- /dev/null +++ b/iosApp/iosApp/Views/PlayerScreen.swift @@ -0,0 +1,97 @@ +import SwiftUI +import LNPopupUI +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 + 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) + .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 + } + } +} + +extension PlayerScreen { + enum Route: Hashable { + case playlist + } +} 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/settings.gradle b/settings.gradle.kts similarity index 88% rename from settings.gradle rename to settings.gradle.kts index 79e7716..de2afa6 100644 --- a/settings.gradle +++ b/settings.gradle.kts @@ -13,4 +13,5 @@ dependencyResolutionManagement { } } rootProject.name = "BlackCandy" -include ':app' +include(":androidApp") +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..e5f0bf0 --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,126 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.android.lint) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.skie) +} + +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 = 28 + + 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.ktor.client.core) + 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) + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.koin.core) + implementation(libs.koin.compose) + implementation(libs.koin.compose.viewmodel) + // Add KMP dependencies here + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.koin.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. + implementation(libs.ktor.client.okhttp) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.session) + implementation(libs.androidx.media3.datasource.okhttp) + implementation(libs.androidx.recyclerview) + implementation(libs.koin.android) + implementation(libs.androidx.security.crypto) + } + } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.runner) + implementation(libs.androidx.core) + implementation(libs.androidx.junit) + } + } + + 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 + // on common by default and will correctly pull the iOS artifacts of any + // KMP dependencies declared in commonMain. + } + } + } +} + +skie { + features { + enableSwiftUIObservingPreview = true + } +} 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..963a61a --- /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.assertEquals +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/data/EncryptedPreferencesDataSource.kt b/shared/src/androidMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt similarity index 63% 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 07de68c..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,26 +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? { - return 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/di/PlatformModule.kt b/shared/src/androidMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt new file mode 100644 index 0000000..b57147c --- /dev/null +++ b/shared/src/androidMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt @@ -0,0 +1,61 @@ +package org.blackcandy.shared.di + +import android.content.Context +import android.content.SharedPreferences +import androidx.datastore.core.DataStore +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 okhttp3.OkHttpClient +import org.blackcandy.shared.data.EncryptedDataSource +import org.blackcandy.shared.media.MusicServiceController +import org.koin.android.ext.koin.androidContext +import org.koin.core.qualifier.named +import org.koin.dsl.module + +actual val platformModule = + module { + single { provideEncryptedSharedPreferences(androidContext()) } + single(named("PreferencesDataStore")) { provideDataStore(androidContext()) } + single { provideDataSourceFactory(get()) } + + single { EncryptedDataSource(get()) } + single { MusicServiceController(androidContext()) } + } + +private const val DATASTORE_PREFERENCES_NAME = "user_preferences" +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 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/app/src/main/java/org/blackcandy/android/media/MusicService.kt b/shared/src/androidMain/kotlin/org/blackcandy/shared/media/MusicService.kt similarity index 80% 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 512f157..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 @@ -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/shared/src/androidMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt b/shared/src/androidMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt new file mode 100644 index 0000000..0454c3d --- /dev/null +++ b/shared/src/androidMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt @@ -0,0 +1,293 @@ +package org.blackcandy.shared.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 +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import com.google.common.util.concurrent.MoreExecutors +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import org.blackcandy.shared.models.Song +import kotlin.time.Duration.Companion.milliseconds + +actual class MusicServiceController( + private val appContext: Context, +) { + private var controller: MediaController? = null + private val _musicState = MutableStateFlow(MusicState()) + + actual val musicState = _musicState.asStateFlow() + actual val currentPosition = + flow { + while (currentCoroutineContext().isActive) { + val currentPosition = (controller?.currentPosition ?: 0) / 1000.0 + emit(currentPosition) + delay(1.milliseconds) + } + } + + actual fun initMediaController(onInitialized: () -> Unit) { + val controllerFuture = + MediaController + .Builder( + appContext, + SessionToken(appContext, ComponentName(appContext, MusicService::class.java)), + ).buildAsync() + + controllerFuture.addListener({ + controller = controllerFuture.get() + controller?.addListener( + object : Player.Listener { + override fun onEvents( + player: Player, + events: Player.Events, + ) { + if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { + _musicState.update { it.copy(playbackState = toPlaybackState(player.playbackState)) } + + if (player.playbackState == Player.STATE_ENDED) { + player.seekToDefaultPosition(0) + player.stop() + } + } + + if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)) { + updateCurrentSong() + } + + if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) { + _musicState.update { it.copy(isPlaying = player.isPlaying) } + } + } + }, + ) + onInitialized() + }, MoreExecutors.directExecutor()) + } + + actual fun updatePlaylist(songs: List) { + val mediaItems = songs.map { toMediaItem(it) } + + 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)) + } + + 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) } + } + + actual fun play() { + controller?.play() + } + + actual fun pause() { + controller?.pause() + } + + actual fun next() { + controller?.run { + seekToNext() + play() + } + } + + actual fun previous() { + controller?.run { + seekToPrevious() + play() + } + } + + actual fun playOn(index: Int) { + controller?.run { + seekToDefaultPosition(index) + play() + } + } + + actual fun seekTo(seconds: Double) { + controller?.seekTo((seconds * 1000).toLong()) + } + + actual fun clearPlaylist() { + updatePlaylist(emptyList()) + } + + actual fun deleteSongFromPlaylist(song: Song) { + val songs = + musicState.value.playlist + .toMutableList() + .apply { remove(song) } + updatePlaylist(songs) + } + + actual fun updateSongInPlaylist(song: Song) { + val songs = musicState.value.playlist.map { if (it.id == song.id) song else it } + updatePlaylist(songs) + + if (song.id.toString() == controller?.currentMediaItem?.mediaId) { + updateCurrentSong() + } + } + + actual fun moveSongInPlaylist( + from: Int, + to: Int, + ) { + val songs = + musicState.value.playlist + .toMutableList() + .apply { add(to, removeAt(from)) } + updatePlaylist(songs) + } + + actual fun setPlaybackMode(playbackMode: PlaybackMode) { + when (playbackMode) { + PlaybackMode.NO_REPEAT -> { + controller?.run { + setRepeatMode(Player.REPEAT_MODE_OFF) + setShuffleModeEnabled(false) + } + } + + PlaybackMode.REPEAT -> { + controller?.run { + setRepeatMode(Player.REPEAT_MODE_ALL) + setShuffleModeEnabled(false) + } + } + + PlaybackMode.REPEAT_ONE -> { + controller?.run { + setRepeatMode(Player.REPEAT_MODE_ONE) + setShuffleModeEnabled(false) + } + } + + PlaybackMode.SHUFFLE -> { + controller?.run { + setRepeatMode(Player.REPEAT_MODE_OFF) + setShuffleModeEnabled(true) + } + } + } + + _musicState.update { it.copy(playbackMode = playbackMode) } + } + + actual fun getSongIndex(songId: Int): Int = musicState.value.playlist.indexOfFirst { it.id == songId } + + actual 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) } + } else { + musicState.value.playlist + .toMutableList() + .apply { add(0, song) } + } + + updatePlaylist(songs) + + return songs.indexOf(song) + } + + actual fun addSongToLast(song: Song) { + val songs = + musicState.value.playlist + .toMutableList() + .apply { add(song) } + updatePlaylist(songs) + } + + private fun updateCurrentSong() { + val currentMediaId = controller?.currentMediaItem?.mediaId + val currentSong = musicState.value.playlist.find { it.id.toString() == currentMediaId } + + _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() + + 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/androidMain/kotlin/org/blackcandy/shared/utils/Constants.android.kt b/shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Constants.android.kt new file mode 100644 index 0000000..7521e0b --- /dev/null +++ b/shared/src/androidMain/kotlin/org/blackcandy/shared/utils/Constants.android.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/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/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/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/api/BlackCandyService.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/api/BlackCandyService.kt similarity index 75% 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 87d1a5e..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,10 @@ 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.models.AuthenticationResponse +import org.blackcandy.shared.models.Song +import org.blackcandy.shared.models.SystemInfo +import org.blackcandy.shared.models.User interface BlackCandyService { suspend fun getSystemInfo(): ApiResponse @@ -63,8 +63,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 +78,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 +114,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/shared/src/commonMain/kotlin/org/blackcandy/shared/data/CurrentPlaylistRepository.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/CurrentPlaylistRepository.kt new file mode 100644 index 0000000..41d68e0 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/CurrentPlaylistRepository.kt @@ -0,0 +1,33 @@ +package org.blackcandy.shared.data + +import org.blackcandy.shared.api.BlackCandyService +import org.blackcandy.shared.models.Song +import org.blackcandy.shared.utils.TaskResult + +class CurrentPlaylistRepository( + private val service: BlackCandyService, +) { + suspend fun getSongs(): TaskResult> = service.getSongsFromCurrentPlaylist().asResult() + + suspend fun removeAllSongs(): TaskResult = service.removeAllSongsFromCurrentPlaylist().asResult() + + suspend fun removeSong(songId: Int): TaskResult = service.removeSongFromCurrentPlaylist(songId).asResult() + + suspend fun moveSong( + songId: Int, + destinationSongId: Int, + ): TaskResult = service.moveSongInCurrentPlaylist(songId, destinationSongId).asResult() + + suspend fun replaceWithAlbumSongs(albumId: Int): TaskResult> = + service.replaceCurrentPlaylistWithAlbumSongs(albumId).asResult() + + suspend fun replaceWithPlaylistSongs(playlistId: Int): TaskResult> = + service.replaceCurrentPlaylistWithPlaylistSongs(playlistId).asResult() + + suspend fun addSongToNext( + songId: Int, + currentSongId: Int, + ): TaskResult = service.addSongToCurrentPlaylist(songId, currentSongId, null).asResult() + + suspend fun addSongToLast(songId: Int): TaskResult = service.addSongToCurrentPlaylist(songId, null, "last").asResult() +} 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/FavoritePlaylistRepository.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/FavoritePlaylistRepository.kt similarity index 63% 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 44507c3..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,8 +1,8 @@ -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.android.utils.TaskResult +import org.blackcandy.shared.api.BlackCandyService +import org.blackcandy.shared.models.Song +import org.blackcandy.shared.utils.TaskResult class FavoritePlaylistRepository( private val service: BlackCandyService, diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/data/PreferencesDataSource.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/PreferencesDataSource.kt new file mode 100644 index 0000000..c42ca7e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/PreferencesDataSource.kt @@ -0,0 +1,37 @@ +package org.blackcandy.shared.data + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +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/app/src/main/java/org/blackcandy/android/data/ServerAddressRepository.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/ServerAddressRepository.kt similarity index 51% 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 cbfe971..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,19 +1,15 @@ -package org.blackcandy.android.data +package org.blackcandy.shared.data 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/shared/src/commonMain/kotlin/org/blackcandy/shared/data/SystemInfoRepository.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/SystemInfoRepository.kt new file mode 100644 index 0000000..56ff092 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/SystemInfoRepository.kt @@ -0,0 +1,11 @@ +package org.blackcandy.shared.data + +import org.blackcandy.shared.api.BlackCandyService +import org.blackcandy.shared.models.SystemInfo +import org.blackcandy.shared.utils.TaskResult + +class SystemInfoRepository( + private val service: BlackCandyService, +) { + suspend fun getSystemInfo(): TaskResult = service.getSystemInfo().asResult() +} diff --git a/shared/src/commonMain/kotlin/org/blackcandy/shared/data/UserRepository.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/UserRepository.kt new file mode 100644 index 0000000..b722de4 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/data/UserRepository.kt @@ -0,0 +1,56 @@ +package org.blackcandy.shared.data + +import androidx.datastore.core.DataStore +import io.ktor.client.HttpClient +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.shared.api.BlackCandyService +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 preferencesDataSource: PreferencesDataSource, + private val encryptedDataSource: EncryptedDataSource, +) { + suspend fun login( + email: String, + password: String, + ): TaskResult { + try { + val response = service.createAuthentication(email, password).orThrow() + val serverAddress = preferencesDataSource.getServerAddress() + + Cookies.update(serverAddress, response.cookies) + preferencesDataSource.updateCurrentUser(response.user) + encryptedDataSource.updateApiToken(response.token) + + // Clear previous cached auth token in http client + httpClient + .plugin(Auth) + .providers + .filterIsInstance() + .first() + .clearToken() + + return TaskResult.Success(serverAddress) + } catch (e: Exception) { + return TaskResult.Failure(e.message) + } + } + + suspend fun logout() { + service.removeAuthentication() + encryptedDataSource.removeApiToken() + Cookies.clean() + preferencesDataSource.updateCurrentUser(null) + } + + suspend fun getCurrentUser(): User? = preferencesDataSource.getCurrentUser() + + fun getCurrentUserFlow(): Flow = preferencesDataSource.getCurrentUserFlow() +} 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/shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt new file mode 100644 index 0000000..4ec74e1 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/di/CommonModule.kt @@ -0,0 +1,133 @@ +package org.blackcandy.shared.di + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.HttpResponseValidator +import io.ktor.client.plugins.UserAgent +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.bearer +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.runBlocking +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNamingStrategy +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.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.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 +import org.blackcandy.shared.viewmodels.WebViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val commonModule = + module { + single { provideJson() } + single { provideHttpClient(get(), get(), get()) } + + single { PreferencesDataSource(get(named("PreferencesDataStore"))) } + single { BlackCandyServiceImpl(get()) } + single { ServerAddressRepository(get()) } + single { SystemInfoRepository(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()) } + viewModel { WebViewModel(get(), get(), get()) } + viewModel { MusicServiceViewModel(get(), get()) } + } + +private fun provideHttpClient( + json: Json, + preferencesDataSource: PreferencesDataSource, + encryptedDataSource: EncryptedDataSource, +): HttpClient = + HttpClient { + expectSuccess = true + + install(UserAgent) { + agent = BLACK_CANDY_USER_AGENT + } + + install(ContentNegotiation) { + json(json) + } + + install(Auth) { + bearer { + loadTokens { + encryptedDataSource.getApiToken()?.let { + BearerTokens(it, "") + } + } + } + } + + defaultRequest { + val serverAddress = + runBlocking { + preferencesDataSource.getServerAddress() + } + + url("$serverAddress/api/v1/") + } + + HttpResponseValidator { + handleResponseExceptionWithRequest { exception, _ -> + when (exception) { + is ClientRequestException -> { + val response = exception.response + + val apiError = + try { + json.decodeFromString(response.bodyAsText()) + } catch (e: Exception) { + null + } + + throw ApiException( + code = response.status.value, + message = apiError?.message ?: exception.message, + ) + } + + else -> { + throw ApiException( + code = null, + message = exception.message, + ) + } + } + } + } + } + +@OptIn(ExperimentalSerializationApi::class) +private fun provideJson() = + Json { + isLenient = true + ignoreUnknownKeys = true + namingStrategy = JsonNamingStrategy.SnakeCase + useAlternativeNames = false + } 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 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 7063ee5..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,14 +1,14 @@ -package org.blackcandy.android.models +package org.blackcandy.shared.media -import androidx.media3.common.Player +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/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/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, +} 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() +} 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/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, + ) +} 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/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Constants.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Constants.kt new file mode 100644 index 0000000..36f58ca --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Constants.kt @@ -0,0 +1,4 @@ +package org.blackcandy.shared.utils + +expect val BLACK_CANDY_USER_AGENT: String +const val NONE_DURATION_TEXT = "--:--" 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 +} 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 55% 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..e658da9 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 @@ -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/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/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Theme.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Theme.kt new file mode 100644 index 0000000..df07116 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/utils/Theme.kt @@ -0,0 +1,9 @@ +package org.blackcandy.shared.utils + +enum class Theme { + DARK, + LIGHT, + AUTO, +} + +expect fun updateAppTheme(theme: Theme) 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 75% 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 efa7818..ade35f9 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 @@ -9,13 +8,12 @@ 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.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.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 data class LoginUiState( val serverAddress: String? = null, @@ -67,8 +65,8 @@ class LoginViewModel( serverAddress = "http://$serverAddress" } - if (!Patterns.WEB_URL.matcher(serverAddress).matches()) { - _uiState.update { it.copy(alertMessage = AlertMessage.StringResource(R.string.invalid_server_address)) } + if (!isValidUrl(serverAddress)) { + _uiState.update { it.copy(alertMessage = AlertMessage.LocalizedString(AlertMessage.DefinedMessages.INVALID_SERVER_ADDRESS)) } return } @@ -78,7 +76,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 @@ -97,10 +99,12 @@ 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 + is TaskResult.Success -> { + onSuccess(result.data) + } is TaskResult.Failure -> { _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } @@ -112,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/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MainViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MainViewModel.kt new file mode 100644 index 0000000..a465281 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MainViewModel.kt @@ -0,0 +1,25 @@ +package org.blackcandy.shared.viewmodels + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.runBlocking +import org.blackcandy.shared.data.ServerAddressRepository +import org.blackcandy.shared.data.UserRepository + +class MainViewModel( + private val userRepository: UserRepository, + private val serverAddressRepository: ServerAddressRepository, +) : ViewModel() { + var selectedTabIndex = 0 + + val currentUserFlow = userRepository.getCurrentUserFlow() + + val currentUser = + runBlocking { + userRepository.getCurrentUser() + } + + val serverAddress = + runBlocking { + serverAddressRepository.getServerAddress() + } +} 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 82% 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 1ce6c01..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,7 +1,7 @@ -package org.blackcandy.android.viewmodels +package org.blackcandy.shared.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/MainViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MusicServiceViewModel.kt similarity index 53% rename from app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MusicServiceViewModel.kt index f6b72b5..ef1cf73 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/MainViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/MusicServiceViewModel.kt @@ -1,24 +1,16 @@ -package org.blackcandy.android.viewmodels +package org.blackcandy.shared.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.android.utils.TaskResult +import org.blackcandy.shared.data.CurrentPlaylistRepository +import org.blackcandy.shared.media.MusicServiceController +import org.blackcandy.shared.utils.TaskResult -class MainViewModel( - userRepository: UserRepository, +class MusicServiceViewModel( 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() 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 81% 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 0478a54..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 @@ -8,12 +8,12 @@ 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.android.utils.TaskResult +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 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/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/WebViewModel.kt similarity index 78% rename from app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt rename to shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/WebViewModel.kt index 201f5d9..7d92a0d 100644 --- a/app/src/main/java/org/blackcandy/android/viewmodels/NavHostViewModel.kt +++ b/shared/src/commonMain/kotlin/org/blackcandy/shared/viewmodels/WebViewModel.kt @@ -1,39 +1,43 @@ -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 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.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.android.utils.TaskResult -import org.blackcandy.android.utils.Theme - -data class NavHostUiState( +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 NavHostViewModel( - private val serverAddressRepository: ServerAddressRepository, +class WebViewModel( + private val userRepository: UserRepository, private val currentPlaylistRepository: CurrentPlaylistRepository, private val musicServiceController: MusicServiceController, ) : ViewModel() { - private val _uiState = MutableStateFlow(NavHostUiState()) + private val _uiState = MutableStateFlow(WebUiState()) val uiState = _uiState.asStateFlow() - val serverAddress = - runBlocking { - serverAddressRepository.getServerAddress() + fun logout(onSuccess: () -> Unit = {}) { + viewModelScope.launch { + userRepository.logout() + onSuccess() } + } + + fun showFlashMessage(message: String) { + _uiState.update { it.copy(alertMessage = AlertMessage.String(message)) } + } fun alertMessageShown() { _uiState.update { it.copy(alertMessage = null) } @@ -41,17 +45,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) } } @@ -61,6 +55,7 @@ class NavHostViewModel( is TaskResult.Success -> { playSongs(result.data) } + is TaskResult.Failure -> { _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } } @@ -68,12 +63,16 @@ class NavHostViewModel( } } - fun playPlaylist(playlistId: Int) { + fun playAlbumBeginWith( + albumId: Int, + songId: Int, + ) { viewModelScope.launch { - when (val result = currentPlaylistRepository.replaceWithPlaylistSongs(playlistId)) { + when (val result = currentPlaylistRepository.replaceWithAlbumSongs(albumId)) { is TaskResult.Success -> { - playSongs(result.data) + playSongsBeginWith(result.data, songId) } + is TaskResult.Failure -> { _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } } @@ -81,15 +80,13 @@ class NavHostViewModel( } } - fun playAlbumBeginWith( - albumId: Int, - songId: Int, - ) { + fun playPlaylist(playlistId: Int) { viewModelScope.launch { - when (val result = currentPlaylistRepository.replaceWithAlbumSongs(albumId)) { + when (val result = currentPlaylistRepository.replaceWithPlaylistSongs(playlistId)) { is TaskResult.Success -> { - playSongsBeginWith(result.data, songId) + playSongs(result.data) } + is TaskResult.Failure -> { _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } } @@ -106,6 +103,7 @@ class NavHostViewModel( is TaskResult.Success -> { playSongsBeginWith(result.data, songId) } + is TaskResult.Failure -> { _uiState.update { it.copy(alertMessage = AlertMessage.String(result.message)) } } @@ -130,7 +128,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 -> { @@ -148,7 +150,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 -> { @@ -163,7 +165,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 -> { @@ -173,10 +175,6 @@ class NavHostViewModel( } } - fun showFlashMessage(message: String) { - _uiState.update { it.copy(alertMessage = AlertMessage.String(message)) } - } - private fun playSongs(songs: List) { musicServiceController.updatePlaylist(songs) musicServiceController.playOn(0) 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..2cd7222 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/KoinHelper.kt @@ -0,0 +1,29 @@ +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 +import org.koin.core.context.startKoin + +fun initKoin() { + startKoin { + modules(appModule()) + } +} + +class KoinHelper : KoinComponent { + fun getMainViewModel(): MainViewModel = get() + + 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 new file mode 100644 index 0000000..5ed13c3 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/data/EncryptedDataSource.kt @@ -0,0 +1,103 @@ +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.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.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 { + companion object { + private const val API_TOKEN_KEY = "org.blackcandy.api_token_key" + } + + actual fun getApiToken(): String? { + 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 + } + } + + CFRelease(query) + CFRelease(keyRef) + + return result + } + + actual fun updateApiToken(apiToken: String) { + val apiTokenData = (apiToken as Any as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return + + 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 = 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/di/PlatformModule.kt b/shared/src/iosMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt new file mode 100644 index 0000000..aefb5b0 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/di/PlatformModule.kt @@ -0,0 +1,41 @@ +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.blackcandy.shared.media.MusicServiceController +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() } + single { MusicServiceController() } + } + +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 new file mode 100644 index 0000000..90e21db --- /dev/null +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/media/MusicServiceController.kt @@ -0,0 +1,67 @@ +package org.blackcandy.shared.media + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.blackcandy.shared.models.Song + +actual class MusicServiceController { + 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() { + } + + 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 = 0 + + actual fun addSongToNext(song: Song): Int = 0 + + actual fun addSongToLast(song: Song) { + } +} 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 new file mode 100644 index 0000000..7bef4f4 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Cookies.kt @@ -0,0 +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) + } + } + } +} 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..3a26b40 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/blackcandy/shared/utils/Theme.kt @@ -0,0 +1,4 @@ +package org.blackcandy.shared.utils + +actual fun updateAppTheme(theme: Theme) { +}