feat: aplicativo completo Ciclo com UI Glassmorphism, Hilt, Room, DataStore e integração com Relógio Nativo

This commit is contained in:
Murilo Scolari 2026-10-23 21:12:20 -03:00
parent bd1796228b
commit 14f45c16d4
29 changed files with 3377 additions and 27 deletions

107
README.md
View file

@ -6,40 +6,111 @@
[![Android](https://img.shields.io/badge/Android-8.0%2B%20(API%2026%2B)-green.svg)](https://developer.android.com)
[![Kotlin](https://img.shields.io/badge/Kotlin-2.0.21-purple.svg)](https://kotlinlang.org)
**Ciclo** é um aplicativo Android nativo, minimalista e moderno, projetado para calcular horários de despertar e de deitar com base nos ciclos naturais de sono (90 a 120 minutos).
<p align="center">
<img src="docs/mockup.jpg" alt="Interface do Ciclo" width="360" style="border-radius: 24px; box-shadow: 0 12px 32px rgba(0,0,0,0.4);" />
</p>
Ao selecionar um horário ideal, o Ciclo cria o alarme diretamente no **aplicativo de Relógio nativo do aparelho** (com foco principal em aparelhos **Xiaomi / HyperOS / MIUI**, além de **Samsung One UI** e **Android padrão**).
**Ciclo** é um aplicativo Android nativo, minimalista e focado em bem-estar. Ele calcula horários ideais de despertar e de deitar baseados nos **ciclos biológicos de sono** (compostos por fases REM e sono profundo, com durações típicas entre 90 e 120 minutos).
[⬇️ **Baixar o APK mais recente**](https://github.com/muriloscolari/ciclo-alarme/releases/latest)
Ao selecionar um horário calculado, o Ciclo cria o alarme diretamente no **aplicativo de Relógio nativo do aparelho** (com foco principal em aparelhos **Xiaomi / HyperOS / MIUI**, além de **Samsung One UI** e **Pixel / Motorola / AOSP**), garantindo máxima pontualidade com zero consumo de bateria em segundo plano.
[⬇️ **Baixar o APK mais recente (Release)**](https://github.com/muriloscolari/ciclo-alarme/releases/latest)
---
## 🎯 Por que ciclos de sono?
Acordar no meio de um ciclo de sono (durante o sono profundo) é o principal causador da sensação de inércia do sono — quando você acorda indisposto, cansado e "quebrado", mesmo tendo dormido muitas horas.
O Ciclo resolve isso calculando o momento em que você estará no estágio mais leve do sono:
- **Duração do ciclo:** 90 minutos por padrão (ajustável de 90 a 120 min nas configurações).
- **Tempo para adormecer:** 15 minutos por padrão (ajustável de 0 a 60 min).
- **Opção Recomendada:** destaca **5 ciclos (~7h30 de descanso real)** como o ponto ideal de relaxamento físico e restauração cognitiva.
---
## ✨ Funcionalidades
- **Dormir agora:** calcula os melhores horários para acordar considerando o tempo para pegar no sono e ciclos completos.
- **Vou dormir às X:** planeje a hora de ir para a cama e veja os horários ideais de despertar.
- **Acordar às X:** defina que horas precisa levantar e descubra a hora exata de deitar (com opção de alarme simples se já passou do horário).
- **Opção Recomendada (5 ciclos · ~7h30):** destaque visual para o descanso ideal sem sensação de cansaço.
- **Integração nativa:** utiliza `AlarmClock.ACTION_SET_ALARM` para acionar o alarme do sistema sem precisar de serviços em segundo plano drenando bateria.
- **Histórico completo:** registre os alarmes criados com opção de recriação rápida e gerenciamento.
- **Design System Dark-first com Glassmorphism:** visual translúcido moderno com efeito de desfoque (Haze) e aurora boreal em tons índigo e lavanda.
| Modo | O que você informa | O que o Ciclo calcula | Qual alarme é criado |
|---|---|---|---|
| **Dormir agora** | Nada (usa o horário atual ao vivo) | Melhores horários para despertar | O horário que você tocar |
| **Vou dormir às X** | A hora planejada para deitar | Melhores horários para despertar | O horário que você tocar |
| **Acordar às X** | A hora em que você precisa levantar | Melhores horários para ir para a cama | Sempre o horário X |
- **Detecção de horários passados:** no modo "Acordar às", opções de deitar que já passaram ficam atenuadas. O aplicativo recomenda a maior quantidade de ciclos ainda disponível.
- **Opção de Alarme Simples:** se já for muito tarde da noite para ciclos completos, o Ciclo permite acionar um alarme simples para a hora estipulada com um toque.
- **Proteção contra alarmes duplicados:** detecta se você já enviou um alarme com o mesmo horário nas últimas 12 horas e avisa antes de reenviar.
- **Histórico Completo:** registros de alarmes salvos para sempre (Room Database) com opção de recriação rápida em 1 toque e botão direto para abrir o Relógio do aparelho.
- **Design System Dark-first com Glassmorphism:** interface elegante translúcida com desfoque de fundo (Haze), cantos suaves e auroras coloridas em tons índigo e lavanda.
---
## 🛠️ Stack & Arquitetura
## 📱 Compatibilidade com o Relógio Nativo
O projeto segue as diretrizes modernas recomendadas pelo Google:
O Ciclo utiliza a Intent padrão do sistema Android `AlarmClock.ACTION_SET_ALARM`, o que garante que o alarme toque mesmo se o aplicativo Ciclo estiver fechado:
| Fabricante / Sistema | Pacote do Relógio | Compatibilidade | Comportamento do `EXTRA_SKIP_UI` |
|---|---|---|---|
| **Xiaomi / Redmi / POCO** (MIUI & HyperOS) | `com.android.deskclock` | ✅ Suportado | Em algumas versões do HyperOS abre a tela de confirmação do relógio |
| **Samsung** (One UI) | `com.sec.android.app.clockpackage` | ✅ Suportado | Salva diretamente em segundo plano |
| **Google Pixel / Motorola / AOSP** | `com.google.android.deskclock` | ✅ Suportado | Salva diretamente em segundo plano |
> **Nota:** Nas configurações do Ciclo (**Ajustes > Sobre**), você pode conferir o pacote exato do Relógio detectado no seu aparelho.
---
## 🏗️ Arquitetura & Stack Tecnológica
O projeto foi construído seguindo rigorosamente os padrões modernos recomendados pelo Google:
```mermaid
flowchart TD
UI["Jetpack Compose UI (Material 3 + Haze)"] --> VM["ViewModels (StateFlow / UDF)"]
VM --> CALC["SleepCycleCalculator (Lógica Pura de Domínio)"]
VM --> SET["SettingsRepository (DataStore Preferences)"]
VM --> HIST["HistoryRepository (Room Database / SQLite)"]
VM --> NAT["NativeAlarmScheduler (AlarmClock.ACTION_SET_ALARM)"]
NAT --> CLOCK["Relógio Nativo do Aparelho (Xiaomi / Samsung / AOSP)"]
```
- **Linguagem:** Kotlin 2.0.21
- **UI:** Jetpack Compose + Material 3
- **UI Toolkit:** Jetpack Compose + Material 3 com Material You dinâmico
- **Injeção de Dependências:** Hilt (Dagger)
- **Banco de Dados local:** Room (SQLite) com KSP
- **Configurações:** Jetpack DataStore Preferences
- **Glassmorphism / Blur:** [Haze](https://github.com/chrisbanes/haze)
- **Arquitetura:** MVVM (Model-View-ViewModel) + Unidirectional Data Flow (UDF)
- **Desfoque / Glassmorphism:** [Haze](https://github.com/chrisbanes/haze)
- **Banco de Dados Local:** Room 2.6 com KSP
- **Preferências:** Jetpack DataStore Preferences
- **Datas & Horas:** `java.time` (API nativa desde o Android 8.0 / API 26)
- **CI / CD:** GitHub Actions com geração de releases automáticas assinadas
---
## 🚀 Como Compilar e Rodar
### Pré-requisitos
- JDK 21
- Android SDK com Platform API 35 e Build-Tools 34+
### Comandos
```bash
# Clonar o repositório
git clone https://github.com/muriloscolari/ciclo-alarme.git
cd ciclo-alarme
# Rodar os testes unitários
./gradlew testDebugUnitTest
# Verificar lint de código e strings (i18n)
./gradlew lintDebug
# Compilar o APK Debug
./gradlew assembleDebug
# Instalar diretamente no celular conectado via USB
./gradlew installDebug
```
---
## 📄 Licença
Este projeto é distribuído sob a licença [GNU General Public License v3.0 (GPL-3.0)](LICENSE).
Distribuído sob a licença **GNU General Public License v3.0 (GPL-3.0)**. Consulte [LICENSE](LICENSE) para mais detalhes.

View file

@ -4,27 +4,36 @@ import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import dagger.hilt.android.AndroidEntryPoint
import me.muriloscolari.ciclo.data.settings.SettingsRepository
import me.muriloscolari.ciclo.data.settings.UserSettings
import me.muriloscolari.ciclo.ui.designsystem.CicloTheme
import me.muriloscolari.ciclo.ui.navigation.CicloNavHost
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var settingsRepository: SettingsRepository
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
val userSettings by settingsRepository.settingsFlow.collectAsState(initial = UserSettings())
CicloTheme(
themeMode = userSettings.themeMode,
dynamicColor = userSettings.useDynamicColors,
blurEnabled = userSettings.blurEffects
) {
Text(text = "Ciclo")
CicloNavHost()
}
}
}

View file

@ -0,0 +1,84 @@
package me.muriloscolari.ciclo.alarm
import android.content.Context
import android.content.Intent
import android.provider.AlarmClock
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NativeAlarmScheduler @Inject constructor(
@ApplicationContext private val context: Context
) {
sealed interface AlarmResult {
data object Sent : AlarmResult
data object NoClockApp : AlarmResult
data class Failed(val error: Throwable) : AlarmResult
}
/**
* Envia o comando para criar um alarme no app de Relógio padrão do aparelho via [AlarmClock.ACTION_SET_ALARM].
*
* @param hour Hora do alarme (0..23).
* @param minute Minuto do alarme (0..59).
* @param label Título descritivo exibido no alarme.
* @param skipUi Se verdadeiro, solicita ao app de relógio salvar sem abrir a tela de edição (sujeito à implementação do fabricante).
* @param vibrate Se o alarme deve vibrar ao tocar.
*/
fun setAlarm(
hour: Int,
minute: Int,
label: String,
skipUi: Boolean = true,
vibrate: Boolean = true
): AlarmResult {
val intent = Intent(AlarmClock.ACTION_SET_ALARM).apply {
putExtra(AlarmClock.EXTRA_HOUR, hour)
putExtra(AlarmClock.EXTRA_MINUTES, minute)
putExtra(AlarmClock.EXTRA_MESSAGE, label)
putExtra(AlarmClock.EXTRA_SKIP_UI, skipUi)
putExtra(AlarmClock.EXTRA_VIBRATE, vibrate)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val resolveInfo = intent.resolveActivity(context.packageManager)
if (resolveInfo == null) {
return AlarmResult.NoClockApp
}
return runCatching {
context.startActivity(intent)
}.fold(
onSuccess = { AlarmResult.Sent },
onFailure = { AlarmResult.Failed(it) }
)
}
/**
* Abre a lista de alarmes do app de Relógio nativo via [AlarmClock.ACTION_SHOW_ALARMS].
*/
fun openClockAlarms(): Boolean {
val intent = Intent(AlarmClock.ACTION_SHOW_ALARMS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (intent.resolveActivity(context.packageManager) == null) {
return false
}
return runCatching {
context.startActivity(intent)
true
}.getOrDefault(false)
}
/**
* Retorna o nome do pacote do aplicativo de Relógio que responderá à ação de definir alarme.
* Útil para diagnóstico em Ajustes > Sobre (ex.: "com.android.deskclock" na Xiaomi).
*/
fun resolvedClockPackage(): String? {
val intent = Intent(AlarmClock.ACTION_SET_ALARM)
val componentName = intent.resolveActivity(context.packageManager)
return componentName?.packageName
}
}

View file

@ -0,0 +1,34 @@
package me.muriloscolari.ciclo.data.history
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface AlarmHistoryDao {
@Query("SELECT * FROM alarm_history ORDER BY createdAtMillis DESC")
fun getAllHistory(): Flow<List<AlarmHistoryEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entry: AlarmHistoryEntity): Long
@Delete
suspend fun delete(entry: AlarmHistoryEntity)
@Query("DELETE FROM alarm_history WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM alarm_history")
suspend fun clearAll()
/**
* Busca alarmes criados para o mesmo horário [wakeHour]:[wakeMinute]
* a partir de [sinceMillis] (ex.: nas últimas 12 horas) para detectar duplicidades.
*/
@Query("SELECT * FROM alarm_history WHERE wakeHour = :wakeHour AND wakeMinute = :wakeMinute AND createdAtMillis >= :sinceMillis ORDER BY createdAtMillis DESC LIMIT 1")
suspend fun findRecentSameTime(wakeHour: Int, wakeMinute: Int, sinceMillis: Long): AlarmHistoryEntity?
}

View file

@ -0,0 +1,21 @@
package me.muriloscolari.ciclo.data.history
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "alarm_history")
data class AlarmHistoryEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val createdAtMillis: Long,
val mode: String, // NOW, BEDTIME, WAKE_AT, SIMPLE
val bedTimeMillis: Long,
val wakeTimeMillis: Long,
val wakeHour: Int,
val wakeMinute: Int,
val cycles: Int,
val cycleMinutes: Int,
val fallAsleepMinutes: Int,
val wasRecommended: Boolean,
val clockPackage: String?
)

View file

@ -0,0 +1,13 @@
package me.muriloscolari.ciclo.data.history
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [AlarmHistoryEntity::class],
version = 1,
exportSchema = false
)
abstract class CicloDatabase : RoomDatabase() {
abstract fun alarmHistoryDao(): AlarmHistoryDao
}

View file

@ -0,0 +1,33 @@
package me.muriloscolari.ciclo.data.history
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HistoryRepository @Inject constructor(
private val dao: AlarmHistoryDao
) {
val historyFlow: Flow<List<AlarmHistoryEntity>> = dao.getAllHistory()
suspend fun recordAlarm(entry: AlarmHistoryEntity): Long {
return dao.insert(entry)
}
suspend fun deleteEntry(id: Long) {
dao.deleteById(id)
}
suspend fun deleteEntry(entry: AlarmHistoryEntity) {
dao.delete(entry)
}
suspend fun clearHistory() {
dao.clearAll()
}
suspend fun findRecentDuplicate(wakeHour: Int, wakeMinute: Int, windowMillis: Long = 12 * 60 * 60 * 1000L): AlarmHistoryEntity? {
val since = System.currentTimeMillis() - windowMillis
return dao.findRecentSameTime(wakeHour, wakeMinute, since)
}
}

View file

@ -0,0 +1,141 @@
package me.muriloscolari.ciclo.data.settings
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import me.muriloscolari.ciclo.domain.SleepSettings
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "ciclo_settings")
data class UserSettings(
val sleepSettings: SleepSettings = SleepSettings(),
val skipUi: Boolean = true,
val vibrate: Boolean = true,
val themeMode: ThemeMode = ThemeMode.DARK,
val blurEffects: Boolean = true,
val useDynamicColors: Boolean = false,
val is24HourFormat: Boolean = true
)
enum class ThemeMode {
SYSTEM,
LIGHT,
DARK
}
@Singleton
class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
private object Keys {
val CYCLE_MINUTES = intPreferencesKey("cycle_minutes")
val FALL_ASLEEP_MINUTES = intPreferencesKey("fall_asleep_minutes")
val RECOMMENDED_CYCLES = intPreferencesKey("recommended_cycles")
val MIN_CYCLES = intPreferencesKey("min_cycles")
val MAX_CYCLES = intPreferencesKey("max_cycles")
val MAX_SLEEP_MINUTES = intPreferencesKey("max_sleep_minutes")
val SKIP_UI = booleanPreferencesKey("skip_ui")
val VIBRATE = booleanPreferencesKey("vibrate")
val THEME_MODE = stringPreferencesKey("theme_mode")
val BLUR_EFFECTS = booleanPreferencesKey("blur_effects")
val DYNAMIC_COLORS = booleanPreferencesKey("dynamic_colors")
val IS_24_HOUR = booleanPreferencesKey("is_24_hour")
}
val settingsFlow: Flow<UserSettings> = context.dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { prefs ->
val cycleMinutes = prefs[Keys.CYCLE_MINUTES] ?: 90
val fallAsleepMinutes = prefs[Keys.FALL_ASLEEP_MINUTES] ?: 15
val recommendedCycles = prefs[Keys.RECOMMENDED_CYCLES] ?: 5
val minCycles = prefs[Keys.MIN_CYCLES] ?: 3
val maxCycles = prefs[Keys.MAX_CYCLES] ?: 6
val maxSleepMinutes = prefs[Keys.MAX_SLEEP_MINUTES] ?: 600
val skipUi = prefs[Keys.SKIP_UI] ?: true
val vibrate = prefs[Keys.VIBRATE] ?: true
val themeModeString = prefs[Keys.THEME_MODE] ?: ThemeMode.DARK.name
val themeMode = runCatching { ThemeMode.valueOf(themeModeString) }.getOrDefault(ThemeMode.DARK)
val blurEffects = prefs[Keys.BLUR_EFFECTS] ?: true
val dynamicColors = prefs[Keys.DYNAMIC_COLORS] ?: false
val is24Hour = prefs[Keys.IS_24_HOUR] ?: true
UserSettings(
sleepSettings = SleepSettings(
cycleMinutes = cycleMinutes.coerceIn(90, 120),
fallAsleepMinutes = fallAsleepMinutes.coerceIn(0, 60),
minCycles = minCycles.coerceIn(1, 4),
maxCycles = maxCycles.coerceIn(5, 8),
recommendedCycles = recommendedCycles.coerceIn(minCycles, maxCycles),
maxSleepMinutes = maxSleepMinutes
),
skipUi = skipUi,
vibrate = vibrate,
themeMode = themeMode,
blurEffects = blurEffects,
useDynamicColors = dynamicColors,
is24HourFormat = is24Hour
)
}
suspend fun updateCycleMinutes(minutes: Int) {
context.dataStore.edit { it[Keys.CYCLE_MINUTES] = minutes.coerceIn(90, 120) }
}
suspend fun updateFallAsleepMinutes(minutes: Int) {
context.dataStore.edit { it[Keys.FALL_ASLEEP_MINUTES] = minutes.coerceIn(0, 60) }
}
suspend fun updateRecommendedCycles(cycles: Int) {
context.dataStore.edit { it[Keys.RECOMMENDED_CYCLES] = cycles.coerceIn(1, 8) }
}
suspend fun updateSkipUi(skip: Boolean) {
context.dataStore.edit { it[Keys.SKIP_UI] = skip }
}
suspend fun updateVibrate(vibrate: Boolean) {
context.dataStore.edit { it[Keys.VIBRATE] = vibrate }
}
suspend fun updateThemeMode(mode: ThemeMode) {
context.dataStore.edit { it[Keys.THEME_MODE] = mode.name }
}
suspend fun updateBlurEffects(enabled: Boolean) {
context.dataStore.edit { it[Keys.BLUR_EFFECTS] = enabled }
}
suspend fun updateDynamicColors(enabled: Boolean) {
context.dataStore.edit { it[Keys.DYNAMIC_COLORS] = enabled }
}
suspend fun update24HourFormat(is24Hour: Boolean) {
context.dataStore.edit { it[Keys.IS_24_HOUR] = is24Hour }
}
suspend fun resetToDefaults() {
context.dataStore.edit { it.clear() }
}
}

View file

@ -0,0 +1,42 @@
package me.muriloscolari.ciclo.di
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import me.muriloscolari.ciclo.data.history.AlarmHistoryDao
import me.muriloscolari.ciclo.data.history.CicloDatabase
import java.time.Clock
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides
@Singleton
fun provideCicloDatabase(
@ApplicationContext context: Context
): CicloDatabase {
return Room.databaseBuilder(
context,
CicloDatabase::class.java,
"ciclo.db"
).fallbackToDestructiveMigration().build()
}
@Provides
@Singleton
fun provideAlarmHistoryDao(database: CicloDatabase): AlarmHistoryDao {
return database.alarmHistoryDao()
}
@Provides
@Singleton
fun provideSystemClock(): Clock {
return Clock.systemDefaultZone()
}
}

View file

@ -0,0 +1,182 @@
package me.muriloscolari.ciclo.ui.calculator
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.muriloscolari.ciclo.R
import me.muriloscolari.ciclo.domain.SleepMode
import me.muriloscolari.ciclo.domain.SleepOption
import me.muriloscolari.ciclo.ui.designsystem.DarkSurface
import me.muriloscolari.ciclo.ui.designsystem.PrimaryLavender
import me.muriloscolari.ciclo.ui.designsystem.TextPrimary
import me.muriloscolari.ciclo.ui.designsystem.TextSecondary
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlarmConfirmationSheet(
option: SleepOption,
mode: SleepMode,
isSimpleAlarm: Boolean,
duplicateWarning: String?,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
val wakeDisplay = option.wakeTime.format(timeFormatter)
val bedDisplay = option.bedTime.format(timeFormatter)
val hours = option.sleepDuration.toHours()
val minutes = option.sleepDuration.toMinutes() % 60
val durationText = if (minutes == 0L) {
stringResource(R.string.duration_only_hours, hours)
} else {
stringResource(R.string.duration_hours_minutes, hours, minutes)
}
val summaryText = when {
isSimpleAlarm -> stringResource(R.string.simple_alarm_desc, wakeDisplay)
mode == SleepMode.NOW -> stringResource(R.string.confirm_alarm_summary_now, option.cycles, durationText)
mode == SleepMode.BEDTIME -> stringResource(R.string.confirm_alarm_summary_bedtime, bedDisplay, wakeDisplay, option.cycles)
mode == SleepMode.WAKE_AT -> stringResource(R.string.confirm_alarm_summary_wakeat, wakeDisplay, bedDisplay, option.cycles)
else -> ""
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = DarkSurface,
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
dragHandle = {
Box(
modifier = Modifier
.padding(vertical = 12.dp)
.width(40.dp)
.height(4.dp)
.clip(RoundedCornerShape(999.dp))
.background(Color(0x33FFFFFF))
)
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.navigationBarsPadding()
.padding(bottom = 24.dp)
) {
Text(
text = stringResource(R.string.confirm_alarm_title, wakeDisplay),
style = MaterialTheme.typography.titleLarge.copy(
color = TextPrimary,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp
)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = summaryText,
style = MaterialTheme.typography.bodyMedium.copy(
color = TextSecondary,
lineHeight = 22.sp
)
)
if (duplicateWarning != null) {
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color(0x22F59E0B))
.padding(14.dp)
) {
Text(
text = stringResource(
R.string.confirm_alarm_duplicate_warning,
wakeDisplay,
duplicateWarning
),
style = MaterialTheme.typography.bodyMedium.copy(
color = Color(0xFFFBBF24),
fontSize = 13.sp
)
)
}
}
Spacer(modifier = Modifier.height(28.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
onClick = onDismiss,
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(R.string.action_cancel),
color = TextSecondary
)
}
Spacer(modifier = Modifier.width(12.dp))
Button(
onClick = onConfirm,
modifier = Modifier.weight(1.5f),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryLavender,
contentColor = Color(0xFF130E26)
),
shape = RoundedCornerShape(999.dp)
) {
Icon(
imageVector = Icons.Rounded.Alarm,
contentDescription = null,
tint = Color(0xFF130E26)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.confirm_alarm_action),
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}

View file

@ -0,0 +1,331 @@
package me.muriloscolari.ciclo.ui.calculator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AccessTime
import androidx.compose.material.icons.rounded.NightlightRound
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import me.muriloscolari.ciclo.R
import me.muriloscolari.ciclo.domain.SleepMode
import me.muriloscolari.ciclo.ui.designsystem.GlassBorder
import me.muriloscolari.ciclo.ui.designsystem.GlassFill
import me.muriloscolari.ciclo.ui.designsystem.GlassSurface
import me.muriloscolari.ciclo.ui.designsystem.PrimaryLavender
import me.muriloscolari.ciclo.ui.designsystem.SegmentedPill
import me.muriloscolari.ciclo.ui.designsystem.TextPrimary
import me.muriloscolari.ciclo.ui.designsystem.TextSecondary
import me.muriloscolari.ciclo.ui.designsystem.TextTertiary
import java.time.format.DateTimeFormatter
@Composable
fun CalculatorScreen(
viewModel: CalculatorViewModel,
hazeState: HazeState,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier
) {
val uiState by viewModel.uiState.collectAsState()
var showTimePicker by remember { mutableStateOf(false) }
val successMsg = stringResource(R.string.feedback_alarm_sent)
val noClockMsg = stringResource(R.string.feedback_no_clock_app)
val failedFormat = stringResource(R.string.feedback_alarm_failed)
val labelFormat = stringResource(R.string.alarm_clock_label)
val simpleLabel = stringResource(R.string.alarm_clock_label_simple)
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is CalculatorUiEvent.ShowSnackbar -> {
snackbarHostState.showSnackbar(event.message)
}
}
}
}
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
val currentTimeString = uiState.currentTime.format(timeFormatter)
val modeLabels = mapOf(
SleepMode.NOW to stringResource(R.string.mode_sleep_now),
SleepMode.BEDTIME to stringResource(R.string.mode_sleep_at),
SleepMode.WAKE_AT to stringResource(R.string.mode_wake_at)
)
Column(
modifier = modifier
.fillMaxSize()
.statusBarsPadding()
) {
// Cabeçalho da aplicação
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium.copy(
color = TextPrimary,
fontWeight = FontWeight.Light,
letterSpacing = (-0.5).sp
)
)
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Rounded.NightlightRound,
contentDescription = null,
tint = PrimaryLavender,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.current_time_format, currentTimeString),
style = MaterialTheme.typography.bodyMedium.copy(
color = TextSecondary,
fontSize = 13.sp
)
)
}
}
// Seletor de Modo
Box(modifier = Modifier.padding(horizontal = 24.dp)) {
SegmentedPill(
selectedMode = uiState.selectedMode,
onModeSelected = { viewModel.onModeSelected(it) },
hazeState = hazeState,
labels = modeLabels
)
}
Spacer(modifier = Modifier.height(14.dp))
// Seletor de Horário para BEDTIME ou WAKE_AT
AnimatedVisibility(
visible = uiState.selectedMode != SleepMode.NOW,
enter = fadeIn(),
exit = fadeOut()
) {
val selectedTime = if (uiState.selectedMode == SleepMode.BEDTIME) {
uiState.chosenBedTime
} else {
uiState.chosenWakeTime
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 6.dp)
) {
GlassSurface(
shape = RoundedCornerShape(16.dp),
hazeState = hazeState,
backgroundColor = GlassFill,
modifier = Modifier.fillMaxWidth(),
onClick = { showTimePicker = true }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = if (uiState.selectedMode == SleepMode.BEDTIME) {
stringResource(R.string.go_to_bed_at_label)
} else {
stringResource(R.string.wake_up_at_label)
},
style = MaterialTheme.typography.bodyMedium.copy(color = TextSecondary)
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Rounded.AccessTime,
contentDescription = null,
tint = PrimaryLavender,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = selectedTime.format(timeFormatter),
style = MaterialTheme.typography.titleMedium.copy(
color = PrimaryLavender,
fontWeight = FontWeight.SemiBold
)
)
}
}
}
}
}
// Lista de Cards com as opções
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
item {
Text(
text = if (uiState.selectedMode == SleepMode.WAKE_AT) {
stringResource(R.string.go_to_bed_at_label)
} else {
stringResource(R.string.wake_up_at_label)
},
style = MaterialTheme.typography.labelLarge.copy(
color = TextTertiary,
fontSize = 13.sp
),
modifier = Modifier.padding(start = 4.dp, bottom = 2.dp)
)
}
items(uiState.options) { option ->
SleepOptionCard(
option = option,
onClick = { viewModel.onOptionClicked(option) },
hazeState = hazeState
)
}
// Caso especial: Modo "Acordar às" sem tempo ou opção de alarme simples
if (uiState.selectedMode == SleepMode.WAKE_AT) {
item {
val wakeStr = uiState.chosenWakeTime.format(timeFormatter)
Spacer(modifier = Modifier.height(6.dp))
Button(
onClick = {
viewModel.onSimpleAlarmClicked(
uiState.chosenWakeTime.hour,
uiState.chosenWakeTime.minute
)
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = GlassFill,
contentColor = TextPrimary
),
shape = RoundedCornerShape(16.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, GlassBorder)
) {
Text(
text = stringResource(R.string.action_create_simple_alarm, wakeStr),
style = MaterialTheme.typography.bodyMedium.copy(color = PrimaryLavender)
)
}
}
}
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(
R.string.fall_asleep_notice,
uiState.userSettings.sleepSettings.fallAsleepMinutes
),
style = MaterialTheme.typography.bodyMedium.copy(
color = TextTertiary,
fontSize = 12.sp
)
)
}
}
}
}
// BottomSheet de Confirmação
uiState.selectedOptionForConfirmation?.let { option ->
AlarmConfirmationSheet(
option = option,
mode = uiState.selectedMode,
isSimpleAlarm = uiState.isSimpleAlarmForConfirmation,
duplicateWarning = uiState.duplicateWarningText,
onConfirm = {
viewModel.confirmAndSetAlarm(
labelFormat = labelFormat,
simpleLabel = simpleLabel,
successMessage = successMsg,
noClockMessage = noClockMsg,
failedFormat = failedFormat
)
},
onDismiss = { viewModel.dismissConfirmation() }
)
}
// Diálogo Seletor de Horário
if (showTimePicker) {
val initialTime = if (uiState.selectedMode == SleepMode.BEDTIME) {
uiState.chosenBedTime
} else {
uiState.chosenWakeTime
}
CicloTimePickerDialog(
initialTime = initialTime,
onTimeSelected = { newTime ->
if (uiState.selectedMode == SleepMode.BEDTIME) {
viewModel.onBedTimeChanged(newTime)
} else {
viewModel.onWakeTimeChanged(newTime)
}
showTimePicker = false
},
onDismiss = { showTimePicker = false },
is24Hour = uiState.userSettings.is24HourFormat
)
}
}

View file

@ -0,0 +1,266 @@
package me.muriloscolari.ciclo.ui.calculator
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import me.muriloscolari.ciclo.alarm.NativeAlarmScheduler
import me.muriloscolari.ciclo.alarm.NativeAlarmScheduler.AlarmResult
import me.muriloscolari.ciclo.data.history.AlarmHistoryEntity
import me.muriloscolari.ciclo.data.history.HistoryRepository
import me.muriloscolari.ciclo.data.settings.SettingsRepository
import me.muriloscolari.ciclo.data.settings.UserSettings
import me.muriloscolari.ciclo.domain.SleepCycleCalculator
import me.muriloscolari.ciclo.domain.SleepMode
import me.muriloscolari.ciclo.domain.SleepOption
import me.muriloscolari.ciclo.domain.WakeAtResult
import java.time.Clock
import java.time.Duration
import java.time.LocalDateTime
import java.time.LocalTime
import javax.inject.Inject
data class CalculatorUiState(
val selectedMode: SleepMode = SleepMode.NOW,
val currentTime: LocalDateTime = LocalDateTime.now(),
val chosenBedTime: LocalTime = LocalTime.of(23, 0),
val chosenWakeTime: LocalTime = LocalTime.of(7, 0),
val options: List<SleepOption> = emptyList(),
val hasAvailableOptions: Boolean = true,
val selectedOptionForConfirmation: SleepOption? = null,
val duplicateWarningText: String? = null,
val isSimpleAlarmForConfirmation: Boolean = false,
val userSettings: UserSettings = UserSettings()
)
sealed interface CalculatorUiEvent {
data class ShowSnackbar(val message: String) : CalculatorUiEvent
}
@HiltViewModel
class CalculatorViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val historyRepository: HistoryRepository,
private val alarmScheduler: NativeAlarmScheduler,
private val clock: Clock
) : ViewModel() {
private val _uiState = MutableStateFlow(
CalculatorUiState(
currentTime = LocalDateTime.now(clock)
)
)
val uiState: StateFlow<CalculatorUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<CalculatorUiEvent>()
val events: SharedFlow<CalculatorUiEvent> = _events.asSharedFlow()
private val tickerFlow = MutableStateFlow(LocalDateTime.now(clock))
init {
// Atualiza a cada minuto para o modo "Dormir agora" refletir a passagem do tempo
viewModelScope.launch {
while (isActive) {
delay(15000L) // Checa a cada 15 segundos
val now = LocalDateTime.now(clock)
if (now.minute != tickerFlow.value.minute) {
tickerFlow.value = now
}
}
}
// Combina configurações e tempo para recalcular as opções
viewModelScope.launch {
combine(
settingsRepository.settingsFlow,
tickerFlow,
_uiState
) { settings, now, state ->
Triple(settings, now, state)
}.collect { (settings, now, state) ->
recalculateOptions(settings, now, state.selectedMode, state.chosenBedTime, state.chosenWakeTime)
}
}
}
private fun recalculateOptions(
settings: UserSettings,
now: LocalDateTime,
mode: SleepMode,
bedTimeInput: LocalTime,
wakeTimeInput: LocalTime
) {
val (options, hasAny) = when (mode) {
SleepMode.NOW -> {
val opts = SleepCycleCalculator.wakeTimesFor(now, settings.sleepSettings)
Pair(opts, true)
}
SleepMode.BEDTIME -> {
var bedDateTime = now.toLocalDate().atTime(bedTimeInput)
if (bedDateTime.isBefore(now)) {
bedDateTime = bedDateTime.plusDays(1)
}
val opts = SleepCycleCalculator.wakeTimesFor(bedDateTime, settings.sleepSettings)
Pair(opts, true)
}
SleepMode.WAKE_AT -> {
var wakeDateTime = now.toLocalDate().atTime(wakeTimeInput)
if (wakeDateTime.isBefore(now)) {
wakeDateTime = wakeDateTime.plusDays(1)
}
val result: WakeAtResult = SleepCycleCalculator.bedTimesFor(wakeDateTime, now, settings.sleepSettings)
Pair(result.options, result.hasAnyAvailable)
}
}
_uiState.update { current ->
current.copy(
currentTime = now,
userSettings = settings,
options = options,
hasAvailableOptions = hasAny
)
}
}
fun onModeSelected(mode: SleepMode) {
_uiState.update { it.copy(selectedMode = mode) }
}
fun onBedTimeChanged(time: LocalTime) {
_uiState.update { it.copy(chosenBedTime = time) }
}
fun onWakeTimeChanged(time: LocalTime) {
_uiState.update { it.copy(chosenWakeTime = time) }
}
fun onOptionClicked(option: SleepOption) {
viewModelScope.launch {
val duplicate = historyRepository.findRecentDuplicate(
wakeHour = option.wakeTime.hour,
wakeMinute = option.wakeTime.minute
)
val warning = duplicate?.let {
val diffMinutes = ((System.currentTimeMillis() - it.createdAtMillis) / 60000L).coerceAtLeast(1)
if (diffMinutes < 60) "${diffMinutes} min" else "${diffMinutes / 60} h"
}
_uiState.update {
it.copy(
selectedOptionForConfirmation = option,
duplicateWarningText = warning,
isSimpleAlarmForConfirmation = false
)
}
}
}
fun onSimpleAlarmClicked(wakeHour: Int, wakeMinute: Int) {
viewModelScope.launch {
val duplicate = historyRepository.findRecentDuplicate(wakeHour, wakeMinute)
val warning = duplicate?.let {
val diffMinutes = ((System.currentTimeMillis() - it.createdAtMillis) / 60000L).coerceAtLeast(1)
if (diffMinutes < 60) "${diffMinutes} min" else "${diffMinutes / 60} h"
}
val fakeOption = SleepOption(
cycles = 0,
bedTime = LocalDateTime.now(clock),
wakeTime = LocalDateTime.now(clock).toLocalDate().atTime(wakeHour, wakeMinute),
sleepDuration = Duration.ZERO,
isRecommended = false,
isAvailable = true
)
_uiState.update {
it.copy(
selectedOptionForConfirmation = fakeOption,
duplicateWarningText = warning,
isSimpleAlarmForConfirmation = true
)
}
}
}
fun dismissConfirmation() {
_uiState.update {
it.copy(
selectedOptionForConfirmation = null,
duplicateWarningText = null,
isSimpleAlarmForConfirmation = false
)
}
}
fun confirmAndSetAlarm(
labelFormat: String,
simpleLabel: String,
successMessage: String,
noClockMessage: String,
failedFormat: String
) {
val option = _uiState.value.selectedOptionForConfirmation ?: return
val isSimple = _uiState.value.isSimpleAlarmForConfirmation
val settings = _uiState.value.userSettings
val wakeHour = option.wakeTime.hour
val wakeMinute = option.wakeTime.minute
val label = if (isSimple) simpleLabel else String.format(labelFormat, option.cycles)
val result = alarmScheduler.setAlarm(
hour = wakeHour,
minute = wakeMinute,
label = label,
skipUi = settings.skipUi,
vibrate = settings.vibrate
)
dismissConfirmation()
viewModelScope.launch {
when (result) {
is AlarmResult.Sent -> {
// Salva no histórico do Room
historyRepository.recordAlarm(
AlarmHistoryEntity(
createdAtMillis = System.currentTimeMillis(),
mode = if (isSimple) "SIMPLE" else _uiState.value.selectedMode.name,
bedTimeMillis = option.bedTime.atZone(clock.zone).toInstant().toEpochMilli(),
wakeTimeMillis = option.wakeTime.atZone(clock.zone).toInstant().toEpochMilli(),
wakeHour = wakeHour,
wakeMinute = wakeMinute,
cycles = option.cycles,
cycleMinutes = settings.sleepSettings.cycleMinutes,
fallAsleepMinutes = settings.sleepSettings.fallAsleepMinutes,
wasRecommended = option.isRecommended,
clockPackage = alarmScheduler.resolvedClockPackage()
)
)
_events.emit(CalculatorUiEvent.ShowSnackbar(successMessage))
}
is AlarmResult.NoClockApp -> {
_events.emit(CalculatorUiEvent.ShowSnackbar(noClockMessage))
}
is AlarmResult.Failed -> {
_events.emit(
CalculatorUiEvent.ShowSnackbar(
String.format(failedFormat, result.error.localizedMessage ?: "")
)
)
}
}
}
}
}

