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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -39,6 +39,11 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+173
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-6
@@ -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()
|
||||
}
|
||||
+20
-35
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-5
@@ -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))
|
||||
}
|
||||
|
||||
+8
-1
@@ -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,18 +219,121 @@ 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) {
|
||||
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) {
|
||||
@@ -238,6 +351,14 @@ private fun InlineEditor(
|
||||
)
|
||||
}
|
||||
}
|
||||
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)) {
|
||||
Text("cancel", fontSize = 14.sp, color = subtleColor, modifier = Modifier.clickable(onClick = onCancel))
|
||||
Text("save", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (content.isNotBlank()) accent else subtleColor,
|
||||
@@ -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),
|
||||
if (previewTasks.isNotEmpty()) {
|
||||
Column(modifier = Modifier.padding(top = 6.dp)) {
|
||||
previewTasks.forEach { task ->
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
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 (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,44 +361,103 @@ 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 (composeText.isNotBlank()) accent else subtleColor.copy(alpha = 0.3f),
|
||||
color = if (composeField.text.isNotBlank()) accent else subtleColor.copy(alpha = 0.3f),
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (composeText.isNotBlank()) Modifier.clickable {
|
||||
viewModel.createMemo(composeText, composeVisibility, uploadedAttachmentNames)
|
||||
composeText = ""
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(
|
||||
Modifier.fillMaxWidth().height(1.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,7 +247,13 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user