some backend fixes and add android app
This commit is contained in:
69 files changed
+7111
-7
No files matched your search
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!--
|
||||
Kadence never declares VpnService. Android allows exactly one active VPN, and on
|
||||
this phone it belongs to the user's always-on connection. Website blocking reads
|
||||
the browser's URL bar through an accessibility service instead; app blocking polls
|
||||
usage stats. Neither takes the VPN slot. Please keep it that way.
|
||||
-->
|
||||
|
||||
<application
|
||||
android:name=".KadenceApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Kadence">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.Kadence">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package tech.kadence.android
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class KadenceApplication : Application()
|
||||
@@ -0,0 +1,176 @@
|
||||
package tech.kadence.android
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import tech.kadence.android.ui.components.GhostButton
|
||||
import tech.kadence.android.ui.components.HorizontalRule
|
||||
import tech.kadence.android.ui.components.PageHeader
|
||||
import tech.kadence.android.ui.components.Panel
|
||||
import tech.kadence.android.ui.components.PresetChip
|
||||
import tech.kadence.android.ui.components.PrimaryButton
|
||||
import tech.kadence.android.ui.components.SectionTitle
|
||||
import tech.kadence.android.ui.components.Stat
|
||||
import tech.kadence.android.ui.components.StatSize
|
||||
import tech.kadence.android.ui.components.TimerDial
|
||||
import tech.kadence.android.ui.components.VerticalRule
|
||||
import tech.kadence.android.ui.theme.KadenceTheme
|
||||
import tech.kadence.core.Kind
|
||||
import tech.kadence.core.KIND_LABEL
|
||||
import tech.kadence.core.humanMinutes
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
KadenceTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = KadenceTheme.colors.paper,
|
||||
) {
|
||||
DesignGallery()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A scratch screen for the design system, standing in until the real Timer screen
|
||||
* lands. It exists so the tokens, the type and the dial can be checked against the web
|
||||
* app side by side on a device.
|
||||
*/
|
||||
@Composable
|
||||
private fun DesignGallery() {
|
||||
val colors = KadenceTheme.colors
|
||||
val planned = 25 * 60
|
||||
|
||||
// A live countdown, so the dial and the fixed-advance clock can be watched ticking.
|
||||
var remaining by remember { mutableIntStateOf(18 * 60 + 24) }
|
||||
var paused by remember { mutableStateOf(false) }
|
||||
var preset by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(paused) {
|
||||
while (!paused) {
|
||||
delay(1000)
|
||||
remaining = if (remaining > 0) remaining - 1 else planned
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(22.dp),
|
||||
) {
|
||||
PageHeader(
|
||||
eyebrow = "Monday, 19 October",
|
||||
title = KIND_LABEL.getValue(Kind.WORK),
|
||||
caption = "Tracking \"Fix the flaky importer\"",
|
||||
) {
|
||||
GhostButton(text = if (paused) "Resume" else "Pause", onClick = { paused = !paused })
|
||||
PrimaryButton(text = "Finish", onClick = { remaining = planned })
|
||||
}
|
||||
|
||||
Panel(padding = 24.dp) {
|
||||
TimerDial(
|
||||
label = KIND_LABEL.getValue(Kind.WORK),
|
||||
remaining = { remaining },
|
||||
plannedSeconds = planned,
|
||||
accent = KadenceTheme.viz.work,
|
||||
sublabel = "Cycle 2 of 4 before a long break",
|
||||
paused = paused,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
listOf("Classic", "Пара", "Custom").forEachIndexed { index, name ->
|
||||
PresetChip(
|
||||
text = name,
|
||||
selected = preset == index,
|
||||
onClick = { preset = index },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionTitle("Today", trailing = "6 of 8")
|
||||
Panel(padding = 0.dp) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Stat(
|
||||
label = "Focus today",
|
||||
value = humanMinutes(155),
|
||||
caption = "35m resting",
|
||||
modifier = Modifier.weight(1f).padding(16.dp),
|
||||
)
|
||||
VerticalRule(height = 70.dp)
|
||||
Stat(
|
||||
label = "Interruptions",
|
||||
value = "3",
|
||||
caption = "counted, not stopped",
|
||||
tabular = true,
|
||||
modifier = Modifier.weight(1f).padding(16.dp),
|
||||
)
|
||||
}
|
||||
HorizontalRule()
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Stat(
|
||||
label = "Score",
|
||||
value = "72",
|
||||
caption = "out of 100",
|
||||
size = StatSize.Large,
|
||||
accent = colors.accent,
|
||||
tabular = true,
|
||||
modifier = Modifier.weight(1f).padding(16.dp),
|
||||
)
|
||||
VerticalRule(height = 84.dp)
|
||||
Stat(
|
||||
label = "Streak",
|
||||
value = "4d",
|
||||
caption = "best 11",
|
||||
size = StatSize.Large,
|
||||
modifier = Modifier.weight(1f).padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SectionTitle("Palette")
|
||||
Panel {
|
||||
Text(
|
||||
text = "The accent is taken from the system palette; paper, ink and the " +
|
||||
"hairlines stay Kadence's. Chart colours never follow the accent.",
|
||||
style = KadenceTheme.type.bodySmall,
|
||||
color = colors.inkMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* The wire shapes.
|
||||
*
|
||||
* The backend has **no response models** — `backend/app/api/schemas.py` only declares
|
||||
* request bodies, and every response is a plain dict built by a service function. So
|
||||
* the only contract is the service code plus `frontend/src/lib/types.ts`, and these DTOs
|
||||
* are reverse-engineered from both.
|
||||
*
|
||||
* That means tolerance is the design, not a nicety: **every field has a default and
|
||||
* every nested object is nullable**, so a backend that grows a key, drops one, or sends
|
||||
* null where a value is declared can never crash the phone. `DtoToleranceTest` asserts
|
||||
* exactly that by mutating committed fixtures.
|
||||
*
|
||||
* Two details worth not re-learning the hard way:
|
||||
* * `TimerStateDto.durations` is in **minutes** (`pomodoro.state()`), not seconds.
|
||||
* * `paused_seconds` exists on the wire but is missing from `types.ts`; the outbox
|
||||
* reconciliation wants it, so it is declared here.
|
||||
*/
|
||||
package tech.kadence.android.data.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TaskRefDto(
|
||||
val source: String = "",
|
||||
@SerialName("external_id") val externalId: String = "",
|
||||
val title: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TimerSessionDto(
|
||||
val id: Long = 0,
|
||||
val kind: String = "work",
|
||||
val status: String = "running",
|
||||
@SerialName("started_at") val startedAt: String? = null,
|
||||
@SerialName("ended_at") val endedAt: String? = null,
|
||||
@SerialName("planned_seconds") val plannedSeconds: Int = 0,
|
||||
@SerialName("elapsed_seconds") val elapsedSeconds: Int = 0,
|
||||
@SerialName("remaining_seconds") val remainingSeconds: Int = 0,
|
||||
@SerialName("paused_seconds") val pausedSeconds: Int = 0,
|
||||
val interruptions: Int = 0,
|
||||
@SerialName("cycle_index") val cycleIndex: Int = 0,
|
||||
val task: TaskRefDto? = null,
|
||||
val note: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TimerStateDto(
|
||||
val current: TimerSessionDto? = null,
|
||||
@SerialName("next_kind") val nextKind: String = "work",
|
||||
@SerialName("completed_today") val completedToday: Int = 0,
|
||||
val goal: Int = 8,
|
||||
@SerialName("cycle_position") val cyclePosition: Int = 0,
|
||||
@SerialName("cycles_before_long_break") val cyclesBeforeLongBreak: Int = 4,
|
||||
/** Minutes per kind, not seconds. */
|
||||
val durations: Map<String, Int> = emptyMap(),
|
||||
@SerialName("server_time") val serverTime: String? = null,
|
||||
/** Only present on a `complete` reply. */
|
||||
val finished: TimerSessionDto? = null,
|
||||
@SerialName("auto_started") val autoStarted: TimerSessionDto? = null,
|
||||
val celebration: String? = null,
|
||||
@SerialName("timer_enabled") val timerEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskDto(
|
||||
val id: Long = 0,
|
||||
val source: String = "",
|
||||
@SerialName("source_label") val sourceLabel: String = "",
|
||||
@SerialName("external_id") val externalId: String = "",
|
||||
val title: String = "",
|
||||
val description: String? = null,
|
||||
val url: String? = null,
|
||||
val project: String? = null,
|
||||
val status: String? = null,
|
||||
val priority: String? = null,
|
||||
@SerialName("due_date") val dueDate: String? = null,
|
||||
@SerialName("start_date") val startDate: String? = null,
|
||||
val labels: List<String> = emptyList(),
|
||||
@SerialName("is_done") val isDone: Boolean = false,
|
||||
val pinned: Boolean = false,
|
||||
val stale: Boolean = false,
|
||||
@SerialName("estimated_pomodoros") val estimatedPomodoros: Int? = null,
|
||||
@SerialName("days_overdue") val daysOverdue: Int = 0,
|
||||
val bucket: String = "someday",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskListDto(
|
||||
val date: String = "",
|
||||
val tasks: List<TaskDto> = emptyList(),
|
||||
val counts: Map<String, Int> = emptyMap(),
|
||||
val sources: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RefreshSourceDto(
|
||||
val source: String = "",
|
||||
val label: String = "",
|
||||
val ok: Boolean = true,
|
||||
val error: String? = null,
|
||||
val count: Int = 0,
|
||||
val closed: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RefreshResultDto(
|
||||
@SerialName("refreshed_at") val refreshedAt: String? = null,
|
||||
val date: String = "",
|
||||
val total: Int = 0,
|
||||
val closed: Int = 0,
|
||||
val sources: List<RefreshSourceDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BlockerPolicyDto(
|
||||
val active: Boolean = false,
|
||||
val reason: String = "",
|
||||
val mode: String = "during_work",
|
||||
val enabled: Boolean = false,
|
||||
val strict: Boolean = false,
|
||||
@SerialName("redirect_ip") val redirectIp: String = "127.0.0.1",
|
||||
/** Empty whenever `active` is false — read `all_domains` instead. */
|
||||
val domains: List<String> = emptyList(),
|
||||
/** Likewise empty when inactive. */
|
||||
val apps: List<String> = emptyList(),
|
||||
val unexpandable: List<String> = emptyList(),
|
||||
@SerialName("kill_apps") val killApps: Boolean = true,
|
||||
@SerialName("notify_on_block") val notifyOnBlock: Boolean = true,
|
||||
@SerialName("poll_seconds") val pollSeconds: Int = 5,
|
||||
@SerialName("session_id") val sessionId: Long? = null,
|
||||
/** Always populated, whatever `active` says. */
|
||||
@SerialName("all_domains") val allDomains: List<String> = emptyList(),
|
||||
@SerialName("all_apps") val allApps: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BlockEventDto(
|
||||
val id: Long = 0,
|
||||
val kind: String = "app",
|
||||
val target: String = "",
|
||||
val action: String = "blocked",
|
||||
val at: String? = null,
|
||||
val details: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TargetCountDto(val target: String = "", val count: Int = 0)
|
||||
|
||||
@Serializable
|
||||
data class BlockEventsDto(
|
||||
val date: String = "",
|
||||
val total: Int = 0,
|
||||
@SerialName("by_target") val byTarget: List<TargetCountDto> = emptyList(),
|
||||
val events: List<BlockEventDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HourCountDto(val hour: Int = 0, val count: Int = 0)
|
||||
|
||||
@Serializable
|
||||
data class CompletedTaskDto(
|
||||
val id: Long = 0,
|
||||
val title: String = "",
|
||||
val source: String = "",
|
||||
val project: String? = null,
|
||||
val url: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskMinutesDto(val title: String = "", val minutes: Int = 0)
|
||||
|
||||
@Serializable
|
||||
data class DaySessionDto(
|
||||
val id: Long = 0,
|
||||
val kind: String = "work",
|
||||
val status: String = "completed",
|
||||
@SerialName("started_at") val startedAt: String? = null,
|
||||
val minutes: Int = 0,
|
||||
val task: String? = null,
|
||||
val interruptions: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DayMetricsDto(
|
||||
val date: String = "",
|
||||
val pomodoros: Int = 0,
|
||||
val goal: Int = 8,
|
||||
@SerialName("goal_pct") val goalPct: Double = 0.0,
|
||||
@SerialName("goal_met") val goalMet: Boolean = false,
|
||||
@SerialName("focus_minutes") val focusMinutes: Int = 0,
|
||||
@SerialName("break_minutes") val breakMinutes: Int = 0,
|
||||
@SerialName("wasted_minutes") val wastedMinutes: Int = 0,
|
||||
val abandoned: Int = 0,
|
||||
@SerialName("breaks_taken") val breaksTaken: Int = 0,
|
||||
val interruptions: Int = 0,
|
||||
@SerialName("completion_rate") val completionRate: Double = 0.0,
|
||||
@SerialName("longest_stretch_minutes") val longestStretchMinutes: Int = 0,
|
||||
@SerialName("first_start") val firstStart: String? = null,
|
||||
@SerialName("last_end") val lastEnd: String? = null,
|
||||
@SerialName("by_hour") val byHour: List<HourCountDto> = emptyList(),
|
||||
@SerialName("peak_hour") val peakHour: Int? = null,
|
||||
@SerialName("tasks_completed") val tasksCompleted: List<CompletedTaskDto> = emptyList(),
|
||||
@SerialName("tasks_completed_count") val tasksCompletedCount: Int = 0,
|
||||
@SerialName("time_by_task") val timeByTask: List<TaskMinutesDto> = emptyList(),
|
||||
@SerialName("blocked_attempts") val blockedAttempts: Int = 0,
|
||||
@SerialName("blocked_top") val blockedTop: List<TargetCountDto> = emptyList(),
|
||||
val sessions: List<DaySessionDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StreakDto(
|
||||
val current: Int = 0,
|
||||
val best: Int = 0,
|
||||
@SerialName("goal_streak") val goalStreak: Int = 0,
|
||||
@SerialName("total_days") val totalDays: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LifetimeDto(
|
||||
val pomodoros: Int = 0,
|
||||
@SerialName("focus_minutes") val focusMinutes: Int = 0,
|
||||
@SerialName("focus_hours") val focusHours: Double = 0.0,
|
||||
@SerialName("tasks_completed") val tasksCompleted: Int = 0,
|
||||
@SerialName("blocked_attempts") val blockedAttempts: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AverageDto(
|
||||
val pomodoros: Double = 0.0,
|
||||
@SerialName("focus_minutes") val focusMinutes: Double = 0.0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ComparisonDto(
|
||||
val yesterday: Int = 0,
|
||||
@SerialName("previous_best") val previousBest: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AchievementDto(
|
||||
val code: String = "",
|
||||
val emoji: String = "",
|
||||
val title: String = "",
|
||||
val detail: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DailyReportDto(
|
||||
val date: String = "",
|
||||
val metrics: DayMetricsDto = DayMetricsDto(),
|
||||
val streak: StreakDto = StreakDto(),
|
||||
@SerialName("average_7d") val average7d: AverageDto = AverageDto(),
|
||||
val lifetime: LifetimeDto = LifetimeDto(),
|
||||
val comparison: ComparisonDto = ComparisonDto(),
|
||||
val achievements: List<AchievementDto> = emptyList(),
|
||||
@SerialName("fun_facts") val funFacts: List<String> = emptyList(),
|
||||
val verdict: String = "",
|
||||
@SerialName("open_tasks") val openTasks: Map<String, Int> = emptyMap(),
|
||||
val score: Double = 0.0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DailyReportEnvelopeDto(
|
||||
val report: DailyReportDto = DailyReportDto(),
|
||||
@SerialName("sent_at") val sentAt: String? = null,
|
||||
@SerialName("telegram_preview") val telegramPreview: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RangeDayDto(
|
||||
val date: String = "",
|
||||
val pomodoros: Int = 0,
|
||||
@SerialName("focus_minutes") val focusMinutes: Int = 0,
|
||||
@SerialName("goal_met") val goalMet: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatsSummaryDto(
|
||||
val today: DayMetricsDto = DayMetricsDto(),
|
||||
val streak: StreakDto = StreakDto(),
|
||||
val lifetime: LifetimeDto = LifetimeDto(),
|
||||
@SerialName("average_7d") val average7d: AverageDto = AverageDto(),
|
||||
val range: List<RangeDayDto> = emptyList(),
|
||||
val heatmap: List<RangeDayDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthMeDto(
|
||||
val authenticated: Boolean = false,
|
||||
@SerialName("login_required") val loginRequired: Boolean = false,
|
||||
val username: String = "",
|
||||
@SerialName("password_login_enabled") val passwordLoginEnabled: Boolean = false,
|
||||
@SerialName("telegram_login_enabled") val telegramLoginEnabled: Boolean = false,
|
||||
@SerialName("telegram_bot_username") val telegramBotUsername: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HealthDto(
|
||||
val status: String = "",
|
||||
val version: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionsResponseDto(
|
||||
val date: String = "",
|
||||
val sessions: List<DaySessionDto> = emptyList(),
|
||||
val metrics: DayMetricsDto = DayMetricsDto(),
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Error shapes, and what a call can come back as.
|
||||
*
|
||||
* FastAPI returns `{"detail": "..."}` for an `HTTPException` and
|
||||
* `{"detail": [{loc, msg, ...}]}` for a 422. Both existing clients unwrap both forms —
|
||||
* `frontend/src/lib/api.ts` and `desktop/kadence_desktop/api.py` — so this does too,
|
||||
* and renders a validation error as `field: message` rather than raw JSON.
|
||||
*/
|
||||
package tech.kadence.android.data.net
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/** The outcome of one call. */
|
||||
sealed interface ApiResult<out T> {
|
||||
data class Ok<T>(val value: T) : ApiResult<T>
|
||||
|
||||
/** A 4xx/5xx the server explained. Never retried blindly. */
|
||||
data class HttpError(val status: Int, val detail: String) : ApiResult<Nothing>
|
||||
|
||||
/** The session or token is no longer good; the UI drops to the login gate. */
|
||||
data class Unauthorized(val detail: String) : ApiResult<Nothing>
|
||||
|
||||
/**
|
||||
* Nothing came back. **Ambiguous for a write**: the request may or may not have
|
||||
* been applied, which is why the outbox probes a post-condition instead of
|
||||
* resending. See `Outbox.kt` in `:core`.
|
||||
*/
|
||||
data class NetworkError(val cause: Throwable) : ApiResult<Nothing>
|
||||
}
|
||||
|
||||
/** Pull a human message out of either `detail` shape; fall back to the status line. */
|
||||
fun parseErrorDetail(body: String?, fallback: String): String {
|
||||
if (body.isNullOrBlank()) return fallback
|
||||
val element = runCatching { KadenceJson.parseToJsonElement(body) }.getOrNull() ?: return fallback
|
||||
val detail = (element as? JsonObject)?.get("detail") ?: return fallback
|
||||
|
||||
// The simple case: {"detail": "No interval is running."}
|
||||
(detail as? JsonPrimitive)?.contentOrNull?.let { return it }
|
||||
|
||||
// The 422 case: {"detail": [{"loc": ["body", "minutes"], "msg": "..."}]}
|
||||
if (detail is JsonArray) {
|
||||
val parts = detail.jsonArray.mapNotNull { item ->
|
||||
val obj = item as? JsonObject ?: return@mapNotNull null
|
||||
val loc = (obj["loc"] as? JsonArray)
|
||||
?.drop(1)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?.joinToString(".")
|
||||
.orEmpty()
|
||||
val msg = (obj["msg"] as? JsonPrimitive)?.contentOrNull.orEmpty()
|
||||
listOf(loc, msg).filter { it.isNotBlank() }.joinToString(": ").ifBlank { null }
|
||||
}
|
||||
if (parts.isNotEmpty()) return parts.joinToString("; ")
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The one JSON configuration.
|
||||
*
|
||||
* This object *is* the tolerance strategy for a backend with no response models:
|
||||
*
|
||||
* * `ignoreUnknownKeys` — the server may grow a field at any time.
|
||||
* * `coerceInputValues` — a declared-non-null field arriving as `null` falls back to
|
||||
* its default instead of throwing.
|
||||
* * `explicitNulls = false` — we never send `null` for an absent value, which matters
|
||||
* for the settings PUT: the backend deep-merges, so an explicit null would clobber.
|
||||
* * `isLenient` — survives the odd unquoted value.
|
||||
*/
|
||||
package tech.kadence.android.data.net
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
val KadenceJson: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
explicitNulls = false
|
||||
isLenient = true
|
||||
encodeDefaults = false
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* The one place that talks to the backend.
|
||||
*
|
||||
* Hand-written over OkHttp rather than generated, for two reasons specific to this
|
||||
* server: the base URL is configured by the user at runtime (so a client that freezes
|
||||
* it at construction is the wrong shape), and there are no response models to generate
|
||||
* from anyway. `desktop/kadence_desktop/api.py` does the same job in 150 stdlib lines;
|
||||
* keeping this file shaped like it means the two clients stay diff-able.
|
||||
*
|
||||
* Auth mirrors the other clients: `X-API-Token` when a token is configured, plus a
|
||||
* cookie jar for the `kadence_session` the browser login sets. Either is sufficient —
|
||||
* `backend/app/api/deps.py` accepts either.
|
||||
*/
|
||||
package tech.kadence.android.data.net
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
/** Where the backend is and how we prove who we are. Both user-supplied at runtime. */
|
||||
interface AuthConfig {
|
||||
val baseUrl: String
|
||||
val apiToken: String?
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds `kadence_session` across calls and process restarts.
|
||||
*
|
||||
* In-memory here; the real implementation persists through the Keystore-wrapped store.
|
||||
* Kept behind an interface so the HTTP tests can use a plain map.
|
||||
*/
|
||||
class SimpleCookieJar : CookieJar {
|
||||
private val cookies = mutableMapOf<String, MutableList<Cookie>>()
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val host = url.host
|
||||
val existing = this.cookies.getOrPut(host) { mutableListOf() }
|
||||
for (cookie in cookies) {
|
||||
existing.removeAll { it.name == cookie.name }
|
||||
existing.add(cookie)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> =
|
||||
cookies[url.host]?.filter { it.expiresAt > System.currentTimeMillis() } ?: emptyList()
|
||||
|
||||
fun clear() = cookies.clear()
|
||||
|
||||
fun named(name: String): Cookie? = cookies.values.flatten().firstOrNull { it.name == name }
|
||||
}
|
||||
|
||||
/** Adds the shared-secret header the desktop client also sends. */
|
||||
class AuthInterceptor(private val config: () -> AuthConfig) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
|
||||
val builder = chain.request().newBuilder()
|
||||
.header("Accept", "application/json")
|
||||
config().apiToken?.takeIf { it.isNotBlank() }?.let {
|
||||
builder.header("X-API-Token", it)
|
||||
}
|
||||
return chain.proceed(builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A 401 on anything but an `/api/auth/` path means the session died mid-use.
|
||||
*
|
||||
* The web app does the same (`api.ts`'s `onUnauthorized`) so the UI can drop to the
|
||||
* login screen instead of showing a generic "backend unreachable".
|
||||
*/
|
||||
class UnauthorizedBus {
|
||||
private val listeners = mutableSetOf<() -> Unit>()
|
||||
|
||||
fun subscribe(listener: () -> Unit): () -> Unit {
|
||||
listeners += listener
|
||||
return { listeners -= listener }
|
||||
}
|
||||
|
||||
fun publish() = listeners.toList().forEach { it() }
|
||||
}
|
||||
|
||||
class KadenceApi(
|
||||
private val config: () -> AuthConfig,
|
||||
val cookieJar: SimpleCookieJar = SimpleCookieJar(),
|
||||
val unauthorized: UnauthorizedBus = UnauthorizedBus(),
|
||||
client: OkHttpClient? = null,
|
||||
) {
|
||||
private val http: OkHttpClient = client ?: OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(false)
|
||||
.cookieJar(cookieJar)
|
||||
.addInterceptor(AuthInterceptor(config))
|
||||
.build()
|
||||
|
||||
/** Raw request; every typed call below funnels through here. */
|
||||
suspend fun request(
|
||||
method: String,
|
||||
path: String,
|
||||
body: String? = null,
|
||||
): ApiResult<String> = withContext(Dispatchers.IO) {
|
||||
val base = config().baseUrl.trimEnd('/')
|
||||
val url = "$base$path".toHttpUrlOrNull()
|
||||
?: return@withContext ApiResult.NetworkError(
|
||||
IOException("not a usable backend URL: $base"),
|
||||
)
|
||||
|
||||
val requestBody = when {
|
||||
body != null -> body.toRequestBody(JSON_MEDIA)
|
||||
method == "POST" || method == "PUT" || method == "PATCH" ->
|
||||
"{}".toRequestBody(JSON_MEDIA)
|
||||
else -> null
|
||||
}
|
||||
|
||||
val request = Request.Builder().url(url).method(method, requestBody).build()
|
||||
|
||||
try {
|
||||
http.newCall(request).execute().use { response ->
|
||||
val text = response.body?.string()
|
||||
if (response.code == 401 && !path.startsWith("/api/auth/")) {
|
||||
unauthorized.publish()
|
||||
return@withContext ApiResult.Unauthorized(
|
||||
parseErrorDetail(text, "not authorised"),
|
||||
)
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext ApiResult.HttpError(
|
||||
response.code,
|
||||
parseErrorDetail(text, "${response.code} ${response.message}"),
|
||||
)
|
||||
}
|
||||
ApiResult.Ok(text.orEmpty())
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
// Deliberately not retried here: for a write this is ambiguous, and the
|
||||
// outbox resolves it with a post-condition probe.
|
||||
ApiResult.NetworkError(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> get(path: String): ApiResult<T> = decode(request("GET", path))
|
||||
|
||||
suspend inline fun <reified T> post(path: String, body: String? = null): ApiResult<T> =
|
||||
decode(request("POST", path, body))
|
||||
|
||||
suspend inline fun <reified T> put(path: String, body: String): ApiResult<T> =
|
||||
decode(request("PUT", path, body))
|
||||
|
||||
suspend inline fun <reified T> patch(path: String, body: String): ApiResult<T> =
|
||||
decode(request("PATCH", path, body))
|
||||
|
||||
suspend inline fun <reified T> delete(path: String): ApiResult<T> =
|
||||
decode(request("DELETE", path))
|
||||
|
||||
/** Decode a successful body, turning a malformed payload into an HttpError. */
|
||||
inline fun <reified T> decode(result: ApiResult<String>): ApiResult<T> = when (result) {
|
||||
is ApiResult.Ok -> runCatching {
|
||||
ApiResult.Ok(KadenceJson.decodeFromString<T>(result.value))
|
||||
}.getOrElse { ApiResult.HttpError(200, "unreadable response: ${it.message}") }
|
||||
is ApiResult.HttpError -> result
|
||||
is ApiResult.Unauthorized -> result
|
||||
is ApiResult.NetworkError -> result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* The shared page furniture, ported from `frontend/src/components/Layout.tsx` and the
|
||||
* QML `components/` set so all clients read as one product.
|
||||
*
|
||||
* The rule throughout: a hairline border does the separating. No shadows, no tonal
|
||||
* elevation, no cards floating over the paper.
|
||||
*/
|
||||
package tech.kadence.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import tech.kadence.android.ui.theme.KadenceTheme
|
||||
|
||||
/** One hairline, defined once so no call site hand-rolls a 1px box. */
|
||||
@Composable
|
||||
fun HorizontalRule(modifier: Modifier = Modifier, strong: Boolean = false) {
|
||||
val colors = KadenceTheme.colors
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(if (strong) colors.ruleStrong else colors.rule),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerticalRule(modifier: Modifier = Modifier, height: Dp) {
|
||||
Box(modifier.width(1.dp).height(height).background(KadenceTheme.colors.rule))
|
||||
}
|
||||
|
||||
/** The small-caps letterspaced label that sits above a serif title. */
|
||||
@Composable
|
||||
fun Eyebrow(text: String, modifier: Modifier = Modifier, color: Color? = null) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = KadenceTheme.type.eyebrow,
|
||||
color = color ?: KadenceTheme.colors.inkMuted,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/** Eyebrow, serif title, optional caption, then a rule. */
|
||||
@Composable
|
||||
fun PageHeader(
|
||||
eyebrow: String,
|
||||
title: String,
|
||||
caption: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
actions: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
Eyebrow(eyebrow)
|
||||
Column(Modifier.padding(top = 2.dp)) {
|
||||
Text(text = title, style = KadenceTheme.type.pageTitle, color = colors.ink)
|
||||
}
|
||||
if (caption != null) {
|
||||
Text(
|
||||
text = caption,
|
||||
style = KadenceTheme.type.bodySmall,
|
||||
color = colors.inkMuted,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
if (actions != null) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) { actions() }
|
||||
}
|
||||
HorizontalRule(Modifier.padding(top = 14.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** An eyebrow, a rule filling the rest of the row, and an optional trailing note. */
|
||||
@Composable
|
||||
fun SectionTitle(
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
trailing: String? = null,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
Row(
|
||||
modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Eyebrow(label)
|
||||
HorizontalRule(Modifier.weight(1f))
|
||||
if (trailing != null) {
|
||||
Text(text = trailing, style = KadenceTheme.type.caption, color = colors.inkFaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A bordered surface. No shadow, no tint — the border does the separating. */
|
||||
@Composable
|
||||
fun Panel(
|
||||
modifier: Modifier = Modifier,
|
||||
padding: Dp = 16.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = colors.surface,
|
||||
border = BorderStroke(1.dp, colors.rule),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
Column(Modifier.padding(padding)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
enum class StatSize { Small, Medium, Large }
|
||||
|
||||
/** Label, serif figure, caption — the web app's `Stat`. */
|
||||
@Composable
|
||||
fun Stat(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
caption: String? = null,
|
||||
size: StatSize = StatSize.Medium,
|
||||
accent: Color? = null,
|
||||
tabular: Boolean = false,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
val style = when (size) {
|
||||
StatSize.Small -> KadenceTheme.type.statSmall
|
||||
StatSize.Medium -> KadenceTheme.type.statMedium
|
||||
StatSize.Large -> KadenceTheme.type.statLarge
|
||||
}
|
||||
Column(modifier) {
|
||||
Eyebrow(label)
|
||||
if (tabular) {
|
||||
TabularFigures(
|
||||
text = value,
|
||||
style = style,
|
||||
color = accent ?: colors.ink,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = value,
|
||||
style = style,
|
||||
color = accent ?: colors.ink,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
if (caption != null) {
|
||||
Text(
|
||||
text = caption,
|
||||
style = KadenceTheme.type.caption,
|
||||
color = colors.inkFaint,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The filled accent button. Exactly one per view. */
|
||||
@Composable
|
||||
fun PrimaryButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier.heightIn(min = 40.dp),
|
||||
enabled = enabled,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = colors.accent,
|
||||
contentColor = colors.onAccent,
|
||||
disabledContainerColor = colors.rule,
|
||||
disabledContentColor = colors.inkFaint,
|
||||
),
|
||||
elevation = null,
|
||||
) {
|
||||
Text(text = text, style = KadenceTheme.type.button)
|
||||
}
|
||||
}
|
||||
|
||||
/** The outlined one. Everything that is not the single primary action. */
|
||||
@Composable
|
||||
fun GhostButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.heightIn(min = 40.dp),
|
||||
enabled = enabled,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = BorderStroke(1.dp, colors.ruleStrong),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = colors.ink,
|
||||
disabledContentColor = colors.inkFaint,
|
||||
),
|
||||
) {
|
||||
Text(text = text, style = KadenceTheme.type.button)
|
||||
}
|
||||
}
|
||||
|
||||
/** The pill used for timer presets. */
|
||||
@Composable
|
||||
fun PresetChip(
|
||||
text: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
Surface(
|
||||
modifier = modifier.heightIn(min = 32.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (selected) colors.accent else Color.Transparent,
|
||||
border = BorderStroke(1.dp, if (selected) colors.accent else colors.rule),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Box(
|
||||
Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = KadenceTheme.type.button,
|
||||
color = if (selected) colors.onAccent else colors.ink,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* A serif number that does not twitch.
|
||||
*
|
||||
* Fraunces has no `tnum` feature and its digits are proportional — "1" is 1024 units
|
||||
* where "0" is 1461 — so a clock redrawn every second would visibly jump as the digits
|
||||
* change. This lays each character out in a box as wide as the widest digit in the
|
||||
* font at that style, which is the only way to get tabular behaviour out of a font that
|
||||
* does not provide it.
|
||||
*
|
||||
* Only worth using for numbers that change in place: the dial clock, the Live Update
|
||||
* text, a stat figure that ticks. Static figures can use a plain `Text`.
|
||||
*/
|
||||
package tech.kadence.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
||||
@Composable
|
||||
fun TabularFigures(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
|
||||
// The widest digit at this exact style, measured once per style change.
|
||||
val digitWidth: Dp = remember(style, density) {
|
||||
val widest = (0..9).maxOf { d ->
|
||||
measurer.measure(AnnotatedString(d.toString()), style = style).size.width
|
||||
}
|
||||
with(density) { widest.toDp() }
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier.clearAndSetSemantics { },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
for (ch in text) {
|
||||
if (ch.isDigit()) {
|
||||
Text(
|
||||
text = ch.toString(),
|
||||
style = style.copy(textAlign = TextAlign.Center),
|
||||
color = color,
|
||||
modifier = Modifier.width(digitWidth),
|
||||
)
|
||||
} else {
|
||||
// Separators keep their natural width; only digits need a fixed box.
|
||||
Text(text = ch.toString(), style = style, color = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* The dial.
|
||||
*
|
||||
* Same geometry as `frontend/src/components/TimerDial.tsx` and
|
||||
* `desktop/.../qml/components/TimerDial.qml`, with the tick arithmetic coming from
|
||||
* `:core` (`dialTickCount` / `dialSpentTicks`) so the dial, the Glyph ring and the
|
||||
* tests can never disagree about what one tick is worth.
|
||||
*
|
||||
* `remaining` is passed as a lambda on purpose: the engine emits a value every second,
|
||||
* and reading it inside the draw scope keeps the 1 Hz tick in the draw phase instead of
|
||||
* recomposing the whole dial sixty times a minute.
|
||||
*/
|
||||
package tech.kadence.android.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import tech.kadence.android.ui.theme.KadenceTheme
|
||||
import tech.kadence.core.clock
|
||||
import tech.kadence.core.dialSpentTicks
|
||||
import tech.kadence.core.dialTickCount
|
||||
import tech.kadence.core.progressOf
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
@Composable
|
||||
fun TimerDial(
|
||||
label: String,
|
||||
remaining: () -> Int,
|
||||
plannedSeconds: Int,
|
||||
accent: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
sublabel: String? = null,
|
||||
paused: Boolean = false,
|
||||
idle: Boolean = false,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
val remainingNow = remaining()
|
||||
val elapsed = progressOf(remainingNow, plannedSeconds)
|
||||
|
||||
// Glide the arc over the second rather than stepping it, matching the web app's
|
||||
// `transition: stroke-dashoffset 1s linear`.
|
||||
val animatedElapsed by animateFloatAsState(
|
||||
targetValue = elapsed,
|
||||
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
|
||||
label = "dialElapsed",
|
||||
)
|
||||
|
||||
val clockAlpha by animateFloatAsState(
|
||||
targetValue = if (paused) 0.55f else 1f,
|
||||
animationSpec = tween(durationMillis = 180),
|
||||
label = "clockAlpha",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.semantics {
|
||||
contentDescription = "$label: ${clock(remainingNow)} remaining"
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
Canvas(Modifier.fillMaxWidth().aspectRatio(1f)) {
|
||||
val side = minOf(size.width, size.height)
|
||||
// Keep the web app's 26:300 inset ratio so the dial scales rather than
|
||||
// assuming a 300px box.
|
||||
val inset = side * (26f / 300f)
|
||||
val radius = side / 2f - inset
|
||||
val centre = Offset(size.width / 2f, size.height / 2f)
|
||||
|
||||
val count = dialTickCount(plannedSeconds)
|
||||
val spent = dialSpentTicks(plannedSeconds, remainingNow)
|
||||
|
||||
// The base ring.
|
||||
drawCircle(
|
||||
color = colors.rule,
|
||||
radius = radius,
|
||||
center = centre,
|
||||
style = Stroke(width = with(density) { 1.5.dp.toPx() }),
|
||||
)
|
||||
|
||||
// One tick per minute, every fifth one major, spent ticks tinted.
|
||||
val tickOuter = radius - side * (7f / 300f)
|
||||
for (i in 0 until count) {
|
||||
val major = i % 5 == 0
|
||||
val lengthPx = side * (if (major) 9f else 5f) / 300f
|
||||
val angle = i.toFloat() / count * 2f * PI.toFloat() - PI.toFloat() / 2f
|
||||
val cosA = cos(angle)
|
||||
val sinA = sin(angle)
|
||||
val inner = tickOuter - lengthPx
|
||||
val isSpent = i < spent && !idle
|
||||
drawLine(
|
||||
color = if (isSpent) accent else colors.rule,
|
||||
start = Offset(centre.x + tickOuter * cosA, centre.y + tickOuter * sinA),
|
||||
end = Offset(centre.x + inner * cosA, centre.y + inner * sinA),
|
||||
strokeWidth = with(density) { (if (major) 1.4.dp else 1.dp).toPx() },
|
||||
cap = StrokeCap.Round,
|
||||
alpha = if (idle) 0.5f else 1f,
|
||||
)
|
||||
}
|
||||
|
||||
// The remaining arc, draining clockwise from twelve.
|
||||
if (!idle) {
|
||||
val stroke = with(density) { 2.5.dp.toPx() }
|
||||
drawArc(
|
||||
color = accent,
|
||||
startAngle = -90f,
|
||||
sweepAngle = (1f - animatedElapsed) * 360f,
|
||||
useCenter = false,
|
||||
topLeft = Offset(centre.x - radius, centre.y - radius),
|
||||
size = Size(radius * 2, radius * 2),
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round),
|
||||
alpha = if (paused) 0.4f else 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth(0.7f),
|
||||
) {
|
||||
Eyebrow(label, color = if (idle) colors.inkMuted else accent)
|
||||
// Fraunces has no tabular figures, so the clock gets fixed-advance boxes.
|
||||
TabularFigures(
|
||||
text = clock(remainingNow),
|
||||
style = KadenceTheme.type.clock,
|
||||
color = colors.ink.copy(alpha = clockAlpha),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
if (sublabel != null) {
|
||||
Text(
|
||||
text = sublabel,
|
||||
style = KadenceTheme.type.caption,
|
||||
color = colors.inkMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The theme.
|
||||
*
|
||||
* Colour values are not defined here — they live in `:core` as ARGB longs, checked
|
||||
* against `frontend/src/tokens.ts` by `TokenParityTest`. This file only wraps them in
|
||||
* Compose types and decides the accent.
|
||||
*
|
||||
* The palette is semantic (paper / ink / rule / ember), not Material roles, so it rides
|
||||
* on its own CompositionLocals. A minimal `ColorScheme` is derived from it purely so
|
||||
* that `Switch`, `TextField` and the rest of M3 behave like Android components instead
|
||||
* of being restyled from scratch.
|
||||
*/
|
||||
package tech.kadence.android.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import tech.kadence.core.KadenceTokens
|
||||
import tech.kadence.core.VizTokens
|
||||
import tech.kadence.core.accentArgb
|
||||
import tech.kadence.core.kadenceTokens
|
||||
import tech.kadence.core.onAccentArgb
|
||||
import tech.kadence.core.vizTokens
|
||||
|
||||
/** An `0xAARRGGBB` long from `:core` as a Compose colour. */
|
||||
private fun Long.toColor(): Color = Color(this.toInt())
|
||||
|
||||
/** The semantic palette, as Compose colours. */
|
||||
@Immutable
|
||||
data class KadenceColors(
|
||||
val paper: Color,
|
||||
val surface: Color,
|
||||
val surfaceSunken: Color,
|
||||
val ink: Color,
|
||||
val inkMuted: Color,
|
||||
val inkFaint: Color,
|
||||
val rule: Color,
|
||||
val ruleStrong: Color,
|
||||
val accent: Color,
|
||||
val onAccent: Color,
|
||||
val accentSoft: Color,
|
||||
val pine: Color,
|
||||
val amber: Color,
|
||||
val clay: Color,
|
||||
)
|
||||
|
||||
/** The chart palette. Separate on purpose — see `VizTokens` in `:core`. */
|
||||
@Immutable
|
||||
data class KadenceViz(
|
||||
val work: Color,
|
||||
val shortBreak: Color,
|
||||
val longBreak: Color,
|
||||
val sequential: List<Color>,
|
||||
val grid: Color,
|
||||
val axis: Color,
|
||||
val empty: Color,
|
||||
)
|
||||
|
||||
private fun colorsFrom(tokens: KadenceTokens, accent: Long, accentSoft: Long): KadenceColors =
|
||||
KadenceColors(
|
||||
paper = tokens.paper.toColor(),
|
||||
surface = tokens.surface.toColor(),
|
||||
surfaceSunken = tokens.surfaceSunken.toColor(),
|
||||
ink = tokens.ink.toColor(),
|
||||
inkMuted = tokens.inkMuted.toColor(),
|
||||
inkFaint = tokens.inkFaint.toColor(),
|
||||
rule = tokens.rule.toColor(),
|
||||
ruleStrong = tokens.ruleStrong.toColor(),
|
||||
accent = accent.toColor(),
|
||||
onAccent = onAccentArgb(accent).toColor(),
|
||||
accentSoft = accentSoft.toColor(),
|
||||
pine = tokens.pine.toColor(),
|
||||
amber = tokens.amber.toColor(),
|
||||
clay = tokens.clay.toColor(),
|
||||
)
|
||||
|
||||
private fun vizFrom(tokens: VizTokens): KadenceViz = KadenceViz(
|
||||
work = tokens.work.toColor(),
|
||||
shortBreak = tokens.shortBreak.toColor(),
|
||||
longBreak = tokens.longBreak.toColor(),
|
||||
sequential = tokens.sequential.map { it.toColor() },
|
||||
grid = tokens.grid.toColor(),
|
||||
axis = tokens.axis.toColor(),
|
||||
empty = tokens.empty.toColor(),
|
||||
)
|
||||
|
||||
val LocalKadenceColors = staticCompositionLocalOf {
|
||||
colorsFrom(kadenceTokens(false), kadenceTokens(false).ember, kadenceTokens(false).emberSoft)
|
||||
}
|
||||
val LocalKadenceViz = staticCompositionLocalOf { vizFrom(vizTokens(false)) }
|
||||
val LocalKadenceTypography = staticCompositionLocalOf { kadenceTypography() }
|
||||
|
||||
/** Reads as `KadenceTheme.colors.ink` at a call site. */
|
||||
object KadenceTheme {
|
||||
val colors: KadenceColors
|
||||
@Composable get() = LocalKadenceColors.current
|
||||
val viz: KadenceViz
|
||||
@Composable get() = LocalKadenceViz.current
|
||||
val type: KadenceTypography
|
||||
@Composable get() = LocalKadenceTypography.current
|
||||
}
|
||||
|
||||
/**
|
||||
* @param dynamicAccent take the accent from the system's wallpaper-derived palette.
|
||||
* The canvas stays Kadence's either way; only the single accent changes. See
|
||||
* `accentArgb` / `onAccentArgb` in `:core` for the policy and its contrast guard.
|
||||
*/
|
||||
@Composable
|
||||
fun KadenceTheme(
|
||||
dark: Boolean = isSystemInDarkTheme(),
|
||||
dynamicAccent: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val tokens = kadenceTokens(dark)
|
||||
|
||||
val systemAccent: Long? = if (dynamicAccent && Build.VERSION.SDK_INT >= 31) {
|
||||
val scheme = if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
scheme.primary.toArgb().toLong() and 0xFFFFFFFFL
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val accent = accentArgb(systemAccent, dark)
|
||||
// The soft wash keeps the accent's hue; derive it rather than reusing ember's.
|
||||
val accentSoft = (accent and 0x00FFFFFFL) or (if (dark) 0x24000000L else 0x1A000000L)
|
||||
|
||||
val colors = colorsFrom(tokens, accent, accentSoft)
|
||||
val viz = vizFrom(vizTokens(dark))
|
||||
|
||||
// M3 exists only to make stock components behave; nothing reads these roles for
|
||||
// layout, and tonal elevation is never used.
|
||||
val base = if (dark) darkColorScheme() else lightColorScheme()
|
||||
val scheme = base.copy(
|
||||
primary = colors.accent,
|
||||
onPrimary = colors.onAccent,
|
||||
secondary = colors.pine,
|
||||
tertiary = colors.amber,
|
||||
error = colors.clay,
|
||||
background = colors.paper,
|
||||
onBackground = colors.ink,
|
||||
surface = colors.surface,
|
||||
onSurface = colors.ink,
|
||||
surfaceVariant = colors.surfaceSunken,
|
||||
onSurfaceVariant = colors.inkMuted,
|
||||
outline = colors.ruleStrong,
|
||||
outlineVariant = colors.rule,
|
||||
)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalKadenceColors provides colors,
|
||||
LocalKadenceViz provides viz,
|
||||
LocalKadenceTypography provides kadenceTypography(),
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = scheme,
|
||||
shapes = Shapes(
|
||||
extraSmall = androidx.compose.foundation.shape.RoundedCornerShape(6.dp),
|
||||
small = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
|
||||
medium = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||
large = androidx.compose.foundation.shape.RoundedCornerShape(14.dp),
|
||||
),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Type.
|
||||
*
|
||||
* Fraunces for figures and titles, Inter for everything else — the same pairing as the
|
||||
* web app and the desktop app, bundled as variable fonts (both SIL OFL; the licences
|
||||
* ship in `res/raw`).
|
||||
*
|
||||
* **The tabular-figures problem.** The web app sets `font-variant-numeric: tabular-nums`
|
||||
* globally, but Fraunces has no `tnum` feature *and* its digits are proportional — a
|
||||
* "1" is 1024 units against a "0" at 1461, a 30% difference. So that CSS does nothing
|
||||
* for the serif clock, and a 1 Hz countdown in Fraunces visibly jiggles. Inter does ship
|
||||
* `tnum`, so the sans numbers are fixed with a font feature; the serif ones are fixed by
|
||||
* [tech.kadence.android.ui.components.TabularFigures], which lays every glyph in a box
|
||||
* the width of the widest digit.
|
||||
*/
|
||||
package tech.kadence.android.ui.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import tech.kadence.android.R
|
||||
|
||||
private fun fraunces(weight: Int, opsz: Float) = Font(
|
||||
resId = R.font.fraunces,
|
||||
weight = FontWeight(weight),
|
||||
variationSettings = FontVariation.Settings(
|
||||
FontVariation.weight(weight),
|
||||
FontVariation.opticalSizing(opsz.sp),
|
||||
),
|
||||
)
|
||||
|
||||
private fun inter(weight: Int) = Font(
|
||||
resId = R.font.inter,
|
||||
weight = FontWeight(weight),
|
||||
variationSettings = FontVariation.Settings(FontVariation.weight(weight)),
|
||||
)
|
||||
|
||||
/** Display serif. Optical sizing is set per weight, as the web app's `opsz` axis does. */
|
||||
val Fraunces = FontFamily(
|
||||
fraunces(400, 72f),
|
||||
fraunces(500, 72f),
|
||||
fraunces(600, 72f),
|
||||
)
|
||||
|
||||
val Inter = FontFamily(
|
||||
inter(400),
|
||||
inter(500),
|
||||
inter(600),
|
||||
)
|
||||
|
||||
/** Inter ships `tnum`; this is what keeps the sans numbers from twitching. */
|
||||
private const val TABULAR = "tnum"
|
||||
|
||||
@Immutable
|
||||
data class KadenceTypography(
|
||||
/** Small-caps-ish eyebrow above a title. Uppercase at the call site, not here. */
|
||||
val eyebrow: TextStyle,
|
||||
val pageTitle: TextStyle,
|
||||
val statSmall: TextStyle,
|
||||
val statMedium: TextStyle,
|
||||
val statLarge: TextStyle,
|
||||
/** The dial clock; its size is supplied by the dial (~0.21 x its width). */
|
||||
val clock: TextStyle,
|
||||
val body: TextStyle,
|
||||
val bodySmall: TextStyle,
|
||||
val caption: TextStyle,
|
||||
val button: TextStyle,
|
||||
val mono: TextStyle,
|
||||
)
|
||||
|
||||
fun kadenceTypography(): KadenceTypography = KadenceTypography(
|
||||
eyebrow = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 10.5.sp,
|
||||
letterSpacing = 0.12.em,
|
||||
lineHeight = 16.sp,
|
||||
),
|
||||
pageTitle = TextStyle(
|
||||
fontFamily = Fraunces,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 27.sp,
|
||||
letterSpacing = (-0.02).em,
|
||||
lineHeight = 32.sp,
|
||||
),
|
||||
statSmall = TextStyle(
|
||||
fontFamily = Fraunces,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 24.sp,
|
||||
letterSpacing = (-0.02).em,
|
||||
lineHeight = 27.sp,
|
||||
),
|
||||
statMedium = TextStyle(
|
||||
fontFamily = Fraunces,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 30.sp,
|
||||
letterSpacing = (-0.02).em,
|
||||
lineHeight = 33.sp,
|
||||
),
|
||||
statLarge = TextStyle(
|
||||
fontFamily = Fraunces,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 38.sp,
|
||||
letterSpacing = (-0.02).em,
|
||||
lineHeight = 42.sp,
|
||||
),
|
||||
clock = TextStyle(
|
||||
fontFamily = Fraunces,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 56.sp,
|
||||
letterSpacing = (-0.03).em,
|
||||
lineHeight = 60.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
body = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 23.sp,
|
||||
fontFeatureSettings = TABULAR,
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 13.5.sp,
|
||||
lineHeight = 21.sp,
|
||||
fontFeatureSettings = TABULAR,
|
||||
),
|
||||
caption = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 18.sp,
|
||||
fontFeatureSettings = TABULAR,
|
||||
),
|
||||
button = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
letterSpacing = 0.em,
|
||||
),
|
||||
mono = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The tomato and leaf from desktop/packaging/kadence.svg, in the adaptive-icon
|
||||
safe zone. The white arc is the timer ring, draining clockwise from the top. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<path
|
||||
android:fillColor="#E8502F"
|
||||
android:pathData="M54,60 m-25,0 a25,25 0 1,0 50,0 a25,25 0 1,0 -50,0" />
|
||||
|
||||
<path
|
||||
android:pathData="M54,43 A17,17 0 0 1 71,60"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="5"
|
||||
android:strokeLineCap="round" />
|
||||
|
||||
<path
|
||||
android:fillColor="#3E8E41"
|
||||
android:pathData="M54,35 C46,33 42,27 43,22 C50,22 55,27 54,35 Z" />
|
||||
|
||||
<path
|
||||
android:strokeColor="#2E7D32"
|
||||
android:strokeWidth="3"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M54,35 L54,27" />
|
||||
</vector>
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/icon_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- tokens.ts `dark.paper`. -->
|
||||
<color name="paper">#121110</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Mirrors tokens.ts `light.paper`; only used for the pre-Compose window frame. -->
|
||||
<color name="paper">#FAF7F2</color>
|
||||
<!-- The launcher icon's ground: paper, so the icon reads as the same product. -->
|
||||
<color name="icon_background">#FAF7F2</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Kadence</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
A bare platform theme: the paper ground and every colour come from Compose, not
|
||||
from Android resources, so there is exactly one source of truth for the palette.
|
||||
windowBackground is set so the cold-start frame is paper rather than white.
|
||||
-->
|
||||
<style name="Theme.Kadence" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/paper</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in new issue
Block a user