View file

@ -0,0 +1,147 @@
package me.muriloscolari.ciclo.ui.calculator
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import me.muriloscolari.ciclo.R
import me.muriloscolari.ciclo.domain.SleepOption
import me.muriloscolari.ciclo.ui.designsystem.GlassBorder
import me.muriloscolari.ciclo.ui.designsystem.GlassFill
import me.muriloscolari.ciclo.ui.designsystem.GlassSurface
import me.muriloscolari.ciclo.ui.designsystem.PrimaryLavender
import me.muriloscolari.ciclo.ui.designsystem.RecommendedCardBorder
import me.muriloscolari.ciclo.ui.designsystem.RecommendedGradient
import me.muriloscolari.ciclo.ui.designsystem.TextPrimary
import me.muriloscolari.ciclo.ui.designsystem.TextSecondary
import me.muriloscolari.ciclo.ui.designsystem.TextTertiary
import java.time.format.DateTimeFormatter
@Composable
fun SleepOptionCard(
option: SleepOption,
onClick: () -> Unit,
modifier: Modifier = Modifier,
hazeState: HazeState? = null
) {
val isRecommended = option.isRecommended && option.isAvailable
val isAvailable = option.isAvailable
val cardAlpha by animateFloatAsState(
targetValue = if (isAvailable) 1f else 0.40f,
animationSpec = tween(durationMillis = 250),
label = "cardAlpha"
)
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
val timeDisplay = option.wakeTime.format(timeFormatter)
val hours = option.sleepDuration.toHours()
val minutes = option.sleepDuration.toMinutes() % 60
val durationText = if (minutes == 0L) {
stringResource(R.string.duration_only_hours, hours)
} else {
stringResource(R.string.duration_hours_minutes, hours, minutes)
}
val cyclesCountText = pluralStringResource(
R.plurals.cycles_count,
option.cycles,
option.cycles
)
val subtitle = stringResource(
R.string.cycle_and_duration_format,
cyclesCountText,
durationText
)
GlassSurface(
modifier = modifier
.fillMaxWidth()
.alpha(cardAlpha),
shape = RoundedCornerShape(26.dp),
hazeState = hazeState,
backgroundColor = if (isRecommended) Color(0x1EA78BFA) else GlassFill,
borderStroke = if (!isRecommended) BorderStroke(1.dp, GlassBorder) else null,
borderBrush = if (isRecommended) RecommendedCardBorder else null,
borderWidth = if (isRecommended) 1.5.dp else 1.dp,
onClick = if (isAvailable) onClick else null
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 20.dp)
) {
if (isRecommended) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(RecommendedGradient)
.padding(horizontal = 10.dp, vertical = 4.dp)
) {
Text(
text = stringResource(R.string.recommended_badge),
style = MaterialTheme.typography.labelSmall.copy(
color = Color(0xFF130E26),
fontWeight = FontWeight.Bold,
fontSize = 11.sp
)
)
}
Spacer(modifier = Modifier.height(10.dp))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = timeDisplay,
style = MaterialTheme.typography.displayLarge.copy(
color = if (isRecommended) PrimaryLavender else TextPrimary,
fontWeight = FontWeight.Light,
fontSize = 54.sp
)
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (isAvailable) subtitle else stringResource(R.string.past_time_unavailable),
style = MaterialTheme.typography.bodyMedium.copy(
color = if (isAvailable) TextSecondary else TextTertiary,
fontSize = 14.sp
)
)
}
}
}
}
}

