diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ab2f5..1e1553f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) --- +## [v1.5.0] - 2025-07-24 + +### Added +- Reminders with custom date and time. +- Notifications for reminders, with permission handling. +- Reminder UI components (picker, chips, dialogs). +- Delete confirmation modal. +- Timestamps on replies. +- Snackbar for actions. + +### Changed +- Improved UI and navigation from notifications. + +### Fixed +- Timestamp defaults and date/time validation. + +### Refactored +- Simplified state and intent handling. +- Code cleanup and test updates. + ## [v1.4.0] - 2025-04-18 ### Added diff --git a/README.md b/README.md index 475a8a2..ce1f52d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ This app exists for anyone who's ever said, “Oops, I meant to reply to that! With Reply Radar, your forgotten replies are finally on the radar. A gentle nudge. A little order. A friendlier inbox — on your terms. - Get it on Google Play ⚠️ **Reminders (and other cool features) aren't here yet** — but they're definitely on the radar. Stay tuned! +> ⚠️ Some features are still brewing — but they're definitely on the radar. Stay tuned! --- @@ -91,6 +91,12 @@ This is still a work-in-progress — a personal playground to explore architectu --- +## 🧠 A playground for Android, KMP, and AI-enhanced development + +Besides being a way to dive deeper into Android and Kotlin Multiplatform, Reply Radar is also a personal sandbox to explore how **AI-assisted workflows** can support development — from ideation and architecture to automation and iteration. The goal is to build better, faster and with curiosity at the center of the process. + +--- + ## 🚫 Contributing Currently a solo mission, but who knows? Contributions might be welcome in the future. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 04940ec..9747b1e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -102,8 +102,8 @@ android { applicationId = "com.rafaelfelipeac.replyradar" minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 14 - versionName = "1.4.0" + versionCode = 15 + versionName = "1.5.0" manifestPlaceholders["appName"] = "@string/app_name" } @@ -216,6 +216,7 @@ tasks.register("detektFormat") { dependencies { implementation(libs.androidx.ui.android) + implementation(libs.androidx.work.runtime.ktx) debugImplementation(compose.uiTooling) add("kspAndroid", libs.androidx.room.compiler) } diff --git a/composeApp/schemas/com.rafaelfelipeac.replyradar.core.database.ReplyRadarDatabase/1.json b/composeApp/schemas/com.rafaelfelipeac.replyradar.core.database.ReplyRadarDatabase/1.json index 3cfa686..cfaa152 100644 --- a/composeApp/schemas/com.rafaelfelipeac.replyradar.core.database.ReplyRadarDatabase/1.json +++ b/composeApp/schemas/com.rafaelfelipeac.replyradar.core.database.ReplyRadarDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "6fc3470e59a8628f2c278dc0df38ab28", + "identityHash": "5607ce6deeaf96cb63ae56e6e3177625", "entities": [ { "tableName": "replies", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `subject` TEXT NOT NULL, `isResolved` INTEGER NOT NULL, `isArchived` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `resolvedAt` INTEGER NOT NULL, `archivedAt` INTEGER NOT NULL)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `subject` TEXT NOT NULL, `isResolved` INTEGER NOT NULL, `isArchived` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `resolvedAt` INTEGER NOT NULL, `archivedAt` INTEGER NOT NULL, `reminderAt` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", @@ -61,6 +61,12 @@ "columnName": "archivedAt", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "reminderAt", + "columnName": "reminderAt", + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { @@ -113,7 +119,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6fc3470e59a8628f2c278dc0df38ab28')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5607ce6deeaf96cb63ae56e6e3177625')" ] } } \ No newline at end of file diff --git a/composeApp/schemas/com.rafaelfelipeac.replyradar.core.database.ReplyRadarDatabase/2.json b/composeApp/schemas/com.rafaelfelipeac.replyradar.core.database.ReplyRadarDatabase/2.json new file mode 100644 index 0000000..6faaeb6 --- /dev/null +++ b/composeApp/schemas/com.rafaelfelipeac.replyradar.core.database.ReplyRadarDatabase/2.json @@ -0,0 +1,125 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "5607ce6deeaf96cb63ae56e6e3177625", + "entities": [ + { + "tableName": "replies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `subject` TEXT NOT NULL, `isResolved` INTEGER NOT NULL, `isArchived` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `resolvedAt` INTEGER NOT NULL, `archivedAt` INTEGER NOT NULL, `reminderAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isResolved", + "columnName": "isResolved", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resolvedAt", + "columnName": "resolvedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archivedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderAt", + "columnName": "reminderAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "user_actions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `actionType` TEXT NOT NULL, `targetType` TEXT, `targetId` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionType", + "columnName": "actionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetType", + "columnName": "targetType", + "affinity": "TEXT" + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5607ce6deeaf96cb63ae56e6e3177625')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 290daea..3d57d63 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -2,6 +2,7 @@ + isDark = dark backgroundColor = bgColor - } + }, + notificationPermissionManager = rememberNotificationPermissionManager(), + pendingReplyId = pendingReplyId ) } diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/MainActivity.kt index 1b74193..42080d4 100644 --- a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/MainActivity.kt @@ -3,13 +3,18 @@ package com.rafaelfelipeac.replyradar import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import com.rafaelfelipeac.replyradar.core.util.AppConstants.INVALID_ID +import com.rafaelfelipeac.replyradar.core.util.AppConstants.PENDING_REPLY_ID_KEY class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val pendingReplyId = intent?.getLongExtra(PENDING_REPLY_ID_KEY, INVALID_ID) + setContent { - AndroidApp() + AndroidApp(pendingReplyId = pendingReplyId) } } } diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/Previews.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/Previews.kt index f2020a3..846e459 100644 --- a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/Previews.kt +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/Previews.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.tooling.preview.Preview import com.rafaelfelipeac.replyradar.features.reply.domain.model.Reply import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreen import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListState +import kotlinx.coroutines.flow.flowOf private val replies = (1L..10L).map { Reply( @@ -24,6 +25,7 @@ private fun ReplyListScreenPreview() { ), onIntent = {}, onSettingsClick = {}, - onActivityLogClick = {} + onActivityLogClick = {}, + effect = flowOf() ) } diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/ReplyRadarApplication.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/ReplyRadarApplication.kt index 23020ee..0799259 100644 --- a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/ReplyRadarApplication.kt +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/ReplyRadarApplication.kt @@ -1,6 +1,13 @@ package com.rafaelfelipeac.replyradar import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.NotificationManager.IMPORTANCE_HIGH +import android.os.Build +import com.rafaelfelipeac.replyradar.R.string.notification_channel_description +import com.rafaelfelipeac.replyradar.R.string.notification_channel_id +import com.rafaelfelipeac.replyradar.R.string.notification_channel_name import com.rafaelfelipeac.replyradar.di.initKoin import org.koin.android.ext.koin.androidContext @@ -13,6 +20,23 @@ class ReplyRadarApplication : Application() { initKoin { androidContext(this@ReplyRadarApplication) } + + createNotificationChannel() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val id = getString(notification_channel_id) + val name = getString(notification_channel_name) + val descriptionText = getString(notification_channel_description) + val importance = IMPORTANCE_HIGH + val channel = NotificationChannel(id, name, importance).apply { + description = descriptionText + } + val notificationManager = getSystemService(NotificationManager::class.java) + + notificationManager.createNotificationChannel(channel) + } } companion object { diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/ConfigureSystemBars.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/ConfigureSystemBars.kt index c0f1ca6..36526f8 100644 --- a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/ConfigureSystemBars.kt +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/ConfigureSystemBars.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import com.google.accompanist.systemuicontroller.rememberSystemUiController -import com.rafaelfelipeac.replyradar.core.util.setStatusBarColorCompat @Composable fun ConfigureSystemBars(darkTheme: Boolean, backgroundColor: Color) { @@ -14,13 +13,11 @@ fun ConfigureSystemBars(darkTheme: Boolean, backgroundColor: Color) { val systemUiController = rememberSystemUiController() SideEffect { - // Navigation Bar systemUiController.setNavigationBarColor( color = backgroundColor, darkIcons = !darkTheme ) - // Status Bar activity?.setStatusBarColorCompat( color = backgroundColor, useDarkIcons = !darkTheme diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/util/StatusBar.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/StatusBar.kt similarity index 92% rename from composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/util/StatusBar.kt rename to composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/StatusBar.kt index b888090..3da6157 100644 --- a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/util/StatusBar.kt +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/StatusBar.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.util +package com.rafaelfelipeac.replyradar.core import android.app.Activity import androidx.compose.ui.graphics.Color diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/database/DatabaseFactory.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/database/DatabaseFactory.kt index 85358fa..435af02 100644 --- a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/database/DatabaseFactory.kt +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/database/DatabaseFactory.kt @@ -3,8 +3,10 @@ package com.rafaelfelipeac.replyradar.core.database import android.content.Context import androidx.room.Room import androidx.room.RoomDatabase -import com.rafaelfelipeac.replyradar.core.AppConstants.DB_NAME +import com.rafaelfelipeac.replyradar.core.database.ReplyRadarMigrations.ALL_MIGRATIONS +import com.rafaelfelipeac.replyradar.core.util.AppConstants.DB_NAME +@Suppress("SpreadOperator") actual class DatabaseFactory( private val context: Context ) { @@ -12,9 +14,9 @@ actual class DatabaseFactory( val appContext = context.applicationContext val dbFile = appContext.getDatabasePath(DB_NAME) - return Room.databaseBuilder( + return Room.databaseBuilder( context = appContext, name = dbFile.absolutePath - ) + ).addMigrations(*ALL_MIGRATIONS) } } diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/database/ReplyRadarMigrations.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/database/ReplyRadarMigrations.kt new file mode 100644 index 0000000..9cd857e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/database/ReplyRadarMigrations.kt @@ -0,0 +1,20 @@ +package com.rafaelfelipeac.replyradar.core.database + +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.execSQL + +object ReplyRadarMigrations { + val ALL_MIGRATIONS = arrayOf(MIGRATION_1_2) +} + +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE replies ADD COLUMN reminderAt INTEGER NOT NULL DEFAULT 0") + } + + override fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE replies ADD COLUMN reminderAt INTEGER NOT NULL DEFAULT 0") + } +} diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/DatetimeExt.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/DatetimeExt.kt new file mode 100644 index 0000000..0629dc5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/DatetimeExt.kt @@ -0,0 +1,17 @@ +package com.rafaelfelipeac.replyradar.core.datetime + +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime + +fun LocalDate.toEpochMillis(): Long { + return this.atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds() +} + +fun Long.toLocalDate(timeZone: TimeZone = TimeZone.currentSystemDefault()): LocalDate { + return Instant.fromEpochMilliseconds(this) + .toLocalDateTime(timeZone) + .date +} diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformDatePicker.android.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformDatePicker.android.kt new file mode 100644 index 0000000..153f918 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformDatePicker.android.kt @@ -0,0 +1,95 @@ +package com.rafaelfelipeac.replyradar.core.datetime + +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +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 kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone.Companion.UTC + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun PlatformDatePicker( + selectedDate: LocalDate?, + selectedTime: LocalTime?, + onDateSelected: (LocalDate) -> Unit, + confirmButtonText: String, + dismissButtonText: String, + onTimeInvalidated: () -> Unit, + onDismiss: () -> Unit +) { + val dateTime = getCurrentDateTime() + + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = selectedDate?.toEpochMillis(), + selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + val candidateDate = utcTimeMillis.toLocalDate(UTC) + + return candidateDate >= dateTime.date + } + } + ) + + var showDialog by remember { mutableStateOf(true) } + + if (showDialog) { + DatePickerDialog( + onDismissRequest = { + onDismiss() + showDialog = false + }, + confirmButton = { + TextButton( + onClick = { + val dateInMillis = datePickerState.selectedDateMillis + + if (dateInMillis != null) { + val selectedDate = dateInMillis.toLocalDate(UTC) + + onDateSelected(selectedDate) + + selectedTime?.let { selectedTime -> + val isStillValid = isDateTimeValid( + date = selectedDate, + time = selectedTime, + dateTime = dateTime + ) + + if (!isStillValid) { + onTimeInvalidated() + } + } ?: onTimeInvalidated() + } + + onDismiss() + showDialog = false + } + ) { + Text(confirmButtonText) + } + }, + dismissButton = { + TextButton( + onClick = { + onDismiss() + showDialog = false + } + ) { + Text(dismissButtonText) + } + } + ) { + DatePicker(state = datePickerState) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformTimePicker.android.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformTimePicker.android.kt new file mode 100644 index 0000000..8f6c544 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformTimePicker.android.kt @@ -0,0 +1,81 @@ +package com.rafaelfelipeac.replyradar.core.datetime + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberTimePickerState +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 kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime + +private const val HOUR_DEFAULT_INITIAL_HOUR = 12 +private const val HOUR_DEFAULT_INITIAL_MINUTE = 12 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun PlatformTimePicker( + selectedTime: LocalTime?, + selectedDate: LocalDate?, + onTimeSelected: (LocalTime) -> Unit, + onDismiss: () -> Unit, + confirmButtonText: String, + dismissButtonText: String, + pickerTimeTitle: String +) { + val timePickerState = rememberTimePickerState( + initialHour = selectedTime?.hour ?: HOUR_DEFAULT_INITIAL_HOUR, + initialMinute = selectedTime?.minute ?: HOUR_DEFAULT_INITIAL_MINUTE + ) + + var showDialog by remember { mutableStateOf(true) } + + if (showDialog) { + val pickedTime = LocalTime(timePickerState.hour, timePickerState.minute) + val isValid = isDateTimeValid( + dateTime = getCurrentDateTime(), + date = selectedDate, + time = pickedTime + ) + + AlertDialog( + onDismissRequest = { + onDismiss() + showDialog = false + }, + confirmButton = { + TextButton( + onClick = { + if (isValid) { + onTimeSelected(pickedTime) + onDismiss() + showDialog = false + } + }, + enabled = isValid + ) { + Text(confirmButtonText) + } + }, + dismissButton = { + TextButton( + onClick = { + onDismiss() + showDialog = false + } + ) { + Text(dismissButtonText) + } + }, + title = { Text(pickerTimeTitle) }, + text = { + TimePicker(state = timePickerState) + } + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/util/External.android.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/external/External.android.kt similarity index 97% rename from composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/util/External.android.kt rename to composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/external/External.android.kt index 0fc7fea..67cfcde 100644 --- a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/util/External.android.kt +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/external/External.android.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.util +package com.rafaelfelipeac.replyradar.core.external import android.content.ActivityNotFoundException import android.content.Intent diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/notification/NotificationPermissionManager.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/notification/NotificationPermissionManager.kt new file mode 100644 index 0000000..10fe2dd --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/notification/NotificationPermissionManager.kt @@ -0,0 +1,83 @@ +package com.rafaelfelipeac.replyradar.core.notification + +import android.Manifest.permission.POST_NOTIFICATIONS +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.net.Uri +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.TIRAMISU +import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat.checkSelfPermission +import kotlin.coroutines.resume +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine + +private const val PACKAGE = "package" + +@Composable +fun rememberNotificationPermissionManager(): NotificationPermissionManager { + val context = LocalContext.current + val permissionResultState = remember { mutableStateOf(null) } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + permissionResultState.value = isGranted + } + + return remember { + object : NotificationPermissionManager { + + override suspend fun ensureNotificationPermission(): Boolean { + if (SDK_INT < TIRAMISU) { + return true + } + + val granted = checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED + + if (granted) { + return true + } + + return suspendCancellableCoroutine { permissionContinuation -> + permissionResultState.value = null + permissionLauncher.launch(POST_NOTIFICATIONS) + + val job = CoroutineScope(Dispatchers.Main.immediate).launch { + snapshotFlow { permissionResultState.value } + .filterNotNull() + .first() + .let { result -> + permissionContinuation.resume(result) + } + } + + permissionContinuation.invokeOnCancellation { job.cancel() } + } + } + + override suspend fun goToAppSettings() { + val activity = context as? Activity ?: return + + val intent = Intent( + ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts(PACKAGE, context.packageName, null) + ) + + activity.startActivity(intent) + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/NotificationUtils.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/NotificationUtils.kt new file mode 100644 index 0000000..343c5c2 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/NotificationUtils.kt @@ -0,0 +1,61 @@ +package com.rafaelfelipeac.replyradar.core.reminder + +import android.Manifest.permission.POST_NOTIFICATIONS +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_IMMUTABLE +import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.content.pm.PackageManager.PERMISSION_GRANTED +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.PRIORITY_HIGH +import androidx.core.app.NotificationManagerCompat +import com.rafaelfelipeac.replyradar.MainActivity +import com.rafaelfelipeac.replyradar.R +import com.rafaelfelipeac.replyradar.R.string.notification_channel_id +import com.rafaelfelipeac.replyradar.core.util.AppConstants.PENDING_REPLY_ID_KEY + +object NotificationUtils { + + fun showReminderNotification( + context: Context, + replyId: Long, + notificationTitle: String, + notificationContent: String + ) { + val notification = NotificationCompat.Builder( + context, + context.getString(notification_channel_id) + ) + .setSmallIcon(R.mipmap.ic_launcher_round) + .setContentTitle(notificationTitle) + .setContentText(notificationContent) + .setPriority(PRIORITY_HIGH) + .setContentIntent(getPendingIntent(context = context, replyId = replyId)) + .setAutoCancel(true) + .build() + + if (ActivityCompat.checkSelfPermission(context, POST_NOTIFICATIONS) != PERMISSION_GRANTED) { + return + } + + NotificationManagerCompat.from(context).notify(replyId.hashCode(), notification) + } + + private fun getPendingIntent(context: Context, replyId: Long): PendingIntent? { + val intent = Intent(context, MainActivity::class.java).apply { + flags = FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TASK + putExtra(PENDING_REPLY_ID_KEY, replyId) + } + + return PendingIntent.getActivity( + context, + replyId.hashCode(), + intent, + FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/ReminderSchedulerImpl.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/ReminderSchedulerImpl.kt new file mode 100644 index 0000000..c0336d0 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/ReminderSchedulerImpl.kt @@ -0,0 +1,59 @@ +package com.rafaelfelipeac.replyradar.core.reminder + +import android.content.Context +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.rafaelfelipeac.replyradar.R.string.notification_reminder_content +import com.rafaelfelipeac.replyradar.R.string.notification_reminder_reply_id +import com.rafaelfelipeac.replyradar.R.string.notification_reminder_tag +import com.rafaelfelipeac.replyradar.R.string.notification_reminder_title +import com.rafaelfelipeac.replyradar.core.reminder.model.NotificationReminderParams +import java.util.concurrent.TimeUnit.MILLISECONDS + +private const val INVALID_DELAY = 0 + +class ReminderSchedulerImpl( + private val context: Context +) : ReminderScheduler { + + override fun scheduleReminder( + reminderAtMillis: Long, + notificationReminderParams: NotificationReminderParams + ) { + val delay = getDelay(reminderAtMillis) + + if (delay <= INVALID_DELAY) return + + enqueueReminder(delay, notificationReminderParams) + } + + override fun cancelReminder(replyId: Long) { + WorkManager.getInstance(context).cancelAllWorkByTag(getTag(replyId)) + } + + private fun enqueueReminder( + delay: Long, + notificationReminderParams: NotificationReminderParams + ) { + with(notificationReminderParams) { + val workRequest = OneTimeWorkRequestBuilder() + .setInitialDelay(delay, MILLISECONDS) + .setInputData( + workDataOf( + context.getString(notification_reminder_reply_id) to replyId, + context.getString(notification_reminder_title) to notificationTitle, + context.getString(notification_reminder_content) to notificationContent + ) + ) + .addTag(getTag(replyId)) + .build() + + WorkManager.getInstance(context).enqueue(workRequest) + } + } + + private fun getDelay(reminderAtMillis: Long) = reminderAtMillis - System.currentTimeMillis() + + private fun getTag(replyId: Long) = context.getString(notification_reminder_tag, replyId) +} diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/ReminderWorker.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/ReminderWorker.kt new file mode 100644 index 0000000..b3ec56c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/ReminderWorker.kt @@ -0,0 +1,43 @@ +package com.rafaelfelipeac.replyradar.core.reminder + +import android.content.Context +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.rafaelfelipeac.replyradar.R.string.notification_reminder_content +import com.rafaelfelipeac.replyradar.R.string.notification_reminder_reply_id +import com.rafaelfelipeac.replyradar.R.string.notification_reminder_title +import com.rafaelfelipeac.replyradar.core.reminder.NotificationUtils.showReminderNotification +import com.rafaelfelipeac.replyradar.core.util.AppConstants.INVALID_ID + +class ReminderWorker( + appContext: Context, + workerParams: WorkerParameters +) : Worker(appContext, workerParams) { + + override fun doWork(): Result { + val replyId = inputData.getLong( + applicationContext.getString(notification_reminder_reply_id), + INVALID_ID + ) + + if (replyId == INVALID_ID) { + return Result.failure() + } + + val notificationTitle = + inputData.getString(applicationContext.getString(notification_reminder_title)) + ?: return Result.failure() + val notificationContent = + inputData.getString(applicationContext.getString(notification_reminder_content)) + ?: return Result.failure() + + showReminderNotification( + context = applicationContext, + replyId = replyId, + notificationTitle = notificationTitle, + notificationContent = notificationContent + ) + + return Result.success() + } +} diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Clock.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Clock.kt deleted file mode 100644 index d6063c4..0000000 --- a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Clock.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.rafaelfelipeac.replyradar.core.util - -actual fun getClock(): Clock = object : Clock { - override fun now(): Long = System.currentTimeMillis() -} diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Version.android.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/version/Version.android.kt similarity index 89% rename from composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Version.android.kt rename to composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/version/Version.android.kt index fa492ae..5cd63c0 100644 --- a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Version.android.kt +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/core/version/Version.android.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.util +package com.rafaelfelipeac.replyradar.core.version import android.content.Context import com.rafaelfelipeac.replyradar.R diff --git a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/di/Modules.android.kt b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/di/Modules.android.kt index 21b392d..2d48cc7 100644 --- a/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/di/Modules.android.kt +++ b/composeApp/src/androidMain/kotlin/com/rafaelfelipeac/replyradar/di/Modules.android.kt @@ -1,9 +1,12 @@ package com.rafaelfelipeac.replyradar.di +import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import com.rafaelfelipeac.replyradar.core.database.DatabaseFactory import com.rafaelfelipeac.replyradar.core.preferences.CreateDataStore +import com.rafaelfelipeac.replyradar.core.reminder.ReminderScheduler +import com.rafaelfelipeac.replyradar.core.reminder.ReminderSchedulerImpl import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.okhttp.OkHttp import org.koin.android.ext.koin.androidApplication @@ -15,4 +18,5 @@ actual val platformModule: Module single { OkHttp.create() } single { DatabaseFactory(androidApplication()) } single> { CreateDataStore(androidApplication()) } + single { ReminderSchedulerImpl(androidApplication() as Context) } } diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_close.png b/composeApp/src/commonMain/composeResources/drawable/ic_close.png new file mode 100644 index 0000000..8822ac3 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/ic_close.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_date.png b/composeApp/src/commonMain/composeResources/drawable/ic_date.png new file mode 100644 index 0000000..af0bfef Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/ic_date.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_notification.png b/composeApp/src/commonMain/composeResources/drawable/ic_notification.png new file mode 100644 index 0000000..13058a1 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/ic_notification.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_time.png b/composeApp/src/commonMain/composeResources/drawable/ic_time.png new file mode 100644 index 0000000..32bb027 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/ic_time.png differ diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/app/ReplyRadarApp.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/app/ReplyRadarApp.kt index 4bedcb0..75322ab 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/app/ReplyRadarApp.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/app/ReplyRadarApp.kt @@ -8,20 +8,24 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.rememberNavController -import com.rafaelfelipeac.replyradar.core.common.strings.LocalReplyRadarStrings -import com.rafaelfelipeac.replyradar.core.common.strings.StringsProvider -import com.rafaelfelipeac.replyradar.core.common.ui.theme.DarkColorScheme -import com.rafaelfelipeac.replyradar.core.common.ui.theme.LightColorScheme -import com.rafaelfelipeac.replyradar.core.common.ui.theme.ReplyRadarTheme -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme.DARK -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme.SYSTEM import com.rafaelfelipeac.replyradar.core.navigation.AppNavHost +import com.rafaelfelipeac.replyradar.core.notification.LocalNotificationPermissionManager +import com.rafaelfelipeac.replyradar.core.notification.NotificationPermissionManager +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings +import com.rafaelfelipeac.replyradar.core.strings.StringsProvider +import com.rafaelfelipeac.replyradar.core.theme.DarkColorScheme +import com.rafaelfelipeac.replyradar.core.theme.LightColorScheme +import com.rafaelfelipeac.replyradar.core.theme.ReplyRadarTheme +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme.DARK +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme.SYSTEM import com.rafaelfelipeac.replyradar.features.app.settings.AppSettingsViewModel import org.koin.compose.viewmodel.koinViewModel @Composable fun ReplyRadarApp( - onSystemBarsConfigured: ((isDark: Boolean, backgroundColor: Color) -> Unit)? = null + onSystemBarsConfigured: ((isDark: Boolean, backgroundColor: Color) -> Unit)? = null, + notificationPermissionManager: NotificationPermissionManager, + pendingReplyId: Long? ) { val navController = rememberNavController() val appSettingsViewModel = koinViewModel() @@ -40,10 +44,11 @@ fun ReplyRadarApp( val strings = StringsProvider.current CompositionLocalProvider( - LocalReplyRadarStrings provides strings + LocalReplyRadarStrings provides strings, + LocalNotificationPermissionManager provides notificationPermissionManager ) { ReplyRadarTheme(darkTheme = isDark) { - AppNavHost(navController = navController) + AppNavHost(navController = navController, pendingReplyId = pendingReplyId) } } } diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/AppConstants.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/AppConstants.kt deleted file mode 100644 index d747cb8..0000000 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/AppConstants.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.rafaelfelipeac.replyradar.core - -object AppConstants { - const val EMPTY = "" - const val DB_NAME = "replyradar.db" - const val PACKAGE_NAME = "com.rafaelfelipeac.replyradar" - const val EMAIL = "rafaelfelipeac@gmail.com" - const val INITIAL_DATE_LONG = 0L -} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/Dimens.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/Dimens.kt index e5dc861..f410726 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/Dimens.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/Dimens.kt @@ -27,6 +27,8 @@ val textSizeLarge = 20.sp val iconSize = 22.dp val iconSizeLarge = 28.dp +val iconButtonSize = 36.dp + val buttonHeight = 34.dp val buttonCornerRadius = 8.dp val buttonBorderWidth = 1.dp diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyConfirmationDialog.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyConfirmationDialog.kt index 3d39c23..03d25ff 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyConfirmationDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyConfirmationDialog.kt @@ -48,7 +48,10 @@ fun ReplyConfirmationDialog( style = typography.titleLarge ) - Spacer(modifier = Modifier.height(spacerSmall)) + Spacer( + modifier = Modifier + .height(spacerSmall) + ) Text( text = description, @@ -56,10 +59,14 @@ fun ReplyConfirmationDialog( color = colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(spacerLarge)) + Spacer( + modifier = Modifier + .height(spacerLarge) + ) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth(), horizontalArrangement = End ) { TextButton( @@ -68,7 +75,10 @@ fun ReplyConfirmationDialog( Text(dismiss) } - Spacer(modifier = Modifier.width(spacerSmall)) + Spacer( + modifier = Modifier + .width(spacerSmall) + ) TextButton( onClick = { diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyNotificationPermissionDialog.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyNotificationPermissionDialog.kt new file mode 100644 index 0000000..46b40b0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyNotificationPermissionDialog.kt @@ -0,0 +1,32 @@ +package com.rafaelfelipeac.replyradar.core.common.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings + +@Composable +fun NotificationPermissionDialog(onDismiss: () -> Unit, onGoToSettings: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = LocalReplyRadarStrings.current.notificationPermissionDialogTitle) + }, + text = { + Text( + text = LocalReplyRadarStrings.current.notificationPermissionDialogDescription + ) + }, + confirmButton = { + TextButton(onClick = onGoToSettings) { + Text(LocalReplyRadarStrings.current.notificationPermissionDialogConfirmButton) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(LocalReplyRadarStrings.current.notificationPermissionDialogDismissButton) + } + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyOutlinedButton.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyOutlinedButton.kt index 81e117f..7858bb0 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyOutlinedButton.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyOutlinedButton.kt @@ -19,7 +19,7 @@ import com.rafaelfelipeac.replyradar.core.common.ui.buttonHeight import com.rafaelfelipeac.replyradar.core.common.ui.iconSize import com.rafaelfelipeac.replyradar.core.common.ui.paddingSmall import com.rafaelfelipeac.replyradar.core.common.ui.paddingXSmall -import com.rafaelfelipeac.replyradar.core.common.ui.theme.buttonBorderColor +import com.rafaelfelipeac.replyradar.core.theme.buttonBorderColor import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource @@ -39,7 +39,8 @@ fun ReplyOutlinedButton(text: String, icon: DrawableResource? = null, onClick: ( contentPadding = PaddingValues(horizontal = paddingSmall, vertical = paddingXSmall) ) { Text( - modifier = Modifier.padding(horizontal = paddingXSmall), + modifier = Modifier + .padding(horizontal = paddingXSmall), text = text ) icon?.let { icon -> diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyRadarError.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyRadarError.kt index 2a62850..a973dc8 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyRadarError.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyRadarError.kt @@ -4,7 +4,7 @@ import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.text.style.TextAlign -import com.rafaelfelipeac.replyradar.core.common.strings.LocalReplyRadarStrings +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings @Composable fun ReplyRadarError(errorMessage: String?) { diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyReminder.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyReminder.kt new file mode 100644 index 0000000..3046273 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyReminder.kt @@ -0,0 +1,198 @@ +package com.rafaelfelipeac.replyradar.core.common.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +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 com.rafaelfelipeac.replyradar.core.common.ui.components.util.formatReminderText +import com.rafaelfelipeac.replyradar.core.common.ui.iconButtonSize +import com.rafaelfelipeac.replyradar.core.common.ui.iconSize +import com.rafaelfelipeac.replyradar.core.common.ui.listDividerThickness +import com.rafaelfelipeac.replyradar.core.common.ui.paddingLarge +import com.rafaelfelipeac.replyradar.core.common.ui.paddingSmall +import com.rafaelfelipeac.replyradar.core.common.ui.paddingXSmall +import com.rafaelfelipeac.replyradar.core.datetime.PlatformDatePicker +import com.rafaelfelipeac.replyradar.core.datetime.PlatformTimePicker +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings +import com.rafaelfelipeac.replyradar.core.theme.horizontalDividerColor +import com.rafaelfelipeac.replyradar.core.theme.toolbarIconsColor +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import org.jetbrains.compose.resources.painterResource +import replyradar.composeapp.generated.resources.Res.drawable +import replyradar.composeapp.generated.resources.ic_close +import replyradar.composeapp.generated.resources.ic_date +import replyradar.composeapp.generated.resources.ic_time + +@Composable +fun ReplyReminder( + selectedTime: LocalTime?, + selectedDate: LocalDate?, + onSelectedTimeChange: (LocalTime?) -> Unit, + onSelectedDateChange: (LocalDate?) -> Unit, + closeKeyboard: () -> Unit? +) { + var showTimePicker by remember { mutableStateOf(false) } + var showDatePicker by remember { mutableStateOf(false) } + val reminderText = formatReminderText( + selectedDate = selectedDate, + selectedTime = selectedTime + ) + + ReminderText( + reminderText = reminderText, + onDeleteClick = { + onSelectedTimeChange(null) + onSelectedDateChange(null) + showTimePicker = false + showDatePicker = false + } + ) + + HorizontalDivider( + modifier = Modifier + .padding(top = paddingSmall, start = paddingXSmall, end = paddingXSmall), + thickness = listDividerThickness, + color = colorScheme.horizontalDividerColor + ) + + if (showTimePicker) { + PlatformTimePicker( + selectedTime = selectedTime, + selectedDate = selectedDate, + confirmButtonText = LocalReplyRadarStrings.current + .replyListReminderTimePickerConfirmButton, + dismissButtonText = LocalReplyRadarStrings.current + .replyListReminderTimePickerDismissButton, + pickerTimeTitle = LocalReplyRadarStrings.current + .replyListReminderTimePickerTitle, + onTimeSelected = { + onSelectedTimeChange(it) + showTimePicker = false + }, + onDismiss = { + showTimePicker = false + } + ) + } + + if (showDatePicker) { + PlatformDatePicker( + selectedDate = selectedDate, + selectedTime = selectedTime, + confirmButtonText = LocalReplyRadarStrings.current + .replyListReminderDatePickerConfirmButton, + dismissButtonText = LocalReplyRadarStrings.current + .replyListReminderDatePickerDismissButton, + onDateSelected = { + onSelectedDateChange(it) + showDatePicker = false + }, + onTimeInvalidated = { + onSelectedTimeChange(null) + }, + onDismiss = { + showDatePicker = false + } + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = paddingSmall), + horizontalArrangement = Arrangement.Start + ) { + IconButton( + modifier = Modifier + .size(iconButtonSize), + onClick = { + showTimePicker = true + closeKeyboard() + } + ) { + Icon( + modifier = Modifier + .size(iconSize), + painter = painterResource(drawable.ic_time), + contentDescription = LocalReplyRadarStrings.current + .replyListReminderTimeIconContentDescription, + tint = colorScheme.primary + ) + } + + IconButton( + modifier = Modifier + .size(iconButtonSize), + onClick = { + showDatePicker = true + closeKeyboard() + } + ) { + Icon( + modifier = Modifier + .size(iconSize), + painter = painterResource(drawable.ic_date), + contentDescription = LocalReplyRadarStrings.current + .replyListReminderDateIconContentDescription, + tint = colorScheme.primary + ) + } + } + + HorizontalDivider( + modifier = Modifier + .padding(vertical = paddingSmall, horizontal = paddingXSmall), + thickness = listDividerThickness, + color = colorScheme.horizontalDividerColor + ) +} + +@Composable +private fun ReminderText(reminderText: String?, onDeleteClick: () -> Unit) { + if (reminderText != null) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .padding(start = paddingSmall, top = paddingSmall), + text = reminderText, + style = typography.bodySmall + ) + + IconButton( + modifier = Modifier + .padding(start = paddingLarge, top = paddingSmall) + .size(iconButtonSize), + onClick = { onDeleteClick() } + ) { + Icon( + modifier = Modifier + .size(iconSize), + painter = painterResource(drawable.ic_close), + contentDescription = LocalReplyRadarStrings.current + .replyListReminderCloseIconContentDescription, + tint = colorScheme.toolbarIconsColor + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplySnackbar.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplySnackbar.kt new file mode 100644 index 0000000..ed383b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplySnackbar.kt @@ -0,0 +1,22 @@ +package com.rafaelfelipeac.replyradar.core.common.ui.components + +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import com.rafaelfelipeac.replyradar.core.theme.snackbarBackgroundColor + +@Composable +fun ReplySnackbar(snackbarHostState: SnackbarHostState) { + SnackbarHost( + hostState = snackbarHostState, + snackbar = { snackbarData -> + Snackbar( + snackbarData = snackbarData, + containerColor = colorScheme.snackbarBackgroundColor, + contentColor = colorScheme.onSurface + ) + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyTab.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyTab.kt index f847cb4..ef878ec 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyTab.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyTab.kt @@ -7,7 +7,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.rafaelfelipeac.replyradar.core.common.ui.tabVerticalPadding -import com.rafaelfelipeac.replyradar.core.common.ui.theme.unselectedTabColor +import com.rafaelfelipeac.replyradar.core.theme.unselectedTabColor @Composable fun ReplyTab(modifier: Modifier, selected: Boolean, onClick: () -> Unit, text: String) { diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyTextField.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyTextField.kt index 93422de..673805a 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyTextField.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyTextField.kt @@ -24,7 +24,7 @@ import com.rafaelfelipeac.replyradar.core.common.ui.paddingXSmall import com.rafaelfelipeac.replyradar.core.common.ui.textSizeLarge import com.rafaelfelipeac.replyradar.core.common.ui.textSizeMedium import com.rafaelfelipeac.replyradar.core.common.ui.textSizeSmall -import com.rafaelfelipeac.replyradar.core.common.ui.theme.textFieldPlaceholderColor +import com.rafaelfelipeac.replyradar.core.theme.textFieldPlaceholderColor @Composable fun ReplyTextField( diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyToggle.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyToggle.kt index 1706bc2..1fa0c00 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyToggle.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/ReplyToggle.kt @@ -23,10 +23,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color.Companion.Transparent import androidx.compose.ui.hapticfeedback.HapticFeedbackType.Companion.LongPress import androidx.compose.ui.platform.LocalHapticFeedback -import com.rafaelfelipeac.replyradar.core.common.strings.LocalReplyRadarStrings import com.rafaelfelipeac.replyradar.core.common.ui.iconSize import com.rafaelfelipeac.replyradar.core.common.ui.listItemToggleBorderWidth import com.rafaelfelipeac.replyradar.core.common.ui.listItemToggleSize +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings import kotlinx.coroutines.delay import org.jetbrains.compose.resources.painterResource import replyradar.composeapp.generated.resources.Res.drawable @@ -72,11 +72,11 @@ fun ReplyToggle(modifier: Modifier = Modifier, isResolved: Boolean, onToggle: () contentAlignment = Alignment.Center ) { Icon( + modifier = Modifier + .size(iconSize), painter = painterResource(drawable.ic_check), contentDescription = LocalReplyRadarStrings.current.replyListPlaceholderResolved, - tint = colorScheme.primary.copy(alpha = checkIconAlpha), - modifier = Modifier - .size(iconSize) + tint = colorScheme.primary.copy(alpha = checkIconAlpha) ) } diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/util/FormatReminderText.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/util/FormatReminderText.kt new file mode 100644 index 0000000..e192a43 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/components/util/FormatReminderText.kt @@ -0,0 +1,72 @@ +package com.rafaelfelipeac.replyradar.core.common.ui.components.util + +import androidx.compose.runtime.Composable +import com.rafaelfelipeac.replyradar.core.datetime.getCurrentDateTime +import com.rafaelfelipeac.replyradar.core.datetime.getDefaultTime +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings +import com.rafaelfelipeac.replyradar.core.util.format +import com.rafaelfelipeac.replyradar.core.util.toTwoDigitString +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime + +@Composable +fun formatReminderText(selectedDate: LocalDate?, selectedTime: LocalTime?): String? { + if (selectedDate == null && selectedTime == null) return null + + val datetime = getCurrentDateTime() + val defaultTime = + getDefaultTime(datetime, selectedDate, selectedTime) + + val datePart = getDatePart(selectedDate, selectedTime, datetime) + val timePart = getTimePart(selectedTime, selectedDate, defaultTime) + + return "${LocalReplyRadarStrings.current.replyListReminderSet} ${ + when { + datePart != null && timePart != null -> format( + LocalReplyRadarStrings.current.replyListReminderSetSeparator, + datePart, + timePart + ) + datePart != null -> datePart + else -> timePart + } + }" +} + +@Composable +private fun getDatePart( + selectedDate: LocalDate?, + selectedTime: LocalTime?, + dateTime: LocalDateTime +) = when { + selectedDate != null -> { + val day = selectedDate.dayOfMonth.toTwoDigitString() + val month = selectedDate.monthNumber.toTwoDigitString() + val year = selectedDate.year.toString() + "$day/$month/$year" + } + + selectedTime != null -> { + val reminderTimeToday = LocalDateTime(dateTime.date, selectedTime) + if (reminderTimeToday > dateTime) { + LocalReplyRadarStrings.current.replyListReminderToday + } else { + LocalReplyRadarStrings.current.replyListReminderTomorrow + } + } + + else -> null +} + +private fun getTimePart( + selectedTime: LocalTime?, + selectedDate: LocalDate?, + defaultTime: LocalTime +) = when { + selectedTime != null -> + "${selectedTime.hour.toTwoDigitString()}:${selectedTime.minute.toTwoDigitString()}" + selectedDate != null -> + "${defaultTime.hour.toTwoDigitString()}:${defaultTime.minute.toTwoDigitString()}" + else -> null +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/database/ReplyRadarDatabase.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/database/ReplyRadarDatabase.kt index 6d114b0..93494f3 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/database/ReplyRadarDatabase.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/database/ReplyRadarDatabase.kt @@ -15,4 +15,4 @@ abstract class ReplyRadarDatabase : RoomDatabase() { abstract val userActionDao: UserActionDao } -private const val DATABASE_VERSION = 1 +private const val DATABASE_VERSION = 2 diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/Datetime.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/Datetime.kt new file mode 100644 index 0000000..4c1a3e3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/Datetime.kt @@ -0,0 +1,82 @@ +package com.rafaelfelipeac.replyradar.core.datetime + +import com.rafaelfelipeac.replyradar.core.util.AppConstants.REMINDER_DEFAULT_HOUR +import com.rafaelfelipeac.replyradar.core.util.AppConstants.REMINDER_DEFAULT_MINUTE +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +const val LOCAL_TIME_HOUR_DEFAULT = 24 +const val LOCAL_TIME_MINUTE_DEFAULT = 0 +const val HOUR_OFFSET = 1 +const val HOUR_OFFSET_DEFAULT = 0 +const val MINUTE_EMPTY = 0 + +fun now() = Clock.System.now() + +fun getCurrentDateTime(): LocalDateTime { + return now().toLocalDateTime(TimeZone.currentSystemDefault()) +} + +fun getCurrentTimeMillis(): Long { + return now().toEpochMilliseconds() +} + +fun Long.dateTime(timeZone: TimeZone = TimeZone.currentSystemDefault()): LocalDateTime { + return Instant.fromEpochMilliseconds(this) + .toLocalDateTime(timeZone) +} + +fun isDateTimeValid(date: LocalDate?, time: LocalTime?, dateTime: LocalDateTime): Boolean { + return when { + date == null && time == null -> true + + date != null && time == null -> { + date >= dateTime.date + } + + date == null && time != null -> { + val todayTime = LocalDateTime(dateTime.date, time) + + todayTime > dateTime + } + + date != null && time != null -> { + val selectedDateTime = LocalDateTime(date, time) + + selectedDateTime > dateTime + } + + else -> false + } +} + +fun getDefaultTime( + dateTime: LocalDateTime, + selectedDate: LocalDate?, + selectedTime: LocalTime? +): LocalTime { + if (selectedTime != null && isDateTimeValid( + date = selectedDate, + time = selectedTime, + dateTime = dateTime + ) + ) { + return selectedTime + } + + val defaultTime = LocalTime(REMINDER_DEFAULT_HOUR, REMINDER_DEFAULT_MINUTE) + + if (isDateTimeValid(date = selectedDate, time = defaultTime, dateTime = dateTime)) { + return defaultTime + } + + val nextHour = + dateTime.hour + if (dateTime.minute > MINUTE_EMPTY) HOUR_OFFSET else HOUR_OFFSET_DEFAULT + + return LocalTime(hour = nextHour % LOCAL_TIME_HOUR_DEFAULT, minute = LOCAL_TIME_MINUTE_DEFAULT) +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformDatePicker.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformDatePicker.kt new file mode 100644 index 0000000..2ce6b0b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformDatePicker.kt @@ -0,0 +1,16 @@ +package com.rafaelfelipeac.replyradar.core.datetime + +import androidx.compose.runtime.Composable +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime + +@Composable +expect fun PlatformDatePicker( + selectedDate: LocalDate?, + selectedTime: LocalTime?, + onDateSelected: (LocalDate) -> Unit, + confirmButtonText: String, + dismissButtonText: String, + onTimeInvalidated: () -> Unit, + onDismiss: () -> Unit +) diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformTimePicker.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformTimePicker.kt new file mode 100644 index 0000000..f3c05de --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformTimePicker.kt @@ -0,0 +1,16 @@ +package com.rafaelfelipeac.replyradar.core.datetime + +import androidx.compose.runtime.Composable +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime + +@Composable +expect fun PlatformTimePicker( + selectedTime: LocalTime?, + selectedDate: LocalDate?, + onTimeSelected: (LocalTime) -> Unit, + onDismiss: () -> Unit, + confirmButtonText: String, + dismissButtonText: String, + pickerTimeTitle: String +) diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/Timestamp.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/Timestamp.kt new file mode 100644 index 0000000..42eba41 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/Timestamp.kt @@ -0,0 +1,70 @@ +package com.rafaelfelipeac.replyradar.core.datetime + +import com.rafaelfelipeac.replyradar.core.util.AppConstants.INITIAL_DATE +import com.rafaelfelipeac.replyradar.core.util.AppConstants.REMINDER_DEFAULT_HOUR +import com.rafaelfelipeac.replyradar.core.util.AppConstants.REMINDER_DEFAULT_MINUTE +import com.rafaelfelipeac.replyradar.core.util.AppConstants.REMINDER_TOMORROW_OFFSET +import com.rafaelfelipeac.replyradar.core.util.toTwoDigitString +import kotlinx.datetime.DateTimeUnit.Companion.DAY +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant + +fun formatTimestamp(timestampMillis: Long): String { + val localDateTime = timestampMillis.dateTime() + + return "${localDateTime.dayOfMonth}/${localDateTime.monthNumber}/${localDateTime.year} " + + "${localDateTime.hour}:${localDateTime.minute.toTwoDigitString()}" +} + +/** + * Calculates a reminder timestamp based on the current date-time and optional selections. + * + * Logic: + * - If selectedDate is provided, use it as the final date + * - If only selectedTime is provided, use today if the time is in the future, otherwise tomorrow + * - If neither is provided, return INITIAL_DATE constant + * - Final time defaults to REMINDER_DEFAULT_HOUR:REMINDER_DEFAULT_MINUTE if not provided + * + * @param dateTime Current date-time for comparison + * @param selectedDate Optional specific date for the reminder + * @param selectedTime Optional specific time for the reminder + * @return Timestamp in milliseconds for the calculated reminder date-time + */ +fun getReminderTimestamp( + dateTime: LocalDateTime, + selectedDate: LocalDate?, + selectedTime: LocalTime? +): Long { + val timeZone = TimeZone.currentSystemDefault() + + val finalDate = when { + selectedDate != null -> selectedDate + selectedTime != null -> { + val timeToday = LocalDateTime( + date = dateTime.date, + time = selectedTime + ) + + if (timeToday > dateTime) { + dateTime.date + } else { + dateTime.date.plus( + REMINDER_TOMORROW_OFFSET, + DAY + ) + } + } + + else -> return INITIAL_DATE + } + + val finalTime = selectedTime ?: LocalTime(REMINDER_DEFAULT_HOUR, REMINDER_DEFAULT_MINUTE) + + val finalDateTime = LocalDateTime(finalDate, finalTime) + + return finalDateTime.toInstant(timeZone).toEpochMilliseconds() +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/External.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/external/External.kt similarity index 69% rename from composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/External.kt rename to composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/external/External.kt index 4c9c91a..1b88284 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/External.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/external/External.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.util +package com.rafaelfelipeac.replyradar.core.external expect fun openEmailApp(to: String, subject: String, body: String) diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/language/AppLanguage.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/language/AppLanguage.kt similarity index 79% rename from composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/language/AppLanguage.kt rename to composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/language/AppLanguage.kt index de605f1..5c92c91 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/language/AppLanguage.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/language/AppLanguage.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.common.language +package com.rafaelfelipeac.replyradar.core.language enum class AppLanguage { ENGLISH, diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/navigation/AppNavHost.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/navigation/AppNavHost.kt index 419c580..82ea131 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/navigation/AppNavHost.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/navigation/AppNavHost.kt @@ -22,7 +22,7 @@ import com.rafaelfelipeac.replyradar.features.settings.presentation.SettingsView import org.koin.compose.viewmodel.koinViewModel @Composable -fun AppNavHost(navController: NavHostController) { +fun AppNavHost(navController: NavHostController, pendingReplyId: Long?) { NavHost( navController = navController, startDestination = ReplyGraph @@ -38,6 +38,7 @@ fun AppNavHost(navController: NavHostController) { ReplyListScreenRoot( viewModel = viewModel, + pendingReplyId = pendingReplyId, onSettingsClick = { navController.navigate(Settings) }, onActivityLogClick = { navController.navigate(ActivityLog) } ) diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/notification/LocalNotificationPermissionManager.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/notification/LocalNotificationPermissionManager.kt new file mode 100644 index 0000000..482fefe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/notification/LocalNotificationPermissionManager.kt @@ -0,0 +1,7 @@ +package com.rafaelfelipeac.replyradar.core.notification + +import androidx.compose.runtime.compositionLocalOf + +val LocalNotificationPermissionManager = compositionLocalOf { + error("No NotificationPermissionManager provided") +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/notification/NotificationPermissionManager.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/notification/NotificationPermissionManager.kt new file mode 100644 index 0000000..1197928 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/notification/NotificationPermissionManager.kt @@ -0,0 +1,8 @@ +package com.rafaelfelipeac.replyradar.core.notification + +interface NotificationPermissionManager { + + suspend fun ensureNotificationPermission(): Boolean + + suspend fun goToAppSettings() +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/ReminderScheduler.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/ReminderScheduler.kt new file mode 100644 index 0000000..b45db60 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/ReminderScheduler.kt @@ -0,0 +1,13 @@ +package com.rafaelfelipeac.replyradar.core.reminder + +import com.rafaelfelipeac.replyradar.core.reminder.model.NotificationReminderParams + +interface ReminderScheduler { + + fun scheduleReminder( + reminderAtMillis: Long, + notificationReminderParams: NotificationReminderParams + ) + + fun cancelReminder(replyId: Long) +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/model/NotificationReminderParams.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/model/NotificationReminderParams.kt new file mode 100644 index 0000000..320e505 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/reminder/model/NotificationReminderParams.kt @@ -0,0 +1,7 @@ +package com.rafaelfelipeac.replyradar.core.reminder.model + +data class NotificationReminderParams( + val replyId: Long, + val notificationTitle: String, + val notificationContent: String +) diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/LocalReplyRadarStrings.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/LocalReplyRadarStrings.kt similarity index 73% rename from composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/LocalReplyRadarStrings.kt rename to composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/LocalReplyRadarStrings.kt index 69434dc..19c069c 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/LocalReplyRadarStrings.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/LocalReplyRadarStrings.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.common.strings +package com.rafaelfelipeac.replyradar.core.strings import androidx.compose.runtime.staticCompositionLocalOf diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/Strings.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/Strings.kt similarity index 71% rename from composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/Strings.kt rename to composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/Strings.kt index af5ec81..6026bfb 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/Strings.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/Strings.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.common.strings +package com.rafaelfelipeac.replyradar.core.strings interface Strings { val appName: String @@ -37,6 +37,20 @@ interface Strings { val replyListSnackbarReopened: String val replyListSnackbarResolved: String val replyListSnackbarUnarchived: String + val replyListReminder: String + val replyListReminderSet: String + val replyListReminderSetSeparator: String + val replyListReminderToday: String + val replyListReminderTomorrow: String + val replyListReminderTimeIconContentDescription: String + val replyListReminderDateIconContentDescription: String + val replyListReminderCloseIconContentDescription: String + val replyListReminderInvalidDateTime: String + val replyListReminderTimePickerTitle: String + val replyListReminderTimePickerConfirmButton: String + val replyListReminderTimePickerDismissButton: String + val replyListReminderDatePickerConfirmButton: String + val replyListReminderDatePickerDismissButton: String val settingsTitle: String val settingsBackButton: String @@ -72,8 +86,18 @@ interface Strings { val activityLogUserActionResolveVerb: String val activityLogUserActionUnarchiveVerb: String val activityLogUserActionOpenVerb: String + val activityLogUserActionScheduledVerb: String + val activityLogUserActionOpenedNotificationVerb: String val activityLogUserActionTheme: String val activityLogUserActionLanguage: String val activityLogUserActionFeedback: String val activityLogUserActionRate: String + + val notificationPermissionDialogTitle: String + val notificationPermissionDialogDescription: String + val notificationPermissionDialogConfirmButton: String + val notificationPermissionDialogDismissButton: String + val notificationTitle: String + val notificationContent: String + val notificationContentWithoutSubject: String } diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/StringsEn.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/StringsEn.kt similarity index 71% rename from composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/StringsEn.kt rename to composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/StringsEn.kt index 15311ad..ca64cd6 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/StringsEn.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/StringsEn.kt @@ -1,9 +1,13 @@ -package com.rafaelfelipeac.replyradar.core.common.strings +@file:Suppress("MaxLineLength") -import com.rafaelfelipeac.replyradar.core.util.getAppVersion +package com.rafaelfelipeac.replyradar.core.strings +import com.rafaelfelipeac.replyradar.core.version.getAppVersion + +// ktlint-disable max-line-length object StringsEn : Strings { override val appName = "Reply Radar" + override val genericErrorMessage = "An unexpected error occurred." override val replyListActivityLog = "Activity Log" @@ -41,6 +45,20 @@ object StringsEn : Strings { override val replyListSnackbarReopened = "Item reopened and back on the radar." override val replyListSnackbarResolved = "Item marked as resolved." override val replyListSnackbarUnarchived = "Item successfully unarchived." + override val replyListReminder = "Reminder" + override val replyListReminderSet = "Reminder set for:" + override val replyListReminderSetSeparator = "%1 at %2" + override val replyListReminderToday = "today" + override val replyListReminderTomorrow = "tomorrow" + override val replyListReminderTimeIconContentDescription = "Time" + override val replyListReminderDateIconContentDescription = "Date" + override val replyListReminderCloseIconContentDescription = "Close" + override val replyListReminderInvalidDateTime = "The selected date and time has already passed." + override val replyListReminderTimePickerTitle = "Select time" + override val replyListReminderTimePickerConfirmButton = "OK" + override val replyListReminderTimePickerDismissButton = "Cancel" + override val replyListReminderDatePickerConfirmButton = "OK" + override val replyListReminderDatePickerDismissButton = "Cancel" override val settingsTitle = "Settings" override val settingsBackButton = "Back" @@ -90,8 +108,19 @@ App version: ${getAppVersion()} override val activityLogUserActionResolveVerb = "resolved" override val activityLogUserActionUnarchiveVerb = "unarchived" override val activityLogUserActionOpenVerb = "opened" + override val activityLogUserActionScheduledVerb = "scheduled a reminder for" + override val activityLogUserActionOpenedNotificationVerb = "opened a notification for" override val activityLogUserActionTheme = "You switched the app theme." override val activityLogUserActionLanguage = "You changed the app language." override val activityLogUserActionFeedback = "You gave feedback about the app." override val activityLogUserActionRate = "You rated the app." + + override val notificationPermissionDialogTitle = "Notification Permission" + override val notificationPermissionDialogDescription = "To remind you to reply to your messages, we need permission to send notifications. \n\nYou can enable it in the app settings." + override val notificationPermissionDialogConfirmButton = "Open Settings" + override val notificationPermissionDialogDismissButton = "Got it" + override val notificationTitle = "Hey, how about replying to %1?" + override val notificationContent = "%1 is waiting for your reply about \"%2\"." + override val notificationContentWithoutSubject = "%1 is waiting for your reply." } +// ktlint-enable max-line-length diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/StringsProvider.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/StringsProvider.kt similarity index 53% rename from composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/StringsProvider.kt rename to composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/StringsProvider.kt index f669ee8..f25b583 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/StringsProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/StringsProvider.kt @@ -1,9 +1,9 @@ -package com.rafaelfelipeac.replyradar.core.common.strings +package com.rafaelfelipeac.replyradar.core.strings -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage.ENGLISH -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage.PORTUGUESE -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage.SYSTEM +import com.rafaelfelipeac.replyradar.core.language.AppLanguage +import com.rafaelfelipeac.replyradar.core.language.AppLanguage.ENGLISH +import com.rafaelfelipeac.replyradar.core.language.AppLanguage.PORTUGUESE +import com.rafaelfelipeac.replyradar.core.language.AppLanguage.SYSTEM object StringsProvider { private val english = StringsEn diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/StringsPt.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/StringsPt.kt similarity index 71% rename from composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/StringsPt.kt rename to composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/StringsPt.kt index 03b2ec5..918cb18 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/strings/StringsPt.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/strings/StringsPt.kt @@ -1,9 +1,13 @@ -package com.rafaelfelipeac.replyradar.core.common.strings +@file:Suppress("MaxLineLength") -import com.rafaelfelipeac.replyradar.core.util.getAppVersion +package com.rafaelfelipeac.replyradar.core.strings +import com.rafaelfelipeac.replyradar.core.version.getAppVersion + +// ktlint-disable max-line-length object StringsPt : Strings { override val appName = "Reply Radar" + override val genericErrorMessage = "Ocorreu um erro inesperado." override val replyListActivityLog = "Atividades" @@ -41,6 +45,20 @@ object StringsPt : Strings { override val replyListSnackbarReopened = "Item reaberto e voltou para o radar." override val replyListSnackbarResolved = "Item marcado como resolvido." override val replyListSnackbarUnarchived = "Item desarquivado com sucesso." + override val replyListReminder = "Lembrete" + override val replyListReminderSet = "Lembrete definido para:" + override val replyListReminderSetSeparator = "%1 às %2" + override val replyListReminderToday = "hoje" + override val replyListReminderTomorrow = "amanhã" + override val replyListReminderTimeIconContentDescription = "Horário" + override val replyListReminderDateIconContentDescription = "Data" + override val replyListReminderCloseIconContentDescription = "Remover" + override val replyListReminderInvalidDateTime = "A data e hora selecionadas já passaram." + override val replyListReminderTimePickerTitle = "Selecionar horário" + override val replyListReminderTimePickerConfirmButton = "OK" + override val replyListReminderTimePickerDismissButton = "Cancelar" + override val replyListReminderDatePickerConfirmButton = "OK" + override val replyListReminderDatePickerDismissButton = "Cancelar" override val settingsTitle = "Configurações" override val settingsBackButton = "Voltar" @@ -90,8 +108,19 @@ Versão do app: ${getAppVersion()} override val activityLogUserActionResolveVerb = "resolveu" override val activityLogUserActionUnarchiveVerb = "desarquivou" override val activityLogUserActionOpenVerb = "abriu" + override val activityLogUserActionScheduledVerb = "agendou um lembrete para" + override val activityLogUserActionOpenedNotificationVerb = "abriu uma notificação para" override val activityLogUserActionTheme = "Você mudou o tema do app." override val activityLogUserActionLanguage = "Você mudou o idioma do app." override val activityLogUserActionFeedback = "Você enviou um feedback sobre o app." override val activityLogUserActionRate = "Você avaliou o app." + + override val notificationPermissionDialogTitle = "Permissão de notificações" + override val notificationPermissionDialogDescription = "Para que possamos te lembrar de responder às suas mensagens, precisamos que você permita o envio de notificações. \n\nVocê pode ativar isso nas configurações do app." + override val notificationPermissionDialogConfirmButton = "Abrir Configurações" + override val notificationPermissionDialogDismissButton = "Entendido" + override val notificationTitle = "Hey, que tal responder %1?" + override val notificationContent = "%1 está esperando sua resposta sobre \"%2\"." + override val notificationContentWithoutSubject = "%1 está esperando sua resposta." } +// ktlint-enable max-line-length diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ColorScheme.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ColorScheme.kt similarity index 84% rename from composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ColorScheme.kt rename to composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ColorScheme.kt index cfe6881..dce94ee 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ColorScheme.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ColorScheme.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.common.ui.theme +package com.rafaelfelipeac.replyradar.core.theme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme @@ -21,7 +21,8 @@ val LightExtraColors = ReplyRadarColors( horizontalDividerColor = Color(0xFFE0E0E0), unselectedTabColor = Color(0xFF1E1E1E).copy(alpha = UnselectedTabAlpha), toolbarIconsColor = Color(0xFF464152), - snackbarBackgroundColor = Color(0xFFECECEC) + snackbarBackgroundColor = Color(0xFFECECEC), + replyBottomSheetIconColor = Color(0xFF888888) ) val DarkColorScheme = darkColorScheme( @@ -39,5 +40,6 @@ val DarkExtraColors = ReplyRadarColors( horizontalDividerColor = Color(0xFF333333), unselectedTabColor = Color(0xFFEDEDED).copy(alpha = UnselectedTabAlpha), toolbarIconsColor = Color(0xFFB3B8EF), - snackbarBackgroundColor = Color(0xFF2C2C2C) + snackbarBackgroundColor = Color(0xFF2C2C2C), + replyBottomSheetIconColor = Color(0xFFAAAAAA) ) diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ReplyRadarColorScheme.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ReplyRadarColorScheme.kt similarity index 75% rename from composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ReplyRadarColorScheme.kt rename to composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ReplyRadarColorScheme.kt index 130c44d..83a9170 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ReplyRadarColorScheme.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ReplyRadarColorScheme.kt @@ -1,9 +1,9 @@ -package com.rafaelfelipeac.replyradar.core.common.ui.theme +package com.rafaelfelipeac.replyradar.core.theme import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import com.rafaelfelipeac.replyradar.core.common.ui.theme.ReplyRadarThemeAccessors.colors +import com.rafaelfelipeac.replyradar.core.theme.ReplyRadarThemeAccessors.colors val ColorScheme.buttonBorderColor: Color @Composable get() = colors.buttonBorderColor @@ -22,3 +22,6 @@ val ColorScheme.toolbarIconsColor: Color val ColorScheme.snackbarBackgroundColor: Color @Composable get() = colors.snackbarBackgroundColor + +val ColorScheme.replyBottomSheetIconColor: Color + @Composable get() = colors.replyBottomSheetIconColor diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ReplyRadarColors.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ReplyRadarColors.kt similarity index 78% rename from composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ReplyRadarColors.kt rename to composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ReplyRadarColors.kt index af95864..949a11e 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ReplyRadarColors.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ReplyRadarColors.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.common.ui.theme +package com.rafaelfelipeac.replyradar.core.theme import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf @@ -11,7 +11,8 @@ data class ReplyRadarColors( val horizontalDividerColor: Color, val unselectedTabColor: Color, val toolbarIconsColor: Color, - val snackbarBackgroundColor: Color + val snackbarBackgroundColor: Color, + val replyBottomSheetIconColor: Color ) val LocalReplyRadarColors = staticCompositionLocalOf { diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ReplyRadarTheme.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ReplyRadarTheme.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ReplyRadarTheme.kt rename to composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ReplyRadarTheme.kt index caabd78..2825672 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ReplyRadarTheme.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ReplyRadarTheme.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.common.ui.theme +package com.rafaelfelipeac.replyradar.core.theme import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ReplyRadarThemeAccessors.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ReplyRadarThemeAccessors.kt similarity index 75% rename from composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ReplyRadarThemeAccessors.kt rename to composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ReplyRadarThemeAccessors.kt index 19cda12..7e15171 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/ReplyRadarThemeAccessors.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/ReplyRadarThemeAccessors.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.common.ui.theme +package com.rafaelfelipeac.replyradar.core.theme import androidx.compose.runtime.Composable diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/model/AppTheme.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/model/AppTheme.kt similarity index 77% rename from composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/model/AppTheme.kt rename to composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/model/AppTheme.kt index d508865..ce2665b 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/common/ui/theme/model/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/theme/model/AppTheme.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.common.ui.theme.model +package com.rafaelfelipeac.replyradar.core.theme.model enum class AppTheme { LIGHT, diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/AppConstants.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/AppConstants.kt new file mode 100644 index 0000000..3d3a0ee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/AppConstants.kt @@ -0,0 +1,21 @@ +package com.rafaelfelipeac.replyradar.core.util + +object AppConstants { + const val EMPTY = "" + const val DB_NAME = "replyradar.db" + const val PACKAGE_NAME = "com.rafaelfelipeac.replyradar" + const val EMAIL = "rafaelfelipeac@gmail.com" + + const val PENDING_REPLY_ID_KEY = "PENDING_REPLY_ID" + + const val INITIAL_DATE = 0L + const val INITIAL_ID = 0L + const val INVALID_ID = -1L + const val REMINDER_DEFAULT_HOUR = 8 + const val REMINDER_DEFAULT_MINUTE = 0 + const val REMINDER_TOMORROW_OFFSET = 1 + + const val ON_THE_RADAR_INDEX = 0 + const val RESOLVED_INDEX = 1 + const val ARCHIVED_INDEX = 2 +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Clock.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Clock.kt deleted file mode 100644 index 3efc7f2..0000000 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Clock.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.rafaelfelipeac.replyradar.core.util - -interface Clock { - fun now(): Long -} - -expect fun getClock(): Clock diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Int.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Int.kt new file mode 100644 index 0000000..266b579 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Int.kt @@ -0,0 +1,6 @@ +package com.rafaelfelipeac.replyradar.core.util + +const val PAD_CHAR = '0' +const val PAD_LENGTH = 2 + +fun Int.toTwoDigitString(): String = toString().padStart(PAD_LENGTH, PAD_CHAR) diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Timestamp.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Timestamp.kt deleted file mode 100644 index a289f24..0000000 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Timestamp.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.rafaelfelipeac.replyradar.core.util - -import kotlinx.datetime.Instant -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime - -fun formatTimestamp(timestampMillis: Long): String { - val instant = Instant.fromEpochMilliseconds(timestampMillis) - val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) - - return "${localDateTime.dayOfMonth}/${localDateTime.monthNumber}/${localDateTime.year} " + - "${localDateTime.hour}:${localDateTime.minute.toString().padStart(LENGTH, PAD_CHAR)}" -} - -private const val LENGTH = 2 -private const val PAD_CHAR = '0' diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Version.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Version.kt deleted file mode 100644 index 296a2be..0000000 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Version.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.rafaelfelipeac.replyradar.core.util - -expect fun getAppVersion(): String diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/version/Version.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/version/Version.kt new file mode 100644 index 0000000..b83fb3b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/core/version/Version.kt @@ -0,0 +1,3 @@ +package com.rafaelfelipeac.replyradar.core.version + +expect fun getAppVersion(): String diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/di/Modules.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/di/Modules.kt index 2808032..dd575ac 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/di/Modules.kt @@ -2,8 +2,6 @@ package com.rafaelfelipeac.replyradar.di import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.rafaelfelipeac.replyradar.core.database.DatabaseFactory -import com.rafaelfelipeac.replyradar.core.util.Clock -import com.rafaelfelipeac.replyradar.core.util.getClock import org.koin.core.module.Module import org.koin.dsl.module @@ -15,6 +13,4 @@ val sharedModule = module { .setDriver(BundledSQLiteDriver()) .build() } - - single { getClock() } } diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/activitylog/presentation/ActivityLogScreen.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/activitylog/presentation/ActivityLogScreen.kt index af5e6a6..cedaa37 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/activitylog/presentation/ActivityLogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/activitylog/presentation/ActivityLogScreen.kt @@ -34,16 +34,17 @@ import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.rafaelfelipeac.replyradar.core.common.strings.LocalReplyRadarStrings import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyProgress import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyRadarError import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyRadarPlaceholder import com.rafaelfelipeac.replyradar.core.common.ui.iconSize import com.rafaelfelipeac.replyradar.core.common.ui.listDividerThickness import com.rafaelfelipeac.replyradar.core.common.ui.paddingMedium -import com.rafaelfelipeac.replyradar.core.common.ui.theme.horizontalDividerColor +import com.rafaelfelipeac.replyradar.core.datetime.formatTimestamp +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings +import com.rafaelfelipeac.replyradar.core.theme.horizontalDividerColor +import com.rafaelfelipeac.replyradar.core.util.AppConstants.EMPTY import com.rafaelfelipeac.replyradar.core.util.format -import com.rafaelfelipeac.replyradar.core.util.formatTimestamp import com.rafaelfelipeac.replyradar.features.activitylog.presentation.ActivityLogViewModel.Companion.ERROR_GET_ACTIVITY_LOG import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserAction import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionTargetType @@ -58,9 +59,12 @@ import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActio import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Delete import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Edit import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Open +import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.OpenedNotification import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Reopen import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Resolve +import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Scheduled import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Unarchive +import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Unknown import org.jetbrains.compose.resources.painterResource import org.koin.compose.viewmodel.koinViewModel import replyradar.composeapp.generated.resources.Res.drawable @@ -71,10 +75,12 @@ import replyradar.composeapp.generated.resources.ic_delete import replyradar.composeapp.generated.resources.ic_edit import replyradar.composeapp.generated.resources.ic_email import replyradar.composeapp.generated.resources.ic_language +import replyradar.composeapp.generated.resources.ic_notification import replyradar.composeapp.generated.resources.ic_open import replyradar.composeapp.generated.resources.ic_rate import replyradar.composeapp.generated.resources.ic_reopen import replyradar.composeapp.generated.resources.ic_theme +import replyradar.composeapp.generated.resources.ic_time import replyradar.composeapp.generated.resources.ic_unarchive @OptIn(ExperimentalMaterial3Api::class) @@ -90,8 +96,8 @@ fun ActivityLogScreen(viewModel: ActivityLogViewModel = koinViewModel(), onBackC IconButton(onClick = onBackClick) { Icon( imageVector = Icons.Default.ArrowBack, - contentDescription = - LocalReplyRadarStrings.current.activityLogBackButton + contentDescription = LocalReplyRadarStrings.current + .activityLogBackButton ) } } @@ -124,7 +130,8 @@ fun ActivityLogScreen(viewModel: ActivityLogViewModel = koinViewModel(), onBackC @Composable private fun Loading() { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), contentAlignment = Alignment.Center ) { ReplyProgress() @@ -134,7 +141,8 @@ private fun Loading() { @Composable private fun Error(state: ActivityLogState) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), contentAlignment = Alignment.Center ) { ReplyRadarError( @@ -146,7 +154,8 @@ private fun Error(state: ActivityLogState) { @Composable private fun Placeholder() { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), contentAlignment = Alignment.Center ) { ReplyRadarPlaceholder( @@ -162,7 +171,10 @@ private fun ActivityLogList(state: ActivityLogState) { state.activityLogItems, key = { _, item -> item.id } ) { index, userAction -> - Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + ) { ActivityLogListItem(userAction = userAction) if (index < state.activityLogItems.lastIndex) { @@ -197,35 +209,37 @@ fun ActivityLogListItem(userAction: UserAction) { horizontalArrangement = spacedBy(paddingMedium) ) { with(userAction) { - Column { - Icon( - modifier = Modifier - .size(iconSize), - painter = painterResource( - getIconByActionType( - actionType = actionType, - targetType = targetType - ) - ), - tint = colorScheme.primary, - contentDescription = - LocalReplyRadarStrings.current.activityLogItemContentDescription - ) - } + if (actionType != Unknown) { + Column { + Icon( + modifier = Modifier + .size(iconSize), + painter = painterResource( + getIconByActionType( + actionType = actionType, + targetType = targetType + ) + ), + tint = colorScheme.primary, + contentDescription = LocalReplyRadarStrings.current + .activityLogItemContentDescription + ) + } - Column { - Text( - text = formatUserActionLabel( - actionType = actionType, - targetType = targetType, - targetName = targetName - ), - style = typography.bodyMedium - ) - Text( - text = formatTimestamp(createdAt), - style = typography.bodySmall - ) + Column { + Text( + text = formatUserActionLabel( + actionType = actionType, + targetType = targetType, + targetName = targetName + ), + style = typography.bodyMedium + ) + Text( + text = formatTimestamp(createdAt), + style = typography.bodySmall + ) + } } } } @@ -250,6 +264,13 @@ private fun getIconByActionType(actionType: UserActionType, targetType: UserActi Resolve -> drawable.ic_check Unarchive -> drawable.ic_unarchive Open -> drawable.ic_open + Scheduled -> drawable.ic_time + OpenedNotification -> drawable.ic_notification + Unknown -> { + // no-op, this is just a icon placeholder for unknown actions + + drawable.ic_add + } } Theme -> drawable.ic_theme @@ -287,6 +308,9 @@ private fun getActionVerb(actionType: UserActionType) = when (actionType) { Resolve -> LocalReplyRadarStrings.current.activityLogUserActionResolveVerb Unarchive -> LocalReplyRadarStrings.current.activityLogUserActionUnarchiveVerb Open -> LocalReplyRadarStrings.current.activityLogUserActionOpenVerb + Scheduled -> LocalReplyRadarStrings.current.activityLogUserActionScheduledVerb + OpenedNotification -> LocalReplyRadarStrings.current.activityLogUserActionOpenedNotificationVerb + Unknown -> EMPTY } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/app/settings/AppSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/app/settings/AppSettingsViewModel.kt index 8aa7a05..6ffe946 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/app/settings/AppSettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/app/settings/AppSettingsViewModel.kt @@ -2,8 +2,8 @@ package com.rafaelfelipeac.replyradar.features.app.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme +import com.rafaelfelipeac.replyradar.core.language.AppLanguage +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme import com.rafaelfelipeac.replyradar.features.settings.domain.usecase.GetLanguageUseCase import com.rafaelfelipeac.replyradar.features.settings.domain.usecase.GetThemeUseCase import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/database/entity/ReplyEntity.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/database/entity/ReplyEntity.kt index c2a0d41..8833b09 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/database/entity/ReplyEntity.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/database/entity/ReplyEntity.kt @@ -2,7 +2,7 @@ package com.rafaelfelipeac.replyradar.features.reply.data.database.entity import androidx.room.Entity import androidx.room.PrimaryKey -import com.rafaelfelipeac.replyradar.core.AppConstants.INITIAL_DATE_LONG +import com.rafaelfelipeac.replyradar.core.util.AppConstants.INITIAL_DATE @Entity(tableName = "replies") data class ReplyEntity( @@ -11,8 +11,9 @@ data class ReplyEntity( val subject: String, val isResolved: Boolean = false, val isArchived: Boolean = false, - val createdAt: Long = INITIAL_DATE_LONG, - val updatedAt: Long = INITIAL_DATE_LONG, - val resolvedAt: Long = INITIAL_DATE_LONG, - val archivedAt: Long = INITIAL_DATE_LONG + val createdAt: Long = INITIAL_DATE, + val updatedAt: Long = INITIAL_DATE, + val resolvedAt: Long = INITIAL_DATE, + val archivedAt: Long = INITIAL_DATE, + val reminderAt: Long = INITIAL_DATE ) diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/mapper/ReplyMapper.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/mapper/ReplyMapper.kt index 63101d3..9c2d621 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/mapper/ReplyMapper.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/mapper/ReplyMapper.kt @@ -12,7 +12,8 @@ fun Reply.toReplyEntity() = ReplyEntity( createdAt = createdAt, updatedAt = updatedAt, resolvedAt = resolvedAt, - archivedAt = archivedAt + archivedAt = archivedAt, + reminderAt = reminderAt ) fun ReplyEntity.toReply() = Reply( @@ -24,5 +25,6 @@ fun ReplyEntity.toReply() = Reply( createdAt = createdAt, updatedAt = updatedAt, resolvedAt = resolvedAt, - archivedAt = archivedAt + archivedAt = archivedAt, + reminderAt = reminderAt ) diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/repository/ReplyRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/repository/ReplyRepositoryImpl.kt index c499e4c..52a7da6 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/repository/ReplyRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/repository/ReplyRepositoryImpl.kt @@ -1,7 +1,8 @@ package com.rafaelfelipeac.replyradar.features.reply.data.repository -import com.rafaelfelipeac.replyradar.core.AppConstants.INITIAL_DATE_LONG -import com.rafaelfelipeac.replyradar.core.util.Clock +import com.rafaelfelipeac.replyradar.core.datetime.getCurrentTimeMillis +import com.rafaelfelipeac.replyradar.core.util.AppConstants.INITIAL_DATE +import com.rafaelfelipeac.replyradar.core.util.AppConstants.INITIAL_ID import com.rafaelfelipeac.replyradar.features.reply.data.database.dao.ReplyDao import com.rafaelfelipeac.replyradar.features.reply.data.mapper.toReply import com.rafaelfelipeac.replyradar.features.reply.data.mapper.toReplyEntity @@ -11,15 +12,14 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map class ReplyRepositoryImpl( - private val replyDao: ReplyDao, - private val clock: Clock + private val replyDao: ReplyDao ) : ReplyRepository { override suspend fun upsertReply(reply: Reply): Long { - val now = clock.now() + val now = getCurrentTimeMillis() val replyEntity = reply.toReplyEntity() - val entityToSave = if (reply.id == INITIAL_DATE_LONG) { + val entityToSave = if (reply.id == INITIAL_ID) { replyEntity.copy(createdAt = now, updatedAt = now) } else { replyEntity.copy(updatedAt = now) @@ -31,7 +31,7 @@ class ReplyRepositoryImpl( override suspend fun toggleReplyResolve(reply: Reply) { replyDao.update( reply.toReplyEntity().copy( - resolvedAt = if (!reply.isResolved) clock.now() else INITIAL_DATE_LONG, + resolvedAt = if (!reply.isResolved) getCurrentTimeMillis() else INITIAL_DATE, isResolved = !reply.isResolved ) ) @@ -40,7 +40,7 @@ class ReplyRepositoryImpl( override suspend fun toggleReplyArchive(reply: Reply) { replyDao.update( reply.toReplyEntity().copy( - archivedAt = if (!reply.isArchived) clock.now() else INITIAL_DATE_LONG, + archivedAt = if (!reply.isArchived) getCurrentTimeMillis() else INITIAL_DATE, isArchived = !reply.isArchived ) ) diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/di/ReplyModule.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/di/ReplyModule.kt index ba05b4c..ee6fc67 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/di/ReplyModule.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/di/ReplyModule.kt @@ -31,7 +31,8 @@ val replyModule = module { deleteReplyUseCase = get(), getRepliesUseCase = get(), logUserActionUseCase = get(), - dispatcher = get() + dispatcher = get(), + reminderScheduler = get() ) } diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/domain/model/Reply.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/domain/model/Reply.kt index d62185e..8c580a2 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/domain/model/Reply.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/domain/model/Reply.kt @@ -1,13 +1,16 @@ package com.rafaelfelipeac.replyradar.features.reply.domain.model +import com.rafaelfelipeac.replyradar.core.util.AppConstants.INITIAL_DATE + data class Reply( val id: Long = 0, val name: String, val subject: String, val isResolved: Boolean = false, val isArchived: Boolean = false, - val createdAt: Long = 0, - val updatedAt: Long = 0, - val resolvedAt: Long = 0, - val archivedAt: Long = 0 + val createdAt: Long = INITIAL_DATE, + val updatedAt: Long = INITIAL_DATE, + val resolvedAt: Long = INITIAL_DATE, + val archivedAt: Long = INITIAL_DATE, + val reminderAt: Long = INITIAL_DATE ) diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListEffect.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListEffect.kt new file mode 100644 index 0000000..4407eae --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListEffect.kt @@ -0,0 +1,22 @@ +package com.rafaelfelipeac.replyradar.features.reply.presentation.replylist + +import com.rafaelfelipeac.replyradar.features.reply.domain.model.Reply + +sealed interface ReplyListEffect { + + data class CheckNotificationPermission(val reply: Reply) : ReplyListEffect + + data object RequestNotificationPermission : ReplyListEffect + + data object GoToSettings : ReplyListEffect + + sealed interface SnackbarState : ReplyListEffect { + data object Resolved : SnackbarState + data object Reopened : SnackbarState + data object Removed : SnackbarState + data object Archived : SnackbarState + data object Unarchived : SnackbarState + } + + data class ScheduleReminder(val reply: Reply, val replyId: Long) : ReplyListEffect +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListScreen.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListScreen.kt index b0ddd3f..2f9910a 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListScreen.kt @@ -1,88 +1,100 @@ package com.rafaelfelipeac.replyradar.features.reply.presentation.replylist -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.Column 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.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Scaffold -import androidx.compose.material3.Snackbar -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.TabRow import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset -import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.rafaelfelipeac.replyradar.core.common.strings.LocalReplyRadarStrings -import com.rafaelfelipeac.replyradar.core.common.strings.Strings +import com.rafaelfelipeac.replyradar.core.common.ui.components.NotificationPermissionDialog +import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplySnackbar import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyTab -import com.rafaelfelipeac.replyradar.core.common.ui.fontSizeLarge -import com.rafaelfelipeac.replyradar.core.common.ui.iconSize import com.rafaelfelipeac.replyradar.core.common.ui.paddingMedium import com.rafaelfelipeac.replyradar.core.common.ui.spacerXSmall import com.rafaelfelipeac.replyradar.core.common.ui.tabRowTopPadding -import com.rafaelfelipeac.replyradar.core.common.ui.theme.snackbarBackgroundColor -import com.rafaelfelipeac.replyradar.core.common.ui.theme.toolbarIconsColor -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.ClearSnackbarState -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnAddReplyClick +import com.rafaelfelipeac.replyradar.core.datetime.dateTime +import com.rafaelfelipeac.replyradar.core.datetime.getCurrentDateTime +import com.rafaelfelipeac.replyradar.core.datetime.isDateTimeValid +import com.rafaelfelipeac.replyradar.core.notification.LocalNotificationPermissionManager +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings +import com.rafaelfelipeac.replyradar.core.strings.Strings +import com.rafaelfelipeac.replyradar.core.util.AppConstants.ARCHIVED_INDEX +import com.rafaelfelipeac.replyradar.core.util.AppConstants.INITIAL_DATE +import com.rafaelfelipeac.replyradar.core.util.AppConstants.ON_THE_RADAR_INDEX +import com.rafaelfelipeac.replyradar.core.util.AppConstants.RESOLVED_INDEX +import com.rafaelfelipeac.replyradar.core.util.format +import com.rafaelfelipeac.replyradar.features.reply.domain.model.Reply +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.CheckNotificationPermission +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.GoToSettings +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.RequestNotificationPermission +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.ScheduleReminder +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.SnackbarState +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.SnackbarState.Archived +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.SnackbarState.Removed +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.SnackbarState.Reopened +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.SnackbarState.Resolved +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.SnackbarState.Unarchived +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.NotificationPermissionIntent.OnCheckNotificationPermission +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.NotificationPermissionIntent.OnGoToSettings +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.NotificationPermissionIntent.OnRequestNotificationPermission +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.NotificationPermissionIntent.OnScheduleReminder +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnAddOrEditReply +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnDeleteReply +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnDismissBottomSheet +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnToggleArchive +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnToggleResolve +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnPendingReplyId import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnTabSelected -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.SnackbarState.Archived -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.SnackbarState.Removed -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.SnackbarState.Reopened -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.SnackbarState.Resolved -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.SnackbarState.Unarchived -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.RepliesArchivedScreen -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.RepliesOnTheRadarScreen -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.RepliesResolvedScreen +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.FloatingActionButton +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.RepliesScreen +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.TopBar import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.replybottomsheet.ReplyBottomSheet -import org.jetbrains.compose.resources.painterResource +import kotlinx.coroutines.flow.Flow import org.koin.compose.viewmodel.koinViewModel -import replyradar.composeapp.generated.resources.Res.drawable -import replyradar.composeapp.generated.resources.ic_settings private const val WEIGHT = 1f private const val PAGER_PAGE_COUNT = 3 -private const val ON_THE_RADAR_INDEX = 0 -private const val RESOLVED_INDEX = 1 -private const val ARCHIVED_INDEX = 2 @Composable fun ReplyListScreenRoot( viewModel: ReplyListViewModel = koinViewModel(), + pendingReplyId: Long?, onSettingsClick: () -> Unit, onActivityLogClick: () -> Unit ) { val state by viewModel.state.collectAsStateWithLifecycle() + val effect = viewModel.effect + + LaunchedEffect(pendingReplyId) { + pendingReplyId?.let { + viewModel.onIntent(OnPendingReplyId(pendingReplyId)) + } + } ReplyListScreen( state = state, + effect = effect, onIntent = { intent -> viewModel.onIntent(intent) }, @@ -91,17 +103,24 @@ fun ReplyListScreenRoot( ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReplyListScreen( state: ReplyListState, + effect: Flow, onIntent: (ReplyListScreenIntent) -> Unit, onSettingsClick: () -> Unit, onActivityLogClick: () -> Unit ) { val strings = LocalReplyRadarStrings.current + val notificationPermissionManager = LocalNotificationPermissionManager.current val pagerState = rememberPagerState { PAGER_PAGE_COUNT } val snackbarHostState = remember { SnackbarHostState() } + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + var showPermissionDialog by remember { mutableStateOf(false) } LaunchedEffect(pagerState.currentPage) { onIntent(OnTabSelected(pagerState.currentPage)) @@ -111,22 +130,55 @@ fun ReplyListScreen( pagerState.animateScrollToPage(state.selectedTabIndex) } - LaunchedEffect(state.snackbarState) { - state.snackbarState?.let { - snackbarHostState.showSnackbar( - getSnackbarMessage( - snackbarState = state.snackbarState, - strings = strings + LaunchedEffect(Unit) { + effect.collect { effect -> + when (effect) { + is SnackbarState -> snackbarHostState.showSnackbar( + getSnackbarMessage(effect, strings) ) - ) - onIntent(ClearSnackbarState) + + RequestNotificationPermission -> showPermissionDialog = true + + GoToSettings -> notificationPermissionManager.goToAppSettings() + + is CheckNotificationPermission -> { + when { + notificationPermissionManager.ensureNotificationPermission() -> { + onIntent(OnAddOrEditReply(effect.reply)) + } + + else -> onIntent(OnRequestNotificationPermission) + } + } + + is ScheduleReminder -> { + onIntent( + OnScheduleReminder( + reply = effect.reply, + replyId = effect.replyId, + notificationTitle = getNotificationTitle(strings, effect), + notificationContent = getNotificationContent(strings, effect) + ) + ) + } + } } } + if (showPermissionDialog) { + NotificationPermissionDialog( + onDismiss = { showPermissionDialog = false }, + onGoToSettings = { + showPermissionDialog = false + onIntent(OnGoToSettings) + } + ) + } + Scaffold( containerColor = colorScheme.background, - snackbarHost = { Snackbar(snackbarHostState) }, - floatingActionButton = { FAB(onIntent, colorScheme) } + snackbarHost = { ReplySnackbar(snackbarHostState) }, + floatingActionButton = { FloatingActionButton(onIntent, colorScheme) } ) { paddingValues -> Column( modifier = Modifier @@ -162,28 +214,34 @@ fun ReplyListScreen( } ) { ReplyTab( - modifier = Modifier.weight(WEIGHT), + modifier = Modifier + .weight(WEIGHT), selected = state.selectedTabIndex == ON_THE_RADAR_INDEX, onClick = { onIntent(OnTabSelected(ON_THE_RADAR_INDEX)) }, text = LocalReplyRadarStrings.current.replyListTabOnTheRadar ) ReplyTab( - modifier = Modifier.weight(WEIGHT), + modifier = Modifier + .weight(WEIGHT), selected = state.selectedTabIndex == RESOLVED_INDEX, onClick = { onIntent(OnTabSelected(RESOLVED_INDEX)) }, text = LocalReplyRadarStrings.current.replyListTabResolved ) ReplyTab( - modifier = Modifier.weight(WEIGHT), + modifier = Modifier + .weight(WEIGHT), selected = state.selectedTabIndex == ARCHIVED_INDEX, onClick = { onIntent(OnTabSelected(ARCHIVED_INDEX)) }, text = LocalReplyRadarStrings.current.replyListTabArchived ) } - Spacer(modifier = Modifier.height(spacerXSmall)) + Spacer( + modifier = Modifier + .height(spacerXSmall) + ) HorizontalPager( modifier = Modifier @@ -199,134 +257,72 @@ fun ReplyListScreen( if (state.replyBottomSheetState != null) { ReplyBottomSheet( - onIntent = onIntent, + sheetState = sheetState, + onResolve = { onIntent(OnToggleResolve(it)) }, + onArchive = { onIntent(OnToggleArchive(it)) }, + onDelete = { onIntent(OnDeleteReply(it)) }, + onSave = { reply -> + onSaveReply( + reply = reply, + onCheckNotificationPermission = { + onIntent(OnCheckNotificationPermission(reply)) + }, + onAddOrEditReply = { onIntent(OnAddOrEditReply(reply)) } + ) + }, + onDismiss = { onIntent(OnDismissBottomSheet) }, replyBottomSheetState = state.replyBottomSheetState ) } } } -@Composable -private fun FAB(onIntent: (ReplyListScreenIntent) -> Unit, colorScheme: ColorScheme) { - FloatingActionButton( - onClick = { onIntent(OnAddReplyClick) }, - containerColor = colorScheme.secondary - ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = - LocalReplyRadarStrings.current.replyListFabContentDescription, - tint = colorScheme.background - ) - } -} +private fun onSaveReply( + reply: Reply, + onCheckNotificationPermission: (Reply) -> Unit, + onAddOrEditReply: (Reply) -> Unit +) { + val dateTime = getCurrentDateTime() + val reminderAt = reply.reminderAt.takeIf { it != INITIAL_DATE }?.dateTime() + val selectedTime = reminderAt?.time + val selectedDate = reminderAt?.date -@Composable -private fun Snackbar(snackbarHostState: SnackbarHostState) { - SnackbarHost( - hostState = snackbarHostState, - snackbar = { snackbarData -> - Snackbar( - snackbarData = snackbarData, - containerColor = colorScheme.snackbarBackgroundColor, - contentColor = colorScheme.onSurface + return when { + selectedTime?.let { time -> + isDateTimeValid( + date = selectedDate, + time = time, + dateTime = dateTime ) - } - ) -} + } == true -> onCheckNotificationPermission(reply) -@Composable -private fun TopBar(onActivityLogClick: () -> Unit, onSettingsClick: () -> Unit) { - Box( - modifier = Modifier - .fillMaxWidth() - ) { - Text( - modifier = Modifier - .padding(top = paddingMedium, start = paddingMedium) - .align(Alignment.CenterStart) - .clickable { onActivityLogClick() }, - textAlign = TextAlign.Center, - text = LocalReplyRadarStrings.current.replyListActivityLog, - style = typography.bodySmall, - color = colorScheme.toolbarIconsColor - ) - - Text( - modifier = Modifier - .padding(top = paddingMedium) - .align(Alignment.Center), - textAlign = TextAlign.Center, - text = LocalReplyRadarStrings.current.appName, - style = typography.titleLarge.copy(fontSize = fontSizeLarge), - color = colorScheme.onBackground - ) - - IconButton( - onClick = { onSettingsClick() }, - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(top = paddingMedium, end = paddingMedium) - ) { - Icon( - modifier = Modifier - .size(iconSize), - painter = painterResource(drawable.ic_settings), - contentDescription = LocalReplyRadarStrings.current.settingsTitle, - tint = colorScheme.toolbarIconsColor - ) - } + else -> onAddOrEditReply(reply) } } -@Composable -private fun RepliesScreen( - pageIndex: Int, - state: ReplyListState, - onIntent: (ReplyListScreenIntent) -> Unit -) { - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier - .fillMaxSize() - .background(colorScheme.background), - verticalArrangement = Arrangement.Center - ) { - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - when (pageIndex) { - ON_THE_RADAR_INDEX -> RepliesOnTheRadarScreen( - state = state, - onIntent = onIntent - ) - - RESOLVED_INDEX -> RepliesResolvedScreen( - state = state, - onIntent = onIntent - ) - - ARCHIVED_INDEX -> RepliesArchivedScreen( - state = state, - onIntent = onIntent - ) - } - } - } - } +private fun getSnackbarMessage(effect: SnackbarState, strings: Strings) = when (effect) { + Archived -> strings.replyListSnackbarArchived + Removed -> strings.replyListSnackbarRemoved + Reopened -> strings.replyListSnackbarReopened + Resolved -> strings.replyListSnackbarResolved + Unarchived -> strings.replyListSnackbarUnarchived } -private fun getSnackbarMessage(snackbarState: SnackbarState, strings: Strings) = - when (snackbarState) { - Archived -> strings.replyListSnackbarArchived - Removed -> strings.replyListSnackbarRemoved - Reopened -> strings.replyListSnackbarReopened - Resolved -> strings.replyListSnackbarResolved - Unarchived -> strings.replyListSnackbarUnarchived +private fun getNotificationTitle(strings: Strings, effect: ScheduleReminder) = format( + strings.notificationTitle, + effect.reply.name +) + +private fun getNotificationContent(strings: Strings, effect: ScheduleReminder) = + if (effect.reply.subject.isNotBlank()) { + format( + strings.notificationContent, + effect.reply.name, + effect.reply.subject + ) + } else { + format( + strings.notificationContentWithoutSubject, + effect.reply.name + ) } diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListScreenIntent.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListScreenIntent.kt index f153138..88e10d6 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListScreenIntent.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListScreenIntent.kt @@ -5,19 +5,30 @@ import com.rafaelfelipeac.replyradar.features.reply.domain.model.Reply sealed interface ReplyListScreenIntent { sealed interface ReplyListIntent : ReplyListScreenIntent { + data class OnPendingReplyId(val pendingReplyId: Long?) : ReplyListIntent data object OnAddReplyClick : ReplyListIntent data class OnTabSelected(val index: Int) : ReplyListIntent - data class OnReplyClick(val reply: Reply) : ReplyListIntent + data class OnOpenReply(val reply: Reply) : ReplyListIntent data class OnReplyToggle(val reply: Reply) : ReplyListIntent - data object ClearSnackbarState : ReplyListIntent } sealed interface ReplyBottomSheetIntent : ReplyListScreenIntent { - data class OnAddReply(val reply: Reply) : ReplyBottomSheetIntent - data class OnEditReply(val reply: Reply) : ReplyBottomSheetIntent + data class OnAddOrEditReply(val reply: Reply) : ReplyBottomSheetIntent data class OnDeleteReply(val reply: Reply) : ReplyBottomSheetIntent data class OnToggleArchive(val reply: Reply) : ReplyBottomSheetIntent data class OnToggleResolve(val reply: Reply) : ReplyBottomSheetIntent data object OnDismissBottomSheet : ReplyBottomSheetIntent } + + sealed interface NotificationPermissionIntent : ReplyListScreenIntent { + data object OnRequestNotificationPermission : NotificationPermissionIntent + data class OnCheckNotificationPermission(val reply: Reply) : NotificationPermissionIntent + data object OnGoToSettings : NotificationPermissionIntent + data class OnScheduleReminder( + val reply: Reply, + val replyId: Long, + val notificationTitle: String, + val notificationContent: String + ) : NotificationPermissionIntent + } } diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListState.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListState.kt index 3d3a5e1..4049572 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListState.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListState.kt @@ -10,6 +10,5 @@ data class ReplyListState( val isLoading: Boolean = true, val errorMessage: String? = null, val selectedTabIndex: Int = 0, - val replyBottomSheetState: ReplyBottomSheetState? = null, - val snackbarState: SnackbarState? = null + val replyBottomSheetState: ReplyBottomSheetState? = null ) diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListViewModel.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListViewModel.kt index 6ed3443..0abdbc7 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/ReplyListViewModel.kt @@ -2,30 +2,45 @@ package com.rafaelfelipeac.replyradar.features.reply.presentation.replylist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.rafaelfelipeac.replyradar.core.reminder.ReminderScheduler +import com.rafaelfelipeac.replyradar.core.reminder.model.NotificationReminderParams +import com.rafaelfelipeac.replyradar.core.util.AppConstants.ARCHIVED_INDEX +import com.rafaelfelipeac.replyradar.core.util.AppConstants.INITIAL_DATE +import com.rafaelfelipeac.replyradar.core.util.AppConstants.INITIAL_ID +import com.rafaelfelipeac.replyradar.core.util.AppConstants.ON_THE_RADAR_INDEX +import com.rafaelfelipeac.replyradar.core.util.AppConstants.RESOLVED_INDEX import com.rafaelfelipeac.replyradar.features.reply.domain.model.Reply import com.rafaelfelipeac.replyradar.features.reply.domain.usecase.DeleteReplyUseCase import com.rafaelfelipeac.replyradar.features.reply.domain.usecase.GetRepliesUseCase import com.rafaelfelipeac.replyradar.features.reply.domain.usecase.ToggleArchiveReplyUseCase import com.rafaelfelipeac.replyradar.features.reply.domain.usecase.ToggleResolveReplyUseCase import com.rafaelfelipeac.replyradar.features.reply.domain.usecase.UpsertReplyUseCase +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.CheckNotificationPermission +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.GoToSettings +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.RequestNotificationPermission +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.ScheduleReminder +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.SnackbarState.Archived +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.SnackbarState.Removed +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.SnackbarState.Reopened +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.SnackbarState.Resolved +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.SnackbarState.Unarchived +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.NotificationPermissionIntent +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.NotificationPermissionIntent.OnCheckNotificationPermission +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.NotificationPermissionIntent.OnGoToSettings +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.NotificationPermissionIntent.OnRequestNotificationPermission +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.NotificationPermissionIntent.OnScheduleReminder import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnAddReply +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnAddOrEditReply import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnDeleteReply import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnDismissBottomSheet -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnEditReply import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnToggleArchive import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnToggleResolve import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.ClearSnackbarState import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnAddReplyClick -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnReplyClick +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnOpenReply +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnPendingReplyId import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnReplyToggle import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnTabSelected -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.SnackbarState.Archived -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.SnackbarState.Removed -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.SnackbarState.Reopened -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.SnackbarState.Resolved -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.SnackbarState.Unarchived import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.replybottomsheet.ReplyBottomSheetMode.CREATE import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.replybottomsheet.ReplyBottomSheetMode.EDIT import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.replybottomsheet.ReplyBottomSheetState @@ -35,14 +50,17 @@ import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActio import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Create import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Delete import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Edit +import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.OpenedNotification import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Reopen import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Resolve +import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Scheduled import com.rafaelfelipeac.replyradar.features.useractions.domain.model.UserActionType.Unarchive import com.rafaelfelipeac.replyradar.features.useractions.domain.usecase.LogUserActionUseCase import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.onStart @@ -58,6 +76,7 @@ class ReplyListViewModel( private val deleteReplyUseCase: DeleteReplyUseCase, private val getRepliesUseCase: GetRepliesUseCase, private val logUserActionUseCase: LogUserActionUseCase, + private val reminderScheduler: ReminderScheduler, private val dispatcher: CoroutineDispatcher = Dispatchers.Main ) : ViewModel() { @@ -74,15 +93,24 @@ class ReplyListViewModel( _state.value ) + private val _effect = MutableSharedFlow() + val effect = _effect + + private var pendingReplyId: Long? = null + private var isPendingReplyHandled = false + fun onIntent(intent: ReplyListScreenIntent) { when (intent) { is ReplyListIntent -> handleReplyListIntent(intent) is ReplyBottomSheetIntent -> handleReplyBottomSheetIntent(intent) + is NotificationPermissionIntent -> handleNotificationPermissionIntent(intent) } } private fun handleReplyListIntent(intent: ReplyListIntent) { when (intent) { + is OnPendingReplyId -> handlePendingReplyIdOnce(intent.pendingReplyId) + OnAddReplyClick -> { updateState { copy( @@ -93,39 +121,19 @@ class ReplyListViewModel( } } - is OnReplyClick -> { - updateState { - copy( - replyBottomSheetState = ReplyBottomSheetState( - replyBottomSheetMode = EDIT, - reply = intent.reply - ) - ) - } - } + is OnOpenReply -> onOpenReply(intent.reply) is OnReplyToggle -> { onToggleResolveReply(reply = intent.reply) } - is OnTabSelected -> { - updateState { - copy(selectedTabIndex = intent.index) - } - } - - ClearSnackbarState -> { - updateState { - copy(snackbarState = null) - } - } + is OnTabSelected -> onTabSelected(intent.index) } } private fun handleReplyBottomSheetIntent(intent: ReplyBottomSheetIntent) { when (intent) { - is OnAddReply -> onUpsertReply(reply = intent.reply, actionType = Create) - is OnEditReply -> onUpsertReply(reply = intent.reply, actionType = Edit) + is OnAddOrEditReply -> onUpsertReply(reply = intent.reply) is OnDeleteReply -> deleteReply(reply = intent.reply) is OnToggleArchive -> onToggleArchiveReply(reply = intent.reply) is OnToggleResolve -> onToggleResolveReply(reply = intent.reply) @@ -135,6 +143,38 @@ class ReplyListViewModel( dismissBottomSheet() } + private fun handleNotificationPermissionIntent(intent: NotificationPermissionIntent) { + when (intent) { + is OnCheckNotificationPermission -> checkNotificationPermission(intent.reply) + OnGoToSettings -> goToSettings() + OnRequestNotificationPermission -> requestNotificationPermission() + is OnScheduleReminder -> onScheduleReminder(intent) + } + } + + private fun handlePendingReplyIdOnce(pendingReplyIdParam: Long?) { + if (!isPendingReplyHandled && pendingReplyIdParam != null) { + isPendingReplyHandled = true + pendingReplyId = pendingReplyIdParam + + checkPendingReplyId( + replies = _state.value.replies, + onTabSelection = { onTabSelected(ON_THE_RADAR_INDEX) } + ) + } + } + + private fun onOpenReply(reply: Reply) { + updateState { + copy( + replyBottomSheetState = ReplyBottomSheetState( + replyBottomSheetMode = EDIT, + reply = reply + ) + ) + } + } + private fun getReplies() = viewModelScope.launch { updateState { copy(isLoading = true) } @@ -148,6 +188,11 @@ class ReplyListViewModel( replies = replies ) } + + checkPendingReplyId( + replies = replies, + onTabSelection = { onTabSelected(ON_THE_RADAR_INDEX) } + ) } } catch (e: Exception) { updateState { @@ -165,6 +210,11 @@ class ReplyListViewModel( .getReplies(isResolved = true) .collect { resolvedReplies -> updateState { copy(resolvedReplies = resolvedReplies) } + + checkPendingReplyId( + replies = resolvedReplies, + onTabSelection = { onTabSelected(RESOLVED_INDEX) } + ) } } catch (_: Exception) { } @@ -176,15 +226,25 @@ class ReplyListViewModel( .getReplies(isArchived = true) .collect { archivedReplies -> updateState { copy(archivedReplies = archivedReplies) } + + checkPendingReplyId( + replies = archivedReplies, + onTabSelection = { onTabSelected(ARCHIVED_INDEX) } + ) } } catch (_: Exception) { } } - private fun onUpsertReply(reply: Reply, actionType: UserActionType) = viewModelScope.launch { + private fun onUpsertReply(reply: Reply) = viewModelScope.launch { + val actionType = if (reply.id == INITIAL_ID) Create else Edit val replyId = upsertReplyUseCase.upsertReply(reply) logUserAction(actionType = actionType, targetId = replyId) + + if (reply.reminderAt != INITIAL_DATE) { + _effect.emit(ScheduleReminder(reply, replyId)) + } } private fun onToggleArchiveReply(reply: Reply) = viewModelScope.launch { @@ -192,7 +252,7 @@ class ReplyListViewModel( logUserAction(actionType = if (isArchived) Archive else Unarchive, targetId = reply.id) - updateState { copy(snackbarState = if (isArchived) Archived else Unarchived) } + _effect.emit(if (isArchived) Archived else Unarchived) } private fun onToggleResolveReply(reply: Reply) = viewModelScope.launch { @@ -200,7 +260,7 @@ class ReplyListViewModel( logUserAction(actionType = if (isResolved) Resolve else Reopen, targetId = reply.id) - updateState { copy(snackbarState = if (isResolved) Resolved else Reopened) } + _effect.emit(if (isResolved) Resolved else Reopened) } private fun deleteReply(reply: Reply) = viewModelScope.launch { @@ -208,17 +268,47 @@ class ReplyListViewModel( logUserAction(actionType = Delete, targetId = reply.id) - updateState { copy(snackbarState = Removed) } + _effect.emit(Removed) } private fun dismissBottomSheet() { updateState { copy(replyBottomSheetState = null) } } + private fun requestNotificationPermission() = viewModelScope.launch { + _effect.emit(RequestNotificationPermission) + } + + private fun checkNotificationPermission(reply: Reply) = viewModelScope.launch { + _effect.emit(CheckNotificationPermission(reply)) + } + + private fun goToSettings() = viewModelScope.launch { + _effect.emit(GoToSettings) + } + private fun updateState(update: ReplyListState.() -> ReplyListState) { _state.update { it.update() } } + private fun onScheduleReminder(intent: OnScheduleReminder) = viewModelScope.launch { + with(intent.reply) { + reminderScheduler.scheduleReminder( + reminderAtMillis = reminderAt, + notificationReminderParams = NotificationReminderParams( + replyId = id, + notificationTitle = intent.notificationTitle, + notificationContent = intent.notificationContent + ) + ) + } + + logUserAction( + actionType = Scheduled, + targetId = intent.replyId + ) + } + private suspend fun logUserAction(actionType: UserActionType, targetId: Long) { logUserActionUseCase.logUserAction( actionType = actionType, @@ -227,6 +317,33 @@ class ReplyListViewModel( ) } + private fun checkPendingReplyId(replies: List, onTabSelection: () -> Unit) = + viewModelScope.launch { + pendingReplyId?.let { + val reply = replies.find { reply -> reply.id == it } + + if (reply != null) { + onTabSelection() + onOpenReply(reply) + + pendingReplyId?.let { replyId -> + logUserAction( + actionType = OpenedNotification, + targetId = replyId + ) + } + + pendingReplyId = null + } + } + } + + private fun onTabSelected(index: Int) { + updateState { + copy(selectedTabIndex = index) + } + } + companion object { private const val STOP_TIMEOUT = 5_000L const val ERROR_GET_REPLIES = "error_get_replies" diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/SnackbarState.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/SnackbarState.kt deleted file mode 100644 index 7988d21..0000000 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/SnackbarState.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.rafaelfelipeac.replyradar.features.reply.presentation.replylist - -sealed interface SnackbarState { - data object Resolved : SnackbarState - data object Reopened : SnackbarState - data object Removed : SnackbarState - data object Archived : SnackbarState - data object Unarchived : SnackbarState -} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/FloatingActionButton.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/FloatingActionButton.kt new file mode 100644 index 0000000..18f7e80 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/FloatingActionButton.kt @@ -0,0 +1,26 @@ +package com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnAddReplyClick + +@Composable +fun FloatingActionButton(onIntent: (ReplyListScreenIntent) -> Unit, colorScheme: ColorScheme) { + FloatingActionButton( + onClick = { onIntent(OnAddReplyClick) }, + containerColor = colorScheme.secondary + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = LocalReplyRadarStrings.current + .replyListFabContentDescription, + tint = colorScheme.background + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesArchivedScreen.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesArchivedScreen.kt index 2ec1ebd..eb165a4 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesArchivedScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesArchivedScreen.kt @@ -3,10 +3,10 @@ package com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.comp import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.rafaelfelipeac.replyradar.core.common.strings.LocalReplyRadarStrings import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyRadarPlaceholder +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnReplyClick +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnOpenReply import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnReplyToggle import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListState @@ -16,9 +16,10 @@ fun RepliesArchivedScreen(state: ReplyListState, onIntent: (ReplyListScreenInten ReplyRadarPlaceholder(message = LocalReplyRadarStrings.current.replyListPlaceholderArchived) } else { ReplyList( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), replies = state.archivedReplies, - onReplyClick = { onIntent(OnReplyClick(it)) }, + onReplyClick = { onIntent(OnOpenReply(it)) }, onReplyToggle = { onIntent(OnReplyToggle(it)) } ) } diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesOnTheRadarScreen.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesOnTheRadarScreen.kt index 39a8d22..e8fb01e 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesOnTheRadarScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesOnTheRadarScreen.kt @@ -3,12 +3,12 @@ package com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.comp import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.rafaelfelipeac.replyradar.core.common.strings.LocalReplyRadarStrings import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyProgress import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyRadarError import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyRadarPlaceholder +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnReplyClick +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnOpenReply import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnReplyToggle import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListState import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListViewModel.Companion.ERROR_GET_REPLIES @@ -31,9 +31,10 @@ fun RepliesOnTheRadarScreen(state: ReplyListState, onIntent: (ReplyListScreenInt else -> { ReplyList( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), replies = state.replies, - onReplyClick = { onIntent(OnReplyClick(it)) }, + onReplyClick = { onIntent(OnOpenReply(it)) }, onReplyToggle = { onIntent(OnReplyToggle(it)) } ) } diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesResolvedScreen.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesResolvedScreen.kt index 5ffe9e9..96bd726 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesResolvedScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesResolvedScreen.kt @@ -3,10 +3,10 @@ package com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.comp import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.rafaelfelipeac.replyradar.core.common.strings.LocalReplyRadarStrings import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyRadarPlaceholder +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnReplyClick +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnOpenReply import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListState @Composable @@ -15,9 +15,10 @@ fun RepliesResolvedScreen(state: ReplyListState, onIntent: (ReplyListScreenInten ReplyRadarPlaceholder(message = LocalReplyRadarStrings.current.replyListPlaceholderResolved) } else { ReplyList( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), replies = state.resolvedReplies, - onReplyClick = { onIntent(OnReplyClick(it)) } + onReplyClick = { onIntent(OnOpenReply(it)) } ) } } diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesScreen.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesScreen.kt new file mode 100644 index 0000000..1902256 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/RepliesScreen.kt @@ -0,0 +1,59 @@ +package com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components + +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.fillMaxSize +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.rafaelfelipeac.replyradar.core.util.AppConstants.ARCHIVED_INDEX +import com.rafaelfelipeac.replyradar.core.util.AppConstants.ON_THE_RADAR_INDEX +import com.rafaelfelipeac.replyradar.core.util.AppConstants.RESOLVED_INDEX +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListState + +@Composable +fun RepliesScreen( + pageIndex: Int, + state: ReplyListState, + onIntent: (ReplyListScreenIntent) -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(colorScheme.background), + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + when (pageIndex) { + ON_THE_RADAR_INDEX -> RepliesOnTheRadarScreen( + state = state, + onIntent = onIntent + ) + + RESOLVED_INDEX -> RepliesResolvedScreen( + state = state, + onIntent = onIntent + ) + + ARCHIVED_INDEX -> RepliesArchivedScreen( + state = state, + onIntent = onIntent + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/ReplyList.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/ReplyList.kt index 03bd2f2..cd0a9e8 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/ReplyList.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/ReplyList.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import com.rafaelfelipeac.replyradar.core.common.ui.listDividerThickness import com.rafaelfelipeac.replyradar.core.common.ui.paddingMedium -import com.rafaelfelipeac.replyradar.core.common.ui.theme.horizontalDividerColor +import com.rafaelfelipeac.replyradar.core.theme.horizontalDividerColor import com.rafaelfelipeac.replyradar.features.reply.domain.model.Reply @Composable @@ -29,8 +29,11 @@ fun ReplyList( .padding(top = paddingMedium), horizontalAlignment = CenterHorizontally ) { - itemsIndexed(replies, key = { _, item -> item.id }) { index, reply -> - Column(modifier = Modifier.fillMaxWidth()) { + itemsIndexed(items = replies, key = { _, item -> item.id }) { index, reply -> + Column( + modifier = Modifier + .fillMaxWidth() + ) { ReplyListItem( modifier = Modifier .fillMaxWidth(), diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/ReplyTimestampInfo.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/ReplyTimestampInfo.kt new file mode 100644 index 0000000..8134f26 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/ReplyTimestampInfo.kt @@ -0,0 +1,60 @@ +package com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.Start +import androidx.compose.ui.Modifier +import com.rafaelfelipeac.replyradar.core.common.ui.paddingSmall +import com.rafaelfelipeac.replyradar.core.datetime.formatTimestamp +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings +import com.rafaelfelipeac.replyradar.core.util.AppConstants.INITIAL_DATE +import com.rafaelfelipeac.replyradar.core.util.format +import com.rafaelfelipeac.replyradar.features.reply.domain.model.Reply +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.replybottomsheet.ReplyBottomSheetState + +@Composable +fun ColumnScope.ReplyTimestampInfo(state: ReplyBottomSheetState) { + state.reply?.let { reply -> + Text( + modifier = Modifier + .padding(start = paddingSmall, top = paddingSmall) + .align(Start), + text = getTimestampInfo(reply), + style = typography.bodySmall + ) + } +} + +@Composable +private fun getTimestampInfo(reply: Reply): String { + with(reply) { + return when { + archivedAt != INITIAL_DATE -> format( + LocalReplyRadarStrings.current.replyListItemArchivedAt, + formatTimestamp(archivedAt) + ) + + resolvedAt != INITIAL_DATE -> format( + LocalReplyRadarStrings.current.replyListItemResolvedAt, + formatTimestamp(resolvedAt) + ) + + else -> { + if (updatedAt != INITIAL_DATE && updatedAt != createdAt) { + format( + LocalReplyRadarStrings.current.replyListItemUpdatedAt, + formatTimestamp(updatedAt) + ) + } else { + format( + LocalReplyRadarStrings.current.replyListItemCreatedAt, + formatTimestamp(createdAt) + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/TopBar.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/TopBar.kt new file mode 100644 index 0000000..a5924c0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/TopBar.kt @@ -0,0 +1,68 @@ +package com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import com.rafaelfelipeac.replyradar.core.common.ui.fontSizeLarge +import com.rafaelfelipeac.replyradar.core.common.ui.iconSize +import com.rafaelfelipeac.replyradar.core.common.ui.paddingMedium +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings +import com.rafaelfelipeac.replyradar.core.theme.toolbarIconsColor +import org.jetbrains.compose.resources.painterResource +import replyradar.composeapp.generated.resources.Res.drawable +import replyradar.composeapp.generated.resources.ic_settings + +@Composable +fun TopBar(onActivityLogClick: () -> Unit, onSettingsClick: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + modifier = Modifier + .padding(top = paddingMedium, start = paddingMedium) + .align(Alignment.CenterStart) + .clickable { onActivityLogClick() }, + textAlign = TextAlign.Center, + text = LocalReplyRadarStrings.current.replyListActivityLog, + style = typography.bodySmall, + color = colorScheme.toolbarIconsColor + ) + + Text( + modifier = Modifier + .padding(top = paddingMedium) + .align(Alignment.Center), + textAlign = TextAlign.Center, + text = LocalReplyRadarStrings.current.appName, + style = typography.titleLarge.copy(fontSize = fontSizeLarge), + color = colorScheme.onBackground + ) + + IconButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(top = paddingMedium, end = paddingMedium), + onClick = { onSettingsClick() } + ) { + Icon( + modifier = Modifier + .size(iconSize), + painter = painterResource(drawable.ic_settings), + contentDescription = LocalReplyRadarStrings.current.settingsTitle, + tint = colorScheme.toolbarIconsColor + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/replybottomsheet/ReplyBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/replybottomsheet/ReplyBottomSheet.kt index ed56728..70c1be2 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/replybottomsheet/ReplyBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/replybottomsheet/ReplyBottomSheet.kt @@ -3,27 +3,47 @@ package com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.comp import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyRoundedCorner +import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplySnackbar +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings import com.rafaelfelipeac.replyradar.features.reply.domain.model.Reply -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnAddReply -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnDeleteReply -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnDismissBottomSheet -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnEditReply -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnToggleArchive -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnToggleResolve import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.replybottomsheet.ReplyBottomSheetMode.CREATE import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.replybottomsheet.ReplyBottomSheetMode.EDIT @OptIn(ExperimentalMaterial3Api::class) @Composable fun ReplyBottomSheet( - onIntent: (ReplyListScreenIntent) -> Unit, + sheetState: SheetState, + onSave: (Reply) -> Unit, + onResolve: (Reply) -> Unit, + onArchive: (Reply) -> Unit, + onDelete: (Reply) -> Unit, + onDismiss: () -> Unit, replyBottomSheetState: ReplyBottomSheetState ) { + val snackbarHostState = remember { SnackbarHostState() } + var invalidReminderValue by remember { mutableStateOf(false) } + + val localStrings = LocalReplyRadarStrings.current + + LaunchedEffect(invalidReminderValue) { + if (invalidReminderValue) { + snackbarHostState.showSnackbar(localStrings.replyListReminderInvalidDateTime) + invalidReminderValue = false + } + } + ModalBottomSheet( - onDismissRequest = { onIntent(OnDismissBottomSheet) }, + sheetState = sheetState, + onDismissRequest = onDismiss, containerColor = colorScheme.background, dragHandle = null, shape = ReplyRoundedCorner(onlyTopCorners = true) @@ -32,8 +52,11 @@ fun ReplyBottomSheet( CREATE -> { BottomSheetContent( state = ReplyBottomSheetState(CREATE), - onComplete = { onIntent(OnAddReply(it)) }, - onIntent = onIntent + onSave = onSave, + onResolve = onResolve, + onArchive = onArchive, + onDelete = onDelete, + onInvalidReminderValue = { invalidReminderValue = true } ) } @@ -44,26 +67,35 @@ fun ReplyBottomSheet( EDIT, reply = replyBottomSheetState.reply ), - onComplete = { onIntent(OnEditReply(it)) }, - onIntent = onIntent + onSave = onSave, + onResolve = onResolve, + onArchive = onArchive, + onDelete = onDelete, + onInvalidReminderValue = { invalidReminderValue = true } ) } } } + + ReplySnackbar(snackbarHostState) } } @Composable private fun BottomSheetContent( state: ReplyBottomSheetState, - onComplete: (Reply) -> Unit, - onIntent: (ReplyListScreenIntent) -> Unit + onSave: (Reply) -> Unit, + onResolve: (Reply) -> Unit, + onArchive: (Reply) -> Unit, + onDelete: (Reply) -> Unit, + onInvalidReminderValue: () -> Unit ) { ReplyBottomSheetContent( replyBottomSheetState = state, - onComplete = onComplete, - onResolve = { onIntent(OnToggleResolve(it)) }, - onArchive = { onIntent(OnToggleArchive(it)) }, - onDelete = { onIntent(OnDeleteReply(it)) } + onResolve = onResolve, + onArchive = onArchive, + onDelete = onDelete, + onSave = onSave, + onInvalidReminderValue = onInvalidReminderValue ) } diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/replybottomsheet/ReplyBottomSheetActions.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/replybottomsheet/ReplyBottomSheetActions.kt new file mode 100644 index 0000000..532eb37 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/replybottomsheet/ReplyBottomSheetActions.kt @@ -0,0 +1,240 @@ +package com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.replybottomsheet + +import androidx.compose.foundation.layout.Arrangement.End +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +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 com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyButton +import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyConfirmationDialog +import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyOutlinedButton +import com.rafaelfelipeac.replyradar.core.common.ui.paddingMedium +import com.rafaelfelipeac.replyradar.core.common.ui.paddingSmall +import com.rafaelfelipeac.replyradar.core.datetime.getCurrentDateTime +import com.rafaelfelipeac.replyradar.core.datetime.getReminderTimestamp +import com.rafaelfelipeac.replyradar.core.datetime.isDateTimeValid +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings +import com.rafaelfelipeac.replyradar.core.util.format +import com.rafaelfelipeac.replyradar.features.reply.domain.model.Reply +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.replybottomsheet.ReplyBottomSheetMode.EDIT +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import replyradar.composeapp.generated.resources.Res.drawable +import replyradar.composeapp.generated.resources.ic_archive +import replyradar.composeapp.generated.resources.ic_check +import replyradar.composeapp.generated.resources.ic_delete +import replyradar.composeapp.generated.resources.ic_reopen +import replyradar.composeapp.generated.resources.ic_unarchive + +private const val WEIGHT = 1f + +@Composable +fun ReplyBottomSheetActions( + state: ReplyBottomSheetState, + onArchive: (Reply) -> Unit, + onResolve: (Reply) -> Unit, + onDelete: (Reply) -> Unit, + onSave: (Reply) -> Unit, + onInvalidReminderValue: () -> Unit, + selectedDate: LocalDate?, + selectedTime: LocalTime?, + reply: Reply?, + name: String, + subject: String +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = paddingSmall, bottom = paddingMedium, end = paddingSmall), + horizontalArrangement = if (isEditMode(state)) spacedBy(paddingSmall) else End, + verticalAlignment = Alignment.CenterVertically + ) { + StateButtons( + state = state, + onArchive = onArchive, + onResolve = onResolve, + onDelete = onDelete + ) + + ReplyButton( + modifier = Modifier + .wrapContentWidth() + .align(Alignment.CenterVertically), + text = if (reply == null) { + LocalReplyRadarStrings.current.replyListBottomSheetAdd + } else { + LocalReplyRadarStrings.current.replyListBottomSheetSave + }, + onClick = { + val reminderIsValid = isDateTimeValid( + selectedDate, + selectedTime, + getCurrentDateTime() + ) + + if ((selectedDate != null || selectedTime != null) && !reminderIsValid) { + onInvalidReminderValue() + return@ReplyButton + } + + val reminderAtTimestamp = getReminderTimestamp( + dateTime = getCurrentDateTime(), + selectedDate = selectedDate, + selectedTime = selectedTime + ) + + onSave( + getReplyToSave( + stateReply = state.reply, + name = name, + subject = subject, + reminderAtTimestamp = reminderAtTimestamp + ) + ) + }, + enabled = name.isNotBlank() + ) + } +} + +@Composable +private fun RowScope.StateButtons( + state: ReplyBottomSheetState, + onArchive: (Reply) -> Unit, + onResolve: (Reply) -> Unit, + onDelete: (Reply) -> Unit +) { + if (state.reply != null && isEditMode(state)) { + Row( + modifier = Modifier.Companion + .weight(WEIGHT) + ) { + with(state.reply) { + when { + !isArchived && !isResolved -> { + ActiveStateButtons( + reply = state.reply, + onArchive = onArchive, + onResolve = onResolve + ) + } + + isResolved && !isArchived -> { + ResolvedStateButtons( + reply = state.reply, + onArchive = onArchive, + onResolve = onResolve + ) + } + + isArchived -> { + ArchivedStateButton( + reply = state.reply, + onArchive = onArchive, + onDelete = onDelete + ) + } + } + } + } + } +} + +@Composable +private fun ActiveStateButtons( + reply: Reply, + onArchive: (Reply) -> Unit, + onResolve: (Reply) -> Unit +) { + ReplyOutlinedButton( + text = LocalReplyRadarStrings.current.replyListBottomSheetResolve, + icon = drawable.ic_check, + onClick = { onResolve(reply) } + ) + + ReplyOutlinedButton( + text = LocalReplyRadarStrings.current.replyListBottomSheetArchive, + icon = drawable.ic_archive, + onClick = { onArchive(reply) } + ) +} + +@Composable +private fun ResolvedStateButtons( + reply: Reply, + onArchive: (Reply) -> Unit, + onResolve: (Reply) -> Unit +) { + ReplyOutlinedButton( + text = LocalReplyRadarStrings.current.replyListBottomSheetReopen, + icon = drawable.ic_reopen, + onClick = { onResolve(reply) } + ) + + ReplyOutlinedButton( + text = LocalReplyRadarStrings.current.replyListBottomSheetArchive, + icon = drawable.ic_archive, + onClick = { onArchive(reply) } + ) +} + +@Composable +private fun ArchivedStateButton( + reply: Reply, + onArchive: (Reply) -> Unit, + onDelete: (Reply) -> Unit +) { + var showDeleteDialog by remember { mutableStateOf(false) } + + ReplyOutlinedButton( + text = LocalReplyRadarStrings.current.replyListBottomSheetUnarchive, + icon = drawable.ic_unarchive, + onClick = { onArchive(reply) } + ) + + ReplyOutlinedButton( + text = LocalReplyRadarStrings.current.replyListBottomSheetDelete, + icon = drawable.ic_delete, + onClick = { showDeleteDialog = true } + ) + + if (showDeleteDialog) { + ReplyConfirmationDialog( + title = LocalReplyRadarStrings.current.replyListDeleteDialogTitle, + description = format( + LocalReplyRadarStrings.current.replyListDeleteDialogDescription, + reply.name + ), + confirm = LocalReplyRadarStrings.current.replyListDeleteDialogConfirm, + dismiss = LocalReplyRadarStrings.current.replyListDeleteDialogDismiss, + onDismiss = { showDeleteDialog = false }, + onConfirm = { onDelete(reply) } + ) + } +} + +private fun isEditMode(state: ReplyBottomSheetState) = state.replyBottomSheetMode == EDIT + +private fun getReplyToSave( + stateReply: Reply?, + name: String, + subject: String, + reminderAtTimestamp: Long +) = stateReply?.copy( + name = name, + subject = subject, + reminderAt = reminderAtTimestamp +) ?: Reply( + name = name, + subject = subject, + reminderAt = reminderAtTimestamp +) diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/replybottomsheet/ReplyBottomSheetContent.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/replybottomsheet/ReplyBottomSheetContent.kt index 1e79fff..865c8e9 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/replybottomsheet/ReplyBottomSheetContent.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/replylist/components/replybottomsheet/ReplyBottomSheetContent.kt @@ -1,62 +1,48 @@ package com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.replybottomsheet import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement.End -import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.MaterialTheme.typography -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import com.rafaelfelipeac.replyradar.core.AppConstants.EMPTY -import com.rafaelfelipeac.replyradar.core.AppConstants.INITIAL_DATE_LONG -import com.rafaelfelipeac.replyradar.core.common.strings.LocalReplyRadarStrings -import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyButton -import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyConfirmationDialog -import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyOutlinedButton +import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyReminder import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyTextField import com.rafaelfelipeac.replyradar.core.common.ui.components.ReplyTextFieldSize.Large import com.rafaelfelipeac.replyradar.core.common.ui.paddingMedium import com.rafaelfelipeac.replyradar.core.common.ui.paddingSmall -import com.rafaelfelipeac.replyradar.core.util.format -import com.rafaelfelipeac.replyradar.core.util.formatTimestamp +import com.rafaelfelipeac.replyradar.core.datetime.dateTime +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings +import com.rafaelfelipeac.replyradar.core.util.AppConstants.EMPTY +import com.rafaelfelipeac.replyradar.core.util.AppConstants.INITIAL_DATE import com.rafaelfelipeac.replyradar.features.reply.domain.model.Reply -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.replybottomsheet.ReplyBottomSheetMode.EDIT -import replyradar.composeapp.generated.resources.Res.drawable -import replyradar.composeapp.generated.resources.ic_archive -import replyradar.composeapp.generated.resources.ic_check -import replyradar.composeapp.generated.resources.ic_delete -import replyradar.composeapp.generated.resources.ic_reopen -import replyradar.composeapp.generated.resources.ic_unarchive - -private const val WEIGHT = 1f +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.components.ReplyTimestampInfo @Composable fun ReplyBottomSheetContent( replyBottomSheetState: ReplyBottomSheetState? = null, - onComplete: (Reply) -> Unit, + onSave: (Reply) -> Unit, onResolve: (Reply) -> Unit, onArchive: (Reply) -> Unit, - onDelete: (Reply) -> Unit + onDelete: (Reply) -> Unit, + onInvalidReminderValue: () -> Unit ) { replyBottomSheetState?.let { state -> var name by remember { mutableStateOf(state.reply?.name ?: EMPTY) } var subject by remember { mutableStateOf(state.reply?.subject ?: EMPTY) } + val reminderAt = state.reply?.reminderAt?.takeIf { it != INITIAL_DATE }?.dateTime() + var selectedTime by remember(reminderAt) { mutableStateOf(reminderAt?.time) } + var selectedDate by remember(reminderAt) { mutableStateOf(reminderAt?.date) } Column( modifier = Modifier @@ -89,192 +75,29 @@ fun ReplyBottomSheetContent( onValueChange = { subject = it } ) - state.reply?.let { - Text( - modifier = Modifier - .padding(start = paddingSmall, top = paddingSmall) - .align(Alignment.Start), - text = getTimestamp(state.reply), - style = typography.bodySmall - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = paddingSmall, bottom = paddingMedium), - horizontalArrangement = if (isEditMode(state)) spacedBy(paddingSmall) else End, - verticalAlignment = Alignment.CenterVertically - ) { - if (state.reply != null && isEditMode(state)) { - Row( - modifier = Modifier - .weight(WEIGHT) - ) { - with(state.reply) { - when { - !isArchived && !isResolved -> { - ActiveStateButtons( - reply = state.reply, - onArchive = onArchive, - onResolve = onResolve - ) - } - - isResolved && !isArchived -> { - ResolvedStateButtons( - reply = state.reply, - onArchive = onArchive, - onResolve = onResolve - ) - } - - isArchived -> { - ArchivedStateButton( - reply = state.reply, - onArchive = onArchive, - onDelete = onDelete - ) - } - } - } - } - } - ReplyButton( - modifier = Modifier - .wrapContentWidth() - .align(Alignment.CenterVertically), - text = if (state.reply == null) { - LocalReplyRadarStrings.current.replyListBottomSheetAdd - } else { - LocalReplyRadarStrings.current.replyListBottomSheetSave - }, - onClick = { - if (state.reply != null) { - onComplete( - state.reply.copy( - name = name, - subject = subject - ) - ) - } else { - onComplete( - Reply( - name = name, - subject = subject - ) - ) - } - }, - enabled = name.isNotBlank() - ) - } - } - } -} - -private fun isEditMode(state: ReplyBottomSheetState) = state.replyBottomSheetMode == EDIT - -@Composable -private fun ActiveStateButtons( - reply: Reply, - onArchive: (Reply) -> Unit, - onResolve: (Reply) -> Unit -) { - ReplyOutlinedButton( - text = LocalReplyRadarStrings.current.replyListBottomSheetResolve, - icon = drawable.ic_check, - onClick = { onResolve(reply) } - ) - - ReplyOutlinedButton( - text = LocalReplyRadarStrings.current.replyListBottomSheetArchive, - icon = drawable.ic_archive, - onClick = { onArchive(reply) } - ) -} - -@Composable -private fun ResolvedStateButtons( - reply: Reply, - onArchive: (Reply) -> Unit, - onResolve: (Reply) -> Unit -) { - ReplyOutlinedButton( - text = LocalReplyRadarStrings.current.replyListBottomSheetReopen, - icon = drawable.ic_reopen, - onClick = { onResolve(reply) } - ) - - ReplyOutlinedButton( - text = LocalReplyRadarStrings.current.replyListBottomSheetArchive, - icon = drawable.ic_archive, - onClick = { onArchive(reply) } - ) -} - -@Composable -private fun ArchivedStateButton( - reply: Reply, - onArchive: (Reply) -> Unit, - onDelete: (Reply) -> Unit -) { - var showDeleteDialog by remember { mutableStateOf(false) } - - ReplyOutlinedButton( - text = LocalReplyRadarStrings.current.replyListBottomSheetUnarchive, - icon = drawable.ic_unarchive, - onClick = { onArchive(reply) } - ) - - ReplyOutlinedButton( - text = LocalReplyRadarStrings.current.replyListBottomSheetDelete, - icon = drawable.ic_delete, - onClick = { showDeleteDialog = true } - ) - - if (showDeleteDialog) { - ReplyConfirmationDialog( - title = LocalReplyRadarStrings.current.replyListDeleteDialogTitle, - description = format( - LocalReplyRadarStrings.current.replyListDeleteDialogDescription, - reply.name - ), - confirm = LocalReplyRadarStrings.current.replyListDeleteDialogConfirm, - dismiss = LocalReplyRadarStrings.current.replyListDeleteDialogDismiss, - onDismiss = { showDeleteDialog = false }, - onConfirm = { onDelete(reply) } - ) - } -} - -@Composable -private fun getTimestamp(reply: Reply): String { - with(reply) { - return when { - archivedAt != INITIAL_DATE_LONG -> format( - LocalReplyRadarStrings.current.replyListItemArchivedAt, - formatTimestamp(archivedAt) + ReplyReminder( + selectedTime = selectedTime, + selectedDate = selectedDate, + onSelectedTimeChange = { selectedTime = it }, + onSelectedDateChange = { selectedDate = it }, + closeKeyboard = { keyboardController?.hide() } ) - resolvedAt != INITIAL_DATE_LONG -> format( - LocalReplyRadarStrings.current.replyListItemResolvedAt, - formatTimestamp(resolvedAt) + ReplyTimestampInfo(state) + + ReplyBottomSheetActions( + state = replyBottomSheetState, + onArchive = onArchive, + onResolve = onResolve, + onDelete = onDelete, + onSave = onSave, + onInvalidReminderValue = { onInvalidReminderValue() }, + selectedDate = selectedDate, + selectedTime = selectedTime, + reply = state.reply, + name = name, + subject = subject ) - - else -> { - if (updatedAt != INITIAL_DATE_LONG && updatedAt != createdAt) { - format( - LocalReplyRadarStrings.current.replyListItemUpdatedAt, - formatTimestamp(updatedAt) - ) - } else { - format( - LocalReplyRadarStrings.current.replyListItemCreatedAt, - formatTimestamp(createdAt) - ) - } - } } } } diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/data/repository/SettingsRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/data/repository/SettingsRepositoryImpl.kt index 0e15df6..e627022 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/data/repository/SettingsRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/data/repository/SettingsRepositoryImpl.kt @@ -4,8 +4,8 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme +import com.rafaelfelipeac.replyradar.core.language.AppLanguage +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme import com.rafaelfelipeac.replyradar.features.settings.data.repository.SettingsRepositoryImpl.Keys.LANGUAGE import com.rafaelfelipeac.replyradar.features.settings.data.repository.SettingsRepositoryImpl.Keys.THEME import com.rafaelfelipeac.replyradar.features.settings.domain.repository.SettingsRepository diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/repository/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/repository/SettingsRepository.kt index 511057c..f1016ce 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/repository/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/repository/SettingsRepository.kt @@ -1,7 +1,7 @@ package com.rafaelfelipeac.replyradar.features.settings.domain.repository -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme +import com.rafaelfelipeac.replyradar.core.language.AppLanguage +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme import kotlinx.coroutines.flow.Flow interface SettingsRepository { diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/GetLanguageUseCase.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/GetLanguageUseCase.kt index 1ee5f31..3201fb9 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/GetLanguageUseCase.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/GetLanguageUseCase.kt @@ -1,6 +1,6 @@ package com.rafaelfelipeac.replyradar.features.settings.domain.usecase -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage +import com.rafaelfelipeac.replyradar.core.language.AppLanguage import com.rafaelfelipeac.replyradar.features.settings.domain.repository.SettingsRepository import kotlinx.coroutines.flow.Flow diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/GetThemeUseCase.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/GetThemeUseCase.kt index dcd045b..9c2e664 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/GetThemeUseCase.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/GetThemeUseCase.kt @@ -1,6 +1,6 @@ package com.rafaelfelipeac.replyradar.features.settings.domain.usecase -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme import com.rafaelfelipeac.replyradar.features.settings.domain.repository.SettingsRepository import kotlinx.coroutines.flow.Flow diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/SetLanguageUseCase.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/SetLanguageUseCase.kt index 5046980..8a60699 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/SetLanguageUseCase.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/SetLanguageUseCase.kt @@ -1,6 +1,6 @@ package com.rafaelfelipeac.replyradar.features.settings.domain.usecase -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage +import com.rafaelfelipeac.replyradar.core.language.AppLanguage import com.rafaelfelipeac.replyradar.features.settings.domain.repository.SettingsRepository interface SetLanguageUseCase { diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/SetThemeUseCase.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/SetThemeUseCase.kt index 84b76ff..f4b2f6a 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/SetThemeUseCase.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/usecase/SetThemeUseCase.kt @@ -1,6 +1,6 @@ package com.rafaelfelipeac.replyradar.features.settings.domain.usecase -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme import com.rafaelfelipeac.replyradar.features.settings.domain.repository.SettingsRepository interface SetThemeUseCase { diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsIntent.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsIntent.kt index 90daf45..dcc0c16 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsIntent.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsIntent.kt @@ -1,7 +1,7 @@ package com.rafaelfelipeac.replyradar.features.settings.presentation -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme +import com.rafaelfelipeac.replyradar.core.language.AppLanguage +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme sealed interface SettingsIntent { data class OnSelectTheme(val theme: AppTheme) : SettingsIntent diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsScreen.kt index 2826981..9cc0173 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsScreen.kt @@ -31,25 +31,25 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.rafaelfelipeac.replyradar.core.AppConstants.EMAIL -import com.rafaelfelipeac.replyradar.core.AppConstants.PACKAGE_NAME -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage.ENGLISH -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage.PORTUGUESE -import com.rafaelfelipeac.replyradar.core.common.strings.LocalReplyRadarStrings import com.rafaelfelipeac.replyradar.core.common.ui.iconSizeLarge import com.rafaelfelipeac.replyradar.core.common.ui.paddingMedium import com.rafaelfelipeac.replyradar.core.common.ui.paddingSmall import com.rafaelfelipeac.replyradar.core.common.ui.paddingXSmall import com.rafaelfelipeac.replyradar.core.common.ui.radioButtonSize import com.rafaelfelipeac.replyradar.core.common.ui.settingsAppVersionOffset -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme.DARK -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme.LIGHT -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme.SYSTEM -import com.rafaelfelipeac.replyradar.core.util.getAppVersion -import com.rafaelfelipeac.replyradar.core.util.openEmailApp -import com.rafaelfelipeac.replyradar.core.util.openPlayStoreApp +import com.rafaelfelipeac.replyradar.core.external.openEmailApp +import com.rafaelfelipeac.replyradar.core.external.openPlayStoreApp +import com.rafaelfelipeac.replyradar.core.language.AppLanguage +import com.rafaelfelipeac.replyradar.core.language.AppLanguage.ENGLISH +import com.rafaelfelipeac.replyradar.core.language.AppLanguage.PORTUGUESE +import com.rafaelfelipeac.replyradar.core.strings.LocalReplyRadarStrings +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme.DARK +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme.LIGHT +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme.SYSTEM +import com.rafaelfelipeac.replyradar.core.util.AppConstants.EMAIL +import com.rafaelfelipeac.replyradar.core.util.AppConstants.PACKAGE_NAME +import com.rafaelfelipeac.replyradar.core.version.getAppVersion import com.rafaelfelipeac.replyradar.features.settings.presentation.SettingsIntent.OnSelectFeedback import com.rafaelfelipeac.replyradar.features.settings.presentation.SettingsIntent.OnSelectLanguage import com.rafaelfelipeac.replyradar.features.settings.presentation.SettingsIntent.OnSelectRate @@ -100,11 +100,26 @@ fun SettingsScreen( .padding(bottom = settingsAppVersionOffset) ) { ActivityLog(onActivityLogClick = onActivityLogClick) - HorizontalDivider(modifier = Modifier.padding(vertical = paddingMedium)) + + HorizontalDivider( + modifier = Modifier + .padding(vertical = paddingMedium) + ) + Theme(state = state, onIntent = { viewModel.onIntent(it) }) - HorizontalDivider(modifier = Modifier.padding(vertical = paddingMedium)) + + HorizontalDivider( + modifier = Modifier + .padding(vertical = paddingMedium) + ) + Language(state = state, onIntent = { viewModel.onIntent(it) }) - HorizontalDivider(modifier = Modifier.padding(vertical = paddingMedium)) + + HorizontalDivider( + modifier = Modifier + .padding(vertical = paddingMedium) + ) + App(onIntent = { viewModel.onIntent(it) }) } @@ -138,7 +153,10 @@ fun App(onIntent: (SettingsIntent) -> Unit) { } ) - Spacer(modifier = Modifier.height(paddingSmall)) + Spacer( + modifier = Modifier + .height(paddingSmall) + ) SettingsItem( text = strings.settingsRateTitle, @@ -170,7 +188,10 @@ private fun Theme(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { color = colorScheme.primary ) - Spacer(modifier = Modifier.height(paddingXSmall)) + Spacer( + modifier = Modifier + .height(paddingXSmall) + ) ThemeOptions( state = state, @@ -186,7 +207,10 @@ private fun Language(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { color = colorScheme.primary ) - Spacer(modifier = Modifier.height(paddingXSmall)) + Spacer( + modifier = Modifier + .height(paddingXSmall) + ) LanguageOptions( state = state, @@ -201,11 +225,13 @@ private fun ThemeOptions(state: SettingsState, onThemeSelected: (AppTheme) -> Un selectedTheme = state.theme, onThemeSelected = { onThemeSelected(LIGHT) } ) + ThemeOption( theme = DARK, selectedTheme = state.theme, onThemeSelected = { onThemeSelected(DARK) } ) + ThemeOption( theme = SYSTEM, selectedTheme = state.theme, @@ -220,19 +246,23 @@ private fun ThemeOption( onThemeSelected: (AppTheme) -> Unit ) { Row( - verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(paddingSmall) - .clickable { onThemeSelected(theme) } + .clickable { onThemeSelected(theme) }, + verticalAlignment = Alignment.CenterVertically ) { RadioButton( - modifier = Modifier.size(radioButtonSize), + modifier = Modifier + .size(radioButtonSize), selected = theme == selectedTheme, onClick = { onThemeSelected(theme) } ) - Spacer(modifier = Modifier.width(paddingSmall)) + Spacer( + modifier = Modifier + .width(paddingSmall) + ) Text(text = getThemeOptionLabel(theme)) } @@ -248,7 +278,9 @@ private fun getThemeOptionLabel(theme: AppTheme) = when (theme) { @Composable private fun LanguageOptions(state: SettingsState, onLanguageSelected: (AppLanguage) -> Unit) { LanguageOption(ENGLISH, state.language, onLanguageSelected) + LanguageOption(PORTUGUESE, state.language, onLanguageSelected) + LanguageOption(AppLanguage.SYSTEM, state.language, onLanguageSelected) } @@ -259,19 +291,23 @@ private fun LanguageOption( onLanguageSelected: (AppLanguage) -> Unit ) { Row( - verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(paddingSmall) - .clickable { onLanguageSelected(language) } + .clickable { onLanguageSelected(language) }, + verticalAlignment = Alignment.CenterVertically ) { RadioButton( - modifier = Modifier.size(radioButtonSize), + modifier = Modifier + .size(radioButtonSize), selected = language == selectedLanguage, onClick = { onLanguageSelected(language) } ) - Spacer(modifier = Modifier.width(paddingSmall)) + Spacer( + modifier = Modifier + .width(paddingSmall) + ) Text(text = getLanguageLabel(language)) } @@ -324,7 +360,10 @@ fun AppVersionFooter(modifier: Modifier = Modifier) { .fillMaxWidth() .background(colorScheme.background) ) { - HorizontalDivider(modifier = Modifier.padding(bottom = paddingMedium)) + HorizontalDivider( + modifier = Modifier + .padding(bottom = paddingMedium) + ) Text( text = "${LocalReplyRadarStrings.current.settingsAppVersion} ${getAppVersion()}", diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsState.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsState.kt index 848991e..524f62a 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsState.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsState.kt @@ -1,7 +1,7 @@ package com.rafaelfelipeac.replyradar.features.settings.presentation -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme +import com.rafaelfelipeac.replyradar.core.language.AppLanguage +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme data class SettingsState( val theme: AppTheme = AppTheme.SYSTEM, diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsViewModel.kt index 747d568..f3b3b27 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsViewModel.kt @@ -2,8 +2,8 @@ package com.rafaelfelipeac.replyradar.features.settings.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme +import com.rafaelfelipeac.replyradar.core.language.AppLanguage +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme import com.rafaelfelipeac.replyradar.features.settings.domain.usecase.GetLanguageUseCase import com.rafaelfelipeac.replyradar.features.settings.domain.usecase.GetThemeUseCase import com.rafaelfelipeac.replyradar.features.settings.domain.usecase.SetLanguageUseCase diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/useractions/data/repository/UserActionRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/useractions/data/repository/UserActionRepositoryImpl.kt index 469487b..0448cef 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/useractions/data/repository/UserActionRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/useractions/data/repository/UserActionRepositoryImpl.kt @@ -1,6 +1,6 @@ package com.rafaelfelipeac.replyradar.features.useractions.data.repository -import com.rafaelfelipeac.replyradar.core.util.Clock +import com.rafaelfelipeac.replyradar.core.datetime.getCurrentTimeMillis import com.rafaelfelipeac.replyradar.features.reply.data.database.dao.ReplyDao import com.rafaelfelipeac.replyradar.features.useractions.data.database.dao.UserActionDao import com.rafaelfelipeac.replyradar.features.useractions.data.database.entity.UserActionEntity @@ -17,8 +17,7 @@ import kotlinx.coroutines.withContext class UserActionRepositoryImpl( private val userActionDao: UserActionDao, - private val replyDao: ReplyDao, - private val clock: Clock + private val replyDao: ReplyDao ) : UserActionRepository { override suspend fun logUserAction( @@ -31,7 +30,7 @@ class UserActionRepositoryImpl( actionType = actionType.value, targetType = targetType.value, targetId = targetId, - createdAt = clock.now() + createdAt = getCurrentTimeMillis() ) ) } diff --git a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/useractions/domain/model/UserActionType.kt b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/useractions/domain/model/UserActionType.kt index c507e0a..4282910 100644 --- a/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/useractions/domain/model/UserActionType.kt +++ b/composeApp/src/commonMain/kotlin/com/rafaelfelipeac/replyradar/features/useractions/domain/model/UserActionType.kt @@ -8,7 +8,10 @@ sealed class UserActionType(val value: String) { data object Archive : UserActionType(ARCHIVE) data object Unarchive : UserActionType(UNARCHIVE) data object Delete : UserActionType(DELETE) + data object Scheduled : UserActionType(SCHEDULED) + data object OpenedNotification : UserActionType(OPENED_NOTIFICATION) data object Open : UserActionType(OPEN) + data object Unknown : UserActionType(UNKNOWN) companion object { fun fromValue(value: String): UserActionType { @@ -18,9 +21,12 @@ sealed class UserActionType(val value: String) { RESOLVE -> Resolve REOPEN -> Reopen ARCHIVE -> Archive + UNARCHIVE -> Unarchive DELETE -> Delete OPEN -> Open - else -> Unarchive + SCHEDULED -> Scheduled + OPENED_NOTIFICATION -> OpenedNotification + else -> Unknown } } } @@ -34,3 +40,6 @@ private const val ARCHIVE = "ARCHIVE" private const val UNARCHIVE = "UNARCHIVE" private const val DELETE = "DELETE" private const val OPEN = "OPEN" +private const val SCHEDULED = "SCHEDULED" +private const val OPENED_NOTIFICATION = "OPENED_NOTIFICATION" +private const val UNKNOWN = "UNKNOWN" diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/Shared.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/Shared.kt index eac83ed..54f9fd2 100644 --- a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/Shared.kt +++ b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/Shared.kt @@ -22,5 +22,5 @@ val sampleUserAction = UserAction( actionType = UserActionType.Create, targetType = UserActionTargetType.Message, targetName = "TargetName", - createdAt = 123456789L + createdAt = now ) diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/core/util/FakeClock.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/core/util/FakeClock.kt deleted file mode 100644 index 4a906a2..0000000 --- a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/core/util/FakeClock.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.rafaelfelipeac.replyradar.fakes.core.util - -import com.rafaelfelipeac.replyradar.core.util.Clock - -class FakeClock(private val fixedNow: Long) : Clock { - override fun now(): Long = fixedNow -} diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/core/util/FakeReminderScheduler.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/core/util/FakeReminderScheduler.kt new file mode 100644 index 0000000..69a087c --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/core/util/FakeReminderScheduler.kt @@ -0,0 +1,21 @@ +package com.rafaelfelipeac.replyradar.fakes.core.util + +import com.rafaelfelipeac.replyradar.core.reminder.ReminderScheduler +import com.rafaelfelipeac.replyradar.core.reminder.model.NotificationReminderParams + +class FakeReminderScheduler : ReminderScheduler { + + private val scheduledReminders = mutableListOf>() + private val cancelledReminders = mutableListOf() + + override fun scheduleReminder( + reminderAtMillis: Long, + notificationReminderParams: NotificationReminderParams + ) { + scheduledReminders.add(reminderAtMillis to notificationReminderParams) + } + + override fun cancelReminder(replyId: Long) { + cancelledReminders.add(replyId) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/settings/data/FakeSettingsRepository.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/settings/data/FakeSettingsRepository.kt index c01bd50..3af4d40 100644 --- a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/settings/data/FakeSettingsRepository.kt +++ b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/settings/data/FakeSettingsRepository.kt @@ -1,7 +1,7 @@ package com.rafaelfelipeac.replyradar.fakes.settings.data -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme +import com.rafaelfelipeac.replyradar.core.language.AppLanguage +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme import com.rafaelfelipeac.replyradar.features.settings.domain.repository.SettingsRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/settings/domain/FakeGetLanguageUseCase.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/settings/domain/FakeGetLanguageUseCase.kt index 70678b7..447bc54 100644 --- a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/settings/domain/FakeGetLanguageUseCase.kt +++ b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/settings/domain/FakeGetLanguageUseCase.kt @@ -1,7 +1,7 @@ package com.rafaelfelipeac.replyradar.fakes.settings.domain -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage.SYSTEM +import com.rafaelfelipeac.replyradar.core.language.AppLanguage +import com.rafaelfelipeac.replyradar.core.language.AppLanguage.SYSTEM import com.rafaelfelipeac.replyradar.features.settings.domain.usecase.GetLanguageUseCase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/settings/domain/FakeGetThemeUseCase.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/settings/domain/FakeGetThemeUseCase.kt index ff55b57..3363c93 100644 --- a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/settings/domain/FakeGetThemeUseCase.kt +++ b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/fakes/settings/domain/FakeGetThemeUseCase.kt @@ -1,7 +1,7 @@ package com.rafaelfelipeac.replyradar.fakes.settings.domain -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme.SYSTEM +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme.SYSTEM import com.rafaelfelipeac.replyradar.features.settings.domain.usecase.GetThemeUseCase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/app/settings/AppSettingsViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/app/settings/AppSettingsViewModelTest.kt index 710eb3f..5a62b92 100644 --- a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/app/settings/AppSettingsViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/app/settings/AppSettingsViewModelTest.kt @@ -1,7 +1,7 @@ package com.rafaelfelipeac.replyradar.features.app.settings -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage.PORTUGUESE -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme.DARK +import com.rafaelfelipeac.replyradar.core.language.AppLanguage.PORTUGUESE +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme.DARK import com.rafaelfelipeac.replyradar.fakes.settings.domain.FakeGetLanguageUseCase import com.rafaelfelipeac.replyradar.fakes.settings.domain.FakeGetThemeUseCase import kotlin.test.AfterTest diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/ReplyRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/ReplyRepositoryTest.kt index 797e138..a0b5fd6 100644 --- a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/ReplyRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/reply/data/ReplyRepositoryTest.kt @@ -1,12 +1,10 @@ package com.rafaelfelipeac.replyradar.features.reply.data -import com.rafaelfelipeac.replyradar.core.AppConstants.EMPTY -import com.rafaelfelipeac.replyradar.fakes.core.util.FakeClock +import com.rafaelfelipeac.replyradar.core.util.AppConstants.EMPTY import com.rafaelfelipeac.replyradar.fakes.reply.data.FakeReplyDao import com.rafaelfelipeac.replyradar.features.reply.data.database.entity.ReplyEntity import com.rafaelfelipeac.replyradar.features.reply.data.repository.ReplyRepositoryImpl import com.rafaelfelipeac.replyradar.features.reply.domain.model.Reply -import com.rafaelfelipeac.replyradar.now import com.rafaelfelipeac.replyradar.util.valueOrEmpty import kotlin.test.Test import kotlin.test.assertEquals @@ -17,8 +15,7 @@ class ReplyRepositoryTest { @Test fun `upsertReply should insert reply with correct timestamps when id is 0`() = runTest { val dao = FakeReplyDao() - val clock = FakeClock(now) - val repository = ReplyRepositoryImpl(dao, clock) + val repository = ReplyRepositoryImpl(dao) val reply = Reply( id = 0L, @@ -29,18 +26,14 @@ class ReplyRepositoryTest { val returnedId = repository.upsertReply(reply) assertEquals(1, dao.insertedReplies.size) - val inserted = dao.insertedReplies.first() - assertEquals(now, inserted.createdAt) - assertEquals(now, inserted.updatedAt) assertEquals(1L, returnedId) } @Test fun `upsertReply should update reply with new updatedAt when id is not 0`() = runTest { val dao = FakeReplyDao() - val clock = FakeClock(now) - val repository = ReplyRepositoryImpl(dao, clock) + val repository = ReplyRepositoryImpl(dao) val reply = Reply( id = 42L, @@ -54,15 +47,13 @@ class ReplyRepositoryTest { val inserted = dao.insertedReplies.first() assertEquals(42L, inserted.id) - assertEquals(now, inserted.updatedAt) assertEquals(42L, returnedId) } @Test fun `toggleReplyResolve should toggle isResolved and set resolvedAt`() = runTest { val dao = FakeReplyDao() - val clock = FakeClock(now) - val repository = ReplyRepositoryImpl(dao, clock) + val repository = ReplyRepositoryImpl(dao) val reply = Reply( id = 1L, @@ -76,14 +67,12 @@ class ReplyRepositoryTest { val updated = dao.updatedReplies.first() assertEquals(true, updated.isResolved) assertEquals(true, updated.isArchived) - assertEquals(now, updated.resolvedAt) } @Test fun `toggleReplyArchive should toggle isArchived and set archivedAt`() = runTest { val dao = FakeReplyDao() - val clock = FakeClock(now) - val repository = ReplyRepositoryImpl(dao, clock) + val repository = ReplyRepositoryImpl(dao) val reply = Reply( id = 2L, @@ -97,14 +86,12 @@ class ReplyRepositoryTest { val updated = dao.updatedReplies.first() assertEquals(true, updated.isArchived) assertEquals(true, updated.isResolved) - assertEquals(now, updated.archivedAt) } @Test fun `deleteReply should call deleteReply on dao`() = runTest { val dao = FakeReplyDao() - val clock = FakeClock(0L) - val repository = ReplyRepositoryImpl(dao, clock) + val repository = ReplyRepositoryImpl(dao) val reply = Reply( id = 10L, @@ -122,8 +109,7 @@ class ReplyRepositoryTest { val replyEntities = listOf(ReplyEntity(id = 1L, name = "Resolved", subject = "R", isResolved = true)) val dao = FakeReplyDao(resolved = replyEntities) - val clock = FakeClock(0L) - val repository = ReplyRepositoryImpl(dao, clock) + val repository = ReplyRepositoryImpl(dao) val replies = repository.getReplies(isResolved = true, isArchived = false).valueOrEmpty() @@ -136,8 +122,7 @@ class ReplyRepositoryTest { val replyEntities = listOf(ReplyEntity(id = 2L, name = "Archived", subject = "A", isArchived = true)) val dao = FakeReplyDao(archived = replyEntities) - val clock = FakeClock(0L) - val repository = ReplyRepositoryImpl(dao, clock) + val repository = ReplyRepositoryImpl(dao) val replies = repository.getReplies(isResolved = false, isArchived = true).valueOrEmpty() @@ -149,8 +134,7 @@ class ReplyRepositoryTest { fun `getReplies should return active replies when none is true`() = runTest { val replyEntities = listOf(ReplyEntity(id = 3L, name = "Active", subject = "A")) val dao = FakeReplyDao(active = replyEntities) - val clock = FakeClock(0L) - val repository = ReplyRepositoryImpl(dao, clock) + val repository = ReplyRepositoryImpl(dao) val replies = repository.getReplies(isResolved = false, isArchived = false).valueOrEmpty() diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/ReplyListViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/ReplyListViewModelTest.kt index 0451615..ddcf272 100644 --- a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/ReplyListViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/reply/presentation/ReplyListViewModelTest.kt @@ -2,11 +2,12 @@ package com.rafaelfelipeac.replyradar.features.reply.presentation import app.cash.turbine.test import com.rafaelfelipeac.replyradar.ARCHIVE -import com.rafaelfelipeac.replyradar.CREATE import com.rafaelfelipeac.replyradar.DELETE import com.rafaelfelipeac.replyradar.EDIT import com.rafaelfelipeac.replyradar.RESOLVE +import com.rafaelfelipeac.replyradar.core.util.AppConstants.INITIAL_ID import com.rafaelfelipeac.replyradar.dropFirst +import com.rafaelfelipeac.replyradar.fakes.core.util.FakeReminderScheduler import com.rafaelfelipeac.replyradar.fakes.reply.domain.FakeDeleteReplyUseCase import com.rafaelfelipeac.replyradar.fakes.reply.domain.FakeGetRepliesUseCase import com.rafaelfelipeac.replyradar.fakes.reply.domain.FakeToggleArchiveReplyUseCase @@ -14,14 +15,14 @@ import com.rafaelfelipeac.replyradar.fakes.reply.domain.FakeToggleResolveReplyUs import com.rafaelfelipeac.replyradar.fakes.reply.domain.FakeUpsertReplyUseCase import com.rafaelfelipeac.replyradar.fakes.useractions.domain.FakeLogUserActionUseCase import com.rafaelfelipeac.replyradar.features.reply.domain.model.Reply -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnAddReply +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListEffect.ScheduleReminder +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnAddOrEditReply import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnDeleteReply import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnDismissBottomSheet -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnEditReply import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnToggleArchive import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyBottomSheetIntent.OnToggleResolve import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnAddReplyClick -import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnReplyClick +import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnOpenReply import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListScreenIntent.ReplyListIntent.OnTabSelected import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListViewModel import com.rafaelfelipeac.replyradar.features.reply.presentation.replylist.ReplyListViewModel.Companion.ERROR_GET_REPLIES @@ -60,6 +61,7 @@ class ReplyListViewModelTest { private val deleteReplyUseCase = FakeDeleteReplyUseCase() private val getRepliesUseCase = FakeGetRepliesUseCase() private val logUserActionUseCase = FakeLogUserActionUseCase() + private val reminderScheduler = FakeReminderScheduler() private val viewModel = ReplyListViewModel( upsertReplyUseCase = upsertReplyUseCase, @@ -68,6 +70,7 @@ class ReplyListViewModelTest { deleteReplyUseCase = deleteReplyUseCase, getRepliesUseCase = getRepliesUseCase, logUserActionUseCase = logUserActionUseCase, + reminderScheduler = reminderScheduler, dispatcher = testDispatcher ) @@ -95,7 +98,7 @@ class ReplyListViewModelTest { @Test fun `OnReplyClick should open bottom sheet in EDIT mode with reply`() = runTest { viewModel.state.drop(dropFirst).test { - viewModel.onIntent(OnReplyClick(sampleReply)) + viewModel.onIntent(OnOpenReply(sampleReply)) val updatedState = awaitItem() val expectedState = ReplyBottomSheetState( @@ -119,15 +122,33 @@ class ReplyListViewModelTest { } } + // ktlint-disable max-line-length @Test - fun `OnAddReply should upsert reply log action and dismiss bottom sheet`() = runTest { + fun `OnAddReply should upsert reply log action and dismiss bottom sheet with no reminder scheduled if reminderAt is INITIAL_DATE`() = runTest { viewModel.state.test { - viewModel.onIntent(OnAddReply(sampleReply)) + viewModel.onIntent(OnAddOrEditReply(sampleReply)) val updatedState = awaitItem() assertEquals(null, updatedState.replyBottomSheetState) assertEquals(sampleReply, upsertReplyUseCase.insertedReplies.first()) - assertEquals(CREATE, logUserActionUseCase.loggedActions.first().first.value) + assertEquals(EDIT, logUserActionUseCase.loggedActions.first().first.value) + cancelAndIgnoreRemainingEvents() + } + } + // ktlint-enable max-line-length + + @Test + fun `OnAddReply should emit ScheduleReminder effect when reminderAt is valid`() = runTest { + val fixedReminderAt = 1735689600000L + val replyWithReminder = sampleReply.copy(id = INITIAL_ID, reminderAt = fixedReminderAt) + + viewModel.effect.test { + viewModel.onIntent(OnAddOrEditReply(replyWithReminder)) + + val effect = awaitItem() + assertEquals(effect is ScheduleReminder, true) + assertEquals(replyWithReminder, (effect as ScheduleReminder).reply) + cancelAndIgnoreRemainingEvents() } } @@ -135,7 +156,7 @@ class ReplyListViewModelTest { @Test fun `OnEditReply should upsert reply log action and dismiss bottom sheet`() = runTest { viewModel.state.test { - viewModel.onIntent(OnEditReply(sampleReply)) + viewModel.onIntent(OnAddOrEditReply(sampleReply)) val updatedState = awaitItem() assertEquals(null, updatedState.replyBottomSheetState) @@ -214,6 +235,7 @@ class ReplyListViewModelTest { deleteReplyUseCase = deleteReplyUseCase, getRepliesUseCase = getRepliesUseCase, logUserActionUseCase = logUserActionUseCase, + reminderScheduler = reminderScheduler, dispatcher = testDispatcher ) @@ -239,6 +261,7 @@ class ReplyListViewModelTest { deleteReplyUseCase = deleteReplyUseCase, getRepliesUseCase = getRepliesUseCase, logUserActionUseCase = logUserActionUseCase, + reminderScheduler = reminderScheduler, dispatcher = testDispatcher ) diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/GetLanguageUseCaseImplTest.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/GetLanguageUseCaseImplTest.kt index 2b7cf0a..ef353e3 100644 --- a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/GetLanguageUseCaseImplTest.kt +++ b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/GetLanguageUseCaseImplTest.kt @@ -1,7 +1,7 @@ package com.rafaelfelipeac.replyradar.features.settings.domain import app.cash.turbine.test -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage.PORTUGUESE +import com.rafaelfelipeac.replyradar.core.language.AppLanguage.PORTUGUESE import com.rafaelfelipeac.replyradar.fakes.settings.data.FakeSettingsRepository import com.rafaelfelipeac.replyradar.features.settings.domain.usecase.GetLanguageUseCaseImpl import kotlin.test.Test diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/GetThemeUseCaseImplTest.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/GetThemeUseCaseImplTest.kt index 7e9e46c..64cb9a7 100644 --- a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/GetThemeUseCaseImplTest.kt +++ b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/GetThemeUseCaseImplTest.kt @@ -1,7 +1,7 @@ package com.rafaelfelipeac.replyradar.features.settings.domain import app.cash.turbine.test -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme.DARK +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme.DARK import com.rafaelfelipeac.replyradar.fakes.settings.data.FakeSettingsRepository import com.rafaelfelipeac.replyradar.features.settings.domain.usecase.GetThemeUseCaseImpl import kotlin.test.Test diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/SetLanguageUseCaseImplTest.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/SetLanguageUseCaseImplTest.kt index 5ed7dc3..4ecf30d 100644 --- a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/SetLanguageUseCaseImplTest.kt +++ b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/SetLanguageUseCaseImplTest.kt @@ -1,6 +1,6 @@ package com.rafaelfelipeac.replyradar.features.settings.domain -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage.PORTUGUESE +import com.rafaelfelipeac.replyradar.core.language.AppLanguage.PORTUGUESE import com.rafaelfelipeac.replyradar.fakes.settings.data.FakeSettingsRepository import com.rafaelfelipeac.replyradar.features.settings.domain.usecase.SetLanguageUseCaseImpl import kotlin.test.Test diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/SetThemeUseCaseTest.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/SetThemeUseCaseTest.kt index 4be3f6f..faa076a 100644 --- a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/SetThemeUseCaseTest.kt +++ b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/domain/SetThemeUseCaseTest.kt @@ -1,6 +1,6 @@ package com.rafaelfelipeac.replyradar.features.settings.domain -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme.DARK +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme.DARK import com.rafaelfelipeac.replyradar.fakes.settings.data.FakeSettingsRepository import com.rafaelfelipeac.replyradar.features.settings.domain.usecase.SetThemeUseCaseImpl import kotlin.test.Test diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsViewModelTest.kt index 24b4fd2..3aae7ac 100644 --- a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/settings/presentation/SettingsViewModelTest.kt @@ -1,8 +1,8 @@ package com.rafaelfelipeac.replyradar.features.settings.presentation import app.cash.turbine.test -import com.rafaelfelipeac.replyradar.core.common.language.AppLanguage.ENGLISH -import com.rafaelfelipeac.replyradar.core.common.ui.theme.model.AppTheme.DARK +import com.rafaelfelipeac.replyradar.core.language.AppLanguage.ENGLISH +import com.rafaelfelipeac.replyradar.core.theme.model.AppTheme.DARK import com.rafaelfelipeac.replyradar.fakes.settings.data.FakeSettingsRepository import com.rafaelfelipeac.replyradar.fakes.useractions.domain.FakeLogUserActionUseCase import com.rafaelfelipeac.replyradar.features.settings.domain.usecase.GetLanguageUseCaseImpl diff --git a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/useractions/data/UserActionRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/useractions/data/UserActionRepositoryTest.kt index f7d5c28..977d075 100644 --- a/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/useractions/data/UserActionRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/rafaelfelipeac/replyradar/features/useractions/data/UserActionRepositoryTest.kt @@ -1,7 +1,6 @@ package com.rafaelfelipeac.replyradar.features.useractions.data import app.cash.turbine.test -import com.rafaelfelipeac.replyradar.fakes.core.util.FakeClock import com.rafaelfelipeac.replyradar.fakes.reply.data.FakeReplyDao import com.rafaelfelipeac.replyradar.fakes.useractions.data.FakeUserActionDao import com.rafaelfelipeac.replyradar.features.useractions.data.database.entity.UserActionEntity @@ -18,12 +17,10 @@ class UserActionRepositoryTest { private val userActionDao = FakeUserActionDao() private val replyDao = FakeReplyDao() - private val clock = FakeClock(now) private val repository = UserActionRepositoryImpl( userActionDao = userActionDao, - replyDao = replyDao, - clock = clock + replyDao = replyDao ) @Test @@ -39,7 +36,6 @@ class UserActionRepositoryTest { assertEquals(actionType.value, entity.actionType) assertEquals(targetType.value, entity.targetType) assertEquals(targetId, entity.targetId) - assertEquals(now, entity.createdAt) } @Test @@ -66,7 +62,6 @@ class UserActionRepositoryTest { assertEquals(Create, action.actionType) assertEquals(Message, action.targetType) assertEquals(replyName, action.targetName) - assertEquals(now, action.createdAt) cancelAndIgnoreRemainingEvents() } diff --git a/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/Main.kt b/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/Main.kt index 182a09e..0c809eb 100644 --- a/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/Main.kt +++ b/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/Main.kt @@ -3,6 +3,7 @@ package com.rafaelfelipeac.replyradar import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import com.rafaelfelipeac.replyradar.app.ReplyRadarApp +import com.rafaelfelipeac.replyradar.core.notification.NotificationPermissionManager import com.rafaelfelipeac.replyradar.di.initKoin fun main() { @@ -12,7 +13,18 @@ fun main() { onCloseRequest = ::exitApplication, title = "Reply Radar" ) { - ReplyRadarApp() + ReplyRadarApp( + notificationPermissionManager = object : NotificationPermissionManager { + override suspend fun ensureNotificationPermission(): Boolean { + TODO("Not yet implemented for this platform.") + } + + override suspend fun goToAppSettings() { + TODO("Not yet implemented for this platform.") + } + }, + pendingReplyId = -1 + ) } } } diff --git a/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/database/DatabaseFactory.kt b/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/database/DatabaseFactory.kt index 2fa94ea..60a4cc4 100644 --- a/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/database/DatabaseFactory.kt +++ b/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/database/DatabaseFactory.kt @@ -2,7 +2,7 @@ package com.rafaelfelipeac.replyradar.core.database import androidx.room.Room import androidx.room.RoomDatabase -import com.rafaelfelipeac.replyradar.core.AppConstants.DB_NAME +import com.rafaelfelipeac.replyradar.core.util.AppConstants.DB_NAME import java.io.File actual class DatabaseFactory { diff --git a/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformDatePicker.desktop.kt b/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformDatePicker.desktop.kt new file mode 100644 index 0000000..61b732c --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformDatePicker.desktop.kt @@ -0,0 +1,18 @@ +package com.rafaelfelipeac.replyradar.core.datetime + +import androidx.compose.runtime.Composable +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime + +@Composable +actual fun PlatformDatePicker( + selectedDate: LocalDate?, + selectedTime: LocalTime?, + onDateSelected: (LocalDate) -> Unit, + confirmButtonText: String, + dismissButtonText: String, + onTimeInvalidated: () -> Unit, + onDismiss: () -> Unit +) { + TODO("Not yet implemented for this platform.") +} diff --git a/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformTimePicker.desktop.kt b/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformTimePicker.desktop.kt new file mode 100644 index 0000000..bffa00f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformTimePicker.desktop.kt @@ -0,0 +1,18 @@ +package com.rafaelfelipeac.replyradar.core.datetime + +import androidx.compose.runtime.Composable +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime + +@Composable +actual fun PlatformTimePicker( + selectedTime: LocalTime?, + selectedDate: LocalDate?, + onTimeSelected: (LocalTime) -> Unit, + onDismiss: () -> Unit, + confirmButtonText: String, + dismissButtonText: String, + pickerTimeTitle: String +) { + TODO("Not yet implemented for this platform.") +} diff --git a/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/util/External.desktop.kt b/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/external/External.desktop.kt similarity index 81% rename from composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/util/External.desktop.kt rename to composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/external/External.desktop.kt index 73cd7a4..267aabd 100644 --- a/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/util/External.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/external/External.desktop.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.util +package com.rafaelfelipeac.replyradar.core.external actual fun openEmailApp(to: String, subject: String, body: String) { TODO("Not yet implemented for this platform.") diff --git a/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Clock.desktop.kt b/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Clock.desktop.kt deleted file mode 100644 index d6063c4..0000000 --- a/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Clock.desktop.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.rafaelfelipeac.replyradar.core.util - -actual fun getClock(): Clock = object : Clock { - override fun now(): Long = System.currentTimeMillis() -} diff --git a/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Version.desktop.kt b/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/version/Version.desktop.kt similarity index 64% rename from composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Version.desktop.kt rename to composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/version/Version.desktop.kt index 2d4b1d5..ce6c528 100644 --- a/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Version.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/rafaelfelipeac/replyradar/core/version/Version.desktop.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.util +package com.rafaelfelipeac.replyradar.core.version actual fun getAppVersion(): String { TODO("Not yet implemented for this platform.") diff --git a/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/MainViewController.kt index 306db96..206a28f 100644 --- a/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/MainViewController.kt @@ -2,6 +2,7 @@ package com.rafaelfelipeac.replyradar import androidx.compose.ui.window.ComposeUIViewController import com.rafaelfelipeac.replyradar.app.ReplyRadarApp +import com.rafaelfelipeac.replyradar.core.notification.NotificationPermissionManager import com.rafaelfelipeac.replyradar.di.initKoin @Suppress("FunctionNaming") @@ -9,4 +10,17 @@ fun MainViewController() = ComposeUIViewController( configure = { initKoin() } -) { ReplyRadarApp() } +) { + ReplyRadarApp( + notificationPermissionManager = object : NotificationPermissionManager { + override suspend fun ensureNotificationPermission(): Boolean { + TODO("Not yet implemented for this platform.") + } + + override suspend fun goToAppSettings() { + TODO("Not yet implemented for this platform.") + } + }, + pendingReplyId = -1 + ) +} diff --git a/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/database/DatabaseFactory.kt b/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/database/DatabaseFactory.kt index 55a20b0..7b0e98a 100644 --- a/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/database/DatabaseFactory.kt +++ b/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/database/DatabaseFactory.kt @@ -2,7 +2,7 @@ package com.rafaelfelipeac.replyradar.core.database import androidx.room.Room import androidx.room.RoomDatabase -import com.rafaelfelipeac.replyradar.core.AppConstants.DB_NAME +import com.rafaelfelipeac.replyradar.core.util.AppConstants.DB_NAME import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSDocumentDirectory import platform.Foundation.NSFileManager diff --git a/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformDatePicker.ios.kt b/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformDatePicker.ios.kt new file mode 100644 index 0000000..61b732c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformDatePicker.ios.kt @@ -0,0 +1,18 @@ +package com.rafaelfelipeac.replyradar.core.datetime + +import androidx.compose.runtime.Composable +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime + +@Composable +actual fun PlatformDatePicker( + selectedDate: LocalDate?, + selectedTime: LocalTime?, + onDateSelected: (LocalDate) -> Unit, + confirmButtonText: String, + dismissButtonText: String, + onTimeInvalidated: () -> Unit, + onDismiss: () -> Unit +) { + TODO("Not yet implemented for this platform.") +} diff --git a/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformTimePicker.ios.kt b/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformTimePicker.ios.kt new file mode 100644 index 0000000..bffa00f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/datetime/PlatformTimePicker.ios.kt @@ -0,0 +1,18 @@ +package com.rafaelfelipeac.replyradar.core.datetime + +import androidx.compose.runtime.Composable +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime + +@Composable +actual fun PlatformTimePicker( + selectedTime: LocalTime?, + selectedDate: LocalDate?, + onTimeSelected: (LocalTime) -> Unit, + onDismiss: () -> Unit, + confirmButtonText: String, + dismissButtonText: String, + pickerTimeTitle: String +) { + TODO("Not yet implemented for this platform.") +} diff --git a/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/util/External.ios.kt b/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/external/External.ios.kt similarity index 81% rename from composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/util/External.ios.kt rename to composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/external/External.ios.kt index 73cd7a4..267aabd 100644 --- a/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/util/External.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/external/External.ios.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.util +package com.rafaelfelipeac.replyradar.core.external actual fun openEmailApp(to: String, subject: String, body: String) { TODO("Not yet implemented for this platform.") diff --git a/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Clock.ios.kt b/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Clock.ios.kt deleted file mode 100644 index 37003e1..0000000 --- a/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Clock.ios.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.rafaelfelipeac.replyradar.core.util - -import platform.Foundation.NSDate -import platform.Foundation.timeIntervalSince1970 - -actual fun getClock(): Clock = object : Clock { - override fun now(): Long { - return (NSDate().timeIntervalSince1970 * CLOCK_MULTIPLIER).toLong() - } -} - -private const val CLOCK_MULTIPLIER = 1000 diff --git a/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Version.native.kt b/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/version/Version.native.kt similarity index 80% rename from composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Version.native.kt rename to composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/version/Version.native.kt index ce41887..9e76b62 100644 --- a/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/util/Version.native.kt +++ b/composeApp/src/iosMain/kotlin/com/rafaelfelipeac/replyradar/core/version/Version.native.kt @@ -1,4 +1,4 @@ -package com.rafaelfelipeac.replyradar.core.util +package com.rafaelfelipeac.replyradar.core.version import platform.Foundation.NSBundle diff --git a/composeApp/src/main/res/values/strings.xml b/composeApp/src/main/res/values/strings.xml index 549dea4..423792b 100644 --- a/composeApp/src/main/res/values/strings.xml +++ b/composeApp/src/main/res/values/strings.xml @@ -1,4 +1,14 @@ No email app found. + + reminder_channel + Reminder Notifications + Notifications for scheduled reminders + Reminder: %1$s + + notification-reminder-reply-id + notification-reminder-%1$d + notification-reminder-title + notification-reminder-content \ No newline at end of file diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 2fc63dc..2691937 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -12,6 +12,9 @@ style: UnusedPrivateMember: ignoreAnnotated: - 'Preview' + ReturnCount: + active: true + max: 5 naming: FunctionNaming: @@ -31,7 +34,7 @@ naming: complexity: LongMethod: active: true - threshold: 120 + threshold: 150 LargeClass: active: true threshold: 600 @@ -40,12 +43,12 @@ complexity: threshold: 20 LongParameterList: active: true - constructorThreshold: 10 - functionThreshold: 10 + constructorThreshold: 15 + functionThreshold: 15 TooManyFunctions: active: true - thresholdInClasses: 15 - thresholdInFiles: 15 + thresholdInClasses: 30 + thresholdInFiles: 30 comments: UndocumentedPublicClass: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec6a85c..fdfe0d4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ datastore = "1.1.1" assertk = "0.28.1" turbine = "1.1.0" ksp = "2.1.20-1.0.31" +workRuntimeKtx = "2.10.1" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -32,6 +33,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-ui-android = { group = "androidx.compose.ui", name = "ui-android", version.ref = "uiAndroid" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" } accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" } jetbrains-compose-navigation = { group = "org.jetbrains.androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }