1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-03 21:40:09 +05:30

Merge pull request #1 from avinal/avinal/next

Add more QoL features and fix security issues
This commit is contained in:
2026-06-05 15:35:27 +05:30
committed by GitHub
34 changed files with 887 additions and 183 deletions
+9 -2
View File
@@ -4,6 +4,13 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
concurrency:
group: build-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -14,12 +21,12 @@ jobs:
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 21
- uses: gradle/actions/setup-gradle@v4 - uses: gradle/actions/setup-gradle@v4
- name: Run tests - name: Run tests
run: ./gradlew :composeApp:testDebugUnitTest run: ./gradlew :composeApp:testAndroidHostTest
- name: Build debug APK - name: Build debug APK
run: ./gradlew :androidApp:assembleDebug run: ./gradlew :androidApp:assembleDebug
+11 -4
View File
@@ -4,6 +4,10 @@ on:
release: release:
types: [created] types: [created]
concurrency:
group: release-${{ github.event.release.tag_name }}
cancel-in-progress: false
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -16,12 +20,14 @@ jobs:
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 21
- uses: gradle/actions/setup-gradle@v4 - uses: gradle/actions/setup-gradle@v4
- name: Decode keystore - 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 - name: Build signed release APK
env: env:
@@ -34,6 +40,7 @@ jobs:
- name: Upload release APK - name: Upload release APK
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.release.tag_name }}
run: | run: |
mv androidApp/build/outputs/apk/release/androidApp-release.apk nikki-${{ github.event.release.tag_name }}.apk mv androidApp/build/outputs/apk/release/androidApp-release.apk "nikki-${TAG}.apk"
gh release upload ${{ github.event.release.tag_name }} nikki-${{ github.event.release.tag_name }}.apk gh release upload "${TAG}" "nikki-${TAG}.apk"
+5
View File
@@ -39,6 +39,11 @@ android {
} }
} }
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
+7
View File
@@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -29,6 +31,11 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity> </activity>
<receiver <receiver
@@ -1,6 +1,7 @@
package com.avinal.memos package com.avinal.memos
import android.Manifest import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -33,22 +34,32 @@ class MainActivity : ComponentActivity() {
requestBatteryOptimizationExemption() requestBatteryOptimizationExemption()
scheduleTaskChecker(applicationContext) scheduleTaskChecker(applicationContext)
val sharedText = if (intent?.action == Intent.ACTION_SEND && intent.type == "text/plain") {
intent.getStringExtra(Intent.EXTRA_TEXT)
} else null
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
CompositionLocalProvider(LocalAppDependencies provides deps) { CompositionLocalProvider(LocalAppDependencies provides deps) {
App() App(sharedText = sharedText)
} }
} }
} }
private fun requestNotificationPermission() { private fun requestNotificationPermission() {
val perms = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
!= PackageManager.PERMISSION_GRANTED perms.add(Manifest.permission.POST_NOTIFICATIONS)
) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001)
} }
} }
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() { private fun requestBatteryOptimizationExemption() {
@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.avinal.memos.notifications.TaskNotificationManager import com.avinal.memos.notifications.TaskNotificationManager
import com.avinal.memos.notifications.runTaskCheckNow
class TaskReminderReceiver : BroadcastReceiver() { class TaskReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
@@ -19,5 +20,6 @@ class TaskReminderReceiver : BroadcastReceiver() {
dueLabel = dueLabel, dueLabel = dueLabel,
priority = priority, priority = priority,
) )
runTaskCheckNow(context)
} }
} }
+6
View File
@@ -36,11 +36,17 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
@Suppress("DEPRECATION")
implementation(compose.runtime) implementation(compose.runtime)
@Suppress("DEPRECATION")
implementation(compose.foundation) implementation(compose.foundation)
@Suppress("DEPRECATION")
implementation(compose.material3) implementation(compose.material3)
@Suppress("DEPRECATION")
implementation(compose.materialIconsExtended) implementation(compose.materialIconsExtended)
@Suppress("DEPRECATION")
implementation(compose.ui) implementation(compose.ui)
@Suppress("DEPRECATION")
implementation(compose.components.resources) implementation(compose.components.resources)
implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.viewmodel)
@@ -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<Memo>) {
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<String>()
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<String>) {
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,
)
}
}
}
}
}
@@ -7,7 +7,6 @@ import android.content.Intent
import com.avinal.memos.domain.Memo import com.avinal.memos.domain.Memo
import com.avinal.memos.parser.TaskParser import com.avinal.memos.parser.TaskParser
import kotlin.time.Clock import kotlin.time.Clock
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
object DirectAlarmScheduler { object DirectAlarmScheduler {
@@ -17,12 +16,9 @@ object DirectAlarmScheduler {
val nowMillis = Clock.System.now().toEpochMilliseconds() val nowMillis = Clock.System.now().toEpochMilliseconds()
val tz = TimeZone.currentSystemDefault() val tz = TimeZone.currentSystemDefault()
val prefs = context.getSharedPreferences("memos_prefs", Context.MODE_PRIVATE) val defaultTime = readDefaultNotifyTime(context)
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 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 val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarms.forEach { alarm -> alarms.forEach { alarm ->
@@ -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()
}
@@ -21,20 +21,28 @@ class TaskCheckWorker(
) : CoroutineWorker(appContext, params) { ) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result { 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 nowMillis = Clock.System.now().toEpochMilliseconds()
val defaultTime = readDefaultNotifyTime(appContext)
// Read default notify time from DataStore file (shared prefs fallback) val memos = com.avinal.memos.util.liveMemosProvider?.invoke()
val notifyPrefs = appContext.getSharedPreferences("memos_prefs", Context.MODE_PRIVATE) ?: readMemosFromDb()
val defaultTimeStr = notifyPrefs.getString("default_notify_time", "20:00") ?: "20:00"
val defaultTimeParts = defaultTimeStr.split(":") val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content, memo.tags) }
val defaultTime = try { val alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
kotlinx.datetime.LocalTime(defaultTimeParts[0].toInt(), defaultTimeParts.getOrElse(1) { "0" }.toInt()) val tz = TimeZone.currentSystemDefault()
} catch (_: Exception) {
kotlinx.datetime.LocalTime(20, 0) 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<com.avinal.memos.domain.Memo> {
val db = Room.databaseBuilder<MemosDatabase>( val db = Room.databaseBuilder<MemosDatabase>(
context = appContext, context = appContext,
name = appContext.getDatabasePath("memos.db").absolutePath, name = appContext.getDatabasePath("memos.db").absolutePath,
@@ -44,33 +52,11 @@ class TaskCheckWorker(
.setQueryCoroutineContext(Dispatchers.IO) .setQueryCoroutineContext(Dispatchers.IO)
.build() .build()
try { return try {
val memos = db.memoDao().getAll().map { it.toDomain() } 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()
} finally { } finally {
db.close() db.close()
} }
return Result.success()
} }
private fun scheduleAlarm( private fun scheduleAlarm(
@@ -103,7 +89,6 @@ class TaskCheckWorker(
try { try {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent) alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
} catch (_: SecurityException) { } catch (_: SecurityException) {
// Fallback if exact alarm permission not granted
alarmManager.set(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent) alarmManager.set(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
} }
} }
@@ -28,17 +28,17 @@ object TaskNotificationManager {
manager.createNotificationChannel(NotificationChannel(CHANNEL_P1, "P1 — Urgent", NotificationManager.IMPORTANCE_HIGH).apply { manager.createNotificationChannel(NotificationChannel(CHANNEL_P1, "P1 — Urgent", NotificationManager.IMPORTANCE_HIGH).apply {
description = "High priority — alarm sound, strong vibration, wakes screen" description = "High priority — alarm sound, strong vibration, wakes screen"
enableVibration(true); vibrationPattern = longArrayOf(0, 500, 200, 500, 200, 500) 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 { manager.createNotificationChannel(NotificationChannel(CHANNEL_P2, "P2 — Medium", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Medium priority — notification sound, vibration" description = "Medium priority — notification sound, vibration"
enableVibration(true); vibrationPattern = longArrayOf(0, 300, 200, 300) 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 { manager.createNotificationChannel(NotificationChannel(CHANNEL_P3, "P3 — Low", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Low priority — notification sound, short vibration" description = "Low priority — notification sound, short vibration"
enableVibration(true); vibrationPattern = longArrayOf(0, 200) 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 { manager.createNotificationChannel(NotificationChannel(CHANNEL_DEFAULT, "No Priority", NotificationManager.IMPORTANCE_LOW).apply {
description = "No priority — silent notification" description = "No priority — silent notification"
@@ -89,7 +89,7 @@ object TaskNotificationManager {
.setShowWhen(true) .setShowWhen(true)
.setPriority(notifPriority) .setPriority(notifPriority)
.setCategory(NotificationCompat.CATEGORY_REMINDER) .setCategory(NotificationCompat.CATEGORY_REMINDER)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setAutoCancel(true) .setAutoCancel(true)
.setOnlyAlertOnce(false) .setOnlyAlertOnce(false)
.setContentIntent(pendingOpen) .setContentIntent(pendingOpen)
@@ -99,7 +99,6 @@ object TaskNotificationManager {
when (priority) { when (priority) {
1 -> { 1 -> {
builder.setFullScreenIntent(pendingOpen, true)
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)) builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM))
builder.setVibrate(longArrayOf(0, 500, 200, 500, 200, 500)) builder.setVibrate(longArrayOf(0, 500, 200, 500, 200, 500))
} }
@@ -6,17 +6,24 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private var liveMemosProvider: (() -> List<com.avinal.memos.domain.Memo>)? = null var liveMemosProvider: (() -> List<com.avinal.memos.domain.Memo>)? = null
private set
actual fun setLiveMemosProvider(provider: () -> List<com.avinal.memos.domain.Memo>) { actual fun setLiveMemosProvider(provider: () -> List<com.avinal.memos.domain.Memo>) {
liveMemosProvider = provider liveMemosProvider = provider
} }
actual fun syncNotifyTime(time: String) {
val ctx = appContext ?: return
com.avinal.memos.notifications.writeDefaultNotifyTime(ctx, time)
}
actual fun triggerReminderCheck() { actual fun triggerReminderCheck() {
val ctx = appContext ?: return val ctx = appContext ?: return
val memos = liveMemosProvider?.invoke() val memos = liveMemosProvider?.invoke()
if (memos != null && memos.isNotEmpty()) { if (memos != null && memos.isNotEmpty()) {
DirectAlarmScheduler.scheduleFromMemos(ctx, memos) DirectAlarmScheduler.scheduleFromMemos(ctx, memos)
com.avinal.memos.notifications.CalendarReminderManager.syncTaskReminders(ctx, memos)
} else { } else {
runTaskCheckNow(ctx) runTaskCheckNow(ctx)
} }
@@ -2,14 +2,16 @@ package com.avinal.memos
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import coil3.ImageLoader import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.setSingletonImageLoaderFactory import coil3.compose.setSingletonImageLoaderFactory
import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.network.ktor3.KtorNetworkFetcherFactory
import com.avinal.memos.ui.navigation.AppNavHost import com.avinal.memos.ui.navigation.AppNavHost
import com.avinal.memos.ui.theme.NikkiTheme import com.avinal.memos.ui.theme.NikkiTheme
import com.avinal.memos.util.LocalAppDependencies import com.avinal.memos.util.LocalAppDependencies
@OptIn(ExperimentalCoilApi::class)
@Composable @Composable
fun App() { fun App(sharedText: String? = null) {
val deps = LocalAppDependencies.current val deps = LocalAppDependencies.current
setSingletonImageLoaderFactory { context -> setSingletonImageLoaderFactory { context ->
@@ -21,6 +23,6 @@ fun App() {
} }
NikkiTheme { NikkiTheme {
AppNavHost(deps) AppNavHost(deps, sharedText = sharedText)
} }
} }
@@ -14,6 +14,7 @@ import kotlinx.coroutines.IO
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import com.avinal.memos.db.entity.toDomain import com.avinal.memos.db.entity.toDomain
import com.avinal.memos.util.syncNotifyTime
class AppDependencies( class AppDependencies(
dataStorePath: String, 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 { val memoRepository: MemoRepository by lazy {
MemoRepository(apiClient, database.memoDao()) { MemoRepository(apiClient, database.memoDao()) {
com.avinal.memos.util.triggerReminderCheck() com.avinal.memos.util.triggerReminderCheck()
@@ -55,6 +60,7 @@ class AppDependencies(
launch { tokenStore.accessToken.collect { cachedToken = it } } launch { tokenStore.accessToken.collect { cachedToken = it } }
launch { tokenStore.serverUrl.collect { cachedServerUrl = it } } launch { tokenStore.serverUrl.collect { cachedServerUrl = it } }
launch { tokenStore.syncInterval.collect { memoRepository.syncIntervalMinutes = it } } launch { tokenStore.syncInterval.collect { memoRepository.syncIntervalMinutes = it } }
launch { tokenStore.defaultNotifyTime.collect { syncNotifyTime(it) } }
launch { initializeLiveMemosProvider() } launch { initializeLiveMemosProvider() }
} }
} }
@@ -52,5 +52,8 @@ class AuthRepository(
suspend fun logout() { suspend fun logout() {
tokenStore.clear() tokenStore.clear()
_currentUser.value = null _currentUser.value = null
onLogout?.invoke()
} }
var onLogout: (() -> Unit)? = null
} }
@@ -127,7 +127,12 @@ class MemoRepository(
is ApiResult.NetworkError -> { is ApiResult.NetworkError -> {
pendingSyncDao?.insert(PendingSyncEntity( pendingSyncDao?.insert(PendingSyncEntity(
memoId = null, action = "CREATE", 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(), createdAt = nowMillis(),
)) ))
result result
@@ -155,14 +160,14 @@ class MemoRepository(
} }
is ApiResult.Error -> result is ApiResult.Error -> result
is ApiResult.NetworkError -> { is ApiResult.NetworkError -> {
val payloadParts = buildList { val fields = buildMap<String, kotlinx.serialization.json.JsonElement> {
if (content != null) add(""""content":"${content.replace("\"", "\\\"")}"""") if (content != null) put("content", kotlinx.serialization.json.JsonPrimitive(content))
if (visibility != null) add(""""visibility":"${visibility.toApiString()}"""") if (visibility != null) put("visibility", kotlinx.serialization.json.JsonPrimitive(visibility.toApiString()))
if (pinned != null) add(""""pinned":$pinned""") if (pinned != null) put("pinned", kotlinx.serialization.json.JsonPrimitive(pinned))
} }
pendingSyncDao?.insert(PendingSyncEntity( pendingSyncDao?.insert(PendingSyncEntity(
memoId = id, action = "UPDATE", memoId = id, action = "UPDATE",
payload = "{${payloadParts.joinToString(",")}}", payload = kotlinx.serialization.json.Json.encodeToString(kotlinx.serialization.json.JsonObject(fields)),
createdAt = nowMillis(), createdAt = nowMillis(),
)) ))
result result
@@ -34,7 +34,6 @@ object ReminderScheduler {
tasks: List<Task>, tasks: List<Task>,
nowMillis: Long, nowMillis: Long,
timeZone: TimeZone, timeZone: TimeZone,
alreadyScheduledIds: Set<String> = emptySet(),
defaultTime: LocalTime = LocalTime(20, 0), defaultTime: LocalTime = LocalTime(20, 0),
): List<ScheduledAlarm> { ): List<ScheduledAlarm> {
val alarms = mutableListOf<ScheduledAlarm>() val alarms = mutableListOf<ScheduledAlarm>()
@@ -48,8 +47,6 @@ object ReminderScheduler {
val dueMs = effectiveDate.atTime(effectiveTime).toInstant(timeZone).toEpochMilliseconds() 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 // Explicit reminder: fire at dueDateTime - duration
if (task.reminder != null) { if (task.reminder != null) {
val offsetMs = durationToMillis(task.reminder) val offsetMs = durationToMillis(task.reminder)
@@ -16,7 +16,7 @@ object TaskParser {
private val taskLineRegex = Regex("""^\s*- \[([ xX])]\s+(.*)$""") private val taskLineRegex = Regex("""^\s*- \[([ xX])]\s+(.*)$""")
private val isoDateRegex = Regex("""\b(\d{4}-\d{2}-\d{2})\b""") 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 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""") private val time24Regex = Regex("""\b(\d{1,2}):(\d{2})\b""")
@@ -116,10 +116,23 @@ object TaskParser {
} }
naturalDateRegex.find(text)?.let { naturalDateRegex.find(text)?.let {
val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
return when (it.groupValues[1].lowercase()) { val matched = it.groupValues[1].lowercase().trim()
"today" -> today return when {
"tomorrow" -> today.plus(1, DateTimeUnit.DAY) matched == "today" -> today
"yesterday" -> today.plus(-1, DateTimeUnit.DAY) 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 else -> null
} }
} }
@@ -286,6 +299,11 @@ object TaskParser {
return warnings 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 { private fun cleanTaskText(text: String): String {
var clean = text var clean = text
clean = priorityRegex.replace(clean, "") clean = priorityRegex.replace(clean, "")
@@ -1,3 +1,4 @@
@file:Suppress("DEPRECATION")
package com.avinal.memos.ui.components package com.avinal.memos.ui.components
import androidx.compose.animation.animateContentSize 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.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.ui.theme.LocalAccentColor
import com.avinal.memos.util.sharePlainText import com.avinal.memos.util.sharePlainText
import kotlin.time.Instant import kotlin.time.Instant
import kotlinx.datetime.todayIn
import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@@ -201,6 +210,7 @@ private fun MetroMenuItem(text: String, color: Color, onClick: () -> Unit) {
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun InlineEditor( private fun InlineEditor(
content: String, visibility: MemoVisibility, accent: Color, content: String, visibility: MemoVisibility, accent: Color,
@@ -209,33 +219,144 @@ private fun InlineEditor(
onSave: () -> Unit, onCancel: () -> Unit, onSave: () -> Unit, onCancel: () -> Unit,
) { ) {
var showVisibilityMenu by remember { mutableStateOf(false) } 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( 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), 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( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = accent, unfocusedIndicatorColor = subtleColor.copy(alpha = 0.3f), cursorColor = accent, 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)) Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Box { Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) {
Text(visibility.name.lowercase(), fontSize = 13.sp, color = subtleColor, modifier = Modifier.clickable { showVisibilityMenu = true }) Box {
if (showVisibilityMenu) { Text(visibility.name.lowercase(), fontSize = 13.sp, color = subtleColor, modifier = Modifier.clickable { showVisibilityMenu = true })
AlertDialog( if (showVisibilityMenu) {
onDismissRequest = { showVisibilityMenu = false }, containerColor = MaterialTheme.colorScheme.surfaceContainer, title = null, AlertDialog(
text = { onDismissRequest = { showVisibilityMenu = false }, containerColor = MaterialTheme.colorScheme.surfaceContainer, title = null,
Column { text = {
MemoVisibility.entries.forEach { vis -> Column {
Text(vis.name.lowercase(), fontSize = 17.sp, color = if (vis == visibility) accent else textColor, MemoVisibility.entries.forEach { vis ->
modifier = Modifier.fillMaxWidth().clickable { onVisibilityChange(vis); showVisibilityMenu = false }.padding(vertical = 10.dp)) 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)) { 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 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") private val dayNames = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
@@ -59,6 +59,7 @@ import com.avinal.memos.ui.theme.LocalAccentColor
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
private val pivotTitles = listOf("explore", "memos", "tasks", "settings") private val pivotTitles = listOf("explore", "memos", "tasks", "settings")
@@ -67,6 +68,7 @@ private const val START_PAGE = 1
@Composable @Composable
fun MainScreen( fun MainScreen(
deps: AppDependencies, deps: AppDependencies,
sharedText: String? = null,
onMemoClick: (String) -> Unit, onMemoClick: (String) -> Unit,
onCreateMemo: () -> Unit, onCreateMemo: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
@@ -74,6 +76,13 @@ fun MainScreen(
val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { pivotTitles.size }) val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { pivotTitles.size })
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val accent = LocalAccentColor.current 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 val density = LocalDensity.current
var dateFilter by remember { mutableStateOf<String?>(null) } var dateFilter by remember { mutableStateOf<String?>(null) }
@@ -129,19 +138,33 @@ fun MainScreen(
val distance = kotlin.math.abs(scrollFraction - index) val distance = kotlin.math.abs(scrollFraction - index)
val alpha = (1f - distance * 0.5f).coerceIn(0.15f, 1f) val alpha = (1f - distance * 0.5f).coerceIn(0.15f, 1f)
val isSelected = pagerState.currentPage == index val isSelected = pagerState.currentPage == index
val titleColor = if (isSelected) accent.copy(alpha = alpha)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha * 0.4f)
Text( Row(
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,
modifier = Modifier modifier = Modifier
.offset { IntOffset(offsetPx, 0) } .offset { IntOffset(offsetPx, 0) }
.clickable { scope.launch { pagerState.animateScrollToPage(index) } } .clickable { scope.launch { pagerState.animateScrollToPage(index) } }
.padding(vertical = 4.dp), .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( 1 -> MemoListScreen(
deps = deps, deps = deps,
sharedText = sharedText,
onMemoClick = onMemoClick, onMemoClick = onMemoClick,
onCreateMemo = onCreateMemo, onCreateMemo = onCreateMemo,
dateFilter = dateFilter, dateFilter = dateFilter,
@@ -16,12 +16,18 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TimePicker
import androidx.compose.material3.pulltorefresh.PullToRefreshBox 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.components.MemoCard
import com.avinal.memos.ui.theme.LocalAccentColor import com.avinal.memos.ui.theme.LocalAccentColor
import com.avinal.memos.util.rememberFilePicker 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.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.todayIn
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun MemoListScreen( fun MemoListScreen(
deps: AppDependencies, deps: AppDependencies,
sharedText: String? = null,
onMemoClick: (String) -> Unit, onMemoClick: (String) -> Unit,
onCreateMemo: () -> Unit, onCreateMemo: () -> Unit,
dateFilter: String? = null, dateFilter: String? = null,
@@ -121,7 +133,7 @@ fun MemoListScreen(
val textColor = MaterialTheme.colorScheme.onBackground val textColor = MaterialTheme.colorScheme.onBackground
val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant
var composeText by remember { mutableStateOf("") } var composeField by remember { mutableStateOf(TextFieldValue(sharedText ?: "")) }
val defaultVis by produceState(MemoVisibility.PRIVATE) { val defaultVis by produceState(MemoVisibility.PRIVATE) {
deps.tokenStore.defaultVisibility.first().let { value = MemoVisibility.fromApiString(it) } deps.tokenStore.defaultVisibility.first().let { value = MemoVisibility.fromApiString(it) }
} }
@@ -130,6 +142,7 @@ fun MemoListScreen(
var uploadedAttachmentNames by remember { mutableStateOf<List<String>>(emptyList()) } var uploadedAttachmentNames by remember { mutableStateOf<List<String>>(emptyList()) }
var isUploading by remember { mutableStateOf(false) } var isUploading by remember { mutableStateOf(false) }
val uploadScope = rememberCoroutineScope() val uploadScope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val launchFilePicker = rememberFilePicker { pickedFile -> val launchFilePicker = rememberFilePicker { pickedFile ->
isUploading = true isUploading = true
@@ -225,8 +238,8 @@ fun MemoListScreen(
.clickable { .clickable {
showInsertMenu = false showInsertMenu = false
when (item) { when (item) {
"code block" -> composeText += "\n```\n\n```" "code block" -> { val t = composeField.text + "\n```\n\n```"; composeField = TextFieldValue(t, TextRange(t.length)) }
"link memo" -> composeText += "\n[memo]()" "link memo" -> { val t = composeField.text + "\n[memo]()"; composeField = TextFieldValue(t, TextRange(t.length)) }
"media", "file" -> launchFilePicker() "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)) { Column(modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 10.dp, bottom = 10.dp)) {
TextField( TextField(
value = composeText, value = composeField,
onValueChange = { newText -> onValueChange = { newField ->
// Auto-checklist: if user pressed enter after a task line, auto-insert "- [ ] " val newText = newField.text
if (newText.length > composeText.length && newText.endsWith("\n")) { val oldText = composeField.text
val beforeNewline = newText.dropLast(1) val oldLines = oldText.lines()
val lastLine = beforeNewline.lines().lastOrNull() ?: "" val lastLine = oldLines.lastOrNull() ?: ""
if (lastLine.trimStart().startsWith("- [")) {
composeText = newText + "- [ ] " // 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 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(), modifier = Modifier.fillMaxWidth(),
placeholder = { placeholder = {
@@ -261,7 +290,7 @@ fun MemoListScreen(
singleLine = false, singleLine = false,
minLines = 1, minLines = 1,
maxLines = 10, 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( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
@@ -271,53 +300,48 @@ fun MemoListScreen(
), ),
) )
// Live metadata preview // Per-task live preview
val previewChips = remember(composeText) { val previewTasks = remember(composeField.text) {
val parser = com.avinal.memos.parser.TaskParser com.avinal.memos.parser.TaskParser.extractTasks("preview", composeField.text)
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()
} }
if (previewChips.isNotEmpty()) { if (previewTasks.isNotEmpty()) {
androidx.compose.foundation.layout.FlowRow( Column(modifier = Modifier.padding(top = 6.dp)) {
modifier = Modifier.padding(top = 4.dp), previewTasks.forEach { task ->
horizontalArrangement = Arrangement.spacedBy(6.dp), Row(
verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(vertical = 2.dp),
) { verticalAlignment = Alignment.CenterVertically,
previewChips.forEach { (label, color) -> ) {
Text( Text(
label, if (task.isCompleted) "" else "",
fontSize = 11.sp, fontSize = 12.sp,
color = color, color = if (task.isCompleted) accent else subtleColor,
modifier = Modifier )
.background(color.copy(alpha = 0.1f), androidx.compose.foundation.shape.RoundedCornerShape(4.dp)) Spacer(Modifier.width(6.dp))
.padding(horizontal = 6.dp, vertical = 2.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()) { if (parseWarnings.isNotEmpty()) {
parseWarnings.forEach { warning -> parseWarnings.forEach { warning ->
Text( Text(
"${warning.taskText}: ${warning.issue}", "${warning.taskText}: ${warning.issue}",
fontSize = 11.sp, color = MaterialTheme.colorScheme.error, fontSize = 11.sp, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 2.dp), modifier = Modifier.padding(top = 2.dp),
) )
@@ -337,42 +361,101 @@ fun MemoListScreen(
Spacer(Modifier.height(6.dp)) 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
Text( 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) {
fontSize = 18.sp, ToolbarButton("due", subtleColor) { showDatePicker = true }
fontWeight = FontWeight.Light, ToolbarButton("at", subtleColor) { showTimePicker = true }
color = subtleColor, }
modifier = Modifier.clickable { showInsertMenu = true }, ToolbarButton("+", subtleColor) { showInsertMenu = true }
) }
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
Text( Text(
composeVisibility.name.lowercase(), composeVisibility.name.lowercase(),
fontSize = 12.sp, fontSize = 11.sp,
color = subtleColor, color = subtleColor,
modifier = Modifier.clickable { showVisibilityPicker = true }, 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) { } else if (memos.isEmpty() && !uiState.isRefreshing) {
item { item {
Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) { 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),
)
}
@@ -20,7 +20,7 @@ import com.avinal.memos.ui.memos.MemoEditorScreen
private const val ANIM_DURATION = 300 private const val ANIM_DURATION = 300
@Composable @Composable
fun AppNavHost(deps: AppDependencies) { fun AppNavHost(deps: AppDependencies, sharedText: String? = null) {
val navController = rememberNavController() val navController = rememberNavController()
val isLoggedIn by deps.authRepository.isLoggedIn.collectAsState(initial = false) val isLoggedIn by deps.authRepository.isLoggedIn.collectAsState(initial = false)
@@ -63,6 +63,7 @@ fun AppNavHost(deps: AppDependencies) {
) { ) {
MainScreen( MainScreen(
deps = deps, deps = deps,
sharedText = sharedText,
onMemoClick = { memoId -> navController.navigate(Route.MemoDetail(memoId)) }, onMemoClick = { memoId -> navController.navigate(Route.MemoDetail(memoId)) },
onCreateMemo = { navController.navigate(Route.MemoEditor()) }, onCreateMemo = { navController.navigate(Route.MemoEditor()) },
onLogout = { onLogout = {
@@ -41,7 +41,7 @@ import com.avinal.memos.domain.ReminderUnit
import com.avinal.memos.domain.Task import com.avinal.memos.domain.Task
import com.avinal.memos.parser.TaskParser import com.avinal.memos.parser.TaskParser
import com.avinal.memos.ui.theme.LocalAccentColor import com.avinal.memos.ui.theme.LocalAccentColor
import kotlinx.datetime.Instant import kotlin.time.Instant
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
@@ -20,18 +20,28 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp 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.PriorityP2
import com.avinal.memos.ui.theme.PriorityP3 import com.avinal.memos.ui.theme.PriorityP3
import kotlin.time.Clock import kotlin.time.Clock
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn import kotlinx.datetime.todayIn
import kotlinx.datetime.daysUntil import kotlinx.datetime.daysUntil
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TaskListScreen( fun TaskListScreen(
deps: AppDependencies, deps: AppDependencies,
@@ -61,6 +73,10 @@ fun TaskListScreen(
val textColor = MaterialTheme.colorScheme.onBackground val textColor = MaterialTheme.colorScheme.onBackground
val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant
var selectedTask by remember { mutableStateOf<Task?>(null) } var selectedTask by remember { mutableStateOf<Task?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
var isRefreshing by remember { mutableStateOf(false) }
selectedTask?.let { task -> selectedTask?.let { task ->
TaskDetailSheet( 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()) { Column(modifier = Modifier.fillMaxSize()) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -187,7 +227,17 @@ fun TaskListScreen(
dotColor = dotColor, dotColor = dotColor,
textColor = textColor, textColor = textColor,
subtleColor = subtleColor, 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 }, onClick = { selectedTask = task },
) )
} }
@@ -197,12 +247,18 @@ fun TaskListScreen(
if (grouped.groups.isEmpty() || grouped.groups.all { it.tasks.isEmpty() }) { if (grouped.groups.isEmpty() || grouped.groups.all { it.tasks.isEmpty() }) {
item { item {
Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) { 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 @Composable
@@ -2,3 +2,4 @@ package com.avinal.memos.util
expect fun triggerReminderCheck() expect fun triggerReminderCheck()
expect fun setLiveMemosProvider(provider: () -> List<com.avinal.memos.domain.Memo>) expect fun setLiveMemosProvider(provider: () -> List<com.avinal.memos.domain.Memo>)
expect fun syncNotifyTime(time: String)
@@ -116,8 +116,25 @@ class ParserDoctorTest {
} }
@Test fun singleTaskNoDateNotWarned() { @Test fun singleTaskNoDateNotWarned() {
// Only 1 task without date should not trigger the combined warning
val w = TaskParser.validateContent("- [ ] Single task") val w = TaskParser.validateContent("- [ ] Single task")
assertTrue(w.none { it.issue.contains("tasks have no due date") }) 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())
}
} }
@@ -29,7 +29,7 @@ class ReminderSchedulerTest {
reminder = reminder, priority = priority) reminder = reminder, priority = priority)
private fun compute(tasks: List<Task>, now: Long = nowMillis) = private fun compute(tasks: List<Task>, 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 completedNoAlarms() { assertTrue(compute(listOf(task(completed = true))).isEmpty()) }
@Test fun noDateNoTimeNoAlarms() { assertTrue(compute(listOf(task(date = null, time = null))).isEmpty()) } @Test fun noDateNoTimeNoAlarms() { assertTrue(compute(listOf(task(date = null, time = null))).isEmpty()) }
@@ -128,7 +128,7 @@ class ReminderSchedulerTest {
@Test fun customDefaultTime() { @Test fun customDefaultTime() {
val alarms = ReminderScheduler.computeAlarms( 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() val expected = dueDate.atTime(LocalTime(9, 0)).toInstant(tz).toEpochMilliseconds()
assertEquals(expected, alarms[0].triggerAtMillis) assertEquals(expected, alarms[0].triggerAtMillis)
@@ -2,6 +2,8 @@ package com.avinal.memos
import com.avinal.memos.domain.ReminderUnit import com.avinal.memos.domain.ReminderUnit
import com.avinal.memos.parser.TaskParser import com.avinal.memos.parser.TaskParser
import kotlinx.datetime.daysUntil
import kotlinx.datetime.todayIn
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
@@ -184,4 +186,69 @@ class TaskParserTest {
assertNotNull(t.dueDate); assertEquals(15, t.dueTime!!.hour) assertNotNull(t.dueDate); assertEquals(15, t.dueTime!!.hour)
assertEquals(15, t.reminder!!.value); assertEquals(ReminderUnit.MIN, t.reminder!!.unit) 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())
}
} }
@@ -2,3 +2,4 @@ package com.avinal.memos.util
actual fun triggerReminderCheck() {} actual fun triggerReminderCheck() {}
actual fun setLiveMemosProvider(provider: () -> List<com.avinal.memos.domain.Memo>) {} actual fun setLiveMemosProvider(provider: () -> List<com.avinal.memos.domain.Memo>) {}
actual fun syncNotifyTime(time: String) {}
@@ -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
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

@@ -0,0 +1 @@
Native Memos client with Todoist-style tasks and WP Metro design