feat: aplicativo completo Ciclo com UI Glassmorphism, Hilt, Room, DataStore e integração com Relógio Nativo
This commit is contained in:
parent
bd1796228b
commit
14f45c16d4
29 changed files with 3377 additions and 27 deletions
107
README.md
107
README.md
|
|
@ -6,40 +6,111 @@
|
|||
[-green.svg)](https://developer.android.com)
|
||||
[](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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
42
app/src/main/java/me/muriloscolari/ciclo/di/DataModule.kt
Normal file
42
app/src/main/java/me/muriloscolari/ciclo/di/DataModule.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
17
app/src/main/res/values/plurals.xml
Normal file
17
app/src/main/res/values/plurals.xml
Normal 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>
|
||||
|
|
@ -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
BIN
docs/mockup.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 479 KiB |
Loading…
Reference in a new issue