diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ba3f2d6..bbbc351 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,13 @@ on: pull_request: branches: [main] +concurrency: + group: build-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest @@ -14,12 +21,12 @@ jobs: - uses: actions/setup-java@v4 with: distribution: temurin - java-version: 17 + java-version: 21 - uses: gradle/actions/setup-gradle@v4 - name: Run tests - run: ./gradlew :composeApp:testDebugUnitTest + run: ./gradlew :composeApp:testAndroidHostTest - name: Build debug APK run: ./gradlew :androidApp:assembleDebug diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4c80b9..a1c06d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,10 @@ on: release: types: [created] +concurrency: + group: release-${{ github.event.release.tag_name }} + cancel-in-progress: false + jobs: build: runs-on: ubuntu-latest @@ -16,12 +20,14 @@ jobs: - uses: actions/setup-java@v4 with: distribution: temurin - java-version: 17 + java-version: 21 - uses: gradle/actions/setup-gradle@v4 - name: Decode keystore - run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > ${{ runner.temp }}/keystore.jks + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + run: echo "$KEYSTORE_BASE64" | base64 -d > "${{ runner.temp }}/keystore.jks" - name: Build signed release APK env: @@ -34,6 +40,7 @@ jobs: - name: Upload release APK env: GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} run: | - mv androidApp/build/outputs/apk/release/androidApp-release.apk nikki-${{ github.event.release.tag_name }}.apk - gh release upload ${{ github.event.release.tag_name }} nikki-${{ github.event.release.tag_name }}.apk + mv androidApp/build/outputs/apk/release/androidApp-release.apk "nikki-${TAG}.apk" + gh release upload "${TAG}" "nikki-${TAG}.apk" diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 3b31125..cc46e60 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -39,6 +39,11 @@ android { } } + dependenciesInfo { + includeInApk = false + includeInBundle = false + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 6616eb6..c0eeef5 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -9,6 +9,8 @@ + + + + + + + () if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED - ) { - ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001) + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + perms.add(Manifest.permission.POST_NOTIFICATIONS) } } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) != PackageManager.PERMISSION_GRANTED) { + perms.add(Manifest.permission.READ_CALENDAR) + perms.add(Manifest.permission.WRITE_CALENDAR) + } + if (perms.isNotEmpty()) { + ActivityCompat.requestPermissions(this, perms.toTypedArray(), 1001) + } } private fun requestBatteryOptimizationExemption() { diff --git a/androidApp/src/main/kotlin/com/avinal/memos/TaskReminderReceiver.kt b/androidApp/src/main/kotlin/com/avinal/memos/TaskReminderReceiver.kt index 38c9bea..d88b052 100644 --- a/androidApp/src/main/kotlin/com/avinal/memos/TaskReminderReceiver.kt +++ b/androidApp/src/main/kotlin/com/avinal/memos/TaskReminderReceiver.kt @@ -4,6 +4,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.avinal.memos.notifications.TaskNotificationManager +import com.avinal.memos.notifications.runTaskCheckNow class TaskReminderReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -19,5 +20,6 @@ class TaskReminderReceiver : BroadcastReceiver() { dueLabel = dueLabel, priority = priority, ) + runTaskCheckNow(context) } } diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index e753ae7..4d10797 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -36,11 +36,17 @@ kotlin { sourceSets { commonMain.dependencies { + @Suppress("DEPRECATION") implementation(compose.runtime) + @Suppress("DEPRECATION") implementation(compose.foundation) + @Suppress("DEPRECATION") implementation(compose.material3) + @Suppress("DEPRECATION") implementation(compose.materialIconsExtended) + @Suppress("DEPRECATION") implementation(compose.ui) + @Suppress("DEPRECATION") implementation(compose.components.resources) implementation(libs.androidx.lifecycle.viewmodel) diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/CalendarReminderManager.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/CalendarReminderManager.kt new file mode 100644 index 0000000..430f476 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/CalendarReminderManager.kt @@ -0,0 +1,173 @@ +package com.avinal.memos.notifications + +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.provider.CalendarContract +import com.avinal.memos.domain.Memo +import com.avinal.memos.domain.ReminderUnit +import com.avinal.memos.parser.TaskParser +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.todayIn +import kotlinx.datetime.toInstant + +object CalendarReminderManager { + + private const val CALENDAR_NAME = "nikki_tasks" + private const val ACCOUNT_NAME = "nikki" + private const val ACCOUNT_TYPE = CalendarContract.ACCOUNT_TYPE_LOCAL + + private fun syncAdapterUri(uri: android.net.Uri): android.net.Uri = + uri.buildUpon() + .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME) + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, ACCOUNT_TYPE) + .build() + + fun syncTaskReminders(context: Context, memos: List) { + try { + val cr = context.contentResolver + val calId = getOrCreateCalendar(context) ?: return + val defaultTime = readDefaultNotifyTime(context) + val tz = TimeZone.currentSystemDefault() + val today = kotlin.time.Clock.System.todayIn(tz) + + val allTasks = memos.flatMap { memo -> + TaskParser.extractTasks(memo.id, memo.content, memo.tags) + } + + val activeTaskIds = mutableSetOf() + + allTasks.forEach { task -> + if (task.isCompleted) return@forEach + val effectiveDate = task.dueDate ?: if (task.dueTime != null) today else return@forEach + val effectiveTime = task.dueTime ?: defaultTime + + val startMs = effectiveDate.atTime(effectiveTime).toInstant(tz).toEpochMilliseconds() + val taskSyncId = task.id + + activeTaskIds.add(taskSyncId) + + val eventValues = ContentValues().apply { + put(CalendarContract.Events.CALENDAR_ID, calId) + put(CalendarContract.Events.TITLE, task.text) + put(CalendarContract.Events.DTSTART, startMs) + put(CalendarContract.Events.DTEND, startMs + 1800_000) + put(CalendarContract.Events.EVENT_TIMEZONE, tz.id) + put(CalendarContract.Events.SYNC_DATA1, taskSyncId) + task.priority?.let { put(CalendarContract.Events.SYNC_DATA2, it.toString()) } + } + + val existingId = findEventBySyncId(context, calId, taskSyncId) + val eventId = if (existingId != null) { + cr.update( + syncAdapterUri(ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, existingId)), + eventValues, null, null, + ) + existingId + } else { + val uri = cr.insert(syncAdapterUri(CalendarContract.Events.CONTENT_URI), eventValues) ?: return@forEach + ContentUris.parseId(uri) + } + + cr.delete( + CalendarContract.Reminders.CONTENT_URI, + "${CalendarContract.Reminders.EVENT_ID} = ?", + arrayOf(eventId.toString()), + ) + + val reminderMinutes = if (task.reminder != null) { + when (task.reminder.unit) { + ReminderUnit.MIN -> task.reminder.value + ReminderUnit.HR -> task.reminder.value * 60 + ReminderUnit.DAY -> task.reminder.value * 1440 + ReminderUnit.WEEK -> task.reminder.value * 10080 + } + } else 0 + + cr.insert(CalendarContract.Reminders.CONTENT_URI, ContentValues().apply { + put(CalendarContract.Reminders.EVENT_ID, eventId) + put(CalendarContract.Reminders.MINUTES, reminderMinutes) + put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) + }) + } + + cleanupStaleEvents(context, calId, activeTaskIds) + } catch (_: SecurityException) { + } + } + + private fun getOrCreateCalendar(context: Context): Long? { + val cr = context.contentResolver + + cr.query( + CalendarContract.Calendars.CONTENT_URI, + arrayOf(CalendarContract.Calendars._ID), + "${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND ${CalendarContract.Calendars.ACCOUNT_TYPE} = ?", + arrayOf(ACCOUNT_NAME, ACCOUNT_TYPE), + null, + )?.use { cursor -> + if (cursor.moveToFirst()) return cursor.getLong(0) + } + + val values = ContentValues().apply { + put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME) + put(CalendarContract.Calendars.ACCOUNT_TYPE, ACCOUNT_TYPE) + put(CalendarContract.Calendars.NAME, CALENDAR_NAME) + put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, "Nikki Tasks") + put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFFEE67A4.toInt()) + put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER) + put(CalendarContract.Calendars.OWNER_ACCOUNT, ACCOUNT_NAME) + put(CalendarContract.Calendars.VISIBLE, 0) + put(CalendarContract.Calendars.SYNC_EVENTS, 1) + put(CalendarContract.Calendars.CALENDAR_TIME_ZONE, TimeZone.currentSystemDefault().id) + } + + val uri = cr.insert( + CalendarContract.Calendars.CONTENT_URI.buildUpon() + .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME) + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, ACCOUNT_TYPE) + .build(), + values, + ) + return uri?.let { ContentUris.parseId(it) } + } + + private fun findEventBySyncId(context: Context, calId: Long, syncId: String): Long? { + context.contentResolver.query( + CalendarContract.Events.CONTENT_URI, + arrayOf(CalendarContract.Events._ID), + "${CalendarContract.Events.CALENDAR_ID} = ? AND ${CalendarContract.Events.SYNC_DATA1} = ?", + arrayOf(calId.toString(), syncId), + null, + )?.use { cursor -> + if (cursor.moveToFirst()) return cursor.getLong(0) + } + return null + } + + private fun cleanupStaleEvents(context: Context, calId: Long, activeIds: Set) { + val cr = context.contentResolver + cr.query( + CalendarContract.Events.CONTENT_URI, + arrayOf(CalendarContract.Events._ID, CalendarContract.Events.SYNC_DATA1), + "${CalendarContract.Events.CALENDAR_ID} = ?", + arrayOf(calId.toString()), + null, + )?.use { cursor -> + val idIdx = cursor.getColumnIndex(CalendarContract.Events._ID) + val syncIdx = cursor.getColumnIndex(CalendarContract.Events.SYNC_DATA1) + while (cursor.moveToNext()) { + val syncId = cursor.getString(syncIdx) ?: continue + if (syncId !in activeIds) { + cr.delete( + syncAdapterUri(ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, cursor.getLong(idIdx))), + null, null, + ) + } + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/DirectAlarmScheduler.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/DirectAlarmScheduler.kt index 8d4991e..e29f654 100644 --- a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/DirectAlarmScheduler.kt +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/DirectAlarmScheduler.kt @@ -7,7 +7,6 @@ import android.content.Intent import com.avinal.memos.domain.Memo import com.avinal.memos.parser.TaskParser import kotlin.time.Clock -import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone object DirectAlarmScheduler { @@ -17,12 +16,9 @@ object DirectAlarmScheduler { val nowMillis = Clock.System.now().toEpochMilliseconds() val tz = TimeZone.currentSystemDefault() - val prefs = context.getSharedPreferences("memos_prefs", Context.MODE_PRIVATE) - val defaultTimeStr = prefs.getString("default_notify_time", "20:00") ?: "20:00" - val parts = defaultTimeStr.split(":") - val defaultTime = try { LocalTime(parts[0].toInt(), parts.getOrElse(1) { "0" }.toInt()) } catch (_: Exception) { LocalTime(20, 0) } + val defaultTime = readDefaultNotifyTime(context) - val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, emptySet(), defaultTime) + val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, defaultTime) val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager alarms.forEach { alarm -> diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/NotifyTimeHelper.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/NotifyTimeHelper.kt new file mode 100644 index 0000000..f9666c8 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/NotifyTimeHelper.kt @@ -0,0 +1,20 @@ +package com.avinal.memos.notifications + +import android.content.Context +import kotlinx.datetime.LocalTime + +fun readDefaultNotifyTime(context: Context): LocalTime { + val prefs = context.getSharedPreferences("nikki_notify", Context.MODE_PRIVATE) + val timeStr = prefs.getString("default_notify_time", "20:00") ?: "20:00" + val parts = timeStr.split(":") + return try { + LocalTime(parts[0].toInt(), parts.getOrElse(1) { "0" }.toInt()) + } catch (_: Exception) { + LocalTime(20, 0) + } +} + +fun writeDefaultNotifyTime(context: Context, time: String) { + context.getSharedPreferences("nikki_notify", Context.MODE_PRIVATE) + .edit().putString("default_notify_time", time).apply() +} diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt index f514574..aab9461 100644 --- a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt @@ -21,20 +21,28 @@ class TaskCheckWorker( ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { - val prefs = appContext.getSharedPreferences("task_notifications", Context.MODE_PRIVATE) - val scheduledIds = prefs.getStringSet("scheduled_ids", emptySet()) ?: emptySet() val nowMillis = Clock.System.now().toEpochMilliseconds() + val defaultTime = readDefaultNotifyTime(appContext) - // Read default notify time from DataStore file (shared prefs fallback) - val notifyPrefs = appContext.getSharedPreferences("memos_prefs", Context.MODE_PRIVATE) - val defaultTimeStr = notifyPrefs.getString("default_notify_time", "20:00") ?: "20:00" - val defaultTimeParts = defaultTimeStr.split(":") - val defaultTime = try { - kotlinx.datetime.LocalTime(defaultTimeParts[0].toInt(), defaultTimeParts.getOrElse(1) { "0" }.toInt()) - } catch (_: Exception) { - kotlinx.datetime.LocalTime(20, 0) + val memos = com.avinal.memos.util.liveMemosProvider?.invoke() + ?: readMemosFromDb() + + val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content, memo.tags) } + val alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val tz = TimeZone.currentSystemDefault() + + val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, defaultTime) + + alarms.forEach { alarm -> + scheduleAlarm(alarmManager, alarm.taskId, alarm.taskText, alarm.label, alarm.triggerAtMillis, alarm.priority) } + CalendarReminderManager.syncTaskReminders(appContext, memos) + + return Result.success() + } + + private suspend fun readMemosFromDb(): List { val db = Room.databaseBuilder( context = appContext, name = appContext.getDatabasePath("memos.db").absolutePath, @@ -44,33 +52,11 @@ class TaskCheckWorker( .setQueryCoroutineContext(Dispatchers.IO) .build() - try { - val memos = db.memoDao().getAll().map { it.toDomain() } - val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content, memo.tags) } - val alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager - val tz = TimeZone.currentSystemDefault() - - val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, scheduledIds, defaultTime) - - alarms.forEach { alarm -> - scheduleAlarm(alarmManager, alarm.taskId, alarm.taskText, alarm.label, alarm.triggerAtMillis, alarm.priority) - } - - val newScheduledIds = scheduledIds.toMutableSet() - alarms.forEach { newScheduledIds.add(it.taskId) } - - val activeTaskIds = allTasks.filter { !it.isCompleted }.map { it.id }.toSet() - val cleaned = newScheduledIds.filter { id -> - val baseId = id.removeSuffix("_am").removeSuffix("_pm").removeSuffix("_remind") - baseId in activeTaskIds - }.toSet() - - prefs.edit().putStringSet("scheduled_ids", cleaned).apply() + return try { + db.memoDao().getAll().map { it.toDomain() } } finally { db.close() } - - return Result.success() } private fun scheduleAlarm( @@ -103,7 +89,6 @@ class TaskCheckWorker( try { alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent) } catch (_: SecurityException) { - // Fallback if exact alarm permission not granted alarmManager.set(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent) } } diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskNotificationManager.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskNotificationManager.kt index 0c52deb..fe1d83d 100644 --- a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskNotificationManager.kt +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskNotificationManager.kt @@ -28,17 +28,17 @@ object TaskNotificationManager { manager.createNotificationChannel(NotificationChannel(CHANNEL_P1, "P1 — Urgent", NotificationManager.IMPORTANCE_HIGH).apply { description = "High priority — alarm sound, strong vibration, wakes screen" enableVibration(true); vibrationPattern = longArrayOf(0, 500, 200, 500, 200, 500) - setSound(alarmUri, audioAttr); lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC; setShowBadge(true); setBypassDnd(true) + setSound(alarmUri, audioAttr); lockscreenVisibility = android.app.Notification.VISIBILITY_PRIVATE; setShowBadge(true); setBypassDnd(true) }) manager.createNotificationChannel(NotificationChannel(CHANNEL_P2, "P2 — Medium", NotificationManager.IMPORTANCE_HIGH).apply { description = "Medium priority — notification sound, vibration" enableVibration(true); vibrationPattern = longArrayOf(0, 300, 200, 300) - setSound(soundUri, audioAttr); lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC; setShowBadge(true) + setSound(soundUri, audioAttr); lockscreenVisibility = android.app.Notification.VISIBILITY_PRIVATE; setShowBadge(true) }) manager.createNotificationChannel(NotificationChannel(CHANNEL_P3, "P3 — Low", NotificationManager.IMPORTANCE_DEFAULT).apply { description = "Low priority — notification sound, short vibration" enableVibration(true); vibrationPattern = longArrayOf(0, 200) - setSound(soundUri, audioAttr); lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC + setSound(soundUri, audioAttr); lockscreenVisibility = android.app.Notification.VISIBILITY_PRIVATE }) manager.createNotificationChannel(NotificationChannel(CHANNEL_DEFAULT, "No Priority", NotificationManager.IMPORTANCE_LOW).apply { description = "No priority — silent notification" @@ -89,7 +89,7 @@ object TaskNotificationManager { .setShowWhen(true) .setPriority(notifPriority) .setCategory(NotificationCompat.CATEGORY_REMINDER) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setAutoCancel(true) .setOnlyAlertOnce(false) .setContentIntent(pendingOpen) @@ -99,7 +99,6 @@ object TaskNotificationManager { when (priority) { 1 -> { - builder.setFullScreenIntent(pendingOpen, true) builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)) builder.setVibrate(longArrayOf(0, 500, 200, 500, 200, 500)) } diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt index b84d718..a7046a3 100644 --- a/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt @@ -6,17 +6,24 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -private var liveMemosProvider: (() -> List)? = null +var liveMemosProvider: (() -> List)? = null + private set actual fun setLiveMemosProvider(provider: () -> List) { liveMemosProvider = provider } +actual fun syncNotifyTime(time: String) { + val ctx = appContext ?: return + com.avinal.memos.notifications.writeDefaultNotifyTime(ctx, time) +} + actual fun triggerReminderCheck() { val ctx = appContext ?: return val memos = liveMemosProvider?.invoke() if (memos != null && memos.isNotEmpty()) { DirectAlarmScheduler.scheduleFromMemos(ctx, memos) + com.avinal.memos.notifications.CalendarReminderManager.syncTaskReminders(ctx, memos) } else { runTaskCheckNow(ctx) } diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/App.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/App.kt index bfca69d..e754bb3 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/App.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/App.kt @@ -2,14 +2,16 @@ package com.avinal.memos import androidx.compose.runtime.Composable import coil3.ImageLoader +import coil3.annotation.ExperimentalCoilApi import coil3.compose.setSingletonImageLoaderFactory import coil3.network.ktor3.KtorNetworkFetcherFactory import com.avinal.memos.ui.navigation.AppNavHost import com.avinal.memos.ui.theme.NikkiTheme import com.avinal.memos.util.LocalAppDependencies +@OptIn(ExperimentalCoilApi::class) @Composable -fun App() { +fun App(sharedText: String? = null) { val deps = LocalAppDependencies.current setSingletonImageLoaderFactory { context -> @@ -21,6 +23,6 @@ fun App() { } NikkiTheme { - AppNavHost(deps) + AppNavHost(deps, sharedText = sharedText) } } diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt index 75ab4d7..d082f5b 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import com.avinal.memos.db.entity.toDomain +import com.avinal.memos.util.syncNotifyTime class AppDependencies( dataStorePath: String, @@ -38,7 +39,11 @@ class AppDependencies( ) } - val authRepository: AuthRepository by lazy { AuthRepository(apiClient, tokenStore) } + val authRepository: AuthRepository by lazy { + AuthRepository(apiClient, tokenStore).also { + it.onLogout = { cachedToken = null; cachedServerUrl = null } + } + } val memoRepository: MemoRepository by lazy { MemoRepository(apiClient, database.memoDao()) { com.avinal.memos.util.triggerReminderCheck() @@ -55,6 +60,7 @@ class AppDependencies( launch { tokenStore.accessToken.collect { cachedToken = it } } launch { tokenStore.serverUrl.collect { cachedServerUrl = it } } launch { tokenStore.syncInterval.collect { memoRepository.syncIntervalMinutes = it } } + launch { tokenStore.defaultNotifyTime.collect { syncNotifyTime(it) } } launch { initializeLiveMemosProvider() } } } diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/AuthRepository.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/AuthRepository.kt index 96513fc..4ee3096 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/AuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/AuthRepository.kt @@ -52,5 +52,8 @@ class AuthRepository( suspend fun logout() { tokenStore.clear() _currentUser.value = null + onLogout?.invoke() } + + var onLogout: (() -> Unit)? = null } diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt index 09598f9..3411bc3 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt @@ -127,7 +127,12 @@ class MemoRepository( is ApiResult.NetworkError -> { pendingSyncDao?.insert(PendingSyncEntity( memoId = null, action = "CREATE", - payload = """{"content":"${content.replace("\"", "\\\"")}","visibility":"${visibility.toApiString()}"}""", + payload = kotlinx.serialization.json.Json.encodeToString( + kotlinx.serialization.json.JsonObject(mapOf( + "content" to kotlinx.serialization.json.JsonPrimitive(content), + "visibility" to kotlinx.serialization.json.JsonPrimitive(visibility.toApiString()), + )) + ), createdAt = nowMillis(), )) result @@ -155,14 +160,14 @@ class MemoRepository( } is ApiResult.Error -> result is ApiResult.NetworkError -> { - val payloadParts = buildList { - if (content != null) add(""""content":"${content.replace("\"", "\\\"")}"""") - if (visibility != null) add(""""visibility":"${visibility.toApiString()}"""") - if (pinned != null) add(""""pinned":$pinned""") + val fields = buildMap { + if (content != null) put("content", kotlinx.serialization.json.JsonPrimitive(content)) + if (visibility != null) put("visibility", kotlinx.serialization.json.JsonPrimitive(visibility.toApiString())) + if (pinned != null) put("pinned", kotlinx.serialization.json.JsonPrimitive(pinned)) } pendingSyncDao?.insert(PendingSyncEntity( memoId = id, action = "UPDATE", - payload = "{${payloadParts.joinToString(",")}}", + payload = kotlinx.serialization.json.Json.encodeToString(kotlinx.serialization.json.JsonObject(fields)), createdAt = nowMillis(), )) result diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/notifications/ReminderScheduler.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/notifications/ReminderScheduler.kt index 8659c05..c018382 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/notifications/ReminderScheduler.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/notifications/ReminderScheduler.kt @@ -34,7 +34,6 @@ object ReminderScheduler { tasks: List, nowMillis: Long, timeZone: TimeZone, - alreadyScheduledIds: Set = emptySet(), defaultTime: LocalTime = LocalTime(20, 0), ): List { val alarms = mutableListOf() @@ -48,8 +47,6 @@ object ReminderScheduler { val dueMs = effectiveDate.atTime(effectiveTime).toInstant(timeZone).toEpochMilliseconds() - println("ReminderScheduler: task=${task.text} effectiveDate=$effectiveDate effectiveTime=$effectiveTime dueMs=$dueMs nowMillis=$nowMillis future=${dueMs > nowMillis}") - // Explicit reminder: fire at dueDateTime - duration if (task.reminder != null) { val offsetMs = durationToMillis(task.reminder) diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt index fe09d66..468cb54 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt @@ -16,7 +16,7 @@ object TaskParser { private val taskLineRegex = Regex("""^\s*- \[([ xX])]\s+(.*)$""") private val isoDateRegex = Regex("""\b(\d{4}-\d{2}-\d{2})\b""") - private val naturalDateRegex = Regex("""\b(today|tomorrow|yesterday)\b""", RegexOption.IGNORE_CASE) + private val naturalDateRegex = Regex("""\b(today|tomorrow|yesterday|next\s+(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)|in\s+\d+\s*days?|next\s+week)\b""", RegexOption.IGNORE_CASE) private val time12Regex = Regex("""\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b""", RegexOption.IGNORE_CASE) private val time24Regex = Regex("""\b(\d{1,2}):(\d{2})\b""") @@ -116,10 +116,23 @@ object TaskParser { } naturalDateRegex.find(text)?.let { val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) - return when (it.groupValues[1].lowercase()) { - "today" -> today - "tomorrow" -> today.plus(1, DateTimeUnit.DAY) - "yesterday" -> today.plus(-1, DateTimeUnit.DAY) + val matched = it.groupValues[1].lowercase().trim() + return when { + matched == "today" -> today + matched == "tomorrow" -> today.plus(1, DateTimeUnit.DAY) + matched == "yesterday" -> today.plus(-1, DateTimeUnit.DAY) + matched == "next week" -> today.plus(7, DateTimeUnit.DAY) + matched.startsWith("in ") -> { + val days = Regex("""\d+""").find(matched)?.value?.toIntOrNull() ?: return null + today.plus(days, DateTimeUnit.DAY) + } + matched.startsWith("next ") -> { + val dayName = matched.removePrefix("next ").trim() + val targetDow = dayOfWeekFromName(dayName) ?: return null + val todayDow = today.dayOfWeek.ordinal + val diff = (targetDow - todayDow + 7) % 7 + today.plus(if (diff == 0) 7 else diff, DateTimeUnit.DAY) + } else -> null } } @@ -286,6 +299,11 @@ object TaskParser { return warnings } + private fun dayOfWeekFromName(name: String): Int? = when (name) { + "monday" -> 0; "tuesday" -> 1; "wednesday" -> 2; "thursday" -> 3 + "friday" -> 4; "saturday" -> 5; "sunday" -> 6; else -> null + } + private fun cleanTaskText(text: String): String { var clean = text clean = priorityRegex.replace(clean, "") diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt index 89384eb..040f9df 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt @@ -1,3 +1,4 @@ +@file:Suppress("DEPRECATION") package com.avinal.memos.ui.components import androidx.compose.animation.animateContentSize @@ -15,11 +16,17 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -38,6 +45,8 @@ import com.avinal.memos.domain.MemoVisibility import com.avinal.memos.ui.theme.LocalAccentColor import com.avinal.memos.util.sharePlainText import kotlin.time.Instant +import kotlinx.datetime.todayIn +import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -201,6 +210,7 @@ private fun MetroMenuItem(text: String, color: Color, onClick: () -> Unit) { ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun InlineEditor( content: String, visibility: MemoVisibility, accent: Color, @@ -209,33 +219,144 @@ private fun InlineEditor( onSave: () -> Unit, onCancel: () -> Unit, ) { var showVisibilityMenu by remember { mutableStateOf(false) } + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + + val isEditingTask = remember(content) { + val lastLine = content.lines().lastOrNull { it.isNotBlank() } ?: "" + lastLine.trimStart().startsWith("- [") + } + + if (showDatePicker) { + val today = kotlin.time.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()) + val dateState = rememberDatePickerState( + initialSelectedDateMillis = today.toEpochDays().toLong() * 86400000L, + ) + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + dateState.selectedDateMillis?.let { ms -> + val d = Instant.fromEpochMilliseconds(ms) + .toLocalDateTime(kotlinx.datetime.TimeZone.UTC).date + onContentChange(content.trimEnd() + " $d") + } + showDatePicker = false + }) { Text("ok", color = accent) } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { Text("cancel") } + }, + ) { DatePicker(state = dateState) } + } + + if (showTimePicker) { + val timeState = rememberTimePickerState() + AlertDialog( + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton(onClick = { + val h = timeState.hour; val m = timeState.minute + val timeStr = if (m == 0) { + if (h == 0) "12am" else if (h < 12) "${h}am" else if (h == 12) "12pm" else "${h - 12}pm" + } else "${h}:${m.toString().padStart(2, '0')}" + onContentChange(content.trimEnd() + " $timeStr") + showTimePicker = false + }) { Text("ok", color = accent) } + }, + dismissButton = { + TextButton(onClick = { showTimePicker = false }) { Text("cancel") } + }, + text = { TimePicker(state = timeState) }, + ) + } TextField( - value = content, onValueChange = onContentChange, + value = content, + onValueChange = { newText -> + val oldLines = content.lines() + val lastLine = oldLines.lastOrNull() ?: "" + + // Backspace on empty auto-inserted task line: remove it + if (newText.length < content.length && lastLine.trim() == "- [ ]" && oldLines.size > 1) { + val withoutLast = oldLines.dropLast(1).joinToString("\n") + if (newText.trimEnd() == withoutLast.trimEnd()) { + onContentChange(withoutLast) + return@TextField + } + } + // Enter on empty auto-inserted task line: remove it + if (newText.length > content.length && newText.endsWith("\n") && lastLine.trim() == "- [ ]" && oldLines.size > 1) { + onContentChange(oldLines.dropLast(1).joinToString("\n") + "\n") + return@TextField + } + // Auto-checklist: continue task list on enter + if (newText.length > content.length && newText.endsWith("\n") && lastLine.trimStart().startsWith("- [") && lastLine.trim() != "- [ ]") { + onContentChange(newText + "- [ ] ") + return@TextField + } + onContentChange(newText) + }, modifier = Modifier.fillMaxWidth().height(180.dp), - textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor), + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = textColor, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ), colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, focusedIndicatorColor = accent, unfocusedIndicatorColor = subtleColor.copy(alpha = 0.3f), cursorColor = accent, ), ) + + val previewTasks = remember(content) { + com.avinal.memos.parser.TaskParser.extractTasks("preview", content) + } + if (previewTasks.isNotEmpty()) { + Column(modifier = Modifier.padding(top = 4.dp)) { + previewTasks.forEach { task -> + Row(modifier = Modifier.padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + Text(if (task.isCompleted) "☑" else "☐", fontSize = 12.sp, color = if (task.isCompleted) accent else subtleColor) + Spacer(Modifier.width(6.dp)) + Text(task.text, fontSize = 12.sp, color = if (task.isCompleted) subtleColor else textColor, modifier = Modifier.weight(1f)) + task.dueDate?.let { EditorChip("$it", accent) } + task.dueTime?.let { EditorChip("$it", accent) } + task.reminder?.let { EditorChip("!$it", subtleColor) } + task.priority?.let { p -> + val c = when (p) { 1 -> com.avinal.memos.ui.theme.PriorityP1; 2 -> com.avinal.memos.ui.theme.PriorityP2; else -> com.avinal.memos.ui.theme.PriorityP3 } + EditorChip("p$p", c) + } + task.lists.forEach { EditorChip("#$it", accent) } + } + } + } + } + Spacer(Modifier.height(8.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Box { - Text(visibility.name.lowercase(), fontSize = 13.sp, color = subtleColor, modifier = Modifier.clickable { showVisibilityMenu = true }) - if (showVisibilityMenu) { - AlertDialog( - onDismissRequest = { showVisibilityMenu = false }, containerColor = MaterialTheme.colorScheme.surfaceContainer, title = null, - text = { - Column { - MemoVisibility.entries.forEach { vis -> - Text(vis.name.lowercase(), fontSize = 17.sp, color = if (vis == visibility) accent else textColor, - modifier = Modifier.fillMaxWidth().clickable { onVisibilityChange(vis); showVisibilityMenu = false }.padding(vertical = 10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) { + Box { + Text(visibility.name.lowercase(), fontSize = 13.sp, color = subtleColor, modifier = Modifier.clickable { showVisibilityMenu = true }) + if (showVisibilityMenu) { + AlertDialog( + onDismissRequest = { showVisibilityMenu = false }, containerColor = MaterialTheme.colorScheme.surfaceContainer, title = null, + text = { + Column { + MemoVisibility.entries.forEach { vis -> + Text(vis.name.lowercase(), fontSize = 17.sp, color = if (vis == visibility) accent else textColor, + modifier = Modifier.fillMaxWidth().clickable { onVisibilityChange(vis); showVisibilityMenu = false }.padding(vertical = 10.dp)) + } } - } - }, - confirmButton = {}, - ) + }, + confirmButton = {}, + ) + } + } + Text("add task", fontSize = 13.sp, color = subtleColor, modifier = Modifier.clickable { + onContentChange(content.let { if (it.isEmpty() || it.endsWith("\n")) it else "$it\n" } + "- [ ] ") + }) + if (isEditingTask) { + Text("due", fontSize = 13.sp, color = subtleColor, modifier = Modifier.clickable { showDatePicker = true }) + Text("at", fontSize = 13.sp, color = subtleColor, modifier = Modifier.clickable { showTimePicker = true }) } } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { @@ -246,6 +367,16 @@ private fun InlineEditor( } } +@Composable +private fun EditorChip(label: String, color: Color) { + Text( + label, fontSize = 10.sp, color = color, + modifier = Modifier.padding(start = 4.dp) + .background(color.copy(alpha = 0.1f), androidx.compose.foundation.shape.RoundedCornerShape(3.dp)) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) +} + private val monthNames = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") private val dayNames = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt index e36b91e..2f8eb31 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt @@ -59,6 +59,7 @@ import com.avinal.memos.ui.theme.LocalAccentColor import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import kotlinx.datetime.todayIn import kotlinx.datetime.toLocalDateTime private val pivotTitles = listOf("explore", "memos", "tasks", "settings") @@ -67,6 +68,7 @@ private const val START_PAGE = 1 @Composable fun MainScreen( deps: AppDependencies, + sharedText: String? = null, onMemoClick: (String) -> Unit, onCreateMemo: () -> Unit, onLogout: () -> Unit, @@ -74,6 +76,13 @@ fun MainScreen( val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { pivotTitles.size }) val scope = rememberCoroutineScope() val accent = LocalAccentColor.current + + val allMemos by deps.memoRepository.observeMemos().collectAsState(initial = emptyList()) + val urgentTaskCount = remember(allMemos) { + val today = kotlin.time.Clock.System.todayIn(TimeZone.currentSystemDefault()) + allMemos.flatMap { memo -> com.avinal.memos.parser.TaskParser.extractTasks(memo.id, memo.content, memo.tags) } + .count { !it.isCompleted && it.dueDate != null && it.dueDate <= today } + } val density = LocalDensity.current var dateFilter by remember { mutableStateOf(null) } @@ -129,19 +138,33 @@ fun MainScreen( val distance = kotlin.math.abs(scrollFraction - index) val alpha = (1f - distance * 0.5f).coerceIn(0.15f, 1f) val isSelected = pagerState.currentPage == index + val titleColor = if (isSelected) accent.copy(alpha = alpha) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha * 0.4f) - Text( - text = title, - fontSize = 42.sp, - fontWeight = FontWeight.Light, - color = if (isSelected) accent.copy(alpha = alpha) - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha * 0.4f), - maxLines = 1, + Row( modifier = Modifier .offset { IntOffset(offsetPx, 0) } .clickable { scope.launch { pagerState.animateScrollToPage(index) } } .padding(vertical = 4.dp), - ) + verticalAlignment = Alignment.Bottom, + ) { + Text( + text = title, + fontSize = 42.sp, + fontWeight = FontWeight.Light, + color = titleColor, + maxLines = 1, + ) + if (title == "tasks" && urgentTaskCount > 0) { + Text( + text = "$urgentTaskCount", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = accent.copy(alpha = alpha), + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp), + ) + } + } } } @@ -180,6 +203,7 @@ fun MainScreen( ) 1 -> MemoListScreen( deps = deps, + sharedText = sharedText, onMemoClick = onMemoClick, onCreateMemo = onCreateMemo, dateFilter = dateFilter, diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt index 809330d..3b4416b 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt @@ -16,12 +16,18 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TimePicker import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -47,16 +53,22 @@ import com.avinal.memos.domain.MemoVisibility import com.avinal.memos.ui.components.MemoCard import com.avinal.memos.ui.theme.LocalAccentColor import com.avinal.memos.util.rememberFilePicker +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlinx.datetime.toLocalDateTime +import kotlinx.datetime.todayIn @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @Composable fun MemoListScreen( deps: AppDependencies, + sharedText: String? = null, onMemoClick: (String) -> Unit, onCreateMemo: () -> Unit, dateFilter: String? = null, @@ -121,7 +133,7 @@ fun MemoListScreen( val textColor = MaterialTheme.colorScheme.onBackground val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant - var composeText by remember { mutableStateOf("") } + var composeField by remember { mutableStateOf(TextFieldValue(sharedText ?: "")) } val defaultVis by produceState(MemoVisibility.PRIVATE) { deps.tokenStore.defaultVisibility.first().let { value = MemoVisibility.fromApiString(it) } } @@ -130,6 +142,7 @@ fun MemoListScreen( var uploadedAttachmentNames by remember { mutableStateOf>(emptyList()) } var isUploading by remember { mutableStateOf(false) } val uploadScope = rememberCoroutineScope() + val haptics = LocalHapticFeedback.current val launchFilePicker = rememberFilePicker { pickedFile -> isUploading = true @@ -225,8 +238,8 @@ fun MemoListScreen( .clickable { showInsertMenu = false when (item) { - "code block" -> composeText += "\n```\n\n```" - "link memo" -> composeText += "\n[memo]()" + "code block" -> { val t = composeField.text + "\n```\n\n```"; composeField = TextFieldValue(t, TextRange(t.length)) } + "link memo" -> { val t = composeField.text + "\n[memo]()"; composeField = TextFieldValue(t, TextRange(t.length)) } "media", "file" -> launchFilePicker() } } @@ -241,18 +254,34 @@ fun MemoListScreen( Column(modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 10.dp, bottom = 10.dp)) { TextField( - value = composeText, - onValueChange = { newText -> - // Auto-checklist: if user pressed enter after a task line, auto-insert "- [ ] " - if (newText.length > composeText.length && newText.endsWith("\n")) { - val beforeNewline = newText.dropLast(1) - val lastLine = beforeNewline.lines().lastOrNull() ?: "" - if (lastLine.trimStart().startsWith("- [")) { - composeText = newText + "- [ ] " + value = composeField, + onValueChange = { newField -> + val newText = newField.text + val oldText = composeField.text + val oldLines = oldText.lines() + val lastLine = oldLines.lastOrNull() ?: "" + + // Backspace on empty auto-inserted task line: remove it + if (newText.length < oldText.length && lastLine.trim() == "- [ ]" && oldLines.size > 1) { + val withoutLast = oldLines.dropLast(1).joinToString("\n") + if (newText.trimEnd() == withoutLast.trimEnd()) { + composeField = TextFieldValue(withoutLast, TextRange(withoutLast.length)) return@TextField } } - composeText = newText + // Enter on empty auto-inserted task line: remove it and exit task mode + if (newText.length > oldText.length && newText.endsWith("\n") && lastLine.trim() == "- [ ]" && oldLines.size > 1) { + val withoutLast = oldLines.dropLast(1).joinToString("\n") + "\n" + composeField = TextFieldValue(withoutLast, TextRange(withoutLast.length)) + return@TextField + } + // Auto-checklist: continue task list on enter + if (newText.length > oldText.length && newText.endsWith("\n") && lastLine.trimStart().startsWith("- [") && lastLine.trim() != "- [ ]") { + val result = newText + "- [ ] " + composeField = TextFieldValue(result, TextRange(result.length)) + return@TextField + } + composeField = newField }, modifier = Modifier.fillMaxWidth(), placeholder = { @@ -261,7 +290,7 @@ fun MemoListScreen( singleLine = false, minLines = 1, maxLines = 10, - textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor), + textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace), colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, @@ -271,53 +300,48 @@ fun MemoListScreen( ), ) - // Live metadata preview - val previewChips = remember(composeText) { - val parser = com.avinal.memos.parser.TaskParser - val tasks = parser.extractTasks("preview", composeText) - if (tasks.isEmpty()) emptyList() - else tasks.flatMap { task -> - buildList { - task.dueDate?.let { add("due: $it" to accent) } - task.dueTime?.let { add("at: $it" to accent) } - task.reminder?.let { add("!$it" to accent) } - task.priority?.let { - val color = when (it) { - 1 -> com.avinal.memos.ui.theme.PriorityP1 - 2 -> com.avinal.memos.ui.theme.PriorityP2 - else -> com.avinal.memos.ui.theme.PriorityP3 - } - add("p$it" to color) - } - task.lists.forEach { add("#$it" to accent) } - } - }.distinct() + // Per-task live preview + val previewTasks = remember(composeField.text) { + com.avinal.memos.parser.TaskParser.extractTasks("preview", composeField.text) } - if (previewChips.isNotEmpty()) { - androidx.compose.foundation.layout.FlowRow( - modifier = Modifier.padding(top = 4.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - previewChips.forEach { (label, color) -> - Text( - label, - fontSize = 11.sp, - color = color, - modifier = Modifier - .background(color.copy(alpha = 0.1f), androidx.compose.foundation.shape.RoundedCornerShape(4.dp)) - .padding(horizontal = 6.dp, vertical = 2.dp), - ) + if (previewTasks.isNotEmpty()) { + Column(modifier = Modifier.padding(top = 6.dp)) { + previewTasks.forEach { task -> + Row( + modifier = Modifier.padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + if (task.isCompleted) "☑" else "☐", + fontSize = 12.sp, + color = if (task.isCompleted) accent else subtleColor, + ) + Spacer(Modifier.width(6.dp)) + Text( + task.text, + fontSize = 12.sp, + color = if (task.isCompleted) subtleColor else textColor, + modifier = Modifier.weight(1f), + ) + task.dueDate?.let { MetadataChip("$it", accent) } + task.dueTime?.let { MetadataChip("$it", accent) } + task.reminder?.let { MetadataChip("!$it", subtleColor) } + task.priority?.let { p -> + val c = when (p) { 1 -> com.avinal.memos.ui.theme.PriorityP1; 2 -> com.avinal.memos.ui.theme.PriorityP2; else -> com.avinal.memos.ui.theme.PriorityP3 } + MetadataChip("p$p", c) + } + task.lists.forEach { MetadataChip("#$it", accent) } + } } } } - val parseWarnings = remember(composeText) { com.avinal.memos.parser.TaskParser.validateContent(composeText) } + val parseWarnings = remember(composeField.text) { com.avinal.memos.parser.TaskParser.validateContent(composeField.text) } if (parseWarnings.isNotEmpty()) { parseWarnings.forEach { warning -> Text( - "⚠ ${warning.taskText}: ${warning.issue}", + "${warning.taskText}: ${warning.issue}", fontSize = 11.sp, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 2.dp), ) @@ -337,42 +361,101 @@ fun MemoListScreen( Spacer(Modifier.height(6.dp)) + // Compose toolbar + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + + if (showDatePicker) { + val today = kotlin.time.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()) + val dateState = rememberDatePickerState( + initialSelectedDateMillis = today.toEpochDays().toLong() * 86400000L, + ) + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + dateState.selectedDateMillis?.let { ms -> + val d = kotlin.time.Instant.fromEpochMilliseconds(ms) + .toLocalDateTime(kotlinx.datetime.TimeZone.UTC).date + val r = composeField.text.trimEnd() + " $d"; composeField = TextFieldValue(r, TextRange(r.length)) + } + showDatePicker = false + }) { Text("ok", color = accent) } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { Text("cancel") } + }, + ) { DatePicker(state = dateState) } + } + + if (showTimePicker) { + val timeState = rememberTimePickerState() + AlertDialog( + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton(onClick = { + val h = timeState.hour + val m = timeState.minute + val timeStr = if (m == 0) { + if (h == 0) "12am" else if (h < 12) "${h}am" else if (h == 12) "12pm" else "${h - 12}pm" + } else { + "${h}:${m.toString().padStart(2, '0')}" + } + val r = composeField.text.trimEnd() + " $timeStr"; composeField = TextFieldValue(r, TextRange(r.length)) + showTimePicker = false + }) { Text("ok", color = accent) } + }, + dismissButton = { + TextButton(onClick = { showTimePicker = false }) { Text("cancel") } + }, + text = { TimePicker(state = timeState) }, + ) + } + + val isEditingTask = remember(composeField.text) { + val lastLine = composeField.text.lines().lastOrNull { it.isNotBlank() } ?: "" + lastLine.trimStart().startsWith("- [") + } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) { - Text( - "+", - fontSize = 18.sp, - fontWeight = FontWeight.Light, - color = subtleColor, - modifier = Modifier.clickable { showInsertMenu = true }, - ) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { + ToolbarButton("add task", subtleColor) { val t = composeField.text.let { if (it.isEmpty() || it.endsWith("\n")) it else "$it\n" } + "- [ ] "; composeField = TextFieldValue(t, TextRange(t.length)) } + if (isEditingTask) { + ToolbarButton("due", subtleColor) { showDatePicker = true } + ToolbarButton("at", subtleColor) { showTimePicker = true } + } + ToolbarButton("+", subtleColor) { showInsertMenu = true } + } + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { Text( composeVisibility.name.lowercase(), - fontSize = 12.sp, + fontSize = 11.sp, color = subtleColor, modifier = Modifier.clickable { showVisibilityPicker = true }, ) + Text( + "post", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = if (composeField.text.isNotBlank()) accent else subtleColor.copy(alpha = 0.3f), + modifier = Modifier + .then( + if (composeField.text.isNotBlank()) Modifier.clickable { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.createMemo(composeField.text, composeVisibility, uploadedAttachmentNames) + composeField = TextFieldValue("") + uploadedAttachmentNames = emptyList() + uploadScope.launch { listState.animateScrollToItem(0) } + } else Modifier + ) + .padding(horizontal = 4.dp, vertical = 4.dp), + ) } - - Text( - "post", - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = if (composeText.isNotBlank()) accent else subtleColor.copy(alpha = 0.3f), - modifier = Modifier - .then( - if (composeText.isNotBlank()) Modifier.clickable { - viewModel.createMemo(composeText, composeVisibility, uploadedAttachmentNames) - composeText = "" - uploadedAttachmentNames = emptyList() - } else Modifier - ) - .padding(horizontal = 4.dp, vertical = 4.dp), - ) } } @@ -410,7 +493,16 @@ fun MemoListScreen( } else if (memos.isEmpty() && !uiState.isRefreshing) { item { Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) { - Text(if (showArchived) "no archived memos" else "no memos yet", fontSize = 15.sp, color = subtleColor) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + if (showArchived) "no archived memos" else "nothing here yet", + fontSize = 17.sp, fontWeight = FontWeight.Light, color = MaterialTheme.colorScheme.onBackground, + ) + if (!showArchived) { + Spacer(Modifier.height(4.dp)) + Text("tap above to write your first memo", fontSize = 13.sp, color = subtleColor) + } + } } } } @@ -454,3 +546,29 @@ fun MemoListScreen( } } } + +@Composable +private fun ToolbarButton(label: String, color: Color, onClick: () -> Unit) { + Text( + label, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = color, + modifier = Modifier + .clickable(onClick = onClick) + .padding(vertical = 4.dp), + ) +} + +@Composable +private fun MetadataChip(label: String, color: Color) { + Text( + label, + fontSize = 10.sp, + color = color, + modifier = Modifier + .padding(start = 4.dp) + .background(color.copy(alpha = 0.1f), androidx.compose.foundation.shape.RoundedCornerShape(3.dp)) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/navigation/AppNavHost.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/navigation/AppNavHost.kt index 034debb..bc3bc47 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/navigation/AppNavHost.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/navigation/AppNavHost.kt @@ -20,7 +20,7 @@ import com.avinal.memos.ui.memos.MemoEditorScreen private const val ANIM_DURATION = 300 @Composable -fun AppNavHost(deps: AppDependencies) { +fun AppNavHost(deps: AppDependencies, sharedText: String? = null) { val navController = rememberNavController() val isLoggedIn by deps.authRepository.isLoggedIn.collectAsState(initial = false) @@ -63,6 +63,7 @@ fun AppNavHost(deps: AppDependencies) { ) { MainScreen( deps = deps, + sharedText = sharedText, onMemoClick = { memoId -> navController.navigate(Route.MemoDetail(memoId)) }, onCreateMemo = { navController.navigate(Route.MemoEditor()) }, onLogout = { diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskDetailSheet.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskDetailSheet.kt index 554f684..127d572 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskDetailSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskDetailSheet.kt @@ -41,7 +41,7 @@ import com.avinal.memos.domain.ReminderUnit import com.avinal.memos.domain.Task import com.avinal.memos.parser.TaskParser import com.avinal.memos.ui.theme.LocalAccentColor -import kotlinx.datetime.Instant +import kotlin.time.Instant import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt index 0a27c14..bf8e259 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt @@ -20,18 +20,28 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp @@ -45,10 +55,12 @@ import com.avinal.memos.ui.theme.PriorityP1 import com.avinal.memos.ui.theme.PriorityP2 import com.avinal.memos.ui.theme.PriorityP3 import kotlin.time.Clock +import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone import kotlinx.datetime.todayIn import kotlinx.datetime.daysUntil +@OptIn(ExperimentalMaterial3Api::class) @Composable fun TaskListScreen( deps: AppDependencies, @@ -61,6 +73,10 @@ fun TaskListScreen( val textColor = MaterialTheme.colorScheme.onBackground val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant var selectedTask by remember { mutableStateOf(null) } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val haptics = LocalHapticFeedback.current + var isRefreshing by remember { mutableStateOf(false) } selectedTask?.let { task -> TaskDetailSheet( @@ -71,6 +87,30 @@ fun TaskListScreen( ) } + Scaffold( + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + Snackbar( + snackbarData = data, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = textColor, + actionColor = accent, + ) + } + }, + containerColor = Color.Transparent, + ) { padding -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { + isRefreshing = true + scope.launch { + deps.memoRepository.refreshMemos() + isRefreshing = false + } + }, + modifier = Modifier.padding(padding), + ) { Column(modifier = Modifier.fillMaxSize()) { Row( modifier = Modifier @@ -187,7 +227,17 @@ fun TaskListScreen( dotColor = dotColor, textColor = textColor, subtleColor = subtleColor, - onToggle = { viewModel.toggleTask(task) }, + onToggle = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.toggleTask(task) + scope.launch { + val label = if (task.isCompleted) "task reopened" else "task completed" + val result = snackbarHostState.showSnackbar(label, actionLabel = "undo", withDismissAction = true) + if (result == SnackbarResult.ActionPerformed) { + viewModel.toggleTask(task) + } + } + }, onClick = { selectedTask = task }, ) } @@ -197,12 +247,18 @@ fun TaskListScreen( if (grouped.groups.isEmpty() || grouped.groups.all { it.tasks.isEmpty() }) { item { Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) { - Text("no tasks", fontSize = 15.sp, color = subtleColor) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("all clear", fontSize = 17.sp, fontWeight = FontWeight.Light, color = textColor) + Spacer(Modifier.height(4.dp)) + Text("tasks from your memos will appear here", fontSize = 13.sp, color = subtleColor) + } } } } } } + } + } } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformNotification.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformNotification.kt index d06a5a1..ec74c60 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformNotification.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformNotification.kt @@ -2,3 +2,4 @@ package com.avinal.memos.util expect fun triggerReminderCheck() expect fun setLiveMemosProvider(provider: () -> List) +expect fun syncNotifyTime(time: String) diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/ParserDoctorTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/ParserDoctorTest.kt index 99df913..3133a80 100644 --- a/composeApp/src/commonTest/kotlin/com/avinal/memos/ParserDoctorTest.kt +++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/ParserDoctorTest.kt @@ -116,8 +116,25 @@ class ParserDoctorTest { } @Test fun singleTaskNoDateNotWarned() { - // Only 1 task without date should not trigger the combined warning val w = TaskParser.validateContent("- [ ] Single task") assertTrue(w.none { it.issue.contains("tasks have no due date") }) } + + // --- Relative dates no false positives --- + + @Test fun noWarningNextMonday() { + assertTrue(TaskParser.validateContent("- [ ] Call next monday 5pm").isEmpty()) + } + + @Test fun noWarningNextWeek() { + assertTrue(TaskParser.validateContent("- [ ] Plan next week").isEmpty()) + } + + @Test fun noWarningInDays() { + assertTrue(TaskParser.validateContent("- [ ] Follow up in 3 days").isEmpty()) + } + + @Test fun reminderWithNextFriday() { + assertTrue(TaskParser.validateContent("- [ ] Deploy next friday !1hr").isEmpty()) + } } diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/ReminderSchedulerTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/ReminderSchedulerTest.kt index 3ad7d64..9fc3728 100644 --- a/composeApp/src/commonTest/kotlin/com/avinal/memos/ReminderSchedulerTest.kt +++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/ReminderSchedulerTest.kt @@ -29,7 +29,7 @@ class ReminderSchedulerTest { reminder = reminder, priority = priority) private fun compute(tasks: List, now: Long = nowMillis) = - ReminderScheduler.computeAlarms(tasks, now, tz, emptySet(), defaultTime) + ReminderScheduler.computeAlarms(tasks, now, tz, defaultTime) @Test fun completedNoAlarms() { assertTrue(compute(listOf(task(completed = true))).isEmpty()) } @Test fun noDateNoTimeNoAlarms() { assertTrue(compute(listOf(task(date = null, time = null))).isEmpty()) } @@ -128,7 +128,7 @@ class ReminderSchedulerTest { @Test fun customDefaultTime() { val alarms = ReminderScheduler.computeAlarms( - listOf(task()), nowMillis, tz, emptySet(), LocalTime(9, 0) + listOf(task()), nowMillis, tz, LocalTime(9, 0) ) val expected = dueDate.atTime(LocalTime(9, 0)).toInstant(tz).toEpochMilliseconds() assertEquals(expected, alarms[0].triggerAtMillis) diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt index ae644bf..cdfb72d 100644 --- a/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt @@ -2,6 +2,8 @@ package com.avinal.memos import com.avinal.memos.domain.ReminderUnit import com.avinal.memos.parser.TaskParser +import kotlinx.datetime.daysUntil +import kotlinx.datetime.todayIn import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -184,4 +186,69 @@ class TaskParserTest { assertNotNull(t.dueDate); assertEquals(15, t.dueTime!!.hour) assertEquals(15, t.reminder!!.value); assertEquals(ReminderUnit.MIN, t.reminder!!.unit) } + + // --- Relative dates --- + + @Test fun parsesNextWeek() { + val t = TaskParser.extractTasks("m1", "- [ ] Plan next week")[0] + assertNotNull(t.dueDate) + } + @Test fun parsesInDays() { + val t = TaskParser.extractTasks("m1", "- [ ] Follow up in 3 days")[0] + assertNotNull(t.dueDate) + } + @Test fun parsesIn1Day() { + val t = TaskParser.extractTasks("m1", "- [ ] Check in 1 day")[0] + assertNotNull(t.dueDate) + } + @Test fun parsesNextMonday() { + val t = TaskParser.extractTasks("m1", "- [ ] Standup next monday")[0] + assertNotNull(t.dueDate) + } + @Test fun parsesNextFriday() { + val t = TaskParser.extractTasks("m1", "- [ ] Deploy next friday")[0] + assertNotNull(t.dueDate) + } + @Test fun nextMondayCleaned() { + val t = TaskParser.extractTasks("m1", "- [ ] Standup next monday")[0] + assertEquals("Standup", t.text) + } + @Test fun inDaysCleaned() { + val t = TaskParser.extractTasks("m1", "- [ ] Follow up in 3 days")[0] + assertEquals("Follow up", t.text) + } + @Test fun nextWeekCleaned() { + val t = TaskParser.extractTasks("m1", "- [ ] Plan next week")[0] + assertEquals("Plan", t.text) + } + @Test fun nextDayIsFuture() { + val today = kotlin.time.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()) + val t = TaskParser.extractTasks("m1", "- [ ] Do next monday")[0] + assertTrue(t.dueDate!! > today) + } + @Test fun nextWeekIs7DaysAhead() { + val today = kotlin.time.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()) + val t = TaskParser.extractTasks("m1", "- [ ] Plan next week")[0] + assertEquals(7, today.daysUntil(t.dueDate!!)) + } + @Test fun in5DaysIs5Ahead() { + val today = kotlin.time.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()) + val t = TaskParser.extractTasks("m1", "- [ ] Review in 5 days")[0] + assertEquals(5, today.daysUntil(t.dueDate!!)) + } + + // --- Tag inheritance --- + + @Test fun taskLevelTagOverridesMemoTag() { + val t = TaskParser.extractTasks("m1", "- [ ] Fix bug #devops", listOf("work"))[0] + assertEquals(listOf("devops"), t.lists) + } + @Test fun noTaskTagInheritsMemoTags() { + val t = TaskParser.extractTasks("m1", "- [ ] Fix bug", listOf("work", "urgent"))[0] + assertEquals(listOf("work", "urgent"), t.lists) + } + @Test fun emptyMemoTagsNoInheritance() { + val t = TaskParser.extractTasks("m1", "- [ ] Fix bug")[0] + assertTrue(t.lists.isEmpty()) + } } diff --git a/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformNotification.ios.kt b/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformNotification.ios.kt index b7fd1c8..eefa3cc 100644 --- a/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformNotification.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformNotification.ios.kt @@ -2,3 +2,4 @@ package com.avinal.memos.util actual fun triggerReminderCheck() {} actual fun setLiveMemosProvider(provider: () -> List) {} +actual fun syncNotifyTime(time: String) {} diff --git a/fastlane/metadata/android/en-US/changelogs/1.txt b/fastlane/metadata/android/en-US/changelogs/1.txt new file mode 100644 index 0000000..0c23676 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1.txt @@ -0,0 +1,12 @@ +Initial release of Nikki. + +• Full Memos API integration with offline sync +• Todoist-style task parsing from markdown checkboxes +• Due dates, times, priorities, reminders, and tags +• Parser doctor with inline validation and typo detection +• 4-channel priority notifications with calendar integration +• Windows Phone Metro design with 20 accent colors +• Activity calendar, tag browser, search, archived memos +• Share intent receiver for quick capture +• JSON backup/restore +• 162 tests passing diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..a2a7057 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,19 @@ +Nikki is a native Android client for Memos, the open-source self-hosted note-taking tool. It adds Todoist-style task management on top of your memos, with a Windows Phone Metro-inspired interface. + +Tasks are parsed directly from markdown checkboxes in your notes. Write "- [ ] buy milk tomorrow 5pm p1 #shopping" and Nikki extracts the due date, time, priority, and tags — giving you a structured task view without duplicating data. + +Features: +• Full Memos API integration (create, edit, pin, archive, delete) +• Rich markdown rendering with code highlighting, tables, and links +• Due dates (ISO, today, tomorrow, next monday, in 3 days) +• Times (12h/24h), priorities (p1-p3), reminders, tags +• Group and sort tasks by date, priority, list, or memo +• Android notifications with 4 priority channels +• Calendar integration for reliable reminders +• Offline-first with sync queue +• 3 themes (dark, light, AMOLED) and 20 accent colors +• Activity calendar, tag browser, and search +• Export/import backup as JSON +• Share text from any app into a new memo + +Requires a running Memos instance (v0.22+). The name comes from the Japanese word 日記 (nikki), meaning diary. diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..27bd4f6 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..7621278 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Native Memos client with Todoist-style tasks and WP Metro design \ No newline at end of file