View file

@ -0,0 +1,112 @@
package me.muriloscolari.ciclo.ui.calculator
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.TimePickerDefaults
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import me.muriloscolari.ciclo.R
import me.muriloscolari.ciclo.ui.designsystem.DarkSurface
import me.muriloscolari.ciclo.ui.designsystem.DarkSurfaceElevated
import me.muriloscolari.ciclo.ui.designsystem.PrimaryLavender
import me.muriloscolari.ciclo.ui.designsystem.TextPrimary
import me.muriloscolari.ciclo.ui.designsystem.TextSecondary
import java.time.LocalTime
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CicloTimePickerDialog(
initialTime: LocalTime,
onTimeSelected: (LocalTime) -> Unit,
onDismiss: () -> Unit,
is24Hour: Boolean = true
) {
val timePickerState = rememberTimePickerState(
initialHour = initialTime.hour,
initialMinute = initialTime.minute,
is24Hour = is24Hour
)
Dialog(onDismissRequest = onDismiss) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.background(DarkSurface)
.padding(24.dp)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.time_picker_title),
style = MaterialTheme.typography.titleMedium.copy(color = TextPrimary),
modifier = Modifier.padding(bottom = 20.dp)
)
TimePicker(
state = timePickerState,
colors = TimePickerDefaults.colors(
clockDialColor = DarkSurfaceElevated,
selectorColor = PrimaryLavender,
timeSelectorSelectedContainerColor = PrimaryLavender,
timeSelectorUnselectedContainerColor = DarkSurfaceElevated,
timeSelectorSelectedContentColor = Color(0xFF130E26),
timeSelectorUnselectedContentColor = TextPrimary
)
)
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
onClick = onDismiss,
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(R.string.action_cancel),
color = TextSecondary
)
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
onTimeSelected(LocalTime.of(timePickerState.hour, timePickerState.minute))
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryLavender,
contentColor = Color(0xFF130E26)
)
) {
Text(text = stringResource(R.string.time_picker_confirm))
}
}
}
}
}
}

