Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb723fba68 | |||
|
|
ffc7ed414f | ||
| af75965a31 | |||
| 1b456d2133 | |||
| a826e82bdc | |||
| ed680b4482 |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.1.1] — 2026-06-08
|
||||
|
||||
### Fixed
|
||||
- F-Droid metadata format: renamed locale dirs from `de/` to `de-DE/`,
|
||||
`short_description.txt` to `summary.txt`, `full_description.txt` to
|
||||
`description.txt` (fastlane format that fdroidserver actually reads,
|
||||
matching the working HouseHoldKeaper convention)
|
||||
- Added `icon.png` (512x512) per locale; fdroidserver does NOT
|
||||
auto-extract icons from APKs that only contain XML adaptive icons
|
||||
(which is what minSdk-29 apps produce), so the app was rendered
|
||||
blank-iconed in F-Droid clients
|
||||
|
||||
### Changed
|
||||
- CI pipeline cleanup: `lintDebug`/`testDebugUnitTest` instead of full
|
||||
`lint`/`test` (cuts ~50% of lint work since release variant lint is
|
||||
redundant for V1 single-variant build)
|
||||
- Release workflow drops the lint step from its CI-sanity job since
|
||||
the same lint already ran via `ci.yaml` when the underlying commit
|
||||
hit main
|
||||
|
||||
## [0.1.0] — 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -74,7 +74,10 @@ android {
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.all { it.useJUnitPlatform() }
|
||||
unitTests {
|
||||
all { it.useJUnitPlatform() }
|
||||
isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +90,7 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
@@ -96,10 +100,14 @@ dependencies {
|
||||
implementation(libs.androidx.material3)
|
||||
|
||||
implementation(libs.hilt.android)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
|
||||
@@ -107,9 +115,12 @@ dependencies {
|
||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
testImplementation(libs.truth)
|
||||
testImplementation(libs.turbine)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.test.rules)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
|
||||
internal object CalendarProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Calendars._ID,
|
||||
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME,
|
||||
CalendarContract.Calendars.ACCOUNT_NAME,
|
||||
CalendarContract.Calendars.ACCOUNT_TYPE,
|
||||
CalendarContract.Calendars.CALENDAR_COLOR,
|
||||
CalendarContract.Calendars.VISIBLE,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
const val IDX_DISPLAY_NAME = 1
|
||||
const val IDX_ACCOUNT_NAME = 2
|
||||
const val IDX_ACCOUNT_TYPE = 3
|
||||
const val IDX_COLOR = 4
|
||||
const val IDX_VISIBLE = 5
|
||||
}
|
||||
|
||||
internal object InstanceProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Instances._ID,
|
||||
CalendarContract.Instances.EVENT_ID,
|
||||
CalendarContract.Instances.CALENDAR_ID,
|
||||
CalendarContract.Instances.TITLE,
|
||||
CalendarContract.Instances.BEGIN,
|
||||
CalendarContract.Instances.END,
|
||||
CalendarContract.Instances.ALL_DAY,
|
||||
CalendarContract.Instances.EVENT_COLOR,
|
||||
CalendarContract.Instances.CALENDAR_COLOR,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
)
|
||||
|
||||
const val IDX_INSTANCE_ID = 0
|
||||
const val IDX_EVENT_ID = 1
|
||||
const val IDX_CALENDAR_ID = 2
|
||||
const val IDX_TITLE = 3
|
||||
const val IDX_BEGIN = 4
|
||||
const val IDX_END = 5
|
||||
const val IDX_ALL_DAY = 6
|
||||
const val IDX_EVENT_COLOR = 7
|
||||
const val IDX_CALENDAR_COLOR = 8
|
||||
const val IDX_LOCATION = 9
|
||||
}
|
||||
|
||||
internal object EventDetailProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
CalendarContract.Events.ORGANIZER,
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.EVENT_COLOR,
|
||||
CalendarContract.Events.CALENDAR_COLOR,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
)
|
||||
|
||||
const val IDX_EVENT_ID = 0
|
||||
const val IDX_TITLE = 1
|
||||
const val IDX_DESCRIPTION = 2
|
||||
const val IDX_ORGANIZER = 3
|
||||
const val IDX_RRULE = 4
|
||||
const val IDX_EVENT_COLOR = 5
|
||||
const val IDX_CALENDAR_COLOR = 6
|
||||
const val IDX_DTSTART = 7
|
||||
const val IDX_DTEND = 8
|
||||
const val IDX_ALL_DAY = 9
|
||||
const val IDX_LOCATION = 10
|
||||
const val IDX_CALENDAR_ID = 11
|
||||
}
|
||||
|
||||
internal object AttendeeProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS,
|
||||
)
|
||||
|
||||
const val IDX_NAME = 0
|
||||
const val IDX_EMAIL = 1
|
||||
const val IDX_STATUS = 2
|
||||
}
|
||||
|
||||
internal object Fallbacks {
|
||||
const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
|
||||
const val UNTITLED_EVENT = "(Ohne Titel)"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
fun Long.toKotlinInstantFromEpochMillis(): Instant = Instant.fromEpochMilliseconds(this)
|
||||
|
||||
fun Instant.toEpochMillis(): Long = toEpochMilliseconds()
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
data class CalendarSource(
|
||||
val id: Long,
|
||||
val displayName: String,
|
||||
val accountName: String,
|
||||
val accountType: String,
|
||||
val color: Int,
|
||||
val isVisibleInSystem: Boolean,
|
||||
)
|
||||
|
||||
data class EventInstance(
|
||||
val instanceId: Long,
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val title: String,
|
||||
val start: Instant,
|
||||
val end: Instant,
|
||||
val isAllDay: Boolean,
|
||||
val color: Int,
|
||||
val location: String?,
|
||||
)
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
val organizer: String?,
|
||||
val attendees: List<Attendee>,
|
||||
val rrule: String?,
|
||||
)
|
||||
|
||||
data class Attendee(
|
||||
val name: String,
|
||||
val email: String?,
|
||||
val status: AttendeeStatus,
|
||||
)
|
||||
|
||||
enum class AttendeeStatus {
|
||||
Accepted,
|
||||
Declined,
|
||||
Tentative,
|
||||
NeedsAction,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
enum class FailureReason {
|
||||
PermissionRevoked,
|
||||
NoCalendarsConfigured,
|
||||
ProviderUnavailable,
|
||||
EventNotFound,
|
||||
Unknown,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TimeBridgeTest {
|
||||
|
||||
@Test
|
||||
fun `epoch millis round-trips through Instant`() {
|
||||
val original = 1_717_840_800_000L // 2024-06-08T10:00:00Z
|
||||
val instant = original.toKotlinInstantFromEpochMillis()
|
||||
assertThat(instant.toEpochMillis()).isEqualTo(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero millis maps to Instant epoch`() {
|
||||
assertThat(0L.toKotlinInstantFromEpochMillis()).isEqualTo(Instant.fromEpochMilliseconds(0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative epoch millis is supported`() {
|
||||
val original = -1_000_000L
|
||||
assertThat(original.toKotlinInstantFromEpochMillis().toEpochMillis()).isEqualTo(original)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ModelsTest {
|
||||
|
||||
@Test
|
||||
fun `CalendarSource is a data class with structural equality`() {
|
||||
val a = CalendarSource(1L, "Work", "x@y", "com.google", 0xFF112233.toInt(), true)
|
||||
val b = CalendarSource(1L, "Work", "x@y", "com.google", 0xFF112233.toInt(), true)
|
||||
assertThat(a).isEqualTo(b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EventInstance is a data class with structural equality`() {
|
||||
val start = Instant.fromEpochMilliseconds(0L)
|
||||
val end = Instant.fromEpochMilliseconds(3_600_000L)
|
||||
val a = EventInstance(10L, 1L, 1L, "Meet", start, end, false, 0xFF000000.toInt(), null)
|
||||
val b = EventInstance(10L, 1L, 1L, "Meet", start, end, false, 0xFF000000.toInt(), null)
|
||||
assertThat(a).isEqualTo(b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AttendeeStatus enum has all five variants`() {
|
||||
assertThat(AttendeeStatus.values().toSet()).isEqualTo(
|
||||
setOf(
|
||||
AttendeeStatus.Accepted,
|
||||
AttendeeStatus.Declined,
|
||||
AttendeeStatus.Tentative,
|
||||
AttendeeStatus.NeedsAction,
|
||||
AttendeeStatus.Unknown,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FailureReason enum has all five variants`() {
|
||||
assertThat(FailureReason.values().toSet()).isEqualTo(
|
||||
setOf(
|
||||
FailureReason.PermissionRevoked,
|
||||
FailureReason.NoCalendarsConfigured,
|
||||
FailureReason.ProviderUnavailable,
|
||||
FailureReason.EventNotFound,
|
||||
FailureReason.Unknown,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EventDetail composes EventInstance plus extras`() {
|
||||
val instance = EventInstance(
|
||||
instanceId = 10L,
|
||||
eventId = 1L,
|
||||
calendarId = 1L,
|
||||
title = "Meet",
|
||||
start = Instant.fromEpochMilliseconds(0L),
|
||||
end = Instant.fromEpochMilliseconds(60_000L),
|
||||
isAllDay = false,
|
||||
color = 0xFFAABBCC.toInt(),
|
||||
location = null,
|
||||
)
|
||||
val detail = EventDetail(
|
||||
instance = instance,
|
||||
description = "Brief description",
|
||||
organizer = "x@y",
|
||||
attendees = listOf(Attendee("Alice", "alice@x", AttendeeStatus.Accepted)),
|
||||
rrule = "FREQ=WEEKLY",
|
||||
)
|
||||
assertThat(detail.instance.title).isEqualTo("Meet")
|
||||
assertThat(detail.attendees).hasSize(1)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
BIN
fdroid-metadata/de.jeanlucmakiola.calendula/de-DE/icon.png
Normal file
BIN
fdroid-metadata/de.jeanlucmakiola.calendula/de-DE/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
fdroid-metadata/de.jeanlucmakiola.calendula/en-US/icon.png
Normal file
BIN
fdroid-metadata/de.jeanlucmakiola.calendula/en-US/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -17,6 +17,12 @@ junitPlatform = "6.1.0"
|
||||
truth = "1.4.5"
|
||||
androidxJunit = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
kotlinxDatetime = "0.7.0"
|
||||
kotlinxCoroutines = "1.10.2"
|
||||
turbine = "1.2.0"
|
||||
hiltNavigationCompose = "1.3.0"
|
||||
lifecycleCompose = "2.10.0"
|
||||
androidxTestRules = "1.7.0"
|
||||
|
||||
[libraries]
|
||||
# AndroidX core
|
||||
@@ -53,6 +59,25 @@ truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
|
||||
# Domain time
|
||||
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
|
||||
|
||||
# Coroutines (transitively pulled by hilt-android, pinned explicit)
|
||||
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||
|
||||
# Test - Flow assertions
|
||||
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
|
||||
|
||||
# Hilt navigation-compose (for hiltViewModel() in Composables)
|
||||
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
||||
|
||||
# Lifecycle compose (for collectAsStateWithLifecycle)
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }
|
||||
|
||||
# Android tests - GrantPermissionRule
|
||||
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
|
||||
Reference in New Issue
Block a user