diff --git a/app/src/main/java/com/bnyro/clock/App.kt b/app/src/main/java/com/bnyro/clock/App.kt index 52a31372..5ddd5433 100644 --- a/app/src/main/java/com/bnyro/clock/App.kt +++ b/app/src/main/java/com/bnyro/clock/App.kt @@ -1,16 +1,19 @@ package com.bnyro.clock import android.app.Application -import com.bnyro.clock.data.database.DatabaseHolder +import com.bnyro.clock.data.database.AppDatabase import com.bnyro.clock.util.NotificationHelper import com.bnyro.clock.util.Preferences class App : Application() { + lateinit var container: AppContainer + private val database by lazy { AppDatabase.getDatabase(this) } override fun onCreate() { super.onCreate() - DatabaseHolder.init(this) Preferences.init(this) - NotificationHelper.createNotificationChannels(this) + NotificationHelper().createNotificationChannels(this) + + container = AppContainer(database) } } diff --git a/app/src/main/java/com/bnyro/clock/AppContainer.kt b/app/src/main/java/com/bnyro/clock/AppContainer.kt new file mode 100644 index 00000000..a079ee69 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/AppContainer.kt @@ -0,0 +1,14 @@ +package com.bnyro.clock + +import com.bnyro.clock.data.database.AppDatabase +import com.bnyro.clock.domain.repository.AlarmRepository +import com.bnyro.clock.domain.repository.TimezoneRepository + +class AppContainer(database: AppDatabase) { + val alarmRepository: AlarmRepository by lazy { + AlarmRepository(database.alarmsDao()) + } + val timezoneRepository: TimezoneRepository by lazy { + TimezoneRepository(database.timeZonesDao()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/clock/data/database/AppDatabase.kt b/app/src/main/java/com/bnyro/clock/data/database/AppDatabase.kt index 436ba39f..0d4dfbe0 100644 --- a/app/src/main/java/com/bnyro/clock/data/database/AppDatabase.kt +++ b/app/src/main/java/com/bnyro/clock/data/database/AppDatabase.kt @@ -1,16 +1,23 @@ package com.bnyro.clock.data.database +import android.content.Context import androidx.room.AutoMigration import androidx.room.Database import androidx.room.DeleteColumn +import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.AutoMigrationSpec +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import com.bnyro.clock.data.database.dao.AlarmsDao import com.bnyro.clock.data.database.dao.Converters import com.bnyro.clock.data.database.dao.TimeZonesDao import com.bnyro.clock.domain.model.Alarm import com.bnyro.clock.domain.model.TimeZone +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Database( entities = [TimeZone::class, Alarm::class], @@ -33,4 +40,67 @@ abstract class AppDatabase : RoomDatabase() { abstract fun timeZonesDao(): TimeZonesDao abstract fun alarmsDao(): AlarmsDao + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + private const val dbName = "com.bnyro.clock" + lateinit var instance: AppDatabase + + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE alarms ADD COLUMN label TEXT DEFAULT NULL" + ) + db.execSQL( + "ALTER TABLE alarms ADD COLUMN soundUri TEXT DEFAULT NULL" + ) + } + } + + private val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE alarms ADD COLUMN soundName TEXT DEFAULT NULL" + ) + } + } + + private val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE timeZones RENAME TO temp_table") + db.execSQL("CREATE TABLE IF NOT EXISTS `timeZones` (`zoneId` TEXT NOT NULL, `zoneName` TEXT NOT NULL, `countryName` TEXT NOT NULL, `offset` INTEGER NOT NULL, `key` TEXT NOT NULL, PRIMARY KEY(`key`))") + db.execSQL("INSERT INTO timeZones (key, zoneId, offset, zoneName, countryName) SELECT name, name, offset, displayName, countryName FROM temp_table") + db.execSQL("DROP TABLE temp_table") + + postMigrate7to8() + } + } + + private fun postMigrate7to8() { + CoroutineScope(Dispatchers.IO).launch { + val zones = instance.timeZonesDao().getAll().map { + it.copy(key = arrayOf(it.zoneId, it.zoneName, it.countryName).joinToString(",")) + } + instance.timeZonesDao().clear() + instance.timeZonesDao().insertAll(*zones.toTypedArray()) + } + } + + fun getDatabase(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room + .databaseBuilder(context, AppDatabase::class.java, dbName) + .addMigrations( + MIGRATION_1_2, + MIGRATION_3_4, + MIGRATION_7_8 + ) + .build() + INSTANCE = instance + instance + } + } + } } diff --git a/app/src/main/java/com/bnyro/clock/data/database/DatabaseHolder.kt b/app/src/main/java/com/bnyro/clock/data/database/DatabaseHolder.kt deleted file mode 100644 index 72304d38..00000000 --- a/app/src/main/java/com/bnyro/clock/data/database/DatabaseHolder.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.bnyro.clock.data.database - -import android.content.Context -import androidx.room.Room -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -object DatabaseHolder { - private const val dbName = "com.bnyro.clock" - lateinit var instance: AppDatabase - - private val MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL( - "ALTER TABLE alarms ADD COLUMN label TEXT DEFAULT NULL" - ) - database.execSQL( - "ALTER TABLE alarms ADD COLUMN soundUri TEXT DEFAULT NULL" - ) - } - } - - private val MIGRATION_3_4 = object : Migration(3, 4) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL( - "ALTER TABLE alarms ADD COLUMN soundName TEXT DEFAULT NULL" - ) - } - } - - private val MIGRATION_7_8 = object : Migration(7, 8) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE timeZones RENAME TO temp_table") - database.execSQL("CREATE TABLE IF NOT EXISTS `timeZones` (`zoneId` TEXT NOT NULL, `zoneName` TEXT NOT NULL, `countryName` TEXT NOT NULL, `offset` INTEGER NOT NULL, `key` TEXT NOT NULL, PRIMARY KEY(`key`))") - database.execSQL("INSERT INTO timeZones (key, zoneId, offset, zoneName, countryName) SELECT name, name, offset, displayName, countryName FROM temp_table") - database.execSQL("DROP TABLE temp_table") - - postMigrate7to8() - } - } - - private fun postMigrate7to8() { - CoroutineScope(Dispatchers.IO).launch { - val zones = instance.timeZonesDao().getAll().map { - it.copy(key = arrayOf(it.zoneId, it.zoneName, it.countryName).joinToString(",")) - } - instance.timeZonesDao().clear() - instance.timeZonesDao().insertAll(*zones.toTypedArray()) - } - } - - fun init(context: Context) { - instance = Room - .databaseBuilder(context, AppDatabase::class.java, dbName) - .addMigrations(MIGRATION_1_2, MIGRATION_3_4, MIGRATION_7_8) - .build() - } -} diff --git a/app/src/main/java/com/bnyro/clock/domain/model/Permission.kt b/app/src/main/java/com/bnyro/clock/domain/model/Permission.kt new file mode 100644 index 00000000..83b63da5 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/domain/model/Permission.kt @@ -0,0 +1,73 @@ +package com.bnyro.clock.domain.model + +import android.Manifest +import android.app.Activity +import android.app.AlarmManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.app.ActivityCompat +import androidx.core.net.toUri +import com.bnyro.clock.BuildConfig +import com.bnyro.clock.R + +sealed class Permission( + @StringRes + val titleRes: Int, + @StringRes + val descriptionRes: Int, + @DrawableRes + val iconRes: Int +) { + abstract fun hasPermission(context: Context): Boolean + abstract fun requestPermission(activity: Activity) + + object NotificationPermission : + Permission( + titleRes = R.string.notification_permission_title, + descriptionRes = R.string.notification_permission_description, + iconRes = R.drawable.ic_alarm + ) { + override fun hasPermission(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true + return ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + + override fun requestPermission(activity: Activity) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 1 + ) + } + } + + object AlarmPermission : Permission( + titleRes = R.string.alarm_permission_title, + descriptionRes = R.string.alarm_permission_description, + iconRes = R.drawable.ic_alarm + ) { + + override fun hasPermission(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + return alarmManager.canScheduleExactAlarms() + } + + override fun requestPermission(activity: Activity) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + val intent = Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply { + data = "package:${BuildConfig.APPLICATION_ID}".toUri() + } + activity.startActivity(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/clock/domain/repository/AlarmRepository.kt b/app/src/main/java/com/bnyro/clock/domain/repository/AlarmRepository.kt new file mode 100644 index 00000000..07098bff --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/domain/repository/AlarmRepository.kt @@ -0,0 +1,22 @@ +package com.bnyro.clock.domain.repository + +import com.bnyro.clock.data.database.dao.AlarmsDao +import com.bnyro.clock.domain.model.Alarm +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +class AlarmRepository(private val alarmsDao: AlarmsDao) { + suspend fun addAlarm(alarm: Alarm) = withContext(Dispatchers.IO) { alarmsDao.insert(alarm) } + + suspend fun getAlarms(): List = withContext(Dispatchers.IO) { alarmsDao.getAll() } + fun getAlarmsStream(): Flow> = alarmsDao.getAllStream() + + suspend fun getAlarmById(id: Long): Alarm = + withContext(Dispatchers.IO) { alarmsDao.findById(id) } + + suspend fun deleteAlarm(alarm: Alarm) = withContext(Dispatchers.IO) { alarmsDao.delete(alarm) } + + suspend fun updateAlarm(alarm: Alarm) = withContext(Dispatchers.IO) { alarmsDao.update(alarm) } + +} \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/clock/domain/repository/TimezoneRepository.kt b/app/src/main/java/com/bnyro/clock/domain/repository/TimezoneRepository.kt new file mode 100644 index 00000000..d38f304c --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/domain/repository/TimezoneRepository.kt @@ -0,0 +1,49 @@ +package com.bnyro.clock.domain.repository + +import android.content.Context +import com.bnyro.clock.data.database.dao.TimeZonesDao +import com.bnyro.clock.domain.model.CountryTimezone +import com.bnyro.clock.domain.model.TimeZone +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import java.util.Calendar + +class TimezoneRepository(private val timeZonesDao: TimeZonesDao) { + suspend fun getTimezones(): List = + withContext(Dispatchers.IO) { timeZonesDao.getAll() } + + fun getTimezonesStream(): Flow> = timeZonesDao.getAllStream() + suspend fun replaceAll(vararg timeZone: TimeZone) = withContext(Dispatchers.IO) { + timeZonesDao.clear() + timeZonesDao.insertAll(*timeZone) + } + + suspend fun delete(timeZone: TimeZone) = + withContext(Dispatchers.IO) { timeZonesDao.delete(timeZone) } + + fun getTimezonesForCountries(context: Context): List { + val countryTimezones = getCountryTimezones(context) + return getTimezonesForCountries(countryTimezones) + } + + private fun getTimezonesForCountries(zoneIds: List): List { + return zoneIds.map { + val zone = java.util.TimeZone.getTimeZone(it.zoneId) + val zoneKey = arrayOf(it.zoneId, it.zoneName, it.countryName).joinToString(",") + val offset = zone.getOffset(Calendar.getInstance().timeInMillis) + TimeZone(zoneKey, it.zoneId, offset, it.zoneName, it.countryName) + }.sortedBy { it.zoneName } + } + + private fun getCountryTimezones(context: Context): List { + val tzData = + context.resources.assets.open("tz_data.json").bufferedReader() + .use { it.readText() } + + val json = Json { ignoreUnknownKeys = true } + return json.decodeFromString(tzData) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/clock/domain/usecase/CreateUpdateDeleteAlarmUseCase.kt b/app/src/main/java/com/bnyro/clock/domain/usecase/CreateUpdateDeleteAlarmUseCase.kt new file mode 100644 index 00000000..09a286f1 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/domain/usecase/CreateUpdateDeleteAlarmUseCase.kt @@ -0,0 +1,27 @@ +package com.bnyro.clock.domain.usecase + +import android.content.Context +import com.bnyro.clock.domain.model.Alarm +import com.bnyro.clock.domain.repository.AlarmRepository +import com.bnyro.clock.util.AlarmHelper + +class CreateUpdateDeleteAlarmUseCase( + private val context: Context, + private val alarmRepository: AlarmRepository +) { + suspend fun createAlarm(alarm: Alarm) { + alarm.enabled = true + AlarmHelper.enqueue(context, alarm) + alarmRepository.addAlarm(alarm) + } + + suspend fun updateAlarm(alarm: Alarm) { + AlarmHelper.enqueue(context, alarm) + alarmRepository.updateAlarm(alarm) + } + + suspend fun deleteAlarm(alarm: Alarm) { + AlarmHelper.cancel(context, alarm) + alarmRepository.deleteAlarm(alarm) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/clock/navigation/HomeNavContainer.kt b/app/src/main/java/com/bnyro/clock/navigation/HomeNavContainer.kt index 109eaa00..7fdbc505 100644 --- a/app/src/main/java/com/bnyro/clock/navigation/HomeNavContainer.kt +++ b/app/src/main/java/com/bnyro/clock/navigation/HomeNavContainer.kt @@ -31,7 +31,7 @@ import com.bnyro.clock.presentation.screens.timer.model.TimerModel @Composable fun HomeNavContainer( - onNavigate: (NavRoutes) -> Unit, + onNavigate: (route: String) -> Unit, initialTab: HomeRoutes, clockModel: ClockModel, timerModel: TimerModel, diff --git a/app/src/main/java/com/bnyro/clock/navigation/HomeNavHost.kt b/app/src/main/java/com/bnyro/clock/navigation/HomeNavHost.kt index 879e13be..dcd7239f 100644 --- a/app/src/main/java/com/bnyro/clock/navigation/HomeNavHost.kt +++ b/app/src/main/java/com/bnyro/clock/navigation/HomeNavHost.kt @@ -16,7 +16,7 @@ import com.bnyro.clock.presentation.screens.timer.model.TimerModel @Composable fun HomeNavHost( navController: NavHostController, - onNavigate: (NavRoutes) -> Unit, + onNavigate: (route: String) -> Unit, startDestination: HomeRoutes, clockModel: ClockModel, alarmModel: AlarmModel, @@ -26,22 +26,24 @@ fun HomeNavHost( NavHost(navController, startDestination = startDestination.route) { composable(HomeRoutes.Alarm.route) { AlarmScreen(onClickSettings = { - onNavigate(NavRoutes.Settings) + onNavigate(NavRoutes.Settings.route) + }, onAlarm = { + onNavigate("${NavRoutes.AlarmPicker.route}/$it") }, alarmModel) } composable(HomeRoutes.Clock.route) { ClockScreen(onClickSettings = { - onNavigate(NavRoutes.Settings) + onNavigate(NavRoutes.Settings.route) }, clockModel) } composable(HomeRoutes.Timer.route) { TimerScreen(onClickSettings = { - onNavigate(NavRoutes.Settings) + onNavigate(NavRoutes.Settings.route) }, timerModel) } composable(HomeRoutes.Stopwatch.route) { StopwatchScreen(onClickSettings = { - onNavigate(NavRoutes.Settings) + onNavigate(NavRoutes.Settings.route) }, stopwatchModel) } } diff --git a/app/src/main/java/com/bnyro/clock/navigation/MainNavContainer.kt b/app/src/main/java/com/bnyro/clock/navigation/MainNavContainer.kt index 6d64603d..069b7f8e 100644 --- a/app/src/main/java/com/bnyro/clock/navigation/MainNavContainer.kt +++ b/app/src/main/java/com/bnyro/clock/navigation/MainNavContainer.kt @@ -8,10 +8,15 @@ import com.bnyro.clock.presentation.screens.settings.model.SettingsModel @Composable fun MainNavContainer( - settingsModel: SettingsModel, initialTab: HomeRoutes + settingsModel: SettingsModel, initialTab: HomeRoutes, + startDestination: String ) { val navController = rememberNavController() AppNavHost( - navController, settingsModel, initialTab = initialTab, modifier = Modifier.fillMaxSize() + navController, + settingsModel, + initialTab = initialTab, + startDestination = startDestination, + modifier = Modifier.fillMaxSize() ) } diff --git a/app/src/main/java/com/bnyro/clock/navigation/NavHost.kt b/app/src/main/java/com/bnyro/clock/navigation/NavHost.kt index b39135a7..6e8ad9d3 100644 --- a/app/src/main/java/com/bnyro/clock/navigation/NavHost.kt +++ b/app/src/main/java/com/bnyro/clock/navigation/NavHost.kt @@ -1,5 +1,8 @@ package com.bnyro.clock.navigation +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.compose.viewModel @@ -7,7 +10,9 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.bnyro.clock.presentation.screens.alarm.model.AlarmModel +import com.bnyro.clock.presentation.screens.alarmpicker.AlarmPickerScreen import com.bnyro.clock.presentation.screens.clock.model.ClockModel +import com.bnyro.clock.presentation.screens.permission.PermissionScreen import com.bnyro.clock.presentation.screens.settings.SettingsScreen import com.bnyro.clock.presentation.screens.settings.model.SettingsModel import com.bnyro.clock.presentation.screens.stopwatch.model.StopwatchModel @@ -18,18 +23,29 @@ fun AppNavHost( navController: NavHostController, settingsModel: SettingsModel, initialTab: HomeRoutes, + startDestination: String, modifier: Modifier = Modifier ) { val alarmModel: AlarmModel = viewModel() val timerModel: TimerModel = viewModel() val stopwatchModel: StopwatchModel = viewModel() - val clockModel: ClockModel = viewModel(factory = ClockModel.Factory) + val clockModel: ClockModel = viewModel() - NavHost(navController, startDestination = NavRoutes.Home.route, modifier = modifier) { - composable(NavRoutes.Home.route) { + NavHost(navController, startDestination = startDestination, modifier = modifier) { + composable(NavRoutes.Home.route, + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Down, + initialOffset = { it / 4 } + ) + fadeIn() + }, + exitTransition = { + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Up, + targetOffset = { it / 4 }) + fadeOut() + }) { HomeNavContainer( onNavigate = { - navController.navigate(it.route) + navController.navigate(it) }, alarmModel = alarmModel, clockModel = clockModel, @@ -38,10 +54,52 @@ fun AppNavHost( initialTab = initialTab ) } - composable(NavRoutes.Settings.route) { + composable(NavRoutes.Settings.route, + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Up, + initialOffset = { it / 4 }) + fadeIn() + }, + exitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Down, + targetOffset = { it / 4 }) + fadeOut() + }) { SettingsScreen(onClickBack = { navController.popBackStack() }, settingsModel, timerModel) } + + composable(NavRoutes.AlarmPicker.routeWithArgs, arguments = NavRoutes.AlarmPicker.args, + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Up, + initialOffset = { it / 4 }) + fadeIn() + }, + exitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Down, + targetOffset = { it / 4 }) + fadeOut() + }) { + AlarmPickerScreen { + navController.popBackStack() + } + } + + composable(NavRoutes.Permissions.route, + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Up, + initialOffset = { it / 4 }) + fadeIn() + }, + exitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Down, + targetOffset = { it / 4 }) + fadeOut() + }) { + PermissionScreen { + navController.navigate(NavRoutes.Home.route) + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/clock/navigation/NavRoutes.kt b/app/src/main/java/com/bnyro/clock/navigation/NavRoutes.kt index 7bcfe0b8..fff55a8d 100644 --- a/app/src/main/java/com/bnyro/clock/navigation/NavRoutes.kt +++ b/app/src/main/java/com/bnyro/clock/navigation/NavRoutes.kt @@ -1,8 +1,18 @@ package com.bnyro.clock.navigation +import androidx.navigation.NavType +import androidx.navigation.navArgument + sealed class NavRoutes( val route: String ) { object Home : NavRoutes("home") object Settings : NavRoutes("settings") + object AlarmPicker : NavRoutes("alarmPicker") { + const val alarmId = "alarmId" + val routeWithArgs = "$route/{$alarmId}" + val args = listOf(navArgument(alarmId) { NavType.LongType }) + } + + object Permissions : NavRoutes("permissions") } \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/clock/presentation/features/AlarmReceiverDialog.kt b/app/src/main/java/com/bnyro/clock/presentation/features/AlarmReceiverDialog.kt index 5293ab02..bbc74730 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/features/AlarmReceiverDialog.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/features/AlarmReceiverDialog.kt @@ -6,25 +6,33 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import com.bnyro.clock.domain.model.Alarm -import com.bnyro.clock.presentation.screens.alarm.components.AlarmSettingsSheet -import com.bnyro.clock.presentation.screens.alarm.model.AlarmModel +import com.bnyro.clock.presentation.screens.alarmpicker.components.AlarmPicker +import com.bnyro.clock.presentation.screens.alarmpicker.model.AlarmPickerModel @Composable fun AlarmReceiverDialog(context: Context, alarm: Alarm) { - var showSheet by remember { + var showDialog by remember { mutableStateOf(true) } - val alarmModel: AlarmModel = viewModel() - if (showSheet) { - AlarmSettingsSheet( - onDismissRequest = { showSheet = false }, - currentAlarm = alarm, - onSave = { - alarmModel.createAlarm(context, alarm) - } - ) + if (showDialog) { + val alarmModel: AlarmPickerModel = viewModel() + Dialog( + onDismissRequest = { showDialog = false }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + AlarmPicker( + onCancel = { showDialog = false }, + currentAlarm = alarm, + onSave = { + alarmModel.createAlarm(alarm) + alarmModel.createToast(alarm, context) + } + ) + } } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/features/RingtonePickerDialog.kt b/app/src/main/java/com/bnyro/clock/presentation/features/RingtonePickerDialog.kt index 74a73290..4253069c 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/features/RingtonePickerDialog.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/features/RingtonePickerDialog.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,7 +43,7 @@ fun RingtonePickerDialog( onSelection: (String, Uri) -> Unit ) { val context = LocalContext.current - val ringingToneModel: RingingToneModel = viewModel(factory = RingingToneModel.Factory) + val ringingToneModel: RingingToneModel = viewModel() val pickSoundFile = rememberLauncherForActivityResult(PickPersistentFileContract()) { uri -> if (uri == null) return@rememberLauncherForActivityResult @@ -50,6 +51,11 @@ fun RingtonePickerDialog( onDismissRequest.invoke() } + DisposableEffect(Unit) { + onDispose { + ringingToneModel.stopRinging() + } + } AlertDialog( onDismissRequest = { ringingToneModel.stopRinging() diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmActivity.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmActivity.kt index 9b9df372..79dd55e4 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmActivity.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmActivity.kt @@ -16,7 +16,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.content.ContextCompat -import com.bnyro.clock.data.database.DatabaseHolder +import com.bnyro.clock.App import com.bnyro.clock.domain.model.Alarm import com.bnyro.clock.util.AlarmHelper import com.bnyro.clock.util.services.AlarmService @@ -87,8 +87,9 @@ class AlarmActivity : ComponentActivity() { private fun handleIntent(intent: Intent) { val id = intent.getLongExtra(AlarmHelper.EXTRA_ID, -1).takeIf { it != -1L } ?: return + val alarmRepository = (application as App).container.alarmRepository this.alarm = runBlocking { - DatabaseHolder.instance.alarmsDao().findById(id) + alarmRepository.getAlarmById(id) } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmScreen.kt index 72bff429..7da94acc 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmScreen.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmScreen.kt @@ -1,8 +1,5 @@ package com.bnyro.clock.presentation.screens.alarm -import android.content.Intent -import android.os.Build -import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height @@ -12,62 +9,38 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.FilterAlt import androidx.compose.material.icons.rounded.Add -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon 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.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import com.bnyro.clock.BuildConfig import com.bnyro.clock.R -import com.bnyro.clock.domain.model.Alarm import com.bnyro.clock.navigation.TopBarScaffold import com.bnyro.clock.presentation.components.BlobIconBox import com.bnyro.clock.presentation.components.ClickableIcon import com.bnyro.clock.presentation.screens.alarm.components.AlarmFilterSection import com.bnyro.clock.presentation.screens.alarm.components.AlarmItem -import com.bnyro.clock.presentation.screens.alarm.components.AlarmSettingsSheet import com.bnyro.clock.presentation.screens.alarm.model.AlarmModel -import com.bnyro.clock.util.AlarmHelper -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AlarmScreen( onClickSettings: () -> Unit, + onAlarm: (alarmId: Long) -> Unit, alarmModel: AlarmModel ) { val context = LocalContext.current - var showCreationDialog by remember { - mutableStateOf(false) - } val alarms by alarmModel.alarms.collectAsState() val filters by alarmModel.filters.collectAsState() - LaunchedEffect(Unit) { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU) { - if (!AlarmHelper.hasPermission(context)) { - val intent = Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply { - data = "package:${BuildConfig.APPLICATION_ID}".toUri() - } - context.startActivity(intent) - } - } - } - TopBarScaffold(title = stringResource(R.string.alarm), onClickSettings, fab = { if (!alarmModel.showFilter) { FloatingActionButton( onClick = { - showCreationDialog = true + onAlarm.invoke(0L) } ) { Icon(Icons.Rounded.Add, null) @@ -105,29 +78,21 @@ fun AlarmScreen( } items(items = alarms, key = { it.id }) { - AlarmItem(it, alarmModel, context) + AlarmItem(it, onClick = { alarm -> + onAlarm.invoke(alarm.id) + }, onDeleteAlarm = { alarm -> + alarmModel.deleteAlarm(alarm) + }, onUpdateAlarm = { alarm -> + alarmModel.updateAlarm(alarm) + if (alarm.enabled) { + alarmModel.createToast(alarm, context) + } + }) } item { Spacer(modifier = Modifier.height(80.dp)) } } - - if (showCreationDialog) { - AlarmSettingsSheet(onDismissRequest = { showCreationDialog = false }, - currentAlarm = remember { Alarm(time = 0) }) { - alarmModel.createAlarm(context, it) - showCreationDialog = false - } - } - } - alarmModel.selectedAlarm?.let { - AlarmSettingsSheet( - onDismissRequest = { alarmModel.selectedAlarm = null }, - currentAlarm = it, - onSave = { newAlarm -> - alarmModel.updateAlarm(context, newAlarm) - } - ) } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmCard.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmCard.kt index e5fd428e..49765b2e 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmCard.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmCard.kt @@ -18,10 +18,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -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.platform.LocalContext @@ -32,16 +29,18 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bnyro.clock.R import com.bnyro.clock.domain.model.Alarm -import com.bnyro.clock.presentation.screens.alarm.model.AlarmModel import com.bnyro.clock.util.AlarmHelper @Composable -fun AlarmCard(alarm: Alarm, alarmModel: AlarmModel) { +fun AlarmCard( + alarm: Alarm, + onClick: () -> Unit, + isAlarmEnabled: Boolean, + onEnable: (Boolean) -> Unit +) { val context = LocalContext.current ElevatedCard( - onClick = { - alarmModel.selectedAlarm = alarm - }, + onClick = onClick, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), shape = RoundedCornerShape(20.dp) ) { @@ -127,17 +126,9 @@ fun AlarmCard(alarm: Alarm, alarmModel: AlarmModel) { } } - var isEnabled by remember { - mutableStateOf(alarm.enabled) - } - Switch( - checked = isEnabled, - onCheckedChange = { newValue -> - alarm.enabled = newValue - isEnabled = newValue - alarmModel.updateAlarm(context, alarm) - } + checked = isAlarmEnabled, + onCheckedChange = onEnable ) } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmItem.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmItem.kt index 18a6a826..3fc49148 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmItem.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmItem.kt @@ -1,6 +1,5 @@ package com.bnyro.clock.presentation.screens.alarm.components -import android.content.Context import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -30,19 +29,22 @@ import androidx.compose.ui.unit.dp import com.bnyro.clock.R import com.bnyro.clock.domain.model.Alarm import com.bnyro.clock.presentation.components.DialogButton -import com.bnyro.clock.presentation.screens.alarm.model.AlarmModel @Composable @OptIn(ExperimentalMaterial3Api::class) fun AlarmItem( - it: Alarm, - alarmModel: AlarmModel, - context: Context + alarm: Alarm, + onClick: (Alarm) -> Unit, + onUpdateAlarm: (Alarm) -> Unit, + onDeleteAlarm: (Alarm) -> Unit ) { var showDeletionDialog by remember { mutableStateOf(false) } + var isAlarmEnabled by remember { + mutableStateOf(alarm.enabled) + } val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { dismissValue -> when (dismissValue) { @@ -60,7 +62,13 @@ fun AlarmItem( enableDismissFromStartToEnd = true, enableDismissFromEndToStart = false, content = { - AlarmCard(it, alarmModel) + AlarmCard(alarm, onClick = { + onClick.invoke(alarm) + }, isAlarmEnabled, onEnable = { enabled -> + isAlarmEnabled = enabled + alarm.enabled = enabled + onUpdateAlarm.invoke(alarm) + }) }, backgroundContent = { Row( @@ -92,7 +100,7 @@ fun AlarmItem( }, confirmButton = { DialogButton(label = android.R.string.ok) { - alarmModel.deleteAlarm(context, it) + onDeleteAlarm(alarm) showDeletionDialog = false } }, diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmSettingsSheet.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmSettingsSheet.kt deleted file mode 100644 index 2fb765e0..00000000 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmSettingsSheet.kt +++ /dev/null @@ -1,268 +0,0 @@ -package com.bnyro.clock.presentation.screens.alarm.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.border -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.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Label -import androidx.compose.material.icons.rounded.Alarm -import androidx.compose.material.icons.rounded.EventRepeat -import androidx.compose.material.icons.rounded.Snooze -import androidx.compose.material.icons.rounded.Vibration -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.toMutableStateList -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.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.window.DialogWindowProvider -import com.bnyro.clock.R -import com.bnyro.clock.domain.model.Alarm -import com.bnyro.clock.presentation.components.SwitchItem -import com.bnyro.clock.presentation.components.SwitchWithDivider -import com.bnyro.clock.presentation.features.RingtonePickerDialog -import com.bnyro.clock.util.AlarmHelper -import com.bnyro.clock.util.TimeHelper - -@Composable -fun AlarmSettingsSheet(onDismissRequest: () -> Unit, currentAlarm: Alarm, onSave: (Alarm) -> Unit) { - val context = LocalContext.current - var showRingtoneDialog by remember { mutableStateOf(false) } - var showSnoozeDialog by remember { mutableStateOf(false) } - - var label by remember { mutableStateOf(currentAlarm.label ?: "") } - val chosenDays = remember { currentAlarm.days.toMutableStateList() } - var vibrationEnabled by remember { - mutableStateOf(currentAlarm.vibrate) - } - var soundName by remember { mutableStateOf(currentAlarm.soundName) } - var soundUri by remember { mutableStateOf(currentAlarm.soundUri) } - var repeat by remember { mutableStateOf(currentAlarm.repeat) } - var snoozeMinutes by remember { mutableStateOf(currentAlarm.snoozeMinutes) } - var snoozeEnabled by remember { mutableStateOf(currentAlarm.snoozeEnabled) } - var soundEnabled by remember { mutableStateOf(currentAlarm.soundEnabled) } - - val initialTime = remember { TimeHelper.millisToTime(currentAlarm.time) } - var hours = remember { initialTime.hours } - var minutes = remember { initialTime.minutes } - Dialog( - onDismissRequest, - properties = DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false - ) - ) { - val window = (LocalView.current.parent as DialogWindowProvider).window - SideEffect { - window.setDimAmount(0f) - } - val scrollState = rememberScrollState() - Surface { - Column( - Modifier - .fillMaxSize() - .padding(horizontal = 8.dp, vertical = 16.dp) - .verticalScroll(scrollState), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceEvenly - ) { - AlarmTimePicker( - hours, - minutes, - onHoursChanged = { - hours = it - }, - onMinutesChanged = { minutes = it } - ) - Column { - SwitchItem( - title = stringResource(R.string.repeat), - isChecked = repeat, - onClick = { newValue -> - repeat = newValue - }, - icon = Icons.Rounded.EventRepeat - ) - AnimatedVisibility(visible = repeat) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - val daysOfWeek = remember { - AlarmHelper.getDaysOfWeekByLocale(context) - } - - daysOfWeek.forEach { (day, index) -> - val enabled = chosenDays.contains(index) - Box( - modifier = Modifier - .size(30.dp) - .background( - if (enabled) MaterialTheme.colorScheme.primary else Color.Transparent, - CircleShape - ) - .clip(CircleShape) - .border( - if (enabled) 0.dp else 1.dp, - MaterialTheme.colorScheme.primary, - CircleShape - ) - .clickable { - if (enabled) { - if (chosenDays.size > 1) chosenDays.remove(index) - } else { - chosenDays.add( - index - ) - } - }, - contentAlignment = Alignment.Center - ) { - Text( - text = day, - color = if (enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } - } - } - Row( - modifier = Modifier.padding(8.dp, 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = label, - onValueChange = { - label = it - }, - label = { - Text(text = stringResource(id = R.string.alarm_name)) - }, - singleLine = true, - leadingIcon = { - Icon(imageVector = Icons.Outlined.Label, contentDescription = null) - } - ) - } - SwitchWithDivider( - title = stringResource(R.string.sound), - description = soundName ?: stringResource(R.string.default_sound), - isChecked = soundEnabled, - icon = Icons.Rounded.Alarm, - onClick = { - showRingtoneDialog = true - }, - onChecked = { - soundEnabled = it - } - ) - SwitchItem( - title = stringResource(R.string.vibrate), - isChecked = vibrationEnabled, - onClick = { newValue -> - vibrationEnabled = newValue - }, - icon = Icons.Rounded.Vibration - ) - SwitchWithDivider( - title = stringResource(R.string.snooze), - description = with(snoozeMinutes) { - pluralStringResource( - id = R.plurals.minutes, - count = this, - this - ) - }, - isChecked = snoozeEnabled, - icon = Icons.Rounded.Snooze, - onClick = { - showSnoozeDialog = true - }, - onChecked = { - snoozeEnabled = it - } - ) - } - Row( - Modifier.align(Alignment.End), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - OutlinedButton(onClick = { onDismissRequest.invoke() }) { - Text(text = stringResource(id = android.R.string.cancel)) - } - Button(onClick = { - val alarm = - currentAlarm.copy( - time = (hours * 60 + minutes) * 60 * 1000L, - label = label.takeIf { l -> l.isNotBlank() }, - days = chosenDays.sorted(), - vibrate = vibrationEnabled, - soundName = soundName, - soundUri = soundUri, - repeat = repeat, - snoozeEnabled = snoozeEnabled, - snoozeMinutes = snoozeMinutes, - soundEnabled = soundEnabled - ) - onSave(alarm) - onDismissRequest.invoke() - }) { - Text(text = stringResource(id = android.R.string.ok)) - } - } - } - } - } - if (showRingtoneDialog) { - RingtonePickerDialog(onDismissRequest = { - showRingtoneDialog = false - }) { title, uri -> - soundUri = uri.toString() - soundName = title - } - } - if (showSnoozeDialog) { - SnoozeTimePickerDialog( - onDismissRequest = { showSnoozeDialog = false }, - currentTime = snoozeMinutes, - onTimeSet = { - snoozeMinutes = it - showSnoozeDialog = false - } - ) - } -} diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/model/AlarmModel.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/model/AlarmModel.kt index 43542702..32cfa1c1 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/model/AlarmModel.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/model/AlarmModel.kt @@ -1,19 +1,21 @@ package com.bnyro.clock.presentation.screens.alarm.model +import android.app.Application import android.content.Context import android.widget.Toast import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.bnyro.clock.App import com.bnyro.clock.R -import com.bnyro.clock.data.database.DatabaseHolder import com.bnyro.clock.domain.model.Alarm import com.bnyro.clock.domain.model.AlarmFilters +import com.bnyro.clock.domain.repository.AlarmRepository +import com.bnyro.clock.domain.usecase.CreateUpdateDeleteAlarmUseCase import com.bnyro.clock.util.AlarmHelper import com.bnyro.clock.util.TimeHelper -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -24,12 +26,15 @@ import kotlinx.coroutines.launch import java.util.Collections import kotlin.time.Duration.Companion.milliseconds -class AlarmModel : ViewModel() { - var selectedAlarm: Alarm? by mutableStateOf(null) +class AlarmModel(application: Application) : AndroidViewModel(application) { + private val alarmRepository: AlarmRepository = (application as App).container.alarmRepository + private val createUpdateDeleteAlarmUseCase = + CreateUpdateDeleteAlarmUseCase(application.applicationContext, alarmRepository) + var showFilter by mutableStateOf(false) val filters = MutableStateFlow(AlarmFilters()) val alarms: StateFlow> = - combine(DatabaseHolder.instance.alarmsDao().getAllStream(), filters) { items, filter -> + combine(alarmRepository.getAlarmsStream(), filters) { items, filter -> items.filter { alarm -> (filter.startTime <= alarm.time && alarm.time <= filter.endTime) && !Collections.disjoint(filter.weekDays, alarm.days) @@ -44,26 +49,13 @@ class AlarmModel : ViewModel() { initialValue = listOf() ) - fun createAlarm(context: Context, alarm: Alarm) { - alarm.enabled = true - AlarmHelper.enqueue(context, alarm) - createToast(alarm, context) - viewModelScope.launch(Dispatchers.IO) { - DatabaseHolder.instance.alarmsDao().insert(alarm) - } - } - - fun updateAlarm(context: Context, alarm: Alarm) { - if (alarm.enabled) { - createToast(alarm, context) - } - AlarmHelper.enqueue(context, alarm) - viewModelScope.launch(Dispatchers.IO) { - DatabaseHolder.instance.alarmsDao().update(alarm) + fun updateAlarm(alarm: Alarm) { + viewModelScope.launch { + createUpdateDeleteAlarmUseCase.updateAlarm(alarm) } } - private fun createToast(alarm: Alarm, context: Context) { + fun createToast(alarm: Alarm, context: Context) { val millisRemainingForAlarm = (AlarmHelper.getAlarmTime(alarm) - System.currentTimeMillis()) val formattedDuration = @@ -75,10 +67,9 @@ class AlarmModel : ViewModel() { ).show() } - fun deleteAlarm(context: Context, alarm: Alarm) { - AlarmHelper.cancel(context, alarm) - viewModelScope.launch(Dispatchers.IO) { - DatabaseHolder.instance.alarmsDao().delete(alarm) + fun deleteAlarm(alarm: Alarm) { + viewModelScope.launch { + createUpdateDeleteAlarmUseCase.deleteAlarm(alarm) } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarmpicker/AlarmPickerScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarmpicker/AlarmPickerScreen.kt new file mode 100644 index 00000000..87fb9e0d --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarmpicker/AlarmPickerScreen.kt @@ -0,0 +1,30 @@ +package com.bnyro.clock.presentation.screens.alarmpicker + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel +import com.bnyro.clock.presentation.screens.alarmpicker.components.AlarmPicker +import com.bnyro.clock.presentation.screens.alarmpicker.model.AlarmPickerModel + +@Composable +fun AlarmPickerScreen(onNavigateBack: () -> Unit) { + val viewModel: AlarmPickerModel = viewModel() + val context = LocalContext.current + AlarmPicker( + onCancel = { onNavigateBack.invoke() }, + currentAlarm = viewModel.alarm, + onSave = { alarm -> + if (alarm.id == 0L) { + //Create New Alarm + viewModel.createAlarm(alarm) + viewModel.createToast(alarm, context) + } else { + //Update Alarm + viewModel.updateAlarm(alarm) + if (alarm.enabled) { + viewModel.createToast(alarm, context) + } + } + onNavigateBack.invoke() + }) +} \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarmpicker/components/AlarmSettingsSheet.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarmpicker/components/AlarmSettingsSheet.kt new file mode 100644 index 00000000..cdfb85ce --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarmpicker/components/AlarmSettingsSheet.kt @@ -0,0 +1,253 @@ +package com.bnyro.clock.presentation.screens.alarmpicker.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Label +import androidx.compose.material.icons.rounded.Alarm +import androidx.compose.material.icons.rounded.EventRepeat +import androidx.compose.material.icons.rounded.Snooze +import androidx.compose.material.icons.rounded.Vibration +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +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.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.bnyro.clock.R +import com.bnyro.clock.domain.model.Alarm +import com.bnyro.clock.presentation.components.SwitchItem +import com.bnyro.clock.presentation.components.SwitchWithDivider +import com.bnyro.clock.presentation.features.RingtonePickerDialog +import com.bnyro.clock.presentation.screens.alarm.components.AlarmTimePicker +import com.bnyro.clock.presentation.screens.alarm.components.SnoozeTimePickerDialog +import com.bnyro.clock.util.AlarmHelper +import com.bnyro.clock.util.TimeHelper + +@Composable +fun AlarmPicker(currentAlarm: Alarm, onSave: (Alarm) -> Unit, onCancel: () -> Unit) { + val context = LocalContext.current + var showRingtoneDialog by remember { mutableStateOf(false) } + var showSnoozeDialog by remember { mutableStateOf(false) } + + var label by remember { mutableStateOf(currentAlarm.label ?: "") } + val chosenDays = remember { currentAlarm.days.toMutableStateList() } + var vibrationEnabled by remember { + mutableStateOf(currentAlarm.vibrate) + } + var soundName by remember { mutableStateOf(currentAlarm.soundName) } + var soundUri by remember { mutableStateOf(currentAlarm.soundUri) } + var repeat by remember { mutableStateOf(currentAlarm.repeat) } + var snoozeMinutes by remember { mutableStateOf(currentAlarm.snoozeMinutes) } + var snoozeEnabled by remember { mutableStateOf(currentAlarm.snoozeEnabled) } + var soundEnabled by remember { mutableStateOf(currentAlarm.soundEnabled) } + + val initialTime = remember { TimeHelper.millisToTime(currentAlarm.time) } + var hours = remember { initialTime.hours } + var minutes = remember { initialTime.minutes } + + val scrollState = rememberScrollState() + Surface { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 16.dp) + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceEvenly + ) { + AlarmTimePicker( + hours, + minutes, + onHoursChanged = { + hours = it + }, + onMinutesChanged = { minutes = it } + ) + Column { + SwitchItem( + title = stringResource(R.string.repeat), + isChecked = repeat, + onClick = { newValue -> + repeat = newValue + }, + icon = Icons.Rounded.EventRepeat + ) + AnimatedVisibility(visible = repeat) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + val daysOfWeek = remember { + AlarmHelper.getDaysOfWeekByLocale(context) + } + + daysOfWeek.forEach { (day, index) -> + val enabled = chosenDays.contains(index) + Box( + modifier = Modifier + .size(30.dp) + .background( + if (enabled) MaterialTheme.colorScheme.primary else Color.Transparent, + CircleShape + ) + .clip(CircleShape) + .border( + if (enabled) 0.dp else 1.dp, + MaterialTheme.colorScheme.primary, + CircleShape + ) + .clickable { + if (enabled) { + if (chosenDays.size > 1) chosenDays.remove(index) + } else { + chosenDays.add( + index + ) + } + }, + contentAlignment = Alignment.Center + ) { + Text( + text = day, + color = if (enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + } + Row( + modifier = Modifier.padding(8.dp, 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = label, + onValueChange = { + label = it + }, + label = { + Text(text = stringResource(id = R.string.alarm_name)) + }, + singleLine = true, + leadingIcon = { + Icon(imageVector = Icons.Outlined.Label, contentDescription = null) + } + ) + } + SwitchWithDivider( + title = stringResource(R.string.sound), + description = soundName ?: stringResource(R.string.default_sound), + isChecked = soundEnabled, + icon = Icons.Rounded.Alarm, + onClick = { + showRingtoneDialog = true + }, + onChecked = { + soundEnabled = it + } + ) + SwitchItem( + title = stringResource(R.string.vibrate), + isChecked = vibrationEnabled, + onClick = { newValue -> + vibrationEnabled = newValue + }, + icon = Icons.Rounded.Vibration + ) + SwitchWithDivider( + title = stringResource(R.string.snooze), + description = with(snoozeMinutes) { + pluralStringResource( + id = R.plurals.minutes, + count = this, + this + ) + }, + isChecked = snoozeEnabled, + icon = Icons.Rounded.Snooze, + onClick = { + showSnoozeDialog = true + }, + onChecked = { + snoozeEnabled = it + } + ) + } + Row( + Modifier.align(Alignment.End), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedButton(onClick = { onCancel.invoke() }) { + Text(text = stringResource(id = android.R.string.cancel)) + } + Button(onClick = { + val alarm = + currentAlarm.copy( + time = (hours * 60 + minutes) * 60 * 1000L, + label = label.takeIf { l -> l.isNotBlank() }, + days = chosenDays.sorted(), + vibrate = vibrationEnabled, + soundName = soundName, + soundUri = soundUri, + repeat = repeat, + snoozeEnabled = snoozeEnabled, + snoozeMinutes = snoozeMinutes, + soundEnabled = soundEnabled + ) + onSave(alarm) + }) { + Text(text = stringResource(id = android.R.string.ok)) + } + } + } + } + if (showRingtoneDialog) { + RingtonePickerDialog(onDismissRequest = { + showRingtoneDialog = false + }) { title, uri -> + soundUri = uri.toString() + soundName = title + } + } + if (showSnoozeDialog) { + SnoozeTimePickerDialog( + onDismissRequest = { showSnoozeDialog = false }, + currentTime = snoozeMinutes, + onTimeSet = { + snoozeMinutes = it + showSnoozeDialog = false + } + ) + } +} diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarmpicker/model/AlarmPickerModel.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarmpicker/model/AlarmPickerModel.kt new file mode 100644 index 00000000..d1669c20 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarmpicker/model/AlarmPickerModel.kt @@ -0,0 +1,66 @@ +package com.bnyro.clock.presentation.screens.alarmpicker.model + +import android.app.Application +import android.content.Context +import android.widget.Toast +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.bnyro.clock.App +import com.bnyro.clock.R +import com.bnyro.clock.domain.model.Alarm +import com.bnyro.clock.domain.usecase.CreateUpdateDeleteAlarmUseCase +import com.bnyro.clock.navigation.NavRoutes +import com.bnyro.clock.util.AlarmHelper +import com.bnyro.clock.util.TimeHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.time.Duration.Companion.milliseconds + +class AlarmPickerModel(application: Application, savedStateHandle: SavedStateHandle) : + AndroidViewModel(application) { + private val id: String? = savedStateHandle[NavRoutes.AlarmPicker.alarmId] + + private val alarmRepository = (application as App).container.alarmRepository + private val createUpdateDeleteAlarmUseCase = + CreateUpdateDeleteAlarmUseCase(application.applicationContext, alarmRepository) + + var alarm: Alarm + + init { + val alarmId = id?.toLong() ?: 0L + if (alarmId == 0L) { + alarm = Alarm(time = 0) + } else { + alarm = runBlocking(Dispatchers.IO) { + alarmRepository.getAlarmById(alarmId) + } + } + + } + + fun createAlarm(alarm: Alarm) { + viewModelScope.launch { + createUpdateDeleteAlarmUseCase.createAlarm(alarm) + } + } + + fun updateAlarm(alarm: Alarm) { + viewModelScope.launch { + createUpdateDeleteAlarmUseCase.updateAlarm(alarm) + } + } + + fun createToast(alarm: Alarm, context: Context) { + val millisRemainingForAlarm = + (AlarmHelper.getAlarmTime(alarm) - System.currentTimeMillis()) + val formattedDuration = + TimeHelper.durationToFormatted(context, millisRemainingForAlarm.milliseconds) + Toast.makeText( + context, + context.resources.getString(R.string.alarm_will_play, formattedDuration), + Toast.LENGTH_SHORT + ).show() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/clock/ClockScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/clock/ClockScreen.kt index 244ab2f3..6f90b278 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/clock/ClockScreen.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/clock/ClockScreen.kt @@ -58,7 +58,7 @@ fun ClockScreen( item { DigitalClockDisplay() } - items(items = selectedZones, key = { it.zoneId }) { timeZone -> + items(items = selectedZones, key = { it.key }) { timeZone -> WorldClockItem(clockModel, timeZone) } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/clock/model/ClockModel.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/clock/model/ClockModel.kt index affae560..b4885c98 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/clock/model/ClockModel.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/clock/model/ClockModel.kt @@ -1,19 +1,13 @@ package com.bnyro.clock.presentation.screens.clock.model import android.app.Application -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory import com.bnyro.clock.App -import com.bnyro.clock.data.database.DatabaseHolder import com.bnyro.clock.domain.model.SortOrder import com.bnyro.clock.domain.model.TimeZone import com.bnyro.clock.util.Preferences import com.bnyro.clock.util.TimeHelper -import com.bnyro.clock.util.getCountryTimezones -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -21,15 +15,16 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class ClockModel(app: Application) : ViewModel() { +class ClockModel(application: Application) : AndroidViewModel(application) { + private val timezoneRepository = (application as App).container.timezoneRepository + private val sortOrderPref = Preferences.instance.getString(Preferences.clockSortOrder, "").orEmpty() val sortOrder = MutableStateFlow(if (sortOrderPref.isNotEmpty()) SortOrder.valueOf(sortOrderPref) else SortOrder.ALPHABETIC) - private val countryTimezones = getCountryTimezones(app.applicationContext) - val timeZones = TimeHelper.getTimezonesForCountries(countryTimezones) + val timeZones = timezoneRepository.getTimezonesForCountries(application.applicationContext) var selectedTimeZones = combine( - DatabaseHolder.instance.timeZonesDao().getAllStream(), + timezoneRepository.getTimezonesStream(), sortOrder ) { selectedZones, sortOrder -> val zones = selectedZones.distinct() @@ -47,28 +42,16 @@ class ClockModel(app: Application) : ViewModel() { sortOrder.update { sort } } - fun setTimeZones(timeZones: List) = viewModelScope.launch( - Dispatchers.IO - ) { - DatabaseHolder.instance.timeZonesDao().clear() - DatabaseHolder.instance.timeZonesDao().insertAll(*timeZones.toTypedArray()) + fun setTimeZones(timeZones: List) = viewModelScope.launch { + timezoneRepository.replaceAll(*timeZones.toTypedArray()) } - fun deleteTimeZone(timeZone: TimeZone) = viewModelScope.launch(Dispatchers.IO) { - DatabaseHolder.instance.timeZonesDao().delete(timeZone = timeZone) + fun deleteTimeZone(timeZone: TimeZone) = viewModelScope.launch { + timezoneRepository.delete(timeZone = timeZone) } fun getDateWithOffset(timeZone: String): Pair { val time = TimeHelper.getTimeByZone(timeZone) return TimeHelper.formatDateTime(time, false) } - - companion object { - val Factory = viewModelFactory { - initializer { - val application = this[APPLICATION_KEY] as App - ClockModel(application) - } - } - } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/permission/PermissionModel.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/permission/PermissionModel.kt new file mode 100644 index 00000000..6ff1706f --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/permission/PermissionModel.kt @@ -0,0 +1,19 @@ +package com.bnyro.clock.presentation.screens.permission + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import com.bnyro.clock.domain.model.Permission + +class PermissionModel(application: Application) : + AndroidViewModel(application) { + val requiredPermissions = allPermissions.filter { + !it.hasPermission(application) + } + + companion object { + val allPermissions = listOf( + Permission.AlarmPermission, + Permission.NotificationPermission + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/permission/PermissionScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/permission/PermissionScreen.kt new file mode 100644 index 00000000..0cbc875f --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/permission/PermissionScreen.kt @@ -0,0 +1,52 @@ +package com.bnyro.clock.presentation.screens.permission + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import com.bnyro.clock.presentation.screens.permission.components.PermissionRequestPage +import com.bnyro.clock.ui.MainActivity +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PermissionScreen(onClose: () -> Unit) { + val permissionModel: PermissionModel = viewModel() + val pagerState = rememberPagerState() { permissionModel.requiredPermissions.size } + val scope = rememberCoroutineScope() + val context = LocalContext.current + HorizontalPager( + state = pagerState, + ) { page -> + with(permissionModel.requiredPermissions[page]) { + PermissionRequestPage( + title = stringResource(id = titleRes), + subtitle = stringResource(id = descriptionRes), + onClickConfirm = { + requestPermission(context as MainActivity) + if (page + 1 < permissionModel.requiredPermissions.size) { + scope.launch { + pagerState.animateScrollToPage(page + 1) + } + } else { + onClose() + } + }, + onClickCancel = { + if (page + 1 < permissionModel.requiredPermissions.size) { + scope.launch { + pagerState.animateScrollToPage(page + 1) + } + } else { + onClose() + } + }, + icon = iconRes + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/permission/components/PermissionPage.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/permission/components/PermissionPage.kt new file mode 100644 index 00000000..25878586 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/permission/components/PermissionPage.kt @@ -0,0 +1,101 @@ +package com.bnyro.clock.presentation.screens.permission.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bnyro.clock.R +import com.bnyro.clock.presentation.components.BlobIconBox + +@Composable +fun PermissionRequestPage( + title: String, + subtitle: String, + onClickConfirm: () -> Unit, + onClickCancel: () -> Unit, + @DrawableRes icon: Int, +) { + Column( + Modifier.fillMaxSize() + ) { + Column( + Modifier + .fillMaxWidth() + .weight(2f) + ) { + BlobIconBox(icon) + } + Column( + Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Normal, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(48.dp)) + Button( + onClick = onClickConfirm, + contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp) + ) { + Text( + text = stringResource(R.string.allow), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + TextButton( + onClick = onClickCancel, colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + ) { + Text( + text = stringResource(R.string.maybe_later), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun PermissionRequestPagePreview() { + PermissionRequestPage( + title = "Enable Alarm Permissions", + subtitle = "Alarm Permissions are required to schedule alarms", + onClickConfirm = {}, + onClickCancel = {}, + icon = R.drawable.ic_alarm + ) +} diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/settings/SettingsScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/settings/SettingsScreen.kt index 7012df82..55c6eca1 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/settings/SettingsScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bnyro.clock.BuildConfig @@ -36,7 +37,6 @@ import com.bnyro.clock.presentation.screens.settings.components.SettingsCategory import com.bnyro.clock.presentation.screens.settings.components.SwitchPref import com.bnyro.clock.presentation.screens.settings.model.SettingsModel import com.bnyro.clock.presentation.screens.timer.model.TimerModel -import com.bnyro.clock.util.IntentHelper import com.bnyro.clock.util.Preferences @OptIn(ExperimentalMaterial3Api::class) @@ -75,6 +75,7 @@ fun SettingsScreen( .padding(pv) .verticalScroll(scrollState) ) { + val uriHandler = LocalUriHandler.current SettingsCategory(stringResource(R.string.appearance)) ButtonGroupPref( title = stringResource(R.string.theme), @@ -157,7 +158,7 @@ fun SettingsScreen( summary = stringResource(R.string.source_code_summary), imageVector = Icons.Default.OpenInNew ) { - IntentHelper.openUrl(context, "https://github.com/you-apps/ClockYou") + uriHandler.openUri("https://github.com/you-apps/ClockYou") } IconPreference( title = stringResource(R.string.app_name), @@ -168,10 +169,7 @@ fun SettingsScreen( ), imageVector = Icons.Default.History ) { - IntentHelper.openUrl( - context, - "https://github.com/you-apps/ClockYou/releases/latest" - ) + uriHandler.openUri("https://github.com/you-apps/ClockYou/releases/latest") } } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/model/RingingToneModel.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/model/RingingToneModel.kt index 2716e62b..af1768e8 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/model/RingingToneModel.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/model/RingingToneModel.kt @@ -1,19 +1,17 @@ package com.bnyro.clock.presentation.screens.timer.model +import android.app.Application import android.content.Context import android.media.Ringtone import android.media.RingtoneManager import android.net.Uri -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import com.bnyro.clock.App +import androidx.lifecycle.AndroidViewModel import com.bnyro.clock.util.RingtoneHelper -class RingingToneModel(val app: App) : ViewModel() { +class RingingToneModel(application: Application) : AndroidViewModel(application) { var sounds = - RingtoneHelper.getAvailableSounds(app.applicationContext).toList().sortedBy { it.first } + RingtoneHelper().getAvailableSounds(application.applicationContext).toList() + .sortedBy { it.first } private set private var currentlyPlayingRingtone: Ringtone? = null @@ -30,13 +28,4 @@ class RingingToneModel(val app: App) : ViewModel() { currentlyPlayingRingtone?.stop() currentlyPlayingRingtone = null } - - companion object { - val Factory = viewModelFactory { - initializer { - val application = this[APPLICATION_KEY] as App - RingingToneModel(application) - } - } - } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/widgets/AnalogClockWidgetConfig.kt b/app/src/main/java/com/bnyro/clock/presentation/widgets/AnalogClockWidgetConfig.kt index 63425908..c96c1a6e 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/widgets/AnalogClockWidgetConfig.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/widgets/AnalogClockWidgetConfig.kt @@ -103,7 +103,7 @@ class AnalogClockWidgetConfig : ComponentActivity() { color = MaterialTheme.colorScheme.background ) { Scaffold(topBar = { - CenterAlignedTopAppBar(title = { Text(text = "Select Clock Face") }) + CenterAlignedTopAppBar(title = { Text(text = stringResource(R.string.select_clock_face)) }) }) { pV -> AnalogClockWidgetSettings( modifier = Modifier.padding(pV), diff --git a/app/src/main/java/com/bnyro/clock/presentation/widgets/DigitalClockWidgetConfig.kt b/app/src/main/java/com/bnyro/clock/presentation/widgets/DigitalClockWidgetConfig.kt index 17742886..c492dede 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/widgets/DigitalClockWidgetConfig.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/widgets/DigitalClockWidgetConfig.kt @@ -130,7 +130,7 @@ fun DigitalClockWidgetSettings( onComplete: (DigitalClockWidgetOptions) -> Unit ) { - val clockModel: ClockModel = viewModel(factory = ClockModel.Factory) + val clockModel: ClockModel = viewModel() var showTimeZoneDialog by remember { mutableStateOf(false) } var customTimeZone by remember { mutableStateOf(options.timeZone) } diff --git a/app/src/main/java/com/bnyro/clock/ui/MainActivity.kt b/app/src/main/java/com/bnyro/clock/ui/MainActivity.kt index e1d5d4f2..bfde7813 100644 --- a/app/src/main/java/com/bnyro/clock/ui/MainActivity.kt +++ b/app/src/main/java/com/bnyro/clock/ui/MainActivity.kt @@ -1,12 +1,9 @@ package com.bnyro.clock.ui -import android.Manifest import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection -import android.content.pm.PackageManager -import android.os.Build import android.os.Bundle import android.os.IBinder import android.provider.AlarmClock @@ -18,16 +15,16 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.core.app.ActivityCompat import androidx.lifecycle.viewmodel.compose.viewModel import com.bnyro.clock.domain.model.Alarm import com.bnyro.clock.navigation.HomeRoutes import com.bnyro.clock.navigation.MainNavContainer +import com.bnyro.clock.navigation.NavRoutes import com.bnyro.clock.navigation.homeRoutes import com.bnyro.clock.presentation.features.AlarmReceiverDialog import com.bnyro.clock.presentation.features.TimerReceiverDialog +import com.bnyro.clock.presentation.screens.permission.PermissionModel import com.bnyro.clock.presentation.screens.settings.model.SettingsModel import com.bnyro.clock.presentation.screens.stopwatch.model.StopwatchModel import com.bnyro.clock.presentation.screens.timer.model.TimerModel @@ -94,6 +91,16 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val allPermissions = PermissionModel.allPermissions + val requiredPermissions = allPermissions.any { + !it.hasPermission(this) + } + val startDestination = if (requiredPermissions) { + NavRoutes.Permissions.route + } else { + NavRoutes.Home.route + } + initialTab = when (intent?.action) { SHOW_STOPWATCH_ACTION -> HomeRoutes.Stopwatch AlarmClock.ACTION_SET_ALARM, AlarmClock.ACTION_SHOW_ALARMS -> HomeRoutes.Alarm @@ -133,13 +140,9 @@ class MainActivity : ComponentActivity() { getInitialTimer()?.let { TimerReceiverDialog(it) } - MainNavContainer(settingsModel, initialTab) + MainNavContainer(settingsModel, initialTab, startDestination) } } - - LaunchedEffect(Unit) { - requestNotificationPermissions() - } } } @@ -187,21 +190,6 @@ class MainActivity : ComponentActivity() { return intent.getIntExtra(AlarmClock.EXTRA_LENGTH, 0).takeIf { it > 0 } } - private fun requestNotificationPermissions() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return - if (ActivityCompat.checkSelfPermission( - this@MainActivity, - Manifest.permission.POST_NOTIFICATIONS - ) != PackageManager.PERMISSION_GRANTED - ) { - ActivityCompat.requestPermissions( - this@MainActivity, - arrayOf(Manifest.permission.POST_NOTIFICATIONS), - 1 - ) - } - } - companion object { const val SHOW_STOPWATCH_ACTION = "com.bnyro.clock.SHOW_STOPWATCH_ACTION" } diff --git a/app/src/main/java/com/bnyro/clock/util/AlarmHelper.kt b/app/src/main/java/com/bnyro/clock/util/AlarmHelper.kt index a9b04c68..e86e3510 100644 --- a/app/src/main/java/com/bnyro/clock/util/AlarmHelper.kt +++ b/app/src/main/java/com/bnyro/clock/util/AlarmHelper.kt @@ -1,14 +1,14 @@ package com.bnyro.clock.util +import android.annotation.SuppressLint import android.app.AlarmManager import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.os.Build import android.util.Log -import androidx.annotation.RequiresApi import com.bnyro.clock.R import com.bnyro.clock.domain.model.Alarm +import com.bnyro.clock.domain.model.Permission import com.bnyro.clock.ui.MainActivity import com.bnyro.clock.util.receivers.AlarmReceiver import java.util.Calendar @@ -18,7 +18,9 @@ import java.util.GregorianCalendar object AlarmHelper { const val EXTRA_ID = "alarm_id" + @SuppressLint("ScheduleExactAlarm") fun enqueue(context: Context, alarm: Alarm) { + if (!Permission.AlarmPermission.hasPermission(context)) return cancel(context, alarm) if (!alarm.enabled) { return @@ -32,12 +34,6 @@ object AlarmHelper { alarmManager.setAlarmClock(alarmInfo, getPendingIntent(context, alarm)) } - @RequiresApi(Build.VERSION_CODES.S) - fun hasPermission(context: Context): Boolean { - val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - return alarmManager.canScheduleExactAlarms() - } - fun cancel(context: Context, alarm: Alarm) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager alarmManager.cancel(getPendingIntent(context, alarm)) diff --git a/app/src/main/java/com/bnyro/clock/util/IntentHelper.kt b/app/src/main/java/com/bnyro/clock/util/IntentHelper.kt deleted file mode 100644 index 77b8fd00..00000000 --- a/app/src/main/java/com/bnyro/clock/util/IntentHelper.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.bnyro.clock.util - -import android.content.Context -import android.content.Intent -import androidx.core.net.toUri - -object IntentHelper { - fun openUrl(context: Context, url: String) { - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - runCatching { - context.startActivity(intent) - } - } -} diff --git a/app/src/main/java/com/bnyro/clock/util/NotificationHelper.kt b/app/src/main/java/com/bnyro/clock/util/NotificationHelper.kt index 8578c07d..34db3c2a 100644 --- a/app/src/main/java/com/bnyro/clock/util/NotificationHelper.kt +++ b/app/src/main/java/com/bnyro/clock/util/NotificationHelper.kt @@ -6,22 +6,25 @@ import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationManagerCompat import com.bnyro.clock.R -object NotificationHelper { - const val STOPWATCH_CHANNEL = "stopwatch" - const val TIMER_CHANNEL = "timer" - const val TIMER_SERVICE_CHANNEL = "timer_service" - const val TIMER_FINISHED_CHANNEL = "timer_finished" - const val ALARM_CHANNEL = "alarm" +class NotificationHelper { + companion object { + const val STOPWATCH_CHANNEL = "stopwatch" + const val TIMER_CHANNEL = "timer" + const val TIMER_SERVICE_CHANNEL = "timer_service" + const val TIMER_FINISHED_CHANNEL = "timer_finished" + const val ALARM_CHANNEL = "alarm" - val vibrationPattern = longArrayOf(1000, 1000, 1000, 1000, 1000) + val vibrationPattern = longArrayOf(1000, 1000, 1000, 1000, 1000) - val audioAttributes: AudioAttributes? = AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_ALARM) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .build() + val audioAttributes: AudioAttributes? = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + } fun createNotificationChannels(context: Context) { val nManager = NotificationManagerCompat.from(context) + val ringtoneHelper = RingtoneHelper() val channels = listOf( NotificationChannelCompat.Builder( @@ -47,7 +50,7 @@ object NotificationHelper { NotificationManagerCompat.IMPORTANCE_HIGH ) .setName(context.getString(R.string.timer_finished)) - .setSound(RingtoneHelper.getDefault(context), audioAttributes) + .setSound(ringtoneHelper.getDefault(context), audioAttributes) .build(), NotificationChannelCompat.Builder( ALARM_CHANNEL, diff --git a/app/src/main/java/com/bnyro/clock/util/PermissionHelper.kt b/app/src/main/java/com/bnyro/clock/util/PermissionHelper.kt deleted file mode 100644 index 8685ca02..00000000 --- a/app/src/main/java/com/bnyro/clock/util/PermissionHelper.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.bnyro.clock.util - -import android.app.Activity -import android.content.Context -import android.content.pm.PackageManager -import androidx.core.app.ActivityCompat - -object PermissionHelper { - fun checkPermissions(context: Context, permissions: Array): Boolean { - permissions.forEach { - if (!hasPermission(context, it)) { - ActivityCompat.requestPermissions(context as Activity, arrayOf(it), 1) - return false - } - } - return true - } - - fun hasPermission(context: Context, permission: String): Boolean { - return ActivityCompat.checkSelfPermission( - context, - permission - ) == PackageManager.PERMISSION_GRANTED - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/clock/util/RingtoneHelper.kt b/app/src/main/java/com/bnyro/clock/util/RingtoneHelper.kt index 81ed62fa..d3f34883 100644 --- a/app/src/main/java/com/bnyro/clock/util/RingtoneHelper.kt +++ b/app/src/main/java/com/bnyro/clock/util/RingtoneHelper.kt @@ -4,7 +4,7 @@ import android.content.Context import android.media.RingtoneManager import android.net.Uri -object RingtoneHelper { +class RingtoneHelper { fun getDefault(context: Context): Uri? { return RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_ALARM) ?: RingtoneManager.getActualDefaultRingtoneUri( diff --git a/app/src/main/java/com/bnyro/clock/util/TimeHelper.kt b/app/src/main/java/com/bnyro/clock/util/TimeHelper.kt index 1d79e4a9..b189857d 100644 --- a/app/src/main/java/com/bnyro/clock/util/TimeHelper.kt +++ b/app/src/main/java/com/bnyro/clock/util/TimeHelper.kt @@ -3,7 +3,6 @@ package com.bnyro.clock.util import android.annotation.SuppressLint import android.content.Context import com.bnyro.clock.R -import com.bnyro.clock.domain.model.CountryTimezone import com.bnyro.clock.domain.model.TimeObject import java.time.Instant import java.time.LocalTime @@ -16,22 +15,12 @@ import java.util.Date import java.util.TimeZone import kotlin.math.abs import kotlin.time.Duration -import com.bnyro.clock.domain.model.TimeZone as DbTimeZone object TimeHelper { val currentTime: Date get() = Calendar.getInstance().time private const val MILLIS_PER_MINUTE: Int = 60_000 private const val MINUTES_PER_HOUR: Int = 60 - fun getTimezonesForCountries(zoneIds: List): List { - return zoneIds.map { - val zone = TimeZone.getTimeZone(it.zoneId) - val zoneKey = arrayOf(it.zoneId, it.zoneName, it.countryName).joinToString(",") - val offset = zone.getOffset(Calendar.getInstance().timeInMillis) - DbTimeZone(zoneKey, it.zoneId, offset, it.zoneName, it.countryName) - }.sortedBy { it.zoneName } - } - fun getCurrentWeekDay(): Int { return Calendar.getInstance().get(Calendar.DAY_OF_WEEK) } diff --git a/app/src/main/java/com/bnyro/clock/util/TimezoneHelper.kt b/app/src/main/java/com/bnyro/clock/util/TimezoneHelper.kt deleted file mode 100644 index 69d50e5e..00000000 --- a/app/src/main/java/com/bnyro/clock/util/TimezoneHelper.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.bnyro.clock.util - -import android.content.Context -import com.bnyro.clock.domain.model.CountryTimezone -import kotlinx.serialization.json.Json - -fun getCountryTimezones(context: Context): List { - val tzData = - context.resources.assets.open("tz_data.json").bufferedReader() - .use { it.readText() } - - val json = Json { ignoreUnknownKeys = true } - return json.decodeFromString(tzData) -} diff --git a/app/src/main/java/com/bnyro/clock/util/receivers/AlarmReceiver.kt b/app/src/main/java/com/bnyro/clock/util/receivers/AlarmReceiver.kt index 039c85be..461e1254 100644 --- a/app/src/main/java/com/bnyro/clock/util/receivers/AlarmReceiver.kt +++ b/app/src/main/java/com/bnyro/clock/util/receivers/AlarmReceiver.kt @@ -5,7 +5,7 @@ import android.content.Context import android.content.Intent import android.util.Log import androidx.core.content.ContextCompat -import com.bnyro.clock.data.database.DatabaseHolder +import com.bnyro.clock.App import com.bnyro.clock.util.AlarmHelper import com.bnyro.clock.util.TimeHelper import com.bnyro.clock.util.services.AlarmService @@ -15,8 +15,9 @@ class AlarmReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { Log.e("receiver", "received") val id = intent.getLongExtra(AlarmHelper.EXTRA_ID, -1).takeIf { it != -1L } ?: return + val alarmRepository = (context.applicationContext as App).container.alarmRepository val alarm = runBlocking { - DatabaseHolder.instance.alarmsDao().findById(id) + alarmRepository.getAlarmById(id) } val currentDay = TimeHelper.getCurrentWeekDay() @@ -33,7 +34,7 @@ class AlarmReceiver : BroadcastReceiver() { } else { alarm.enabled = false runBlocking { - DatabaseHolder.instance.alarmsDao().update(alarm) + alarmRepository.updateAlarm(alarm) } } } diff --git a/app/src/main/java/com/bnyro/clock/util/receivers/BootReceiver.kt b/app/src/main/java/com/bnyro/clock/util/receivers/BootReceiver.kt index b9389f9b..a6fb3a5c 100644 --- a/app/src/main/java/com/bnyro/clock/util/receivers/BootReceiver.kt +++ b/app/src/main/java/com/bnyro/clock/util/receivers/BootReceiver.kt @@ -3,15 +3,16 @@ package com.bnyro.clock.util.receivers import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import com.bnyro.clock.data.database.DatabaseHolder +import com.bnyro.clock.App import com.bnyro.clock.util.AlarmHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { + val alarmRepository = (context.applicationContext as App).container.alarmRepository val alarms = runBlocking(Dispatchers.IO) { - DatabaseHolder.instance.alarmsDao().getAll() + alarmRepository.getAlarms() } alarms.forEach { AlarmHelper.enqueue(context, it) diff --git a/app/src/main/java/com/bnyro/clock/util/services/AlarmService.kt b/app/src/main/java/com/bnyro/clock/util/services/AlarmService.kt index 9cf17f1b..51707395 100644 --- a/app/src/main/java/com/bnyro/clock/util/services/AlarmService.kt +++ b/app/src/main/java/com/bnyro/clock/util/services/AlarmService.kt @@ -20,8 +20,8 @@ import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat import androidx.core.net.toUri +import com.bnyro.clock.App import com.bnyro.clock.R -import com.bnyro.clock.data.database.DatabaseHolder import com.bnyro.clock.domain.model.Alarm import com.bnyro.clock.presentation.screens.alarm.AlarmActivity import com.bnyro.clock.util.AlarmHelper @@ -84,8 +84,9 @@ class AlarmService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val id = intent?.getLongExtra(AlarmHelper.EXTRA_ID, -1).takeIf { it != -1L } ?: return START_STICKY + val alarmRepository = (application as App).container.alarmRepository val alarm = runBlocking { - DatabaseHolder.instance.alarmsDao().findById(id) + alarmRepository.getAlarmById(id) } startForeground(notificationId, createNotification(this, alarm)) play(alarm) diff --git a/app/src/main/java/com/bnyro/clock/util/services/TimerService.kt b/app/src/main/java/com/bnyro/clock/util/services/TimerService.kt index 0c8d2aef..ebb0ff04 100644 --- a/app/src/main/java/com/bnyro/clock/util/services/TimerService.kt +++ b/app/src/main/java/com/bnyro/clock/util/services/TimerService.kt @@ -196,7 +196,7 @@ class TimerService : Service() { NotificationHelper.TIMER_FINISHED_CHANNEL ) .setSmallIcon(R.drawable.ic_notification) - .setSound(timerObject.ringtone ?: RingtoneHelper.getDefault(this)) + .setSound(timerObject.ringtone ?: RingtoneHelper().getDefault(this)) .setVibrate(NotificationHelper.vibrationPattern.takeIf { timerObject.vibrate }) .setContentTitle(getString(R.string.timer_finished)) .setContentText(timerObject.label.value) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e2bf43ef..1e670008 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -121,5 +121,12 @@ Show Widget background Show Time Digital Clock Widget + Allow Notifications to ensure you never miss an alarm or timer. We\'ll only send alerts for your set alarms and timers. + Notification Permission + To wake you up on time, Clock You needs permission to schedule alarms. + Alarm Permission + Allow + Maybe later Design by %1$s + Select Clock Face \ No newline at end of file