View file

@ -0,0 +1,115 @@
package me.muriloscolari.ciclo.ui.designsystem
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@Composable
fun AuroraBackground(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val infiniteTransition = rememberInfiniteTransition(label = "auroraTransition")
val floatAnim1 by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 14000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "auroraFloat1"
)
val floatAnim2 by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 18000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "auroraFloat2"
)
Box(
modifier = modifier
.fillMaxSize()
.background(DarkBackground)
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val width = size.width
val height = size.height
// Orbe Superior Direito (Índigo Profundo)
val center1 = Offset(
x = width * (0.85f - (floatAnim1 * 0.15f)),
y = height * (0.15f + (floatAnim2 * 0.12f))
)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
DeepIndigo.copy(alpha = 0.40f),
DeepViolet.copy(alpha = 0.20f),
Color.Transparent
),
center = center1,
radius = width * 0.75f
),
center = center1,
radius = width * 0.75f
)
// Orbe Inferior Esquerdo (Violeta / Lavanda)
val center2 = Offset(
x = width * (0.15f + (floatAnim2 * 0.15f)),
y = height * (0.65f - (floatAnim1 * 0.15f))
)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
DeepViolet.copy(alpha = 0.35f),
PrimaryLavender.copy(alpha = 0.15f),
Color.Transparent
),
center = center2,
radius = width * 0.70f
),
center = center2,
radius = width * 0.70f
)
// Orbe Central Sutil
val center3 = Offset(
x = width * (0.5f + (floatAnim1 * 0.1f)),
y = height * (0.4f + (floatAnim2 * 0.1f))
)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
AccentIndigo.copy(alpha = 0.18f),
Color.Transparent
),
center = center3,
radius = width * 0.5f
),
center = center3,
radius = width * 0.5f
)
}
content()
}
}

View file

@ -0,0 +1,38 @@
package me.muriloscolari.ciclo.ui.designsystem
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
val DarkBackground = Color(0xFF0A0A12)
val DarkSurface = Color(0xFF141420)
val DarkSurfaceElevated = Color(0xFF1E1E2E)
val GlassFill = Color(0x14FFFFFF) // 8% white
val GlassFillActive = Color(0x24FFFFFF)
val GlassBorder = Color(0x1FFFFFFF) // ~12% white hairline
val GlassBorderActive = Color(0x40A78BFA) // Lavender hairline border
val PrimaryLavender = Color(0xFFA78BFA)
val SecondaryViolet = Color(0xFF8B5CF6)
val AccentIndigo = Color(0xFF6366F1)
val DeepIndigo = Color(0xFF3730A3)
val DeepViolet = Color(0xFF7C3AED)
val TextPrimary = Color(0xFFF2F2F7)
val TextSecondary = Color(0x99F2F2F7) // 60%
val TextTertiary = Color(0x66F2F2F7) // 40%
val RecommendedGradient = Brush.horizontalGradient(
colors = listOf(
Color(0xFFA78BFA),
Color(0xFF6366F1)
)
)
val RecommendedCardBorder = Brush.linearGradient(
colors = listOf(
Color(0x99A78BFA),
Color(0x666366F1),
Color(0x22A78BFA)
)
)

View file

@ -0,0 +1,117 @@
package me.muriloscolari.ciclo.ui.designsystem
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.History
import androidx.compose.material.icons.rounded.NightlightRound
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
enum class NavigationTab(val route: String) {
CALCULATE("calculate"),
HISTORY("history"),
SETTINGS("settings")
}
@Composable
fun GlassNavigationBar(
currentTab: NavigationTab,
onTabSelected: (NavigationTab) -> Unit,
modifier: Modifier = Modifier,
hazeState: HazeState? = null
) {
Box(
modifier = modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 48.dp, vertical = 16.dp),
contentAlignment = Alignment.Center
) {
GlassSurface(
shape = RoundedCornerShape(999.dp),
hazeState = hazeState,
backgroundColor = Color(0x33141420),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
NavigationItem(
icon = Icons.Rounded.NightlightRound,
isSelected = currentTab == NavigationTab.CALCULATE,
onClick = { onTabSelected(NavigationTab.CALCULATE) }
)
NavigationItem(
icon = Icons.Rounded.History,
isSelected = currentTab == NavigationTab.HISTORY,
onClick = { onTabSelected(NavigationTab.HISTORY) }
)
NavigationItem(
icon = Icons.Rounded.Settings,
isSelected = currentTab == NavigationTab.SETTINGS,
onClick = { onTabSelected(NavigationTab.SETTINGS) }
)
}
}
}
}
@Composable
private fun NavigationItem(
icon: ImageVector,
isSelected: Boolean,
onClick: () -> Unit
) {
val bgColor by animateColorAsState(
targetValue = if (isSelected) Color(0x33A78BFA) else Color.Transparent,
animationSpec = tween(durationMillis = 250),
label = "navItemBg"
)
val iconColor by animateColorAsState(
targetValue = if (isSelected) PrimaryLavender else TextSecondary,
animationSpec = tween(durationMillis = 250),
label = "navItemIcon"
)
Box(
modifier = Modifier
.size(52.dp, 44.dp)
.clip(CircleShape)
.background(bgColor)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconColor,
modifier = Modifier.size(24.dp)
)
}
}

View file

@ -0,0 +1,65 @@
package me.muriloscolari.ciclo.ui.designsystem
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild
@Composable
fun GlassSurface(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(24.dp),
hazeState: HazeState? = null,
backgroundColor: Color = GlassFill,
borderStroke: BorderStroke? = BorderStroke(1.dp, GlassBorder),
borderBrush: Brush? = null,
borderWidth: Dp = 1.dp,
onClick: (() -> Unit)? = null,
content: @Composable () -> Unit
) {
val blurEnabled = LocalBlurEnabled.current
var boxModifier = modifier.clip(shape)
boxModifier = if (hazeState != null && blurEnabled) {
boxModifier.hazeChild(
state = hazeState,
style = dev.chrisbanes.haze.HazeDefaults.style(
backgroundColor = backgroundColor,
blurRadius = 24.dp
)
)
} else {
boxModifier.background(
if (blurEnabled) backgroundColor else DarkSurfaceElevated.copy(alpha = 0.85f)
)
}
if (borderBrush != null) {
boxModifier = boxModifier.border(borderWidth, borderBrush, shape)
} else if (borderStroke != null) {
boxModifier = boxModifier.border(borderStroke, shape)
}
if (onClick != null) {
boxModifier = boxModifier.clickable { onClick() }
}
Box(
modifier = boxModifier
) {
content()
}
}

View file

@ -0,0 +1,85 @@
package me.muriloscolari.ciclo.ui.designsystem
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import me.muriloscolari.ciclo.domain.SleepMode
@Composable
fun SegmentedPill(
selectedMode: SleepMode,
onModeSelected: (SleepMode) -> Unit,
modifier: Modifier = Modifier,
hazeState: HazeState? = null,
labels: Map<SleepMode, String>
) {
GlassSurface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(999.dp),
hazeState = hazeState,
backgroundColor = GlassFill
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
val modes = listOf(SleepMode.NOW, SleepMode.BEDTIME, SleepMode.WAKE_AT)
modes.forEach { mode ->
val isSelected = (mode == selectedMode)
val backgroundColor by animateColorAsState(
targetValue = if (isSelected) PrimaryLavender else Color.Transparent,
animationSpec = tween(durationMillis = 250),
label = "pillBgColor"
)
val textColor by animateColorAsState(
targetValue = if (isSelected) Color(0xFF130E26) else TextSecondary,
animationSpec = tween(durationMillis = 250),
label = "pillTextColor"
)
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(999.dp))
.background(backgroundColor)
.clickable { onModeSelected(mode) }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = labels[mode] ?: "",
style = MaterialTheme.typography.labelLarge.copy(
color = textColor,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
fontSize = 13.sp
),
maxLines = 1
)
}
}
}
}
}

View file

@ -0,0 +1,76 @@
package me.muriloscolari.ciclo.ui.designsystem
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import me.muriloscolari.ciclo.data.settings.ThemeMode
val LocalBlurEnabled = staticCompositionLocalOf { true }
private val CicloDarkColorScheme = darkColorScheme(
primary = PrimaryLavender,
onPrimary = Color(0xFF1E1035),
primaryContainer = Color(0xFF38265E),
onPrimaryContainer = Color(0xFFEADBFF),
secondary = SecondaryViolet,
onSecondary = Color.White,
background = DarkBackground,
onBackground = TextPrimary,
surface = DarkSurface,
onSurface = TextPrimary,
surfaceVariant = DarkSurfaceElevated,
onSurfaceVariant = TextSecondary,
outline = GlassBorder
)
private val CicloLightColorScheme = lightColorScheme(
primary = SecondaryViolet,
onPrimary = Color.White,
background = Color(0xFFF7F7FA),
onBackground = Color(0xFF1C1B1F),
surface = Color.White,
onSurface = Color(0xFF1C1B1F),
outline = Color(0xFFE2E2EA)
)
@Composable
fun CicloTheme(
themeMode: ThemeMode = ThemeMode.DARK,
dynamicColor: Boolean = false,
blurEnabled: Boolean = true,
content: @Composable () -> Unit
) {
val darkTheme = when (themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.DARK -> true
ThemeMode.LIGHT -> false
}
val context = LocalContext.current
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> CicloDarkColorScheme
else -> CicloLightColorScheme
}
CompositionLocalProvider(
LocalBlurEnabled provides blurEnabled
) {
MaterialTheme(
colorScheme = colorScheme,
typography = CicloTypography,
content = content
)
}
}

View file

@ -0,0 +1,69 @@
package me.muriloscolari.ciclo.ui.designsystem
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val CicloTypography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Light,
fontSize = 56.sp,
lineHeight = 64.sp,
letterSpacing = (-1).sp,
fontFeatureSettings = "tnum"
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 44.sp,
lineHeight = 52.sp,
letterSpacing = (-0.5).sp,
fontFeatureSettings = "tnum"
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)

View file

@ -0,0 +1,363 @@
package me.muriloscolari.ciclo.ui.history
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Launch
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material.icons.rounded.NightlightRound
import androidx.compose.material.icons.rounded.Replay
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import me.muriloscolari.ciclo.R
import me.muriloscolari.ciclo.data.history.AlarmHistoryEntity
import me.muriloscolari.ciclo.ui.designsystem.DarkSurface
import me.muriloscolari.ciclo.ui.designsystem.GlassBorder
import me.muriloscolari.ciclo.ui.designsystem.GlassFill
import me.muriloscolari.ciclo.ui.designsystem.GlassSurface
import me.muriloscolari.ciclo.ui.designsystem.PrimaryLavender
import me.muriloscolari.ciclo.ui.designsystem.TextPrimary
import me.muriloscolari.ciclo.ui.designsystem.TextSecondary
import me.muriloscolari.ciclo.ui.designsystem.TextTertiary
@Composable
fun HistoryScreen(
viewModel: HistoryViewModel,
hazeState: HazeState,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier
) {
val groupedHistory by viewModel.groupedHistory.collectAsState()
val showClearDialog by viewModel.showClearDialog.collectAsState()
val labelFormat = stringResource(R.string.alarm_clock_label)
val simpleLabel = stringResource(R.string.alarm_clock_label_simple)
val successMsg = stringResource(R.string.feedback_alarm_sent)
val noClockMsg = stringResource(R.string.feedback_no_clock_app)
val failedFormat = stringResource(R.string.feedback_alarm_failed)
val itemDeletedMsg = stringResource(R.string.history_item_deleted)
val undoLabel = stringResource(R.string.action_undo)
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is HistoryUiEvent.ShowSnackbar -> {
val result = snackbarHostState.showSnackbar(
message = event.message,
actionLabel = event.actionLabel,
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
event.onAction?.invoke()
}
}
}
}
}
Column(
modifier = modifier
.fillMaxSize()
.statusBarsPadding()
) {
// Top Bar com Título e Ação de Limpar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.history_title),
style = MaterialTheme.typography.headlineMedium.copy(
color = TextPrimary,
fontWeight = FontWeight.Light,
letterSpacing = (-0.5).sp
)
)
if (groupedHistory.isNotEmpty()) {
IconButton(onClick = { viewModel.promptClearHistory() }) {
Icon(
imageVector = Icons.Rounded.DeleteOutline,
contentDescription = stringResource(R.string.action_clear_all),
tint = TextSecondary
)
}
}
}
// Botão de atalho para abrir o Relógio Nativo do sistema
Box(modifier = Modifier.padding(horizontal = 24.dp, vertical = 6.dp)) {
GlassSurface(
shape = RoundedCornerShape(16.dp),
hazeState = hazeState,
backgroundColor = GlassFill,
modifier = Modifier.fillMaxWidth(),
onClick = { viewModel.openNativeClock() }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Rounded.Alarm,
contentDescription = null,
tint = PrimaryLavender,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(R.string.action_open_clock),
style = MaterialTheme.typography.bodyMedium.copy(
color = TextPrimary,
fontWeight = FontWeight.Medium
)
)
}
Icon(
imageVector = Icons.AutoMirrored.Rounded.Launch,
contentDescription = null,
tint = TextTertiary,
modifier = Modifier.size(18.dp)
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
if (groupedHistory.isEmpty()) {
// Estado Vazio
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp, vertical = 60.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(Color(0x1AA78BFA)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Rounded.NightlightRound,
contentDescription = null,
tint = PrimaryLavender,
modifier = Modifier.size(36.dp)
)
}
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(R.string.history_empty_title),
style = MaterialTheme.typography.titleMedium.copy(
color = TextPrimary,
fontWeight = FontWeight.SemiBold
)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.history_empty_subtitle),
style = MaterialTheme.typography.bodyMedium.copy(
color = TextTertiary,
lineHeight = 20.sp
),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
groupedHistory.forEach { group ->
item {
Text(
text = group.title,
style = MaterialTheme.typography.labelLarge.copy(
color = PrimaryLavender,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp
),
modifier = Modifier.padding(start = 4.dp, top = 8.dp)
)
}
items(group.items, key = { it.id }) { item ->
HistoryItemCard(
item = item,
hazeState = hazeState,
onRecreate = {
viewModel.recreateAlarm(
entry = item,
labelFormat = labelFormat,
simpleLabel = simpleLabel,
successMsg = successMsg,
noClockMsg = noClockMsg,
failedFormat = failedFormat
)
},
onDelete = {
viewModel.deleteItem(item, itemDeletedMsg, undoLabel)
}
)
}
}
}
}
}
// Diálogo de confirmação para limpar histórico
if (showClearDialog) {
AlertDialog(
onDismissRequest = { viewModel.dismissClearDialog() },
title = {
Text(
text = stringResource(R.string.dialog_clear_history_title),
color = TextPrimary
)
},
text = {
Text(
text = stringResource(R.string.dialog_clear_history_message),
color = TextSecondary
)
},
confirmButton = {
Button(
onClick = { viewModel.confirmClearHistory() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEF4444))
) {
Text(text = stringResource(R.string.dialog_action_clear))
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissClearDialog() }) {
Text(text = stringResource(R.string.action_cancel), color = TextSecondary)
}
},
containerColor = DarkSurface
)
}
}
@Composable
private fun HistoryItemCard(
item: AlarmHistoryEntity,
hazeState: HazeState,
onRecreate: () -> Unit,
onDelete: () -> Unit
) {
val timeFormatted = String.format("%02d:%02d", item.wakeHour, item.wakeMinute)
val cyclesText = if (item.mode == "SIMPLE") {
stringResource(R.string.simple_alarm_title)
} else {
pluralStringResource(R.plurals.cycles_count, item.cycles, item.cycles)
}
GlassSurface(
shape = RoundedCornerShape(20.dp),
hazeState = hazeState,
backgroundColor = GlassFill,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = timeFormatted,
style = MaterialTheme.typography.displayMedium.copy(
color = TextPrimary,
fontSize = 36.sp,
fontWeight = FontWeight.Light
)
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = cyclesText,
style = MaterialTheme.typography.bodyMedium.copy(
color = TextSecondary,
fontSize = 13.sp
)
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onRecreate) {
Icon(
imageVector = Icons.Rounded.Replay,
contentDescription = stringResource(R.string.action_recreate_alarm),
tint = PrimaryLavender
)
}
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Rounded.DeleteOutline,
contentDescription = stringResource(R.string.action_delete),
tint = TextTertiary
)
}
}
}
}
}

View file

