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