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