diff --git a/app/src/main/java/space/bloggin/vellum/UiKit.kt b/app/src/main/java/space/bloggin/vellum/UiKit.kt new file mode 100644 index 0000000..e58505c --- /dev/null +++ b/app/src/main/java/space/bloggin/vellum/UiKit.kt @@ -0,0 +1,182 @@ +package space.bloggin.vellum + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +val LocalBackdrop = compositionLocalOf { Backdrops.all[0] } + +/** UI text (menus, buttons) uses the system sans; reading text uses the chosen serif. */ +val UiFont = FontFamily.SansSerif + +@Composable +fun BackdropFill(bd: Backdrop, modifier: Modifier = Modifier) { + if (bd.image != null) { + Image(painterResource(bd.image), null, modifier.fillMaxSize(), contentScale = ContentScale.Crop) + } else { + Box(modifier.fillMaxSize().background(Brush.linearGradient(bd.gradient!!))) + } +} + +@Composable +fun Panel(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + val bd = LocalBackdrop.current + Column( + modifier + .background(bd.panel, RectangleShape) + .border(BorderStroke(1.dp, bd.faint.copy(alpha = 0.45f)), RectangleShape), + content = content + ) +} + +/** Square button, the house style. */ +@Composable +fun SqButton( + text: String, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + filled: Boolean = false, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val bd = LocalBackdrop.current + val fg = if (filled) (if (bd.dark) Color(0xFF1A120A) else Color(0xFFF7EEDC)) else bd.ink + val bg = if (filled) (if (bd.dark) bd.ink else bd.ink) else Color.Transparent + Row( + modifier + .clip(RectangleShape) + .background(bg) + .border(1.dp, bd.ink.copy(alpha = if (enabled) 0.8f else 0.3f), RectangleShape) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + if (icon != null) { + Icon(icon, null, tint = fg.copy(alpha = if (enabled) 1f else 0.4f), modifier = Modifier.size(18.dp)) + if (text.isNotEmpty()) Spacer(Modifier.width(6.dp)) + } + if (text.isNotEmpty()) Text(text, color = fg.copy(alpha = if (enabled) 1f else 0.4f), fontFamily = UiFont, fontSize = 14.sp, maxLines = 1) + } +} + +/** Icon with a small label underneath, used in the selection toolbar. */ +@Composable +fun ToolButton(text: String, icon: ImageVector, active: Boolean = false, onClick: () -> Unit) { + val bd = LocalBackdrop.current + Column( + Modifier + .width(68.dp) + .background(if (active) bd.ink.copy(alpha = 0.12f) else Color.Transparent) + .clickable(onClick = onClick) + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon(icon, text, tint = bd.ink, modifier = Modifier.size(22.dp)) + Spacer(Modifier.height(3.dp)) + Text(text, color = bd.ink, fontSize = 11.sp, fontFamily = UiFont, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) + } +} + +@Composable +fun Chip(text: String, selected: Boolean, modifier: Modifier = Modifier, family: FontFamily = UiFont, onClick: () -> Unit) { + val bd = LocalBackdrop.current + Box( + modifier + .background(if (selected) bd.ink else Color.Transparent, RectangleShape) + .border(1.dp, bd.ink.copy(alpha = 0.7f), RectangleShape) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Text(text, color = if (selected) (if (bd.dark) Color(0xFF1A120A) else Color(0xFFF7EEDC)) else bd.ink, fontFamily = family, fontSize = 14.sp, maxLines = 1) + } +} + +@Composable +fun SectionLabel(text: String, modifier: Modifier = Modifier) { + val bd = LocalBackdrop.current + Text(text.uppercase(), modifier.padding(top = 18.dp, bottom = 8.dp), color = bd.rubric, fontFamily = Fonts.display, fontSize = 15.sp, letterSpacing = 1.5.sp) +} + +@Composable +fun ToggleRow(label: String, checked: Boolean, sub: String? = null, onChange: (Boolean) -> Unit) { + val bd = LocalBackdrop.current + Row( + Modifier.fillMaxWidth().clickable { onChange(!checked) }.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text(label, color = bd.ink, fontFamily = UiFont, fontSize = 16.sp) + if (sub != null) Text(sub, color = bd.faint, fontFamily = UiFont, fontSize = 13.sp) + } + Switch( + checked = checked, onCheckedChange = onChange, + colors = SwitchDefaults.colors( + checkedThumbColor = if (bd.dark) Color(0xFF1A120A) else Color(0xFFF7EEDC), + checkedTrackColor = bd.ink, + uncheckedThumbColor = bd.ink, + uncheckedTrackColor = Color.Transparent, + uncheckedBorderColor = bd.ink, + checkedBorderColor = bd.ink, + ), + thumbContent = null + ) + } +} + +@Composable +fun SliderRow(label: String, value: Float, range: ClosedFloatingPointRange, display: String, steps: Int = 0, onChange: (Float) -> Unit) { + val bd = LocalBackdrop.current + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Row { + Text(label, color = bd.ink, fontFamily = UiFont, fontSize = 16.sp, modifier = Modifier.weight(1f)) + Text(display, color = bd.faint, fontFamily = UiFont, fontSize = 14.sp) + } + Slider( + value = value, onValueChange = onChange, valueRange = range, steps = steps, + colors = SliderDefaults.colors(thumbColor = bd.ink, activeTrackColor = bd.ink, inactiveTrackColor = bd.ink.copy(alpha = 0.25f), + activeTickColor = Color.Transparent, inactiveTickColor = Color.Transparent) + ) + } +} + +@Composable +fun Rule(modifier: Modifier = Modifier, alpha: Float = 0.35f, thickness: Dp = 1.dp) { + val bd = LocalBackdrop.current + Box(modifier.fillMaxWidth().height(thickness).background(bd.faint.copy(alpha = alpha))) +} + +fun inkOn(bd: Backdrop) = if (bd.dark) Color(0xFF1A120A) else Color(0xFFF7EEDC) + +val RoundSmall = RoundedCornerShape(0.dp) + +fun uiStyle(bd: Backdrop, size: Int = 15) = TextStyle(color = bd.ink, fontFamily = UiFont, fontSize = size.sp) diff --git a/app/src/main/java/space/bloggin/vellum/UserData.kt b/app/src/main/java/space/bloggin/vellum/UserData.kt new file mode 100644 index 0000000..bd263fc --- /dev/null +++ b/app/src/main/java/space/bloggin/vellum/UserData.kt @@ -0,0 +1,151 @@ +package space.bloggin.vellum + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import androidx.compose.runtime.mutableIntStateOf +import java.time.LocalDate + +data class Note(val ref: Ref, val text: String, val updated: Long) +data class Mark(val ref: Ref, val created: Long) + +/** Bookmarks, highlights, notes and reading-plan progress. Everything stays on the phone. */ +class UserData private constructor(ctx: Context) : SQLiteOpenHelper(ctx, "user.db", null, 1) { + + /** bumps whenever anything changes, so the UI can recompose */ + val rev = mutableIntStateOf(0) + + private val highlights = HashMap() + private val notes = HashMap() + private val bookmarks = HashMap() + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL("CREATE TABLE highlight(k INTEGER PRIMARY KEY, color INTEGER, created INTEGER)") + db.execSQL("CREATE TABLE note(k INTEGER PRIMARY KEY, text TEXT, updated INTEGER)") + db.execSQL("CREATE TABLE bookmark(k INTEGER PRIMARY KEY, created INTEGER)") + db.execSQL("CREATE TABLE plan(id TEXT PRIMARY KEY, started TEXT)") + db.execSQL("CREATE TABLE plan_day(plan TEXT, day INTEGER, done TEXT, PRIMARY KEY(plan,day))") + db.execSQL("CREATE TABLE activity(date TEXT PRIMARY KEY)") + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {} + + init { + val db = readableDatabase + db.rawQuery("SELECT k,color FROM highlight", null).use { while (it.moveToNext()) highlights[it.getInt(0)] = it.getInt(1) } + db.rawQuery("SELECT k,text,updated FROM note", null).use { while (it.moveToNext()) notes[it.getInt(0)] = Note(Ref.fromKey(it.getInt(0)), it.getString(1), it.getLong(2)) } + db.rawQuery("SELECT k,created FROM bookmark", null).use { while (it.moveToNext()) bookmarks[it.getInt(0)] = it.getLong(1) } + } + + private fun bump() { rev.intValue++ } + + // highlights: color index 1..6, 0 = none + fun highlight(r: Ref): Int = highlights[r.key()] ?: 0 + fun setHighlight(refs: Collection, color: Int) { + val db = writableDatabase + db.beginTransaction() + try { + for (r in refs) { + if (color == 0) { db.delete("highlight", "k=?", arrayOf(r.key().toString())); highlights.remove(r.key()) } + else { + db.insertWithOnConflict("highlight", null, ContentValues().apply { + put("k", r.key()); put("color", color); put("created", System.currentTimeMillis()) + }, SQLiteDatabase.CONFLICT_REPLACE) + highlights[r.key()] = color + } + } + db.setTransactionSuccessful() + } finally { db.endTransaction() } + bump() + } + fun allHighlights(): List> = highlights.entries.sortedBy { it.key }.map { Ref.fromKey(it.key) to it.value } + + fun note(r: Ref): Note? = notes[r.key()] + fun setNote(r: Ref, text: String) { + val db = writableDatabase + if (text.isBlank()) { db.delete("note", "k=?", arrayOf(r.key().toString())); notes.remove(r.key()) } + else { + val now = System.currentTimeMillis() + db.insertWithOnConflict("note", null, ContentValues().apply { put("k", r.key()); put("text", text); put("updated", now) }, SQLiteDatabase.CONFLICT_REPLACE) + notes[r.key()] = Note(r, text, now) + } + bump() + } + fun allNotes(): List = notes.values.sortedByDescending { it.updated } + + fun isBookmarked(r: Ref) = bookmarks.containsKey(r.key()) + fun toggleBookmark(r: Ref) { + val db = writableDatabase + if (isBookmarked(r)) { db.delete("bookmark", "k=?", arrayOf(r.key().toString())); bookmarks.remove(r.key()) } + else { + val now = System.currentTimeMillis() + db.insertWithOnConflict("bookmark", null, ContentValues().apply { put("k", r.key()); put("created", now) }, SQLiteDatabase.CONFLICT_REPLACE) + bookmarks[r.key()] = now + } + bump() + } + fun allBookmarks(): List = bookmarks.entries.sortedByDescending { it.value }.map { Mark(Ref.fromKey(it.key), it.value) } + + // ---------------- reading plans ---------------- + fun planStarted(id: String): LocalDate? = + readableDatabase.rawQuery("SELECT started FROM plan WHERE id=?", arrayOf(id)).use { if (it.moveToFirst()) LocalDate.parse(it.getString(0)) else null } + + fun startPlan(id: String) { + val db = writableDatabase + db.delete("plan_day", "plan=?", arrayOf(id)) + db.insertWithOnConflict("plan", null, ContentValues().apply { put("id", id); put("started", LocalDate.now().toString()) }, SQLiteDatabase.CONFLICT_REPLACE) + bump() + } + + fun stopPlan(id: String) { + writableDatabase.delete("plan", "id=?", arrayOf(id)) + writableDatabase.delete("plan_day", "plan=?", arrayOf(id)) + bump() + } + + fun daysDone(id: String): Set = + readableDatabase.rawQuery("SELECT day FROM plan_day WHERE plan=?", arrayOf(id)).use { c -> + buildSet { while (c.moveToNext()) add(c.getInt(0)) } + } + + fun setDayDone(id: String, day: Int, done: Boolean) { + val db = writableDatabase + if (done) { + db.insertWithOnConflict("plan_day", null, ContentValues().apply { put("plan", id); put("day", day); put("done", LocalDate.now().toString()) }, SQLiteDatabase.CONFLICT_REPLACE) + db.insertWithOnConflict("activity", null, ContentValues().apply { put("date", LocalDate.now().toString()) }, SQLiteDatabase.CONFLICT_IGNORE) + } else db.delete("plan_day", "plan=? AND day=?", arrayOf(id, day.toString())) + bump() + } + + /** Consecutive days (ending today, or yesterday if today isn't done yet) with a finished plan reading. */ + fun streak(): Int { + val dates = readableDatabase.rawQuery("SELECT date FROM activity", null).use { c -> + buildSet { while (c.moveToNext()) add(c.getString(0)) } + } + var d = LocalDate.now() + if (!dates.contains(d.toString())) d = d.minusDays(1) + var n = 0 + while (dates.contains(d.toString())) { n++; d = d.minusDays(1) } + return n + } + + fun bestStreak(): Int { + val dates = readableDatabase.rawQuery("SELECT date FROM activity ORDER BY date", null).use { c -> + buildList { while (c.moveToNext()) add(LocalDate.parse(c.getString(0))) } + } + var best = 0; var cur = 0; var prev: LocalDate? = null + for (d in dates) { + cur = if (prev != null && prev.plusDays(1) == d) cur + 1 else 1 + best = maxOf(best, cur); prev = d + } + return best + } + + companion object { + @Volatile private var instance: UserData? = null + fun get(ctx: Context): UserData = instance ?: synchronized(this) { + instance ?: UserData(ctx.applicationContext).also { instance = it } + } + } +} diff --git a/app/src/main/java/space/bloggin/vellum/Widget.kt b/app/src/main/java/space/bloggin/vellum/Widget.kt new file mode 100644 index 0000000..4aca5fb --- /dev/null +++ b/app/src/main/java/space/bloggin/vellum/Widget.kt @@ -0,0 +1,113 @@ +package space.bloggin.vellum + +import android.Manifest +import android.app.AlarmManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.widget.RemoteViews +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import java.util.Calendar + +object Today { + data class V(val ref: Ref, val label: String, val text: String) + + fun get(ctx: Context): V { + val bible = Bible.get(ctx) + val s = Settings.get(ctx) + val r = bible.parseRef(Votd.refFor()) ?: Ref(43, 3, 16) + val ref = if (r.verse == 0) r.copy(verse = 1) else r + return V(ref, bible.refLabel(ref) + " " + bible.trCode(s.tr), bible.plainRange(s.tr, ref)) + } + + fun openIntent(ctx: Context, ref: Ref, code: Int): PendingIntent { + val i = Intent(ctx, MainActivity::class.java).apply { + action = "space.bloggin.vellum.OPEN_${ref.key()}" + putExtra("ref", ref.key()) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + return PendingIntent.getActivity(ctx, code, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } +} + +class VotdWidget : AppWidgetProvider() { + override fun onUpdate(ctx: Context, mgr: AppWidgetManager, ids: IntArray) { + update(ctx, mgr, ids) + } + + companion object { + fun update(ctx: Context, mgr: AppWidgetManager = AppWidgetManager.getInstance(ctx), + ids: IntArray = mgr.getAppWidgetIds(ComponentName(ctx, VotdWidget::class.java))) { + if (ids.isEmpty()) return + val v = Today.get(ctx) + for (id in ids) { + val rv = RemoteViews(ctx.packageName, R.layout.widget_votd) + rv.setTextViewText(R.id.w_text, "“" + v.text + "”") + rv.setTextViewText(R.id.w_ref, v.label) + rv.setOnClickPendingIntent(R.id.w_root, Today.openIntent(ctx, v.ref, 1)) + mgr.updateAppWidget(id, rv) + } + } + + fun refreshAll(ctx: Context) = try { update(ctx) } catch (_: Exception) {} + } +} + +object Daily { + private const val CHANNEL = "votd" + + fun schedule(ctx: Context) { + val s = Settings.get(ctx) + val am = ctx.getSystemService(AlarmManager::class.java) + val pi = PendingIntent.getBroadcast(ctx, 7, Intent(ctx, DailyReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + am.cancel(pi) + val cal = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, if (s.notify) s.notifyHour else 0) + set(Calendar.MINUTE, if (s.notify) s.notifyMinute else 5) + set(Calendar.SECOND, 0) + if (timeInMillis <= System.currentTimeMillis()) add(Calendar.DAY_OF_YEAR, 1) + } + // daily tick: refreshes the widget and (if turned on) posts the verse notification + am.setInexactRepeating(AlarmManager.RTC_WAKEUP, cal.timeInMillis, AlarmManager.INTERVAL_DAY, pi) + } + + fun notifyNow(ctx: Context) { + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(ctx, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return + val nm = ctx.getSystemService(NotificationManager::class.java) + nm.createNotificationChannel(NotificationChannel(CHANNEL, "Verse of the day", NotificationManager.IMPORTANCE_DEFAULT)) + val v = Today.get(ctx) + val n = NotificationCompat.Builder(ctx, CHANNEL) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(v.label) + .setContentText(v.text) + .setStyle(NotificationCompat.BigTextStyle().bigText("“" + v.text + "”")) + .setContentIntent(Today.openIntent(ctx, v.ref, 2)) + .setAutoCancel(true) + .build() + nm.notify(1, n) + } +} + +class DailyReceiver : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + VotdWidget.refreshAll(ctx) + if (Settings.get(ctx).notify) Daily.notifyNow(ctx) + } +} + +class BootReceiver : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + Daily.schedule(ctx) + VotdWidget.refreshAll(ctx) + } +}