@ -0,0 +1,166 @@
package me.muriloscolari.ciclo.ui.history
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import me.muriloscolari.ciclo.alarm.NativeAlarmScheduler
import me.muriloscolari.ciclo.alarm.NativeAlarmScheduler.AlarmResult
import me.muriloscolari.ciclo.data.history.AlarmHistoryEntity
import me.muriloscolari.ciclo.data.history.HistoryRepository
import me.muriloscolari.ciclo.data.settings.SettingsRepository
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import javax.inject.Inject
data class HistoryGroup(
val title: String,
val items: List<AlarmHistoryEntity>
)
sealed interface HistoryUiEvent {
data class ShowSnackbar(
val message: String,
val actionLabel: String? = null,
val onAction: (() -> Unit)? = null
) : HistoryUiEvent
}
@HiltViewModel
class HistoryViewModel @Inject constructor(
private val historyRepository: HistoryRepository,
private val settingsRepository: SettingsRepository,
private val alarmScheduler: NativeAlarmScheduler
) : ViewModel() {
private val _events = MutableSharedFlow<HistoryUiEvent>()
val events: SharedFlow<HistoryUiEvent> = _events.asSharedFlow()
private val _showClearDialog = MutableStateFlow(false)
val showClearDialog: StateFlow<Boolean> = _showClearDialog.asStateFlow()
private var recentlyDeletedItem: AlarmHistoryEntity? = null
val groupedHistory: StateFlow<List<HistoryGroup>> = historyRepository.historyFlow
.map { entries ->
val zone = ZoneId.systemDefault()
val today = LocalDate.now()
val yesterday = today.minusDays(1)
val dateFormatter = DateTimeFormatter.ofPattern("d 'de' MMMM", Locale("pt", "BR"))
entries.groupBy { entry ->
val date = Instant.ofEpochMilli(entry.createdAtMillis).atZone(zone).toLocalDate()
when (date) {
today -> "Hoje"
yesterday -> "Ontem"
else -> date.format(dateFormatter)
}
}.map { (title, items) ->
HistoryGroup(title = title, items = items)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun openNativeClock(): Boolean {
return alarmScheduler.openClockAlarms()
}
fun promptClearHistory() {
_showClearDialog.value = true
}
fun dismissClearDialog() {
_showClearDialog.value = false
}
fun confirmClearHistory() {
viewModelScope.launch {
historyRepository.clearHistory()
_showClearDialog.value = false
}
}
fun deleteItem(item: AlarmHistoryEntity, deletedMsg: String, undoLabel: String) {
recentlyDeletedItem = item
viewModelScope.launch {
historyRepository.deleteEntry(item.id)
_events.emit(
HistoryUiEvent.ShowSnackbar(
message = deletedMsg,
actionLabel = undoLabel,
onAction = {
recentlyDeletedItem?.let { restored ->
viewModelScope.launch {
historyRepository.recordAlarm(restored)
}
}
}
)
)
}
}
fun recreateAlarm(
entry: AlarmHistoryEntity,
labelFormat: String,
simpleLabel: String,
successMsg: String,
noClockMsg: String,
failedFormat: String
) {
viewModelScope.launch {
val label = if (entry.mode == "SIMPLE") {
simpleLabel
} else {
String.format(labelFormat, entry.cycles)
}
val result = alarmScheduler.setAlarm(
hour = entry.wakeHour,
minute = entry.wakeMinute,
label = label,
skipUi = true,
vibrate = true
)
when (result) {
is AlarmResult.Sent -> {
// Atualiza o registro adicionando novo evento no histórico
historyRepository.recordAlarm(
entry.copy(
id = 0,
createdAtMillis = System.currentTimeMillis()
)
)
_events.emit(HistoryUiEvent.ShowSnackbar(successMsg))
}
is AlarmResult.NoClockApp -> {
_events.emit(HistoryUiEvent.ShowSnackbar(noClockMsg))
}
is AlarmResult.Failed -> {
_events.emit(
HistoryUiEvent.ShowSnackbar(
String.format(failedFormat, result.error.localizedMessage ?: "")
)
)
}
}
}
}
}

View file

@ -0,0 +1,122 @@
package me.muriloscolari.ciclo.ui.navigation
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import me.muriloscolari.ciclo.ui.calculator.CalculatorScreen
import me.muriloscolari.ciclo.ui.calculator.CalculatorViewModel
import me.muriloscolari.ciclo.ui.designsystem.AuroraBackground
import me.muriloscolari.ciclo.ui.designsystem.GlassNavigationBar
import me.muriloscolari.ciclo.ui.designsystem.NavigationTab
import me.muriloscolari.ciclo.ui.history.HistoryScreen
import me.muriloscolari.ciclo.ui.history.HistoryViewModel
import me.muriloscolari.ciclo.ui.settings.SettingsScreen
import me.muriloscolari.ciclo.ui.settings.SettingsViewModel
@Composable
fun CicloNavHost(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController()
) {
val hazeState = remember { HazeState() }
val snackbarHostState = remember { SnackbarHostState() }
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route ?: NavigationTab.CALCULATE.route
val currentTab = when (currentRoute) {
NavigationTab.HISTORY.route -> NavigationTab.HISTORY
NavigationTab.SETTINGS.route -> NavigationTab.SETTINGS
else -> NavigationTab.CALCULATE
}
AuroraBackground(
modifier = modifier
.fillMaxSize()
.haze(state = hazeState)
) {
Scaffold(
containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
NavHost(
navController = navController,
startDestination = NavigationTab.CALCULATE.route,
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = { ExitTransition.None },
modifier = Modifier.fillMaxSize()
) {
composable(NavigationTab.CALCULATE.route) {
val viewModel = hiltViewModel<CalculatorViewModel>()
CalculatorScreen(
viewModel = viewModel,
hazeState = hazeState,
snackbarHostState = snackbarHostState
)
}
composable(NavigationTab.HISTORY.route) {
val viewModel = hiltViewModel<HistoryViewModel>()
HistoryScreen(
viewModel = viewModel,
hazeState = hazeState,
snackbarHostState = snackbarHostState
)
}
composable(NavigationTab.SETTINGS.route) {
val viewModel = hiltViewModel<SettingsViewModel>()
SettingsScreen(
viewModel = viewModel,
hazeState = hazeState
)
}
}
// Barra de Navegação Inferior de Vidro Flutuante
GlassNavigationBar(
currentTab = currentTab,
onTabSelected = { tab ->
if (tab.route != currentRoute) {
navController.navigate(tab.route) {
popUpTo(NavigationTab.CALCULATE.route) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
},
hazeState = hazeState,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
}
}

View file

@ -0,0 +1,448 @@
package me.muriloscolari.ciclo.ui.settings
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
import androidx.compose.material.icons.rounded.RestartAlt
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import me.muriloscolari.ciclo.BuildConfig
import me.muriloscolari.ciclo.R
import me.muriloscolari.ciclo.ui.designsystem.GlassFill
import me.muriloscolari.ciclo.ui.designsystem.GlassSurface
import me.muriloscolari.ciclo.ui.designsystem.PrimaryLavender
import me.muriloscolari.ciclo.ui.designsystem.TextPrimary
import me.muriloscolari.ciclo.ui.designsystem.TextSecondary
import me.muriloscolari.ciclo.ui.designsystem.TextTertiary
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel,
hazeState: HazeState,
modifier: Modifier = Modifier
) {
val settings by viewModel.userSettings.collectAsState()
val clockPkg = viewModel.detectedClockPackage
val context = LocalContext.current
Column(
modifier = modifier
.fillMaxSize()
.statusBarsPadding()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.settings_title),
style = MaterialTheme.typography.headlineMedium.copy(
color = TextPrimary,
fontWeight = FontWeight.Light,
letterSpacing = (-0.5).sp
)
)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// Seção: Ciclos de Sono
item {
SectionHeader(stringResource(R.string.settings_section_sleep))
Spacer(modifier = Modifier.height(8.dp))
GlassSurface(
shape = RoundedCornerShape(24.dp),
hazeState = hazeState,
backgroundColor = GlassFill,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(20.dp)) {
// Slider: Duração do ciclo
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.settings_cycle_duration_label),
style = MaterialTheme.typography.bodyMedium.copy(color = TextPrimary)
)
Text(
text = stringResource(
R.string.settings_cycle_duration_value,
settings.sleepSettings.cycleMinutes
),
style = MaterialTheme.typography.bodyMedium.copy(
color = PrimaryLavender,
fontWeight = FontWeight.SemiBold
)
)
}
Slider(
value = settings.sleepSettings.cycleMinutes.toFloat(),
onValueChange = { viewModel.updateCycleMinutes(it.toInt()) },
valueRange = 90f..120f,
steps = 5, // 90, 95, 100, 105, 110, 115, 120
colors = SliderDefaults.colors(
thumbColor = PrimaryLavender,
activeTrackColor = PrimaryLavender
)
)
Spacer(modifier = Modifier.height(16.dp))
// Slider: Latência para adormecer
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.settings_latency_label),
style = MaterialTheme.typography.bodyMedium.copy(color = TextPrimary)
)
Text(
text = stringResource(
R.string.settings_latency_value,
settings.sleepSettings.fallAsleepMinutes
),
style = MaterialTheme.typography.bodyMedium.copy(
color = PrimaryLavender,
fontWeight = FontWeight.SemiBold
)
)
}
Slider(
value = settings.sleepSettings.fallAsleepMinutes.toFloat(),
onValueChange = { viewModel.updateFallAsleepMinutes(it.toInt()) },
valueRange = 0f..60f,
steps = 11, // múltiplos de 5min
colors = SliderDefaults.colors(
thumbColor = PrimaryLavender,
activeTrackColor = PrimaryLavender
)
)
Spacer(modifier = Modifier.height(16.dp))
// Slider: Ciclos Recomendados
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.settings_recommended_cycles_label),
style = MaterialTheme.typography.bodyMedium.copy(color = TextPrimary)
)
Text(
text = stringResource(
R.string.settings_recommended_cycles_value,
settings.sleepSettings.recommendedCycles
),
style = MaterialTheme.typography.bodyMedium.copy(
color = PrimaryLavender,
fontWeight = FontWeight.SemiBold
)
)
}
Slider(
value = settings.sleepSettings.recommendedCycles.toFloat(),
onValueChange = { viewModel.updateRecommendedCycles(it.toInt()) },
valueRange = 3f..6f,
steps = 2,
colors = SliderDefaults.colors(
thumbColor = PrimaryLavender,
activeTrackColor = PrimaryLavender
)
)
}
}
}
// Seção: Relógio Nativo
item {
SectionHeader(stringResource(R.string.settings_section_alarm))
Spacer(modifier = Modifier.height(8.dp))
GlassSurface(
shape = RoundedCornerShape(24.dp),
hazeState = hazeState,
backgroundColor = GlassFill,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.settings_skip_ui_label),
style = MaterialTheme.typography.bodyMedium.copy(
color = TextPrimary,
fontWeight = FontWeight.Medium
)
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.settings_skip_ui_desc),
style = MaterialTheme.typography.bodyMedium.copy(
color = TextTertiary,
fontSize = 12.sp
)
)
}
Spacer(modifier = Modifier.width(12.dp))
Switch(
checked = settings.skipUi,
onCheckedChange = { viewModel.updateSkipUi(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = Color(0xFF130E26),
checkedTrackColor = PrimaryLavender
)
)
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.settings_vibrate_label),
style = MaterialTheme.typography.bodyMedium.copy(color = TextPrimary)
)
Switch(
checked = settings.vibrate,
onCheckedChange = { viewModel.updateVibrate(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = Color(0xFF130E26),
checkedTrackColor = PrimaryLavender
)
)
}
}
}
}
// Seção: Aparência
item {
SectionHeader(stringResource(R.string.settings_section_appearance))
Spacer(modifier = Modifier.height(8.dp))
GlassSurface(
shape = RoundedCornerShape(24.dp),
hazeState = hazeState,
backgroundColor = GlassFill,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.settings_blur_label),
style = MaterialTheme.typography.bodyMedium.copy(
color = TextPrimary,
fontWeight = FontWeight.Medium
)
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.settings_blur_desc),
style = MaterialTheme.typography.bodyMedium.copy(
color = TextTertiary,
fontSize = 12.sp
)
)
}
Spacer(modifier = Modifier.width(12.dp))
Switch(
checked = settings.blurEffects,
onCheckedChange = { viewModel.updateBlurEffects(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = Color(0xFF130E26),
checkedTrackColor = PrimaryLavender
)
)
}
}
}
}
// Seção: Sobre
item {
SectionHeader(stringResource(R.string.settings_section_about))
Spacer(modifier = Modifier.height(8.dp))
GlassSurface(
shape = RoundedCornerShape(24.dp),
hazeState = hazeState,
backgroundColor = GlassFill,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(20.dp)) {
// Relógio detectado
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.settings_clock_detected_label),
style = MaterialTheme.typography.bodyMedium.copy(color = TextSecondary)
)
Text(
text = clockPkg ?: stringResource(R.string.settings_clock_detected_none),
style = MaterialTheme.typography.bodyMedium.copy(
color = PrimaryLavender,
fontWeight = FontWeight.Medium
)
)
}
Spacer(modifier = Modifier.height(14.dp))
// Versão
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.settings_version_label),
style = MaterialTheme.typography.bodyMedium.copy(color = TextSecondary)
)
Text(
text = BuildConfig.VERSION_NAME,
style = MaterialTheme.typography.bodyMedium.copy(
color = TextPrimary,
fontWeight = FontWeight.Medium
)
)
}
Spacer(modifier = Modifier.height(14.dp))
// Repositório GitHub
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.settings_source_code_label),
style = MaterialTheme.typography.bodyMedium.copy(color = TextSecondary)
)
Button(
onClick = {
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse("https://github.com/muriloscolari/ciclo-alarme")
)
context.startActivity(intent)
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = PrimaryLavender
),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 2.dp)
) {
Text(text = "GitHub", fontSize = 13.sp)
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.AutoMirrored.Rounded.OpenInNew,
contentDescription = null,
modifier = Modifier.size(14.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.settings_license_label),
style = MaterialTheme.typography.bodyMedium.copy(
color = TextTertiary,
fontSize = 12.sp
)
)
}
}
}
// Botão Restaurar Padrões
item {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { viewModel.resetToDefaults() },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0x15FFFFFF),
contentColor = TextSecondary
),
shape = RoundedCornerShape(16.dp)
) {
Icon(
imageVector = Icons.Rounded.RestartAlt,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.settings_reset_button))
}
}
}
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge.copy(
color = PrimaryLavender,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp
),
modifier = Modifier.padding(start = 4.dp)
)
}

