From 861b0e2b8f0b227f7e3794f6e5e06f016f9b3064 Mon Sep 17 00:00:00 2001 From: nk Date: Sat, 6 Jun 2026 15:25:42 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B1=D1=83=D0=B4=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=20=D1=81=20=D1=80=D0=B0=D0=B4=D0=B8=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D1=86=D0=B8=D0=B5=D0=B9=20+=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B1=D0=B8=D1=82=D1=80=D0=B5=D0=B9?= =?UTF-8?q?=D1=82=D0=B0=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Будильник (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 --- app/src/main/AndroidManifest.xml | 15 + app/src/main/java/com/radiola/MainActivity.kt | 9 + .../com/radiola/data/local/AppDatabase.kt | 27 +- .../com/radiola/data/local/dao/AlarmDao.kt | 30 ++ .../com/radiola/data/local/dao/StationDao.kt | 4 + .../radiola/data/local/entity/AlarmEntity.kt | 20 + app/src/main/java/com/radiola/di/AppModule.kt | 12 +- .../java/com/radiola/service/AlarmReceiver.kt | 23 ++ .../com/radiola/service/AlarmScheduler.kt | 147 +++++++ .../java/com/radiola/service/BootReceiver.kt | 36 ++ .../com/radiola/service/PlayerController.kt | 28 ++ .../java/com/radiola/service/PlayerService.kt | 115 ++++++ .../com/radiola/ui/alarms/AlarmsScreen.kt | 386 ++++++++++++++++++ .../com/radiola/ui/alarms/AlarmsViewModel.kt | 57 +++ .../radiola/ui/navigation/NavDestinations.kt | 2 + .../com/radiola/ui/settings/SettingsScreen.kt | 98 +++++ .../radiola/ui/settings/SettingsViewModel.kt | 8 + 17 files changed, 1014 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/radiola/data/local/dao/AlarmDao.kt create mode 100644 app/src/main/java/com/radiola/data/local/entity/AlarmEntity.kt create mode 100644 app/src/main/java/com/radiola/service/AlarmReceiver.kt create mode 100644 app/src/main/java/com/radiola/service/AlarmScheduler.kt create mode 100644 app/src/main/java/com/radiola/service/BootReceiver.kt create mode 100644 app/src/main/java/com/radiola/ui/alarms/AlarmsScreen.kt create mode 100644 app/src/main/java/com/radiola/ui/alarms/AlarmsViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f073ab6..f22d796 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ + + + + + + + + + + + diff --git a/app/src/main/java/com/radiola/MainActivity.kt b/app/src/main/java/com/radiola/MainActivity.kt index 894ceca..fb7202a 100644 --- a/app/src/main/java/com/radiola/MainActivity.kt +++ b/app/src/main/java/com/radiola/MainActivity.kt @@ -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 = { diff --git a/app/src/main/java/com/radiola/data/local/AppDatabase.kt b/app/src/main/java/com/radiola/data/local/AppDatabase.kt index 96750b8..5d1ce93 100644 --- a/app/src/main/java/com/radiola/data/local/AppDatabase.kt +++ b/app/src/main/java/com/radiola/data/local/AppDatabase.kt @@ -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 } diff --git a/app/src/main/java/com/radiola/data/local/dao/AlarmDao.kt b/app/src/main/java/com/radiola/data/local/dao/AlarmDao.kt new file mode 100644 index 0000000..cc8fc2c --- /dev/null +++ b/app/src/main/java/com/radiola/data/local/dao/AlarmDao.kt @@ -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> + + @Query("SELECT * FROM alarms ORDER BY hour ASC, minute ASC") + suspend fun getAllOnce(): List + + @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) +} diff --git a/app/src/main/java/com/radiola/data/local/dao/StationDao.kt b/app/src/main/java/com/radiola/data/local/dao/StationDao.kt index 3364a71..8b90359 100644 --- a/app/src/main/java/com/radiola/data/local/dao/StationDao.kt +++ b/app/src/main/java/com/radiola/data/local/dao/StationDao.kt @@ -42,4 +42,8 @@ interface StationDao { // не потерять отметки «избранное» (insertAll = REPLACE затирает строки). @Query("SELECT id FROM stations WHERE isFavorite = 1") suspend fun getFavoriteIdsOnce(): List + + // Разовое чтение станции по id — используется в сервисе будильника. + @Query("SELECT * FROM stations WHERE id = :id") + suspend fun getByIdOnce(id: Int): StationEntity? } diff --git a/app/src/main/java/com/radiola/data/local/entity/AlarmEntity.kt b/app/src/main/java/com/radiola/data/local/entity/AlarmEntity.kt new file mode 100644 index 0000000..84edef0 --- /dev/null +++ b/app/src/main/java/com/radiola/data/local/entity/AlarmEntity.kt @@ -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 +) diff --git a/app/src/main/java/com/radiola/di/AppModule.kt b/app/src/main/java/com/radiola/di/AppModule.kt index 5933dd3..afed78e 100644 --- a/app/src/main/java/com/radiola/di/AppModule.kt +++ b/app/src/main/java/com/radiola/di/AppModule.kt @@ -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( diff --git a/app/src/main/java/com/radiola/service/AlarmReceiver.kt b/app/src/main/java/com/radiola/service/AlarmReceiver.kt new file mode 100644 index 0000000..0a8e4af --- /dev/null +++ b/app/src/main/java/com/radiola/service/AlarmReceiver.kt @@ -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) + } +} diff --git a/app/src/main/java/com/radiola/service/AlarmScheduler.kt b/app/src/main/java/com/radiola/service/AlarmScheduler.kt new file mode 100644 index 0000000..c629b7e --- /dev/null +++ b/app/src/main/java/com/radiola/service/AlarmScheduler.kt @@ -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 + } +} diff --git a/app/src/main/java/com/radiola/service/BootReceiver.kt b/app/src/main/java/com/radiola/service/BootReceiver.kt new file mode 100644 index 0000000..6484ee1 --- /dev/null +++ b/app/src/main/java/com/radiola/service/BootReceiver.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/com/radiola/service/PlayerController.kt b/app/src/main/java/com/radiola/service/PlayerController.kt index 722ec4d..5f1ade1 100644 --- a/app/src/main/java/com/radiola/service/PlayerController.kt +++ b/app/src/main/java/com/radiola/service/PlayerController.kt @@ -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() diff --git a/app/src/main/java/com/radiola/service/PlayerService.kt b/app/src/main/java/com/radiola/service/PlayerService.kt index 0946bd9..f8ac843 100644 --- a/app/src/main/java/com/radiola/service/PlayerService.kt +++ b/app/src/main/java/com/radiola/service/PlayerService.kt @@ -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 diff --git a/app/src/main/java/com/radiola/ui/alarms/AlarmsScreen.kt b/app/src/main/java/com/radiola/ui/alarms/AlarmsScreen.kt new file mode 100644 index 0000000..497a07f --- /dev/null +++ b/app/src/main/java/com/radiola/ui/alarms/AlarmsScreen.kt @@ -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(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, + 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) + } + } + } + } +} diff --git a/app/src/main/java/com/radiola/ui/alarms/AlarmsViewModel.kt b/app/src/main/java/com/radiola/ui/alarms/AlarmsViewModel.kt new file mode 100644 index 0000000..63a8eee --- /dev/null +++ b/app/src/main/java/com/radiola/ui/alarms/AlarmsViewModel.kt @@ -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> = alarmDao.getAll() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val stations: StateFlow> = 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) + } + } +} diff --git a/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt b/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt index 03e7820..a2fcefc 100644 --- a/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt +++ b/app/src/main/java/com/radiola/ui/navigation/NavDestinations.kt @@ -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) diff --git a/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt b/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt index 7307827..2e13733 100644 --- a/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt @@ -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("ЭКВАЛАЙЗЕР") diff --git a/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt index ae0d723..83b9291 100644 --- a/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/radiola/ui/settings/SettingsViewModel.kt @@ -38,6 +38,10 @@ class SettingsViewModel @Inject constructor( val isRecordingEnabled: StateFlow = settingsRepository.isRecordingEnabled() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + // Предпочитаемый битрейт по умолчанию (0 = авто/станция сама выбирает). + val preferredBitrate: StateFlow = settingsRepository.getPreferredBitrate() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + val isLoggedIn: StateFlow = 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()