Будильник (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>
387 lines
16 KiB
Kotlin
387 lines
16 KiB
Kotlin
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|