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:
nk
2026-06-06 15:25:42 +03:00
parent 4411d53a6c
commit 861b0e2b8f
17 changed files with 1014 additions and 3 deletions

View File

@@ -8,6 +8,9 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" /> android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
android:name=".RadiolaApplication" android:name=".RadiolaApplication"
@@ -57,6 +60,18 @@
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
<receiver
android:name=".service.AlarmReceiver"
android:exported="false" />
<receiver
android:name=".service.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver <receiver
android:name=".widget.PlayerWidgetProvider" android:name=".widget.PlayerWidgetProvider"
android:exported="false"> android:exported="false">

View File

@@ -31,6 +31,7 @@ import com.radiola.ui.player.PlayerViewModel
import com.radiola.ui.recordings.RecordingsScreen import com.radiola.ui.recordings.RecordingsScreen
import com.radiola.ui.settings.SettingsScreen import com.radiola.ui.settings.SettingsScreen
import com.radiola.ui.stations.StationsScreen import com.radiola.ui.stations.StationsScreen
import com.radiola.ui.alarms.AlarmsScreen
import com.radiola.ui.stations.StationsViewModel import com.radiola.ui.stations.StationsViewModel
import com.radiola.service.PlayerService import com.radiola.service.PlayerService
import com.radiola.ui.theme.RadiolaTheme import com.radiola.ui.theme.RadiolaTheme
@@ -156,9 +157,17 @@ class MainActivity : ComponentActivity() {
SettingsScreen( SettingsScreen(
onNavigateToAuth = { onNavigateToAuth = {
navController.navigate(NavDestinations.Auth.route) navController.navigate(NavDestinations.Auth.route)
},
onNavigateToAlarms = {
navController.navigate(NavDestinations.Alarms.route)
} }
) )
} }
composable(NavDestinations.Alarms.route) {
AlarmsScreen(
onNavigateBack = { navController.popBackStack() }
)
}
composable(NavDestinations.Auth.route) { composable(NavDestinations.Auth.route) {
AuthScreen( AuthScreen(
onAuthSuccess = { onAuthSuccess = {

View File

@@ -4,10 +4,12 @@ import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase 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.RecordingDao
import com.radiola.data.local.dao.StationDao import com.radiola.data.local.dao.StationDao
import com.radiola.data.local.dao.TagDao import com.radiola.data.local.dao.TagDao
import com.radiola.data.local.dao.TrackHistoryDao 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.RecordingEntity
import com.radiola.data.local.entity.StationEntity import com.radiola.data.local.entity.StationEntity
import com.radiola.data.local.entity.TagEntity 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( @Database(
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class], entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class, AlarmEntity::class],
version = 6 version = 7
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun stationDao(): StationDao abstract fun stationDao(): StationDao
abstract fun trackHistoryDao(): TrackHistoryDao abstract fun trackHistoryDao(): TrackHistoryDao
abstract fun tagDao(): TagDao abstract fun tagDao(): TagDao
abstract fun recordingDao(): RecordingDao abstract fun recordingDao(): RecordingDao
abstract fun alarmDao(): AlarmDao
} }

View 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)
}

View File

@@ -42,4 +42,8 @@ interface StationDao {
// не потерять отметки «избранное» (insertAll = REPLACE затирает строки). // не потерять отметки «избранное» (insertAll = REPLACE затирает строки).
@Query("SELECT id FROM stations WHERE isFavorite = 1") @Query("SELECT id FROM stations WHERE isFavorite = 1")
suspend fun getFavoriteIdsOnce(): List<Int> suspend fun getFavoriteIdsOnce(): List<Int>
// Разовое чтение станции по id — используется в сервисе будильника.
@Query("SELECT * FROM stations WHERE id = :id")
suspend fun getByIdOnce(id: Int): StationEntity?
} }

View File

@@ -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
)

View File

@@ -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_3_4
import com.radiola.data.local.MIGRATION_4_5 import com.radiola.data.local.MIGRATION_4_5
import com.radiola.data.local.MIGRATION_5_6 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.AuthInterceptor
import com.radiola.data.remote.LrcLibApi import com.radiola.data.remote.LrcLibApi
import com.radiola.data.remote.LoveApi import com.radiola.data.remote.LoveApi
@@ -154,9 +156,17 @@ object AppModule {
@Singleton @Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase = fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "radiola.db") 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() .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 @Provides
@Singleton @Singleton
fun provideLocalStationDataSource( fun provideLocalStationDataSource(

View 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)
}
}

View 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
}
}

View 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()
}
}
}
}

View File

@@ -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() { fun cancelSleepTimer() {
sleepJob?.cancel() sleepJob?.cancel()

View File

@@ -1,21 +1,54 @@
package com.radiola.service package com.radiola.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.radiola.MainActivity 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 dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@UnstableApi @UnstableApi
class PlayerService : MediaSessionService() { 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 @Inject
lateinit var playerController: PlayerController 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 private var mediaSession: MediaSession? = null
override fun onCreate() { override fun onCreate() {
@@ -30,6 +63,88 @@ class PlayerService : MediaSessionService() {
) )
) )
.build() .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 override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession

View 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)
}
}
}
}
}

View 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)
}
}
}

View File

@@ -1,6 +1,7 @@
package com.radiola.ui.navigation package com.radiola.ui.navigation
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import com.composables.icons.lucide.AlarmClock
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Heart import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.History import com.composables.icons.lucide.History
@@ -22,6 +23,7 @@ sealed class NavDestinations(
data object Recordings : NavDestinations("recordings", "Записи", Lucide.Mic) data object Recordings : NavDestinations("recordings", "Записи", Lucide.Mic)
data object Settings : NavDestinations("settings", "Настройки", Lucide.Settings) data object Settings : NavDestinations("settings", "Настройки", Lucide.Settings)
data object Auth : NavDestinations("auth", "Вход", Lucide.Settings, showInBottomBar = false) data object Auth : NavDestinations("auth", "Вход", Lucide.Settings, showInBottomBar = false)
data object Alarms : NavDestinations("alarms", "Будильник", Lucide.AlarmClock, showInBottomBar = false)
companion object { companion object {
val items = listOf(Stations, Charts, Favorites, History, Recordings, Settings) val items = listOf(Stations, Charts, Favorites, History, Recordings, Settings)

View File

@@ -24,6 +24,8 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel 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.Lucide
import com.composables.icons.lucide.User import com.composables.icons.lucide.User
import com.radiola.domain.model.DeeplinkService import com.radiola.domain.model.DeeplinkService
@@ -35,6 +37,7 @@ import com.radiola.ui.theme.RadiolaTheme
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
onNavigateToAuth: () -> Unit, onNavigateToAuth: () -> Unit,
onNavigateToAlarms: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel() viewModel: SettingsViewModel = hiltViewModel()
) { ) {
@@ -43,6 +46,7 @@ fun SettingsScreen(
val equalizerPreset by viewModel.equalizerPreset.collectAsState() val equalizerPreset by viewModel.equalizerPreset.collectAsState()
val visualizerStyle by viewModel.visualizerStyle.collectAsState() val visualizerStyle by viewModel.visualizerStyle.collectAsState()
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState() val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
val preferredBitrate by viewModel.preferredBitrate.collectAsState()
val isTesting by viewModel.isTesting.collectAsState() val isTesting by viewModel.isTesting.collectAsState()
val testProgress by viewModel.testProgress.collectAsState() val testProgress by viewModel.testProgress.collectAsState()
val testTotal by viewModel.testTotal.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 { item {
SectionLabel("ТАЙМЕР СНА") 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 { item {
SectionLabel("ЭКВАЛАЙЗЕР") SectionLabel("ЭКВАЛАЙЗЕР")

View File

@@ -38,6 +38,10 @@ class SettingsViewModel @Inject constructor(
val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled() val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
// Предпочитаемый битрейт по умолчанию (0 = авто/станция сама выбирает).
val preferredBitrate: StateFlow<Int> = settingsRepository.getPreferredBitrate()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
val isLoggedIn: StateFlow<Boolean> = getAuthStateUseCase() val isLoggedIn: StateFlow<Boolean> = getAuthStateUseCase()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@@ -60,6 +64,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.setSleepTimerMinutes(minutes) } viewModelScope.launch { settingsRepository.setSleepTimerMinutes(minutes) }
} }
fun setPreferredBitrate(bitrate: Int) {
viewModelScope.launch { settingsRepository.setPreferredBitrate(bitrate) }
}
fun toggleService(serviceId: String, enabled: Boolean) { fun toggleService(serviceId: String, enabled: Boolean) {
viewModelScope.launch { viewModelScope.launch {
val current = enabledServices.value.toMutableSet() val current = enabledServices.value.toMutableSet()