feat: auth screen with auto-redirect, sync favorites/history with backend

This commit is contained in:
nk
2026-06-02 19:12:07 +03:00
parent d4adb1e7be
commit a83672b455
2934 changed files with 97351 additions and 163 deletions

View File

@@ -0,0 +1,12 @@
package com.radiola.domain.model
data class Recording(
val id: Long,
val stationName: String,
val stationId: Int,
val filePath: String,
val startTime: Long,
val endTime: Long?,
val trackName: String?,
val duration: Long?
)

View File

@@ -8,5 +8,6 @@ data class Station(
val coverUrl: String,
val genre: String,
val tags: List<String>,
val sortOrder: Int
val sortOrder: Int,
val source: String = "record"
)

View File

@@ -0,0 +1,22 @@
package com.radiola.domain.model
enum class StationTestStatus {
OK, // Stream OK + has metadata (Icy or NowPlaying)
OK_NO_META, // Stream OK, no metadata
OFFLINE, // HTTP error / timeout / no response
ERROR // Other exception
}
data class StationTestResult(
val stationId: Int,
val stationName: String,
val streamUrl: String,
val status: StationTestStatus,
val httpCode: Int? = null,
val contentType: String? = null,
val hasIcyMetadata: Boolean = false,
val icyTitle: String? = null,
val hasNowPlaying: Boolean = false,
val nowPlayingTrack: String? = null,
val errorMessage: String? = null
)

View File

@@ -0,0 +1,7 @@
package com.radiola.domain.model
data class User(
val id: String,
val email: String,
val name: String? = null
)

View File

@@ -0,0 +1,12 @@
package com.radiola.domain.repository
import com.radiola.domain.model.User
import kotlinx.coroutines.flow.Flow
interface AuthRepository {
suspend fun requestMagicLink(email: String): Result<Unit>
suspend fun verifyMagicLink(email: String, code: String): Result<User>
fun isLoggedIn(): Flow<Boolean>
fun currentUser(): Flow<User?>
suspend fun logout()
}

View File