View file

@ -0,0 +1,84 @@
package me.muriloscolari.ciclo.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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 me.muriloscolari.ciclo.alarm.NativeAlarmScheduler
import me.muriloscolari.ciclo.data.settings.SettingsRepository
import me.muriloscolari.ciclo.data.settings.ThemeMode
import me.muriloscolari.ciclo.data.settings.UserSettings
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val alarmScheduler: NativeAlarmScheduler
) : ViewModel() {
val userSettings: StateFlow<UserSettings> = settingsRepository.settingsFlow
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UserSettings()
)
val detectedClockPackage: String? = alarmScheduler.resolvedClockPackage()
fun updateCycleMinutes(minutes: Int) {
viewModelScope.launch {
settingsRepository.updateCycleMinutes(minutes)
}
}
fun updateFallAsleepMinutes(minutes: Int) {
viewModelScope.launch {
settingsRepository.updateFallAsleepMinutes(minutes)
}
}
fun updateRecommendedCycles(cycles: Int) {
viewModelScope.launch {
settingsRepository.updateRecommendedCycles(cycles)
}
}
fun updateSkipUi(enabled: Boolean) {
viewModelScope.launch {
settingsRepository.updateSkipUi(enabled)
}
}
fun updateVibrate(enabled: Boolean) {
viewModelScope.launch {
settingsRepository.updateVibrate(enabled)
}
}
fun updateThemeMode(mode: ThemeMode) {
viewModelScope.launch {
settingsRepository.updateThemeMode(mode)
}
}
fun updateBlurEffects(enabled: Boolean) {
viewModelScope.launch {
settingsRepository.updateBlurEffects(enabled)
}
}
fun updateDynamicColors(enabled: Boolean) {
viewModelScope.launch {
settingsRepository.updateDynamicColors(enabled)
}
}
fun resetToDefaults() {
viewModelScope.launch {
settingsRepository.resetToDefaults()
}
}
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="cycles_count">
<item quantity="one">%1$d ciclo</item>
<item quantity="other">%1$d ciclos</item>
</plurals>
<plurals name="minutes_duration">
<item quantity="one">%1$d minuto</item>
<item quantity="other">%1$d minutos</item>
</plurals>
<plurals name="hours_duration">
<item quantity="one">%1$d hora</item>
<item quantity="other">%1$d horas</item>
</plurals>
</resources>

View file

@ -1,4 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Ciclo</string>
<!-- Navegação -->
<string name="nav_calculate">Calcular</string>
<string name="nav_history">Histórico</string>
<string name="nav_settings">Ajustes</string>
<!-- Cabeçalho & Status -->
<string name="current_time_format">Agora são %1$s</string>
<string name="wake_up_at_label">Acorde às</string>
<string name="go_to_bed_at_label">Vá para a cama às</string>
<string name="fall_asleep_notice">Inclui %1$d min para pegar no sono</string>
<!-- Modos de cálculo -->
<string name="mode_sleep_now">Dormir agora</string>
<string name="mode_sleep_at">Vou dormir às</string>
<string name="mode_wake_at">Acordar às</string>
<!-- Cards de opções -->
<string name="recommended_badge">★ Recomendado</string>
<string name="cycle_and_duration_format">%1$s · %2$s de sono</string>
<string name="duration_hours_minutes">%1$dh%2$02d</string>
<string name="duration_only_hours">%1$dh00</string>
<string name="duration_only_minutes">%1$d min</string>
<string name="past_time_unavailable">Horário já passou</string>
<!-- Alarme Simples -->
<string name="simple_alarm_title">Alarme simples</string>
<string name="simple_alarm_desc">Não há tempo hábil para ciclos completos. Deseja criar um alarme simples para as %1$s?</string>
<string name="action_create_simple_alarm">Criar alarme simples às %1$s</string>
<!-- Confirmação de Alarme (Bottom Sheet) -->
<string name="confirm_alarm_title">Definir alarme às %1$s?</string>
<string name="confirm_alarm_summary_now">Se deitar agora, você acorda após %1$d ciclos (%2$s de sono).</string>
<string name="confirm_alarm_summary_bedtime">Deitando às %1$s, você acorda às %2$s com %3$d ciclos de sono.</string>
<string name="confirm_alarm_summary_wakeat">Para acordar às %1$s descansado, vá para a cama às %2$s (%3$d ciclos).</string>
<string name="confirm_alarm_duplicate_warning">⚠️ Você já enviou um alarme para as %1$s há %2$s. Deseja enviar novamente?</string>
<string name="confirm_alarm_action">Criar no Relógio</string>
<string name="action_cancel">Cancelar</string>
<!-- Rótulo do Alarme nativo -->
<string name="alarm_clock_label">💤 %1$d ciclos · Ciclo</string>
<string name="alarm_clock_label_simple">⏰ Despertador · Ciclo</string>
<!-- Feedback e Erros -->
<string name="feedback_alarm_sent">Alarme enviado ao aplicativo de Relógio!</string>
<string name="feedback_alarm_failed">Não foi possível criar o alarme: %1$s</string>
<string name="feedback_no_clock_app">Nenhum aplicativo de Relógio nativo compatível foi encontrado no dispositivo.</string>
<string name="action_open_clock">Abrir alarmes do Relógio</string>
<!-- Histórico -->
<string name="history_title">Histórico de Alarmes</string>
<string name="history_empty_title">Nenhum alarme recente</string>
<string name="history_empty_subtitle">Os alarmes que você criar aparecerão aqui para consulta e recriação rápida.</string>
<string name="history_today">Hoje</string>
<string name="history_yesterday">Ontem</string>
<string name="action_recreate_alarm">Criar de novo</string>
<string name="action_delete">Excluir</string>
<string name="action_undo">Desfazer</string>
<string name="history_item_deleted">Item removido do histórico.</string>
<string name="action_clear_all">Limpar tudo</string>
<string name="dialog_clear_history_title">Limpar histórico?</string>
<string name="dialog_clear_history_message">Esta ação removerá todos os registros locais do aplicativo. Os alarmes já criados no Relógio nativo permanecerão salvos nele.</string>
<string name="dialog_action_clear">Limpar</string>
<!-- Ajustes -->
<string name="settings_title">Ajustes</string>
<string name="settings_section_sleep">Ciclos de Sono</string>
<string name="settings_cycle_duration_label">Duração do ciclo</string>
<string name="settings_cycle_duration_value">%1$d minutos</string>
<string name="settings_latency_label">Tempo para pegar no sono</string>
<string name="settings_latency_value">%1$d minutos</string>
<string name="settings_recommended_cycles_label">Ciclos recomendados</string>
<string name="settings_recommended_cycles_value">%1$d ciclos</string>
<string name="settings_section_alarm">Relógio Nativo</string>
<string name="settings_skip_ui_label">Salvar em segundo plano</string>
<string name="settings_skip_ui_desc">Tenta criar o alarme diretamente sem abrir a tela do Relógio (depende do fabricante).</string>
<string name="settings_vibrate_label">Vibrar ao despertar</string>
<string name="settings_section_appearance">Aparência</string>
<string name="settings_theme_label">Tema</string>
<string name="settings_theme_dark">Escuro</string>
<string name="settings_theme_light">Claro</string>
<string name="settings_theme_system">Padrão do sistema</string>
<string name="settings_blur_label">Efeitos de vidro e desfoque</string>
<string name="settings_blur_desc">Utiliza efeito frosted glass moderno (desative caso queira poupar bateria extra).</string>
<string name="settings_dynamic_colors_label">Cores do sistema (Material You)</string>
<string name="settings_section_about">Sobre</string>
<string name="settings_clock_detected_label">Relógio nativo detectado</string>
<string name="settings_clock_detected_none">Nenhum detectado</string>
<string name="settings_version_label">Versão do Ciclo</string>
<string name="settings_source_code_label">Código aberto no GitHub</string>
<string name="settings_license_label">Licença GPL-3.0</string>
<string name="settings_reset_button">Restaurar padrões</string>
<!-- Diálogo Seletor de Hora -->
<string name="time_picker_title">Escolha o horário</string>
<string name="time_picker_confirm">Confirmar</string>
</resources>

BIN
docs/mockup.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB