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:
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
+11 -4
View File
@@ -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"
+5
View File
@@ -39,6 +39,11 @@ android {
}
}
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
compileOptions {
sourceCompatibility = 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.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<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
android:allowBackup="true"
@@ -29,6 +31,11 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</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>
<receiver
@@ -1,6 +1,7 @@
package com.avinal.memos
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
@@ -33,22 +34,32 @@ class MainActivity : ComponentActivity() {
requestBatteryOptimizationExemption()
scheduleTaskChecker(applicationContext)
val sharedText = if (intent?.action == Intent.ACTION_SEND && intent.type == "text/plain") {
intent.getStringExtra(Intent.EXTRA_TEXT)
} else null
enableEdgeToEdge()
setContent {
CompositionLocalProvider(LocalAppDependencies provides deps) {
App()
App(sharedText = sharedText)
}
}
}
private fun requestNotificationPermission() {
val perms = mutableListOf<String>()
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() {
@@ -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)
}
}
+6
View File
@@ -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)
@@ -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.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 ->
@@ -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) {
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<com.avinal.memos.domain.Memo> {
val db = Room.databaseBuilder<MemosDatabase>(
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)
}
}
@@ -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))
}
@@ -6,17 +6,24 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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>) {
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)
}
@@ -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)
}
}
@@ -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() }
}
}
@@ -52,5 +52,8 @@ class AuthRepository(
suspend fun logout() {
tokenStore.clear()
_currentUser.value = null
onLogout?.invoke()
}
var onLogout: (() -> Unit)? = null
}
@@ -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<String, kotlinx.serialization.json.JsonElement> {
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
@@ -34,7 +34,6 @@ object ReminderScheduler {
tasks: List<Task>,
nowMillis: Long,
timeZone: TimeZone,
alreadyScheduledIds: Set<String> = emptySet(),
defaultTime: LocalTime = LocalTime(20, 0),
): List<ScheduledAlarm> {
val alarms = mutableListOf<ScheduledAlarm>()
@@ -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)
@@ -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, "")
@@ -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")
@@ -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<String?>(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,
@@ -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<List<String>>(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),
)
}
@@ -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 = {
@@ -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
@@ -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<Task?>(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
@@ -2,3 +2,4 @@ package com.avinal.memos.util
expect fun triggerReminderCheck()
expect fun setLiveMemosProvider(provider: () -> List<com.avinal.memos.domain.Memo>)
expect fun syncNotifyTime(time: String)
@@ -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())
}
}
@@ -29,7 +29,7 @@ class ReminderSchedulerTest {
reminder = reminder, priority = priority)
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 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)
@@ -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())
}
}
@@ -2,3 +2,4 @@ package com.avinal.memos.util
actual fun triggerReminderCheck() {}
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