@@ -5,7 +5,9 @@ import kotlinx.coroutines.flow.Flow
interface FavoritesRepository {
fun getFavorites(): Flow<List<Station>>
fun getFavoriteIds(): Flow<Set<Int>>
suspend fun addFavorite(station: Station)
suspend fun addFavorite(stationId: Int)
suspend fun removeFavorite(stationId: Int)
fun isFavorite(stationId: Int): Flow<Boolean>
suspend fun reorderFavorites(orderedIds: List<Int>)

View File

@@ -4,7 +4,7 @@ import com.radiola.domain.model.Track
import kotlinx.coroutines.flow.Flow
interface NowPlayingRepository {
fun getNowPlaying(stationPrefix: String): Flow<Track?>
fun getAllNowPlaying(): Flow<Map<String, Track>>
fun getNowPlaying(stationId: Int): Flow<Track?>
fun getAllNowPlaying(): Flow<Map<Int, Track>>
suspend fun refreshNowPlaying(): Result<Unit>
}

View File

@@ -0,0 +1,15 @@
package com.radiola.domain.repository
import com.radiola.domain.model.Recording
import com.radiola.domain.model.Station
import com.radiola.domain.model.Track
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface RecordingRepository {
val isRecording: StateFlow<Boolean>
fun getRecordings(): Flow<List<Recording>>
suspend fun startRecording(station: Station, track: Track?)
suspend fun stopRecording()
suspend fun deleteRecording(id: Long)
}

View File

@@ -7,4 +7,5 @@ interface StationRepository {
fun getStations(): Flow<List<Station>>
suspend fun refreshStations(): Result<Unit>
fun getStationById(id: Int): Flow<Station?>
fun getTags(): Flow<List<String>>
}

View File

@@ -0,0 +1,11 @@
package com.radiola.domain.repository
import com.radiola.domain.model.Station
interface SyncRepository {
suspend fun pushFavorite(stationId: Int): Result<Unit>
suspend fun removeFavorite(stationId: Int): Result<Unit>
suspend fun pushHistory(stationId: Int): Result<Unit>
suspend fun fetchRemoteFavorites(): Result<List<Int>>
suspend fun fetchRemoteHistory(): Result<List<Int>>
}

View File

@@ -8,7 +8,7 @@ import javax.inject.Inject
class GetNowPlayingUseCase @Inject constructor(
private val nowPlayingRepository: NowPlayingRepository
) {
operator fun invoke(stationPrefix: String): Flow<Track?> {
return nowPlayingRepository.getNowPlaying(stationPrefix)
operator fun invoke(stationId: Int): Flow<Track?> {
return nowPlayingRepository.getNowPlaying(stationId)
}
}

View File

@@ -0,0 +1,10 @@
package com.radiola.domain.usecase
import com.radiola.domain.repository.StationRepository
import javax.inject.Inject
class RefreshStationsUseCase @Inject constructor(
private val stationRepository: StationRepository
) {
suspend operator fun invoke(): Result<Unit> = stationRepository.refreshStations()
}

View File

@@ -0,0 +1,39 @@
package com.radiola.domain.usecase
import com.radiola.data.repository.StationTester
import com.radiola.domain.model.StationTestResult
import com.radiola.domain.repository.NowPlayingRepository
import com.radiola.domain.repository.StationRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import javax.inject.Inject
class TestStationsUseCase @Inject constructor(
private val stationRepository: StationRepository,
private val nowPlayingRepository: NowPlayingRepository,
private val stationTester: StationTester
) {
data class Progress(
val current: Int,
val total: Int,
val result: StationTestResult? = null
)
operator fun invoke(): Flow<Progress> = flow {
val stations = stationRepository.getStations().first()
nowPlayingRepository.refreshNowPlaying()
val nowPlayingMap = nowPlayingRepository.getAllNowPlaying().first()
stations.forEachIndexed { index, station ->
val result = withContext(Dispatchers.IO) {
stationTester.test(station, nowPlayingMap[station.id])
}
emit(Progress(current = index + 1, total = stations.size, result = result))
}
}
}

View File

@@ -0,0 +1,11 @@
package com.radiola.domain.usecase.auth
import com.radiola.domain.repository.AuthRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class GetAuthStateUseCase @Inject constructor(
private val repository: AuthRepository
) {
operator fun invoke(): Flow<Boolean> = repository.isLoggedIn()
}

View File

@@ -0,0 +1,12 @@
package com.radiola.domain.usecase.auth
import com.radiola.domain.model.User
import com.radiola.domain.repository.AuthRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class GetCurrentUserUseCase @Inject constructor(
private val repository: AuthRepository
) {
operator fun invoke(): Flow<User?> = repository.currentUser()
}

View File

@@ -0,0 +1,10 @@
package com.radiola.domain.usecase.auth
import com.radiola.domain.repository.AuthRepository
import javax.inject.Inject
class LogoutUseCase @Inject constructor(
private val repository: AuthRepository
) {
suspend operator fun invoke() = repository.logout()
}

View File

@@ -0,0 +1,12 @@
package com.radiola.domain.usecase.auth
import com.radiola.domain.repository.SyncRepository
import javax.inject.Inject
class PushFavoriteUseCase @Inject constructor(
private val syncRepository: SyncRepository
) {
suspend operator fun invoke(stationId: Int, isAdding: Boolean): Result<Unit> =
if (isAdding) syncRepository.pushFavorite(stationId)
else syncRepository.removeFavorite(stationId)
}

View File

@@ -0,0 +1,11 @@
package com.radiola.domain.usecase.auth
import com.radiola.domain.repository.SyncRepository
import javax.inject.Inject
class PushHistoryUseCase @Inject constructor(
private val syncRepository: SyncRepository
) {
suspend operator fun invoke(stationId: Int): Result<Unit> =
syncRepository.pushHistory(stationId)
}

View File

@@ -0,0 +1,11 @@
package com.radiola.domain.usecase.auth
import com.radiola.domain.repository.AuthRepository
import javax.inject.Inject
class RequestMagicLinkUseCase @Inject constructor(
private val repository: AuthRepository
) {
suspend operator fun invoke(email: String): Result<Unit> =
repository.requestMagicLink(email)
}

View File

@@ -0,0 +1,24 @@
package com.radiola.domain.usecase.auth
import com.radiola.domain.repository.FavoritesRepository
import com.radiola.domain.repository.SyncRepository
import kotlinx.coroutines.flow.first
import javax.inject.Inject
class SyncFavoritesUseCase @Inject constructor(
private val favoritesRepository: FavoritesRepository,
private val syncRepository: SyncRepository
) {
suspend operator fun invoke() {
syncRepository.fetchRemoteFavorites()
.onSuccess { remoteIds ->
val localIds = favoritesRepository.getFavoriteIds().first()
// Add remote favorites that are missing locally
remoteIds.forEach { id ->
if (id !in localIds) {
favoritesRepository.addFavorite(id)
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
package com.radiola.domain.usecase.auth
import com.radiola.domain.repository.AuthRepository
import javax.inject.Inject
class VerifyMagicLinkUseCase @Inject constructor(
private val repository: AuthRepository
) {
suspend operator fun invoke(email: String, code: String) =
repository.verifyMagicLink(email, code)
}