From 50bf6e3852b3785ecbd2fd2f1ca40f1a4a22ad5b Mon Sep 17 00:00:00 2001 From: Alex Gallardo Date: Wed, 21 May 2025 10:28:47 +0200 Subject: [PATCH 1/4] iOS: Implement background task for data synchronization --- .../shared/data/Scheduler.android.kt | 4 + .../ui/screen/SetSystemBarsColor.android.kt | 12 ++ .../shared/domain/service/NavigationHelper.kt | 6 + .../shared/domain/service/Scheduler.kt | 1 + .../shared/ui/screen/SetSystemBarsColor.kt | 11 + .../shared/domain/service/Scheduler.ios.kt | 197 +++++++++++------- iosApp/iosApp/Info.plist | 2 +- iosApp/iosApp/iOSApp.swift | 1 + 8 files changed, 159 insertions(+), 75 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/SetSystemBarsColor.android.kt create mode 100644 composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/SetSystemBarsColor.kt diff --git a/composeApp/src/androidMain/kotlin/io/middlepoint/morestuff/shared/data/Scheduler.android.kt b/composeApp/src/androidMain/kotlin/io/middlepoint/morestuff/shared/data/Scheduler.android.kt index 58a4161b9..58189d189 100644 --- a/composeApp/src/androidMain/kotlin/io/middlepoint/morestuff/shared/data/Scheduler.android.kt +++ b/composeApp/src/androidMain/kotlin/io/middlepoint/morestuff/shared/data/Scheduler.android.kt @@ -100,6 +100,10 @@ class SchedulerImpl( workManager.cancelAllWorkByTag(DATA_SYNC_WORK) } + override fun dataSyncWorker() { + TODO("Not yet implemented") + } + private fun getScheduleWorkTag(scheduleId: Uuid) = "SCHEDULE_${scheduleId.value}" companion object { diff --git a/composeApp/src/androidMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/SetSystemBarsColor.android.kt b/composeApp/src/androidMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/SetSystemBarsColor.android.kt new file mode 100644 index 000000000..31aac08cf --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/SetSystemBarsColor.android.kt @@ -0,0 +1,12 @@ +package io.middlepoint.morestuff.shared.ui.screen + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +@Composable +actual fun SetSystemBarsColor( + statusBarColor: Color, + navigationBarColor: Color, + darkIcons: Boolean? +) { +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/service/NavigationHelper.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/service/NavigationHelper.kt index 5518b0538..6dcb13646 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/service/NavigationHelper.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/service/NavigationHelper.kt @@ -21,6 +21,8 @@ class NavigationHelper: ViewModel(), KoinComponent { val navigation = MutableSharedFlow() val code = MutableSharedFlow() private val cancelActiveScheduleUseCase: CancelActiveScheduleUseCase by inject() + private val scheduler: Scheduler by inject() + fun shareText(message: String) { @@ -80,6 +82,10 @@ class NavigationHelper: ViewModel(), KoinComponent { } } + fun triggerDataSyncSchedule() { + logger.d { "Calling scheduleDataSyncWorker from NavigationHelper" } + scheduler.dataSyncWorker() + } } diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.kt index 8688a6045..25373eeb3 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.kt @@ -7,4 +7,5 @@ interface Scheduler { fun scheduleDataSyncWorker() fun cancelSchedule(scheduleId: Uuid) fun cancelPlannedPriorityUpdate() + fun dataSyncWorker() } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/SetSystemBarsColor.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/SetSystemBarsColor.kt new file mode 100644 index 000000000..cdfcbbd87 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/SetSystemBarsColor.kt @@ -0,0 +1,11 @@ +package io.middlepoint.morestuff.shared.ui.screen + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +@Composable +expect fun SetSystemBarsColor( + statusBarColor: Color, + navigationBarColor: Color, + darkIcons: Boolean? +) \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.ios.kt b/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.ios.kt index 6908e95fd..f2108074b 100644 --- a/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.ios.kt +++ b/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.ios.kt @@ -3,94 +3,143 @@ package io.middlepoint.morestuff.shared.domain.service import co.touchlab.kermit.Logger import io.middlepoint.morestuff.shared.domain.model.Uuid -import kotlinx.datetime.LocalDateTime +import io.middlepoint.morestuff.shared.domain.redux.AppStore +import io.middlepoint.morestuff.shared.domain.redux.action.SyncAction +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import platform.BackgroundTasks.BGAppRefreshTaskRequest +import platform.BackgroundTasks.BGTaskScheduler +import platform.Foundation.NSDate import platform.Foundation.NSDateComponents +import platform.Foundation.dateWithTimeIntervalSinceNow import platform.UserNotifications.UNCalendarNotificationTrigger import platform.UserNotifications.UNMutableNotificationContent import platform.UserNotifications.UNNotificationRequest import platform.UserNotifications.UNUserNotificationCenter -import kotlinx.datetime.Instant -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime class SchedulerImpl( private val timeManager: TimeManager, -) : Scheduler { - private val logger = Logger.withTag("SchedulerImpl") - private val notificationCenter = UNUserNotificationCenter.currentNotificationCenter() +) : Scheduler, KoinComponent { + private val logger = Logger.withTag("SchedulerImpl") + private val notificationCenter = UNUserNotificationCenter.currentNotificationCenter() + private val store: AppStore by inject() + init { + notificationCenter.delegate() + } - init { - notificationCenter.delegate() + override fun scheduleAtExact( + scheduleId: Uuid, + scheduleTime: String, + taskTitle: String, + taskId: Uuid + ) { + logger.i { "Scheduling notification with scheduleId=$scheduleId at $scheduleTime" } + + val localDateTime = Instant.parse(scheduleTime).toLocalDateTime(TimeZone.currentSystemDefault()) + + val triggerDate = NSDateComponents().apply { + setYear(localDateTime.year.toLong()) + setMonth(localDateTime.monthNumber.toLong()) + setDay(localDateTime.dayOfMonth.toLong()) + setHour(localDateTime.hour.toLong()) + setMinute(localDateTime.minute.toLong()) + setSecond(localDateTime.second.toLong()) } - override fun scheduleAtExact(scheduleId: Uuid, scheduleTime: String, taskTitle: String, taskId: Uuid) { - logger.i { "Scheduling notification with scheduleId=$scheduleId at $scheduleTime" } + val content = UNMutableNotificationContent().apply { + setTitle("Reminder") + setBody(taskTitle) + setUserInfo(mapOf("scheduleId" to scheduleId.value, "taskId" to taskId.value)) + } - val localDateTime = Instant.parse(scheduleTime).toLocalDateTime(TimeZone.currentSystemDefault()) + val trigger = UNCalendarNotificationTrigger.triggerWithDateMatchingComponents( + dateComponents = triggerDate, + repeats = false + ) - val triggerDate = NSDateComponents().apply { - setYear(localDateTime.year.toLong()) - setMonth(localDateTime.monthNumber.toLong()) - setDay(localDateTime.dayOfMonth.toLong()) - setHour(localDateTime.hour.toLong()) - setMinute(localDateTime.minute.toLong()) - setSecond(localDateTime.second.toLong()) - } + val request = UNNotificationRequest.requestWithIdentifier( + identifier = getScheduleWorkTag(scheduleId), + content = content, + trigger = trigger + ) - val content = UNMutableNotificationContent().apply { - setTitle("Reminder") - setBody(taskTitle) - setUserInfo(mapOf("scheduleId" to scheduleId.value, "taskId" to taskId.value)) - } + notificationCenter.addNotificationRequest(request) { error -> + if (error == null) { - val trigger = UNCalendarNotificationTrigger.triggerWithDateMatchingComponents( - dateComponents = triggerDate, - repeats = false - ) + logger.i { "Successfully scheduled notification for scheduleId=$scheduleId" } + } else { + logger.e { "Error scheduling notification: ${'$'}{error.localizedDescription}" } + } + } - val request = UNNotificationRequest.requestWithIdentifier( - identifier = getScheduleWorkTag(scheduleId), - content = content, - trigger = trigger - ) + } - notificationCenter.addNotificationRequest(request) { error -> - if (error == null) { - logger.i { "Successfully scheduled notification for scheduleId=$scheduleId" } - } else { - logger.e { "Error scheduling notification: ${'$'}{error.localizedDescription}" } - } + override fun scheduleDataSyncWorker() { +/* BGTaskScheduler.sharedScheduler.registerForTaskWithIdentifier( + identifier = "io.middlepoint.morestuff", + usingQueue = null, + launchHandler = { task -> + logger.d(" Tarea de sincronización ejecutándose en segundo plano") + + registerBackgroundTaskHandler() + MainScope().launch { + try { + store.dispatchSuspend(SyncAction.SyncIntervalAction) + task?.setTaskCompletedWithSuccess(true) + } catch (e: Exception) { + logger.d("Error al sincronizar datos: ${e.message}") + task?.setTaskCompletedWithSuccess(false) + } } + } + )*/ + } + override fun dataSyncWorker() { + BGTaskScheduler.sharedScheduler.registerForTaskWithIdentifier( + identifier = "io.middlepoint.morestuff", + usingQueue = null, + launchHandler = { task -> + logger.d(" Tarea de sincronización ejecutándose en segundo plano") + + registerBackgroundTaskHandler() + MainScope().launch { + try { + store.dispatchSuspend(SyncAction.SyncIntervalAction) + task?.setTaskCompletedWithSuccess(true) + } catch (e: Exception) { + logger.d("Error al sincronizar datos: ${e.message}") + task?.setTaskCompletedWithSuccess(false) + } + } + } + ) + } + @OptIn(ExperimentalForeignApi::class) + fun registerBackgroundTaskHandler() { + val request = BGAppRefreshTaskRequest("io.middlepoint.morestuff").apply { + earliestBeginDate = NSDate.dateWithTimeIntervalSinceNow(15 * 60.0) } - - override fun scheduleDataSyncWorker() { - /* val content = UNMutableNotificationContent() - content.setTitle("Planned Priority Update") - content.setBody("Executing planned priority update.") - - - val trigger = UNTimeIntervalNotificationTrigger.triggerWithTimeInterval( - timeInterval = 1.days.inWholeSeconds.toDouble(), - repeats = true - ) - val request = UNNotificationRequest.requestWithIdentifier( - identifier = PLANNED_PRIORITY_WORK, - content = content, - trigger = trigger - ) - - UNUserNotificationCenter.currentNotificationCenter() - .addNotificationRequest(request) { error -> - error?.let { - println("Error scheduling planned priority worker: ${it.localizedDescription}") - } - }*/ + try { + val success = BGTaskScheduler.sharedScheduler.submitTaskRequest(request, null) + if (success) { + logger.d("Tarea de sincronización periódica registrada correctamente") + } else { + logger.d("Error al registrar la tarea de sincronización periódica") + } + } catch (e: Exception) { + logger.d("Error al registrar la tarea: ${e.message}") } - + } override fun cancelSchedule(scheduleId: Uuid) { @@ -144,18 +193,18 @@ class SchedulerImpl( } }*/ - override fun cancelPlannedPriorityUpdate() { - UNUserNotificationCenter.currentNotificationCenter() - .removePendingNotificationRequestsWithIdentifiers( - listOf(PLANNED_PRIORITY_WORK) - ) - } + override fun cancelPlannedPriorityUpdate() { + UNUserNotificationCenter.currentNotificationCenter() + .removePendingNotificationRequestsWithIdentifiers( + listOf(PLANNED_PRIORITY_WORK) + ) + } private fun getScheduleWorkTag(scheduleId: Uuid) = "SCHEDULE_$scheduleId" - companion object { - private const val PLANNED_PRIORITY_WORK = "SmartReminder" - private const val PRIORITY_REVIEW_WORK = "PriorityReview" - } + companion object { + private const val PLANNED_PRIORITY_WORK = "SmartReminder" + private const val PRIORITY_REVIEW_WORK = "PriorityReview" + } } diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 4703c25bd..26e9f05f5 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -76,7 +76,7 @@ BGTaskSchedulerPermittedIdentifiers - io.middlepoint.morestuff.backgroundTask + io.middlepoint.morestuff UIBackgroundModes diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index c28a9b5b1..60cb1c01b 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -110,6 +110,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele print("Permisos de notificación denegados.") } } + navigationHelper.triggerDataSyncSchedule() return true } From ae1ced0455e59e1f2fc0ee2ea2eb242227d362bb Mon Sep 17 00:00:00 2001 From: Alex Gallardo Date: Thu, 22 May 2025 12:35:12 +0200 Subject: [PATCH 2/4] Enable iOS notification actions and user replies --- .../data/middleware/NotificationMiddleware.kt | 8 +- .../data/middleware/ScheduleMiddleware.kt | 25 ++++- .../morestuff/shared/di/DomainModule.kt | 4 + .../shared/domain/service/NavigationHelper.kt | 16 +++- .../schedule/ScheduleUserReplyUseCase.kt | 65 +++++++++++++ .../shared/domain/service/Scheduler.ios.kt | 39 +++++++- iosApp/iosApp/iOSApp.swift | 92 ++++++++++++++++--- 7 files changed, 227 insertions(+), 22 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/schedule/ScheduleUserReplyUseCase.kt diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/NotificationMiddleware.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/NotificationMiddleware.kt index b413c3c55..d75e37129 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/NotificationMiddleware.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/NotificationMiddleware.kt @@ -1,18 +1,19 @@ package io.middlepoint.morestuff.shared.data.middleware -import io.middlepoint.morestuff.shared.domain.redux.state.AppState import io.middlepoint.morestuff.shared.domain.redux.Middleware -import io.middlepoint.morestuff.shared.domain.redux.action.NotificationAction -import io.middlepoint.morestuff.shared.domain.redux.action.NotificationAction.* import io.middlepoint.morestuff.shared.domain.redux.action.NotificationAction.RemoveScheduleNotificationAction import io.middlepoint.morestuff.shared.domain.redux.action.NotificationAction.ShowReminderNotificationAction +import io.middlepoint.morestuff.shared.domain.redux.action.NotificationAction.ShowReviewNotification +import io.middlepoint.morestuff.shared.domain.redux.action.NotificationAction.UserResponseAction import io.middlepoint.morestuff.shared.domain.redux.action.ScheduleAction import io.middlepoint.morestuff.shared.domain.redux.action.TaskAction +import io.middlepoint.morestuff.shared.domain.redux.state.AppState import io.middlepoint.morestuff.shared.domain.redux.store.Action import io.middlepoint.morestuff.shared.domain.redux.store.Dispatch import io.middlepoint.morestuff.shared.domain.redux.store.Next import io.middlepoint.morestuff.shared.domain.redux.store.NoOp import io.middlepoint.morestuff.shared.domain.service.Notifier +import io.middlepoint.morestuff.shared.domain.service.logger import io.middlepoint.morestuff.shared.domain.usecase.task.ClearTaskNotificationsUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -44,6 +45,7 @@ class NotificationMiddleware( } is UserResponseAction -> with(action) { + logger.i { "Received user response for scheduleId=$scheduleId, replyType=$replyType" } notifier.clearScheduleNotification(scheduleId) dispatch(ScheduleAction.ScheduleReplyAction(scheduleId, replyType)) } diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/ScheduleMiddleware.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/ScheduleMiddleware.kt index 62c773e6f..3524ecf3b 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/ScheduleMiddleware.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/ScheduleMiddleware.kt @@ -22,11 +22,13 @@ import io.middlepoint.morestuff.shared.domain.redux.store.Dispatch import io.middlepoint.morestuff.shared.domain.redux.store.Next import io.middlepoint.morestuff.shared.domain.redux.store.NoOp import io.middlepoint.morestuff.shared.domain.service.TimeManager +import io.middlepoint.morestuff.shared.domain.service.logger import io.middlepoint.morestuff.shared.domain.usecase.schedule.CancelActiveScheduleUseCase import io.middlepoint.morestuff.shared.domain.usecase.schedule.CreateOneTimeScheduleUseCase import io.middlepoint.morestuff.shared.domain.usecase.schedule.CreateScheduleUseCase import io.middlepoint.morestuff.shared.domain.usecase.schedule.GetScheduleUseCase import io.middlepoint.morestuff.shared.domain.usecase.schedule.ScheduleAtTimeUseCase +import io.middlepoint.morestuff.shared.domain.usecase.schedule.ScheduleUserReplyUseCase import io.middlepoint.morestuff.shared.domain.usecase.schedule.ScheduleWorkUseCase import io.middlepoint.morestuff.shared.domain.usecase.schedule.SetScheduleFulfilledUseCase import io.middlepoint.morestuff.shared.domain.usecase.task.GetTaskUseCase @@ -41,7 +43,8 @@ class ScheduleMiddleware( private val createOneTimeScheduleUseCase: CreateOneTimeScheduleUseCase, private val cancelActiveScheduleUseCase: CancelActiveScheduleUseCase, private val setScheduleFulfilledUseCase: SetScheduleFulfilledUseCase, - private val getTaskUseCase: GetTaskUseCase + private val getTaskUseCase: GetTaskUseCase, + private val scheduleUserReplyUseCase: ScheduleUserReplyUseCase ) : Middleware { val timeManager: TimeManager = TimeManagerImpl() @@ -88,9 +91,27 @@ class ScheduleMiddleware( } is ScheduleAction.ScheduleReplyAction -> { - scheduleUserReply(scope, action.scheduleId, action.replyType, dispatch) + logger.i { "⏳ ScheduleReplyAction: scheduleId=${action.scheduleId}, replyType=${action.replyType}" } + + scope.launch { + val result = scheduleUserReplyUseCase(action.scheduleId, action.replyType) + + result.onRight { actionResult -> + if (actionResult != null) { + logger.i { "🚀 Ejecutando acción resultante del use case: $actionResult" } + dispatch(actionResult) + } else { + logger.i { "⚠️ No se generó ninguna acción (null)" } + } + } + + result.onLeft { failure -> + logger.e { "❌ Error al ejecutar ScheduleUserReplyUseCase: $failure" } + } + } } + is ScheduleCreatedAction -> { scope.launch { action.schedule.scheduledAt.let { time -> diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/di/DomainModule.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/di/DomainModule.kt index a6833e6b3..f49549798 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/di/DomainModule.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/di/DomainModule.kt @@ -91,6 +91,9 @@ import io.middlepoint.morestuff.shared.domain.usecase.schedule.GetScheduleImpl import io.middlepoint.morestuff.shared.domain.usecase.schedule.GetScheduleUseCase import io.middlepoint.morestuff.shared.domain.usecase.schedule.ScheduleAtTimeUseCase import io.middlepoint.morestuff.shared.domain.usecase.schedule.ScheduleAtTimeUseCaseImpl +import io.middlepoint.morestuff.shared.domain.usecase.schedule.ScheduleUserReplyUseCase +import io.middlepoint.morestuff.shared.domain.usecase.schedule.ScheduleUserReplyUseCaseImpl + import io.middlepoint.morestuff.shared.domain.usecase.schedule.ScheduleWorkUseCase import io.middlepoint.morestuff.shared.domain.usecase.schedule.ScheduleWorkUseCaseImpl import io.middlepoint.morestuff.shared.domain.usecase.schedule.SetScheduleFulfilledUseCase @@ -278,6 +281,7 @@ val scheduleUseCases = module { factoryOf(::SetScheduleFulfilledUseCaseImpl) bind SetScheduleFulfilledUseCase::class factoryOf(::ScheduleAtTimeUseCaseImpl) bind ScheduleAtTimeUseCase::class factoryOf(::ScheduleWorkUseCaseImpl) bind ScheduleWorkUseCase::class + factoryOf(::ScheduleUserReplyUseCaseImpl) bind ScheduleUserReplyUseCase::class } val messageUseCases = module { diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/service/NavigationHelper.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/service/NavigationHelper.kt index 6dcb13646..89799eff1 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/service/NavigationHelper.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/service/NavigationHelper.kt @@ -3,12 +3,15 @@ package io.middlepoint.morestuff.shared.domain.service import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import io.middlepoint.morestuff.shared.domain.enums.ReplyType import io.middlepoint.morestuff.shared.domain.model.Shareable import io.middlepoint.morestuff.shared.domain.model.Uuid import io.middlepoint.morestuff.shared.domain.nav.Screen +import io.middlepoint.morestuff.shared.domain.redux.AppStore +import io.middlepoint.morestuff.shared.domain.redux.action.ScheduleAction import io.middlepoint.morestuff.shared.domain.usecase.schedule.CancelActiveScheduleUseCase -import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -22,6 +25,7 @@ class NavigationHelper: ViewModel(), KoinComponent { val code = MutableSharedFlow() private val cancelActiveScheduleUseCase: CancelActiveScheduleUseCase by inject() private val scheduler: Scheduler by inject() + private val store: AppStore by inject() @@ -88,5 +92,15 @@ class NavigationHelper: ViewModel(), KoinComponent { } + fun replyToSchedule(scheduleId: Uuid, replyTypeString: String) { + viewModelScope.launch { + val replyType = ReplyType.valueOf(replyTypeString.uppercase()) + logger.d { "Dispatching ScheduleReplyAction with id: $scheduleId and type: $replyType" } + store.dispatch(ScheduleAction.ScheduleReplyAction(scheduleId, replyType)) + } + } + + + } diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/schedule/ScheduleUserReplyUseCase.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/schedule/ScheduleUserReplyUseCase.kt new file mode 100644 index 000000000..5e80ce499 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/schedule/ScheduleUserReplyUseCase.kt @@ -0,0 +1,65 @@ +package io.middlepoint.morestuff.shared.domain.usecase.schedule + +import arrow.core.Either +import io.middlepoint.morestuff.shared.domain.enums.ReplyType +import io.middlepoint.morestuff.shared.domain.model.Failure +import io.middlepoint.morestuff.shared.domain.model.Uuid +import io.middlepoint.morestuff.shared.domain.redux.action.ScheduleAction +import io.middlepoint.morestuff.shared.domain.redux.action.TaskAction +import io.middlepoint.morestuff.shared.domain.redux.store.Action +import io.middlepoint.morestuff.shared.domain.service.TimeManager +import io.middlepoint.morestuff.shared.domain.service.logger + +interface ScheduleUserReplyUseCase { + suspend operator fun invoke(scheduleId: Uuid, replyType: ReplyType): Either +} + +class ScheduleUserReplyUseCaseImpl( + private val getScheduleUseCase: GetScheduleUseCase, + private val timeManager: TimeManager, + +) : ScheduleUserReplyUseCase { + + override suspend fun invoke(scheduleId: Uuid, replyType: ReplyType): Either { + logger.i { "🟡 Invocando ScheduleUserReplyUseCase con scheduleId=$scheduleId y replyType=$replyType" } + + return getScheduleUseCase(scheduleId).map { schedule -> + logger.i { "🟢 Schedule obtenido: taskId=${schedule.taskId}, type=${schedule.scheduleType}" } + + when (replyType) { + ReplyType.LATER -> { + logger.i { "⏳ ReplyType.LATER: no se genera ninguna acción" } + null + } + + ReplyType.TOMORROW -> { + val tomorrowTime = timeManager.tomorrowLocalDateTime(12) + logger.i { "📅 ReplyType.TOMORROW: reprogramando para mañana a las 12:00 => $tomorrowTime" } + ScheduleAction.RescheduleTaskAction( + schedule.taskId, + schedule.scheduleType, + tomorrowTime + ) + } + + ReplyType.SNOOZE -> { + val snoozeTime = timeManager.todayLocalDateTimeByAdding(hour = 1, minute = 0) + logger.i { "🔁 ReplyType.SNOOZE: reprogramando para dentro de 1h => $snoozeTime" } + ScheduleAction.RescheduleTaskAction( + schedule.taskId, + schedule.scheduleType, + snoozeTime + ) + } + + ReplyType.DONE -> { + logger.i { "✅ ReplyType.DONE: marcando como completada taskId=${schedule.taskId}" } + TaskAction.CompleteTasksAction(listOf(schedule.taskId), true) + } + } + }.also { + logger.i { "✅ Acción generada: $it" } + } + } +} + diff --git a/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.ios.kt b/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.ios.kt index f2108074b..126504056 100644 --- a/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.ios.kt +++ b/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.ios.kt @@ -20,6 +20,8 @@ import platform.Foundation.NSDateComponents import platform.Foundation.dateWithTimeIntervalSinceNow import platform.UserNotifications.UNCalendarNotificationTrigger import platform.UserNotifications.UNMutableNotificationContent +import platform.UserNotifications.UNNotificationAction +import platform.UserNotifications.UNNotificationCategory import platform.UserNotifications.UNNotificationRequest import platform.UserNotifications.UNUserNotificationCenter @@ -32,6 +34,8 @@ class SchedulerImpl( init { notificationCenter.delegate() + registerNotificationCategories() + } override fun scheduleAtExact( @@ -57,6 +61,8 @@ class SchedulerImpl( setTitle("Reminder") setBody(taskTitle) setUserInfo(mapOf("scheduleId" to scheduleId.value, "taskId" to taskId.value)) + setCategoryIdentifier("TASK_REPLY_CATEGORY") + } val trigger = UNCalendarNotificationTrigger.triggerWithDateMatchingComponents( @@ -69,7 +75,6 @@ class SchedulerImpl( content = content, trigger = trigger ) - notificationCenter.addNotificationRequest(request) { error -> if (error == null) { @@ -82,6 +87,38 @@ class SchedulerImpl( } + private fun registerNotificationCategories() { + val snoozeAction = UNNotificationAction.actionWithIdentifier( + identifier = "SNOOZE_ACTION", + title = "Snooze", + options = 0u + ) + + val tomorrowAction = UNNotificationAction.actionWithIdentifier( + identifier = "TOMORROW_ACTION", + title = "Tomorrow", + options = 0u + ) + + val doneAction = UNNotificationAction.actionWithIdentifier( + identifier = "DONE_ACTION", + title = "Done", + options = 0u + ) + + val reminderCategory = UNNotificationCategory.categoryWithIdentifier( + identifier = "TASK_REPLY_CATEGORY", + actions = listOf(snoozeAction, tomorrowAction, doneAction), + intentIdentifiers = emptyList(), + options = 0u + ) + + UNUserNotificationCenter.currentNotificationCenter() + .setNotificationCategories(setOf(reminderCategory)) + } + + + override fun scheduleDataSyncWorker() { /* BGTaskScheduler.sharedScheduler.registerForTaskWithIdentifier( identifier = "io.middlepoint.morestuff", diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 60cb1c01b..25244cfe7 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -98,38 +98,90 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele let holder: DefaultRouterHolder = DefaultRouterHolder() let navigationHelper: NavigationHelper = NavigationHelper() - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + let center = UNUserNotificationCenter.current() - center.delegate = self - center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in - if let error = error { - print("Error al solicitar permisos de notificación: \(error.localizedDescription)") - } else if granted { - print("Permisos de notificación concedidos.") - } else { - print("Permisos de notificación denegados.") - } - } + center.delegate = self + + + let snoozeAction = UNNotificationAction( + identifier: "SNOOZE_ACTION", + title: "Snooze", + options: [] + ) + + let tomorrowAction = UNNotificationAction( + identifier: "TOMORROW_ACTION", + title: "Tomorrow", + options: [] + ) + + let doneAction = UNNotificationAction( + identifier: "DONE_ACTION", + title: "Done", + options: [.authenticationRequired] + ) + + // 📦 CATEGORÍA + let taskCategory = UNNotificationCategory( + identifier: "TASK_REPLY_CATEGORY", + actions: [snoozeAction, tomorrowAction, doneAction], + intentIdentifiers: [], + options: [] + ) + + // ✅ REGISTRAR LA CATEGORÍA + center.setNotificationCategories([taskCategory]) + + // 🔐 PEDIR PERMISOS + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + if let error = error { + print("Error al solicitar permisos de notificación: \(error.localizedDescription)") + } else if granted { + print("Permisos de notificación concedidos.") + } else { + print("Permisos de notificación denegados.") + } + } + navigationHelper.triggerDataSyncSchedule() return true } + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let userInfo = response.notification.request.content.userInfo - if let taskIdString = userInfo["taskId"] as? String { - navigateToTaskChat(taskId: taskIdString) - } - if let scheduleId = userInfo["scheduleId"] as? String { + switch response.actionIdentifier { + case "SNOOZE_ACTION": + print("🔁 Snooze pressed for schedule \(scheduleId)") + self.replyToSchedule(scheduleId: scheduleId, replyType: "SNOOZE") + + case "TOMORROW_ACTION": + print("📅 Tomorrow pressed for schedule \(scheduleId)") + self.replyToSchedule(scheduleId: scheduleId, replyType: "TOMORROW") + + case "DONE_ACTION": + print("✅ Done pressed for schedule \(scheduleId)") + self.replyToSchedule(scheduleId: scheduleId, replyType: "DONE") + + default: + if let taskIdString = userInfo["taskId"] as? String { + navigateToTaskChat(taskId: taskIdString) + } + } + center.removePendingNotificationRequests(withIdentifiers: ["SCHEDULE_\(scheduleId)"]) } completionHandler() } + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, @@ -165,6 +217,16 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele self.navigationHelper.navigateToHome(accessToken: accessToken) } } + + private func replyToSchedule(scheduleId: String, replyType: String) { + DispatchQueue.main.async { + self.navigationHelper.replyToSchedule( + scheduleId: scheduleId, + replyTypeString: replyType + ) + } + } + From 2b7058bb4a64f56021b4aed99be566f5fd75a6e9 Mon Sep 17 00:00:00 2001 From: Alex Gallardo Date: Thu, 22 May 2025 12:47:31 +0200 Subject: [PATCH 3/4] Refactor iOS notifications - Add custom dismiss action to notification category - Handle swipe-to-dismiss action for schedules - Remove unnecessary comments --- iosApp/iosApp/iOSApp.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 25244cfe7..c17ce426b 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -123,18 +123,18 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele options: [.authenticationRequired] ) - // 📦 CATEGORÍA + let taskCategory = UNNotificationCategory( identifier: "TASK_REPLY_CATEGORY", actions: [snoozeAction, tomorrowAction, doneAction], intentIdentifiers: [], - options: [] + options: [.customDismissAction] ) - // ✅ REGISTRAR LA CATEGORÍA + center.setNotificationCategories([taskCategory]) - // 🔐 PEDIR PERMISOS + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in if let error = error { print("Error al solicitar permisos de notificación: \(error.localizedDescription)") @@ -169,6 +169,13 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele case "DONE_ACTION": print("✅ Done pressed for schedule \(scheduleId)") self.replyToSchedule(scheduleId: scheduleId, replyType: "DONE") + + case UNNotificationDismissActionIdentifier: + print("👋 Swipe to dismiss para schedule \(scheduleId)") + if let taskIdString = userInfo["taskId"] as? String { + cancelSchedule(taskId: taskIdString) + } + default: if let taskIdString = userInfo["taskId"] as? String { From a09cb9d4803698ae09946db9a3e6b2b4d36d4e4e Mon Sep 17 00:00:00 2001 From: Alex Gallardo Date: Mon, 26 May 2025 11:59:29 +0200 Subject: [PATCH 4/4] Refactor notification configuration into NotificationConfigurator.swift --- iosApp/iosApp.xcodeproj/project.pbxproj | 4 ++ iosApp/iosApp/NotificationConfigurator.swift | 54 ++++++++++++++++++++ iosApp/iosApp/iOSApp.swift | 46 ++--------------- 3 files changed, 62 insertions(+), 42 deletions(-) create mode 100644 iosApp/iosApp/NotificationConfigurator.swift diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 8553fc69d..479ed24e7 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; }; 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; + 5D11336F2DE46E740040AB6B /* NotificationConfigurator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D11336E2DE46E710040AB6B /* NotificationConfigurator.swift */; }; 5D7E06EB2CF7633000479A35 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D7E06EA2CF7633000479A35 /* Extensions.swift */; }; 5D7E07062CF765A000479A35 /* MoreStuffShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D7E06FC2CF765A000479A35 /* MoreStuffShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; }; @@ -46,6 +47,7 @@ 1D3B18588DF0672077A53825 /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; }; 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; 38ACC1D97BD8F4823C681232 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; }; + 5D11336E2DE46E710040AB6B /* NotificationConfigurator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationConfigurator.swift; sourceTree = ""; }; 5D7E06EA2CF7633000479A35 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; 5D7E06FC2CF765A000479A35 /* MoreStuffShare.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MoreStuffShare.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 5D7E07112CF76BE300479A35 /* iosApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iosApp.entitlements; sourceTree = ""; }; @@ -127,6 +129,7 @@ 7555FF7D242A565900829871 /* iosApp */ = { isa = PBXGroup; children = ( + 5D11336E2DE46E710040AB6B /* NotificationConfigurator.swift */, 5D7E07112CF76BE300479A35 /* iosApp.entitlements */, 058557BA273AAA24004C7B11 /* Assets.xcassets */, 7555FF82242A565900829871 /* ContentView.swift */, @@ -324,6 +327,7 @@ 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, 5D7E06EB2CF7633000479A35 /* Extensions.swift in Sources */, 7555FF83242A565900829871 /* ContentView.swift in Sources */, + 5D11336F2DE46E740040AB6B /* NotificationConfigurator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iosApp/iosApp/NotificationConfigurator.swift b/iosApp/iosApp/NotificationConfigurator.swift new file mode 100644 index 000000000..2082b581a --- /dev/null +++ b/iosApp/iosApp/NotificationConfigurator.swift @@ -0,0 +1,54 @@ +// +// NotificationConfigurator.swift +// iosApp +// +// Created by Àlex Gallardo Moreno on 26/5/25. +// Copyright © 2025 orgName. All rights reserved. +// + +import UserNotifications +import UIKit + +class NotificationConfigurator{ + func configureNotifications() { + let center = UNUserNotificationCenter.current() + + + let snoozeAction = UNNotificationAction( + identifier: "SNOOZE_ACTION", + title: "Snooze", + options: [] + ) + + let tomorrowAction = UNNotificationAction( + identifier: "TOMORROW_ACTION", + title: "Tomorrow", + options: [] + ) + + let doneAction = UNNotificationAction( + identifier: "DONE_ACTION", + title: "Done", + options: [.authenticationRequired] + ) + + let taskCategory = UNNotificationCategory( + identifier: "TASK_REPLY_CATEGORY", + actions: [snoozeAction, tomorrowAction, doneAction], + intentIdentifiers: [], + options: [.customDismissAction] + ) + + center.setNotificationCategories([taskCategory]) + + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + if let error = error { + print("Error al solicitar permisos de notificación: \(error.localizedDescription)") + } else if granted { + print("Permisos de notificación concedidos.") + } else { + print("Permisos de notificación denegados.") + } + } + } +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index c17ce426b..2d277043b 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -97,54 +97,16 @@ class DefaultRouterHolder : ObservableObject { class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { let holder: DefaultRouterHolder = DefaultRouterHolder() let navigationHelper: NavigationHelper = NavigationHelper() + let notificationConfigurator = NotificationConfigurator() + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { let center = UNUserNotificationCenter.current() - center.delegate = self - - - let snoozeAction = UNNotificationAction( - identifier: "SNOOZE_ACTION", - title: "Snooze", - options: [] - ) - - let tomorrowAction = UNNotificationAction( - identifier: "TOMORROW_ACTION", - title: "Tomorrow", - options: [] - ) - - let doneAction = UNNotificationAction( - identifier: "DONE_ACTION", - title: "Done", - options: [.authenticationRequired] - ) - + center.delegate = self - let taskCategory = UNNotificationCategory( - identifier: "TASK_REPLY_CATEGORY", - actions: [snoozeAction, tomorrowAction, doneAction], - intentIdentifiers: [], - options: [.customDismissAction] - ) - - - center.setNotificationCategories([taskCategory]) - - - center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in - if let error = error { - print("Error al solicitar permisos de notificación: \(error.localizedDescription)") - } else if granted { - print("Permisos de notificación concedidos.") - } else { - print("Permisos de notificación denegados.") - } - } - + notificationConfigurator.configureNotifications() navigationHelper.triggerDataSyncSchedule() return true }