feat: будильник с радиостанцией + выбор битрейта по умолчанию
Будильник (Settings → Будильник): несколько будильников, время, станция, дни недели, fade-in пробуждения. AlarmManager.setAlarmClock (вне doze) + фолбэк, BootReceiver перепланирует после перезагрузки, AlarmReceiver→PlayerService (foreground) → PlayerController.startAlarmPlayback (нарастание громкости). Room: AlarmEntity/Dao, БД v7. Выбор битрейта по умолчанию в Settings (Авто/Эконом/Стандарт/Высокое) → preferredBitrate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ import com.radiola.ui.player.PlayerViewModel
|
||||
import com.radiola.ui.recordings.RecordingsScreen
|
||||
import com.radiola.ui.settings.SettingsScreen
|
||||
import com.radiola.ui.stations.StationsScreen
|
||||
import com.radiola.ui.alarms.AlarmsScreen
|
||||
import com.radiola.ui.stations.StationsViewModel
|
||||
import com.radiola.service.PlayerService
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
@@ -156,9 +157,17 @@ class MainActivity : ComponentActivity() {
|
||||
SettingsScreen(
|
||||
onNavigateToAuth = {
|
||||
navController.navigate(NavDestinations.Auth.route)
|
||||
},
|
||||
onNavigateToAlarms = {
|
||||
navController.navigate(NavDestinations.Alarms.route)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(NavDestinations.Alarms.route) {
|
||||
AlarmsScreen(
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(NavDestinations.Auth.route) {
|
||||
AuthScreen(
|
||||
onAuthSuccess = {
|
||||
|
||||
@@ -4,10 +4,12 @@ import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.radiola.data.local.dao.AlarmDao
|
||||
import com.radiola.data.local.dao.RecordingDao
|
||||
import com.radiola.data.local.dao.StationDao
|
||||
import com.radiola.data.local.dao.TagDao
|
||||
import com.radiola.data.local.dao.TrackHistoryDao
|
||||
import com.radiola.data.local.entity.AlarmEntity
|
||||
import com.radiola.data.local.entity.RecordingEntity
|
||||
import com.radiola.data.local.entity.StationEntity
|
||||
import com.radiola.data.local.entity.TagEntity
|
||||
@@ -56,13 +58,34 @@ val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем таблицу будильников
|
||||
val MIGRATION_6_7 = object : Migration(6, 7) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS alarms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
hour INTEGER NOT NULL,
|
||||
minute INTEGER NOT NULL,
|
||||
daysMask INTEGER NOT NULL,
|
||||
stationId INTEGER NOT NULL,
|
||||
stationName TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
fadeInSec INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class],
|
||||
version = 6
|
||||
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class, AlarmEntity::class],
|
||||
version = 7
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun stationDao(): StationDao
|
||||
abstract fun trackHistoryDao(): TrackHistoryDao
|
||||
abstract fun tagDao(): TagDao
|
||||
abstract fun recordingDao(): RecordingDao
|
||||
abstract fun alarmDao(): AlarmDao
|
||||
}
|
||||
|
||||
30
app/src/main/java/com/radiola/data/local/dao/AlarmDao.kt
Normal file
30
app/src/main/java/com/radiola/data/local/dao/AlarmDao.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.radiola.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.radiola.data.local.entity.AlarmEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface AlarmDao {
|
||||
|
||||
@Query("SELECT * FROM alarms ORDER BY hour ASC, minute ASC")
|
||||
fun getAll(): Flow<List<AlarmEntity>>
|
||||
|
||||
@Query("SELECT * FROM alarms ORDER BY hour ASC, minute ASC")
|
||||
suspend fun getAllOnce(): List<AlarmEntity>
|
||||
|
||||
@Query("SELECT * FROM alarms WHERE id = :id")
|
||||
suspend fun getById(id: Int): AlarmEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(alarm: AlarmEntity): Long
|
||||
|
||||
@Query("DELETE FROM alarms WHERE id = :id")
|
||||
suspend fun delete(id: Int)
|
||||
|
||||
@Query("UPDATE alarms SET enabled = :enabled WHERE id = :id")
|
||||
suspend fun setEnabled(id: Int, enabled: Boolean)
|
||||
}
|
||||
@@ -42,4 +42,8 @@ interface StationDao {
|
||||
// не потерять отметки «избранное» (insertAll = REPLACE затирает строки).
|
||||
@Query("SELECT id FROM stations WHERE isFavorite = 1")
|
||||
suspend fun getFavoriteIdsOnce(): List<Int>
|
||||
|
||||
// Разовое чтение станции по id — используется в сервисе будильника.
|
||||
@Query("SELECT * FROM stations WHERE id = :id")
|
||||
suspend fun getByIdOnce(id: Int): StationEntity?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.radiola.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Будильник: время, дни недели, станция, fade-in.
|
||||
* daysMask — битовая маска Пн..Вс (биты 0..6); 0 = разовый (следующее совпадение).
|
||||
*/
|
||||
@Entity(tableName = "alarms")
|
||||
data class AlarmEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Int = 0,
|
||||
val hour: Int,
|
||||
val minute: Int,
|
||||
val daysMask: Int, // 0 = разовый; бит 0=Пн, ..., бит 6=Вс
|
||||
val stationId: Int,
|
||||
val stationName: String,
|
||||
val enabled: Boolean = true,
|
||||
val fadeInSec: Int = 60
|
||||
)
|
||||
@@ -9,6 +9,8 @@ import com.radiola.data.local.MIGRATION_2_3
|
||||
import com.radiola.data.local.MIGRATION_3_4
|
||||
import com.radiola.data.local.MIGRATION_4_5
|
||||
import com.radiola.data.local.MIGRATION_5_6
|
||||
import com.radiola.data.local.MIGRATION_6_7
|
||||
import com.radiola.data.local.dao.AlarmDao
|
||||
import com.radiola.data.remote.AuthInterceptor
|
||||
import com.radiola.data.remote.LrcLibApi
|
||||
import com.radiola.data.remote.LoveApi
|
||||
@@ -154,9 +156,17 @@ object AppModule {
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
|
||||
Room.databaseBuilder(context, AppDatabase::class.java, "radiola.db")
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAlarmDao(db: AppDatabase): AlarmDao = db.alarmDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideStationDao(db: AppDatabase): com.radiola.data.local.dao.StationDao = db.stationDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLocalStationDataSource(
|
||||
|
||||
23
app/src/main/java/com/radiola/service/AlarmReceiver.kt
Normal file
23
app/src/main/java/com/radiola/service/AlarmReceiver.kt
Normal file
@@ -0,0 +1,23 @@
|
||||
package com.radiola.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* BroadcastReceiver-триггер будильника.
|
||||
* Не Hilt-инжектируемый — намеренно простой: только передаёт id в PlayerService.
|
||||
*/
|
||||
class AlarmReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val alarmId = intent.getIntExtra("alarm_id", -1)
|
||||
if (alarmId < 0) return
|
||||
|
||||
val serviceIntent = Intent(context, PlayerService::class.java).apply {
|
||||
action = PlayerService.ACTION_ALARM
|
||||
putExtra("alarm_id", alarmId)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, serviceIntent)
|
||||
}
|
||||
}
|
||||
147
app/src/main/java/com/radiola/service/AlarmScheduler.kt
Normal file
147
app/src/main/java/com/radiola/service/AlarmScheduler.kt
Normal file
@@ -0,0 +1,147 @@
|
||||
package com.radiola.service
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.radiola.MainActivity
|
||||
import com.radiola.data.local.dao.AlarmDao
|
||||
import com.radiola.data.local.entity.AlarmEntity
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.Calendar
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AlarmScheduler @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
/**
|
||||
* Запланировать следующее срабатывание будильника.
|
||||
* Если daysMask == 0 — разовый: берём ближайшее сегодня/завтра совпадение по времени.
|
||||
* Если daysMask != 0 — повторяющийся: ищем ближайший день недели из маски.
|
||||
*/
|
||||
fun schedule(alarm: AlarmEntity) {
|
||||
val triggerMs = nextTriggerMillis(alarm)
|
||||
val operation = buildPendingIntent(alarm.id)
|
||||
val showIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
if (alarmManager.canScheduleExactAlarms()) {
|
||||
alarmManager.setAlarmClock(
|
||||
AlarmManager.AlarmClockInfo(triggerMs, showIntent),
|
||||
operation
|
||||
)
|
||||
} else {
|
||||
// Нет разрешения на точные будильники — используем менее точный метод
|
||||
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerMs, operation)
|
||||
}
|
||||
} else {
|
||||
alarmManager.setAlarmClock(
|
||||
AlarmManager.AlarmClockInfo(triggerMs, showIntent),
|
||||
operation
|
||||
)
|
||||
}
|
||||
Log.d("AlarmScheduler", "Будильник #${alarm.id} запланирован на $triggerMs")
|
||||
} catch (e: SecurityException) {
|
||||
Log.w("AlarmScheduler", "SecurityException при setAlarmClock — фолбэк на setAndAllowWhileIdle", e)
|
||||
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerMs, operation)
|
||||
}
|
||||
}
|
||||
|
||||
/** Отменить будильник по id. */
|
||||
fun cancel(alarmId: Int) {
|
||||
alarmManager.cancel(buildPendingIntent(alarmId))
|
||||
Log.d("AlarmScheduler", "Будильник #$alarmId отменён")
|
||||
}
|
||||
|
||||
/** Пересчитать расписание всех будильников из базы. */
|
||||
suspend fun rescheduleAll(alarmDao: AlarmDao) {
|
||||
val alarms = alarmDao.getAllOnce()
|
||||
alarms.forEach { alarm ->
|
||||
cancel(alarm.id)
|
||||
if (alarm.enabled) schedule(alarm)
|
||||
}
|
||||
Log.d("AlarmScheduler", "Перепланировано ${alarms.size} будильников")
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
private fun buildPendingIntent(alarmId: Int): PendingIntent {
|
||||
val intent = Intent(context, AlarmReceiver::class.java).apply {
|
||||
putExtra("alarm_id", alarmId)
|
||||
}
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
alarmId,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычислить эпоху (мс) следующего срабатывания будильника.
|
||||
* Calendar.MONDAY=2 .. Calendar.SUNDAY=1 — маппим в биты 0..6 (Пн..Вс).
|
||||
*/
|
||||
private fun nextTriggerMillis(alarm: AlarmEntity): Long {
|
||||
val cal = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, alarm.hour)
|
||||
set(Calendar.MINUTE, alarm.minute)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
|
||||
return if (alarm.daysMask == 0) {
|
||||
// Разовый: если время уже прошло сегодня — сдвигаем на завтра
|
||||
if (cal.timeInMillis <= System.currentTimeMillis()) {
|
||||
cal.add(Calendar.DAY_OF_YEAR, 1)
|
||||
}
|
||||
cal.timeInMillis
|
||||
} else {
|
||||
// Повторяющийся: ищем ближайший день из маски
|
||||
var found = false
|
||||
repeat(7) { offset ->
|
||||
if (!found) {
|
||||
val checkCal = cal.clone() as Calendar
|
||||
if (offset > 0) checkCal.add(Calendar.DAY_OF_YEAR, offset)
|
||||
val bit = calDayToBit(checkCal.get(Calendar.DAY_OF_WEEK))
|
||||
if (alarm.daysMask and (1 shl bit) != 0) {
|
||||
// Тот же день, но время уже прошло → пропускаем
|
||||
if (offset == 0 && checkCal.timeInMillis <= System.currentTimeMillis()) return@repeat
|
||||
cal.timeInMillis = checkCal.timeInMillis
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// Крайний случай: все биты проверены — ждём ещё 7 дней (не должно случиться)
|
||||
cal.add(Calendar.DAY_OF_YEAR, 7)
|
||||
}
|
||||
cal.timeInMillis
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Перевод Calendar.DAY_OF_WEEK → бит в daysMask (0=Пн, 6=Вс).
|
||||
* Calendar: SUNDAY=1, MONDAY=2, ..., SATURDAY=7
|
||||
*/
|
||||
private fun calDayToBit(calDay: Int): Int = when (calDay) {
|
||||
Calendar.MONDAY -> 0
|
||||
Calendar.TUESDAY -> 1
|
||||
Calendar.WEDNESDAY -> 2
|
||||
Calendar.THURSDAY -> 3
|
||||
Calendar.FRIDAY -> 4
|
||||
Calendar.SATURDAY -> 5
|
||||
Calendar.SUNDAY -> 6
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
36
app/src/main/java/com/radiola/service/BootReceiver.kt
Normal file
36
app/src/main/java/com/radiola/service/BootReceiver.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
package com.radiola.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.radiola.data.local.dao.AlarmDao
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Перепланирует будильники после перезагрузки устройства.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var alarmDao: AlarmDao
|
||||
|
||||
@Inject
|
||||
lateinit var alarmScheduler: AlarmScheduler
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
val pending = goAsync()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
alarmScheduler.rescheduleAll(alarmDao)
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,6 +309,34 @@ class PlayerController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запуск воспроизведения станции по будильнику: играет [url] и плавно нарастает
|
||||
* громкость 0 → 1 за [fadeInMs] (мягкое пробуждение).
|
||||
*/
|
||||
fun startAlarmPlayback(
|
||||
url: String,
|
||||
prefix: String,
|
||||
name: String,
|
||||
id: Int?,
|
||||
fadeInMs: Long = 60_000L,
|
||||
) {
|
||||
cancelSleepTimer()
|
||||
play(url, prefix, name, id)
|
||||
exoPlayer.volume = 0f
|
||||
sleepJob?.cancel()
|
||||
sleepJob = timerScope.launch {
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
while (true) {
|
||||
val elapsed = SystemClock.elapsedRealtime() - start
|
||||
if (elapsed >= fadeInMs) break
|
||||
exoPlayer.volume = (elapsed.toFloat() / fadeInMs).coerceIn(0f, 1f)
|
||||
delay(200)
|
||||
}
|
||||
exoPlayer.volume = 1f
|
||||
sleepJob = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Отменить таймер сна, вернуть громкость и заглушить звук сна. */
|
||||
fun cancelSleepTimer() {
|
||||
sleepJob?.cancel()
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
package com.radiola.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.radiola.MainActivity
|
||||
import com.radiola.data.local.dao.AlarmDao
|
||||
import com.radiola.data.local.dao.StationDao
|
||||
import com.radiola.data.remote.LoveStreamResolver
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@UnstableApi
|
||||
class PlayerService : MediaSessionService() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_ALARM = "com.radiola.ALARM"
|
||||
private const val CHANNEL_ALARM = "radiola_alarm"
|
||||
private const val NOTIF_ID_ALARM = 9001
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var playerController: PlayerController
|
||||
|
||||
@Inject
|
||||
lateinit var alarmDao: AlarmDao
|
||||
|
||||
@Inject
|
||||
lateinit var stationDao: StationDao
|
||||
|
||||
@Inject
|
||||
lateinit var loveStreamResolver: LoveStreamResolver
|
||||
|
||||
@Inject
|
||||
lateinit var alarmScheduler: AlarmScheduler
|
||||
|
||||
private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
private var mediaSession: MediaSession? = null
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -30,6 +63,88 @@ class PlayerService : MediaSessionService() {
|
||||
)
|
||||
)
|
||||
.build()
|
||||
ensureAlarmChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_ALARM) {
|
||||
val alarmId = intent.getIntExtra("alarm_id", -1)
|
||||
if (alarmId >= 0) handleAlarm(alarmId)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun handleAlarm(alarmId: Int) {
|
||||
// Немедленно поднимаем foreground-уведомление (Android требует ≤5 с после startForegroundService)
|
||||
startForeground(NOTIF_ID_ALARM, buildAlarmNotification())
|
||||
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val alarm = alarmDao.getById(alarmId)
|
||||
if (alarm == null) {
|
||||
Log.w("PlayerService", "Будильник #$alarmId не найден в БД")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
return@launch
|
||||
}
|
||||
val station = stationDao.getByIdOnce(alarm.stationId)
|
||||
if (station == null) {
|
||||
Log.w("PlayerService", "Станция #${alarm.stationId} не найдена, будильник #$alarmId")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
return@launch
|
||||
}
|
||||
val url = loveStreamResolver.resolve(station.streamUrl)
|
||||
playerController.startAlarmPlayback(
|
||||
url = url,
|
||||
prefix = station.prefix,
|
||||
name = station.name,
|
||||
id = station.id,
|
||||
fadeInMs = alarm.fadeInSec * 1000L
|
||||
)
|
||||
// Перепланируем или деактивируем будильник
|
||||
if (alarm.daysMask != 0) {
|
||||
// Повторяющийся — планируем следующее срабатывание
|
||||
alarmScheduler.schedule(alarm)
|
||||
} else {
|
||||
// Разовый — отключаем
|
||||
alarmDao.setEnabled(alarmId, false)
|
||||
}
|
||||
// MediaSession-уведомление возьмёт на себя отображение воспроизведения
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
} catch (e: Exception) {
|
||||
Log.e("PlayerService", "Ошибка воспроизведения будильника #$alarmId", e)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureAlarmChannel() {
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (nm.getNotificationChannel(CHANNEL_ALARM) == null) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ALARM,
|
||||
"Будильник",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Уведомления о срабатывании будильника"
|
||||
}
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAlarmNotification(): Notification {
|
||||
val mainIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ALARM)
|
||||
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
||||
.setContentTitle("Будильник")
|
||||
.setContentText("Запуск радио…")
|
||||
.setContentIntent(mainIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession
|
||||
|
||||
386
app/src/main/java/com/radiola/ui/alarms/AlarmsScreen.kt
Normal file
386
app/src/main/java/com/radiola/ui/alarms/AlarmsScreen.kt
Normal file
@@ -0,0 +1,386 @@
|
||||
package com.radiola.ui.alarms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.composables.icons.lucide.AlarmClock
|
||||
import com.composables.icons.lucide.ArrowLeft
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Plus
|
||||
import com.composables.icons.lucide.Trash2
|
||||
import com.radiola.data.local.entity.AlarmEntity
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
|
||||
/** Экран управления будильниками. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AlarmsScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AlarmsViewModel = hiltViewModel()
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val alarms by viewModel.alarms.collectAsState()
|
||||
val stations by viewModel.stations.collectAsState()
|
||||
|
||||
// Состояние диалога добавления/редактирования
|
||||
var editingAlarm by remember { mutableStateOf<AlarmEntity?>(null) }
|
||||
var showEditor by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.bgBase)
|
||||
) {
|
||||
// Шапка с кнопкой «Назад»
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Lucide.ArrowLeft, contentDescription = "Назад", tint = colors.textPrimary)
|
||||
}
|
||||
Text(
|
||||
text = "Будильник",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = colors.textPrimary,
|
||||
modifier = Modifier.weight(1f).padding(start = 4.dp)
|
||||
)
|
||||
IconButton(onClick = {
|
||||
editingAlarm = null
|
||||
showEditor = true
|
||||
}) {
|
||||
Icon(Lucide.Plus, contentDescription = "Добавить будильник", tint = colors.accent)
|
||||
}
|
||||
}
|
||||
|
||||
if (alarms.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Icon(Lucide.AlarmClock, contentDescription = null, tint = colors.textMuted, modifier = Modifier.size(48.dp))
|
||||
Text("Нет будильников", color = colors.textMuted, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Нажмите «+» чтобы добавить",
|
||||
color = colors.textMuted,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(alarms, key = { it.id }) { alarm ->
|
||||
AlarmCard(
|
||||
alarm = alarm,
|
||||
onToggle = { viewModel.toggle(alarm) },
|
||||
onEdit = {
|
||||
editingAlarm = alarm
|
||||
showEditor = true
|
||||
},
|
||||
onDelete = { viewModel.delete(alarm) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Диалог добавления / редактирования
|
||||
if (showEditor) {
|
||||
AlarmEditorSheet(
|
||||
initial = editingAlarm,
|
||||
stations = stations,
|
||||
onSave = { alarm ->
|
||||
viewModel.addOrUpdate(alarm)
|
||||
showEditor = false
|
||||
},
|
||||
onDismiss = { showEditor = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun AlarmCard(
|
||||
alarm: AlarmEntity,
|
||||
onToggle: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(colors.surface)
|
||||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||||
.clickable(onClick = onEdit)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Время
|
||||
Text(
|
||||
text = "%02d:%02d".format(alarm.hour, alarm.minute),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (alarm.enabled) colors.textPrimary else colors.textMuted
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
// Станция + дни
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = alarm.stationName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (alarm.enabled) colors.textPrimary else colors.textMuted,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = daysSummary(alarm.daysMask),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.textSecondary
|
||||
)
|
||||
}
|
||||
// Удалить
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Lucide.Trash2, contentDescription = "Удалить", tint = colors.textMuted, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
// Вкл/выкл
|
||||
Switch(
|
||||
checked = alarm.enabled,
|
||||
onCheckedChange = { onToggle() },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = colors.bgBase,
|
||||
checkedTrackColor = colors.accent,
|
||||
uncheckedThumbColor = colors.textMuted,
|
||||
uncheckedTrackColor = colors.surface2
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private val DAY_LABELS = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс")
|
||||
|
||||
private fun daysSummary(mask: Int): String {
|
||||
if (mask == 0) return "Один раз"
|
||||
val all = (1 shl 7) - 1
|
||||
if (mask == all) return "Каждый день"
|
||||
val weekdays = 0b0011111 // Пн-Пт
|
||||
if (mask == weekdays) return "По будням"
|
||||
val weekend = 0b1100000 // Сб-Вс
|
||||
if (mask == weekend) return "По выходным"
|
||||
return DAY_LABELS.filterIndexed { i, _ -> mask and (1 shl i) != 0 }.joinToString(" ")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Нижний лист редактирования / создания будильника.
|
||||
* Использует Material3 TimePicker + выбор станции + чипы дней.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AlarmEditorSheet(
|
||||
initial: AlarmEntity?,
|
||||
stations: List<Station>,
|
||||
onSave: (AlarmEntity) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
|
||||
// Начальные значения
|
||||
val initHour = initial?.hour ?: 7
|
||||
val initMinute = initial?.minute ?: 0
|
||||
var selectedHour by remember { mutableStateOf(initHour) }
|
||||
var selectedMinute by remember { mutableStateOf(initMinute) }
|
||||
var daysMask by remember { mutableStateOf(initial?.daysMask ?: 0) }
|
||||
var selectedStation by remember {
|
||||
mutableStateOf(stations.firstOrNull { it.id == initial?.stationId } ?: stations.firstOrNull())
|
||||
}
|
||||
var fadeInSec by remember { mutableStateOf(initial?.fadeInSec ?: 60) }
|
||||
var stationDropdownExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
// Обновим станцию если список подгрузился после открытия
|
||||
LaunchedEffect(stations) {
|
||||
if (selectedStation == null) selectedStation = stations.firstOrNull()
|
||||
}
|
||||
|
||||
val timePickerState = rememberTimePickerState(
|
||||
initialHour = initHour,
|
||||
initialMinute = initMinute,
|
||||
is24Hour = true
|
||||
)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = colors.elevated
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (initial == null) "Новый будильник" else "Изменить будильник",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = colors.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
// Выбор времени
|
||||
TimePicker(
|
||||
state = timePickerState,
|
||||
colors = TimePickerDefaults.colors(
|
||||
clockDialColor = colors.surface2,
|
||||
selectorColor = colors.accent,
|
||||
timeSelectorSelectedContainerColor = colors.accent,
|
||||
timeSelectorUnselectedContainerColor = colors.surface,
|
||||
timeSelectorSelectedContentColor = colors.bgBase,
|
||||
timeSelectorUnselectedContentColor = colors.textPrimary,
|
||||
periodSelectorBorderColor = colors.border,
|
||||
clockDialSelectedContentColor = colors.bgBase,
|
||||
clockDialUnselectedContentColor = colors.textPrimary
|
||||
),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
|
||||
// Выбор станции
|
||||
Column {
|
||||
Text("Станция", style = MaterialTheme.typography.labelSmall, color = colors.textMuted, letterSpacing = 1.sp)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(colors.surface)
|
||||
.border(1.dp, colors.border, RoundedCornerShape(12.dp))
|
||||
.clickable { stationDropdownExpanded = true }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = selectedStation?.name ?: "Выберите станцию",
|
||||
color = if (selectedStation != null) colors.textPrimary else colors.textMuted,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = stationDropdownExpanded,
|
||||
onDismissRequest = { stationDropdownExpanded = false },
|
||||
modifier = Modifier.background(colors.elevated).heightIn(max = 300.dp)
|
||||
) {
|
||||
stations.forEach { station ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(station.name, color = colors.textPrimary) },
|
||||
onClick = {
|
||||
selectedStation = station
|
||||
stationDropdownExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Дни недели
|
||||
Column {
|
||||
Text("Повтор", style = MaterialTheme.typography.labelSmall, color = colors.textMuted, letterSpacing = 1.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
DAY_LABELS.forEachIndexed { i, label ->
|
||||
val selected = daysMask and (1 shl i) != 0
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (selected) colors.accent else colors.surface)
|
||||
.border(1.dp, if (selected) colors.accent else colors.border, RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
daysMask = daysMask xor (1 shl i)
|
||||
}
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (selected) colors.bgBase else colors.textSecondary,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (daysMask == 0) "Один раз (ближайшее совпадение)" else daysSummary(daysMask),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.textSecondary
|
||||
)
|
||||
}
|
||||
|
||||
// Кнопки
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.textSecondary),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, colors.border)
|
||||
) {
|
||||
Text("Отмена")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val station = selectedStation ?: return@Button
|
||||
onSave(
|
||||
AlarmEntity(
|
||||
id = initial?.id ?: 0,
|
||||
hour = timePickerState.hour,
|
||||
minute = timePickerState.minute,
|
||||
daysMask = daysMask,
|
||||
stationId = station.id,
|
||||
stationName = station.name,
|
||||
enabled = initial?.enabled ?: true,
|
||||
fadeInSec = fadeInSec
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = colors.accent,
|
||||
contentColor = colors.bgBase
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
) {
|
||||
Text("Сохранить", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
app/src/main/java/com/radiola/ui/alarms/AlarmsViewModel.kt
Normal file
57
app/src/main/java/com/radiola/ui/alarms/AlarmsViewModel.kt
Normal file
@@ -0,0 +1,57 @@
|
||||
package com.radiola.ui.alarms
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.data.local.dao.AlarmDao
|
||||
import com.radiola.data.local.entity.AlarmEntity
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.usecase.GetStationsUseCase
|
||||
import com.radiola.service.AlarmScheduler
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AlarmsViewModel @Inject constructor(
|
||||
private val alarmDao: AlarmDao,
|
||||
private val alarmScheduler: AlarmScheduler,
|
||||
getStationsUseCase: GetStationsUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
val alarms: StateFlow<List<AlarmEntity>> = alarmDao.getAll()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val stations: StateFlow<List<Station>> = getStationsUseCase()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
/** Добавить или обновить будильник; если включён — запланировать. */
|
||||
fun addOrUpdate(alarm: AlarmEntity) {
|
||||
viewModelScope.launch {
|
||||
val id = alarmDao.upsert(alarm).toInt()
|
||||
val saved = alarm.copy(id = if (alarm.id == 0) id else alarm.id)
|
||||
if (saved.enabled) alarmScheduler.schedule(saved)
|
||||
else alarmScheduler.cancel(saved.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Переключить включён/выключен. */
|
||||
fun toggle(alarm: AlarmEntity) {
|
||||
viewModelScope.launch {
|
||||
val newEnabled = !alarm.enabled
|
||||
alarmDao.setEnabled(alarm.id, newEnabled)
|
||||
if (newEnabled) alarmScheduler.schedule(alarm.copy(enabled = true))
|
||||
else alarmScheduler.cancel(alarm.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Удалить будильник и отменить планировщик. */
|
||||
fun delete(alarm: AlarmEntity) {
|
||||
viewModelScope.launch {
|
||||
alarmScheduler.cancel(alarm.id)
|
||||
alarmDao.delete(alarm.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.radiola.ui.navigation
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.composables.icons.lucide.AlarmClock
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Heart
|
||||
import com.composables.icons.lucide.History
|
||||
@@ -22,6 +23,7 @@ sealed class NavDestinations(
|
||||
data object Recordings : NavDestinations("recordings", "Записи", Lucide.Mic)
|
||||
data object Settings : NavDestinations("settings", "Настройки", Lucide.Settings)
|
||||
data object Auth : NavDestinations("auth", "Вход", Lucide.Settings, showInBottomBar = false)
|
||||
data object Alarms : NavDestinations("alarms", "Будильник", Lucide.AlarmClock, showInBottomBar = false)
|
||||
|
||||
companion object {
|
||||
val items = listOf(Stations, Charts, Favorites, History, Recordings, Settings)
|
||||
|
||||
@@ -24,6 +24,8 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.composables.icons.lucide.AlarmClock
|
||||
import com.composables.icons.lucide.ChevronRight
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.User
|
||||
import com.radiola.domain.model.DeeplinkService
|
||||
@@ -35,6 +37,7 @@ import com.radiola.ui.theme.RadiolaTheme
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onNavigateToAuth: () -> Unit,
|
||||
onNavigateToAlarms: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
@@ -43,6 +46,7 @@ fun SettingsScreen(
|
||||
val equalizerPreset by viewModel.equalizerPreset.collectAsState()
|
||||
val visualizerStyle by viewModel.visualizerStyle.collectAsState()
|
||||
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
|
||||
val preferredBitrate by viewModel.preferredBitrate.collectAsState()
|
||||
val isTesting by viewModel.isTesting.collectAsState()
|
||||
val testProgress by viewModel.testProgress.collectAsState()
|
||||
val testTotal by viewModel.testTotal.collectAsState()
|
||||
@@ -144,6 +148,48 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Будильник ---
|
||||
item {
|
||||
SectionLabel("БУДИЛЬНИК")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(colors.surface)
|
||||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||||
.clickable { onNavigateToAlarms() }
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Icon(
|
||||
Lucide.AlarmClock,
|
||||
contentDescription = null,
|
||||
tint = colors.accent,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Будильники",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colors.textPrimary
|
||||
)
|
||||
Text(
|
||||
text = "Просыпайтесь под любимое радио",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.textSecondary
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Lucide.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = colors.textMuted,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Таймер сна ---
|
||||
item {
|
||||
SectionLabel("ТАЙМЕР СНА")
|
||||
@@ -187,6 +233,58 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Качество звука по умолчанию ---
|
||||
item {
|
||||
SectionLabel("КАЧЕСТВО ЗВУКА")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val options = listOf(0 to "Авто", 64 to "Эконом", 128 to "Стандарт", 320 to "Высокое")
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(colors.surface)
|
||||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
options.forEach { (bitrate, label) ->
|
||||
val selected = preferredBitrate == bitrate
|
||||
val bgColor by animateColorAsState(
|
||||
targetValue = if (selected) colors.accent else colors.surface2,
|
||||
animationSpec = tween(Motion.Medium),
|
||||
label = "qSegment"
|
||||
)
|
||||
val textColor by animateColorAsState(
|
||||
targetValue = if (selected) colors.bgBase else colors.textSecondary,
|
||||
animationSpec = tween(Motion.Medium),
|
||||
label = "qText"
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bgColor)
|
||||
.clickable { viewModel.setPreferredBitrate(bitrate) }
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = textColor,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Применяется к станциям с несколькими потоками. «Авто» — выбор станции.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colors.textMuted,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// --- Эквалайзер ---
|
||||
item {
|
||||
SectionLabel("ЭКВАЛАЙЗЕР")
|
||||
|
||||
@@ -38,6 +38,10 @@ class SettingsViewModel @Inject constructor(
|
||||
val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
// Предпочитаемый битрейт по умолчанию (0 = авто/станция сама выбирает).
|
||||
val preferredBitrate: StateFlow<Int> = settingsRepository.getPreferredBitrate()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
val isLoggedIn: StateFlow<Boolean> = getAuthStateUseCase()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
@@ -60,6 +64,10 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { settingsRepository.setSleepTimerMinutes(minutes) }
|
||||
}
|
||||
|
||||
fun setPreferredBitrate(bitrate: Int) {
|
||||
viewModelScope.launch { settingsRepository.setPreferredBitrate(bitrate) }
|
||||
}
|
||||
|
||||
fun toggleService(serviceId: String, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = enabledServices.value.toMutableSet()
|
||||
|
||||
Reference in New Issue
Block a user