diff --git a/README.md b/README.md
index a319557..232c5ed 100644
--- a/README.md
+++ b/README.md
@@ -37,13 +37,16 @@ redistribution, and reading belongs with the outlet that did the work.
## Screenshots
-
For you
Explore
+
Explore
Dark theme
-
-
+
+
+Captures from the R8-minified build against the live API — see
+[playstore/README.md](playstore/README.md) for how they are made.
+
## Architecture
Clean Architecture with MVI on the screens that have a real state machine. Full notes in
diff --git a/app/build.gradle b/app/build.gradle
index 2bb70cc..dcb4120 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,150 +1,145 @@
-apply plugin: 'com.android.application'
-apply plugin: 'kotlin-android'
-apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
-apply plugin: 'org.jetbrains.kotlin.plugin.compose'
-apply plugin: 'kotlin-kapt'
-apply plugin: 'dagger.hilt.android.plugin'
+// No kotlin-android plugin: AGP 9 compiles Kotlin itself (built-in Kotlin).
+// No kotlin-kapt either — it is incompatible with built-in Kotlin, so Room and Hilt both
+// run through KSP now.
+//
+// Versions live in gradle/libs.versions.toml. There are no version literals in this file.
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.hilt)
+}
+
apply from: '../ktlint.gradle'
// No API key here, and none in the APK: the app talks to infotify-api.nativia.co, which
// holds the provider key server-side.
android {
- namespace "com.thecode.infotify"
- compileSdk 35
+ namespace = "com.thecode.infotify"
+ compileSdk = 37
+
defaultConfig {
- applicationId "com.thecode.infotify"
- // 26, not 24: the typographic identity depends on variable-font axes, which
- // Android only honours from API 26. It also makes java.time native, so the
- // desugaring library is no longer needed.
- minSdkVersion 26
- targetSdkVersion 35
- versionCode 2000
- versionName "2.0.0"
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ applicationId = "com.thecode.infotify"
+ // 26, not lower: the typographic identity depends on variable-font axes, which
+ // Android only honours from API 26. It also makes java.time native, so no
+ // desugaring library is needed.
+ minSdk = 26
+ targetSdk = 37
+ versionCode = 3000
+ versionName = "3.0.0"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
+
buildTypes {
release {
- minifyEnabled true
- shrinkResources true
+ minifyEnabled = true
+ shrinkResources = true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
// A release build that can actually be installed and exercised locally.
//
// R8 breaks things silently — a missing keep rule produces null fields, not a
- // crash — so the minified build has to be run, not merely produced. This variant
- // is identical to release except that it is signed with the debug key, so the real
- // release signing config is never involved in local testing.
+ // crash — so the minified build has to be run, not merely produced. Identical to
+ // release except that it is signed with the debug key, so the real release
+ // signing config is never involved in local testing.
releaseTest {
initWith release
- signingConfig signingConfigs.debug
+ signingConfig = signingConfigs.debug
matchingFallbacks = ['release']
}
}
+
buildFeatures {
- compose true
- buildConfig true
+ compose = true
+ buildConfig = true
}
compileOptions {
- sourceCompatibility JavaVersion.VERSION_11
- targetCompatibility JavaVersion.VERSION_11
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
- kotlinOptions { jvmTarget = "11" }
-
testOptions {
unitTests.returnDefaultValues = true
}
}
// Room schemas are exported so migrations can be diffed and tested.
-kapt {
- arguments {
- arg("room.schemaLocation", "$projectDir/schemas")
- }
+// This moved from the kapt block when annotation processing moved to KSP.
+ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies {
- // COMPOSE — single source of UI. No View system, no ViewBinding, no ComposeView bridges.
- def composeBom = platform('androidx.compose:compose-bom:2024.12.01')
- implementation composeBom
- androidTestImplementation composeBom
- implementation 'androidx.compose.ui:ui'
- implementation 'androidx.compose.ui:ui-graphics'
- implementation 'androidx.compose.ui:ui-tooling-preview'
- implementation 'androidx.compose.material3:material3'
- implementation 'androidx.compose.material:material-icons-extended'
- implementation 'androidx.activity:activity-compose:1.9.3'
- implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
- implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
- implementation 'androidx.navigation:navigation-compose:2.8.5'
- implementation 'androidx.hilt:hilt-navigation-compose:1.2.0'
- implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3'
- debugImplementation 'androidx.compose.ui:ui-tooling'
- androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
- debugImplementation 'androidx.compose.ui:ui-test-manifest'
+ // COMPOSE — the only UI toolkit here. No View system, no ViewBinding, no ComposeView.
+ // The BOM sets every Compose artifact's version, so those are declared without one.
+ implementation platform(libs.compose.bom)
+ implementation libs.compose.ui
+ implementation libs.compose.ui.graphics
+ implementation libs.compose.ui.tooling.preview
+ implementation libs.compose.material3
+ implementation libs.compose.material.icons.extended
+ implementation libs.androidx.activity.compose
+ implementation libs.androidx.lifecycle.runtime.compose
+ implementation libs.androidx.lifecycle.viewmodel.compose
+ implementation libs.androidx.navigation.compose
+ implementation libs.androidx.hilt.navigation.compose
+ implementation libs.kotlinx.serialization.json
+ debugImplementation libs.compose.ui.tooling
// SPLASH SCREEN API
- implementation 'androidx.core:core-splashscreen:1.0.1'
+ implementation libs.androidx.core.splashscreen
- // COIL — Compose-native image loading, replaces Glide
- implementation 'io.coil-kt:coil-compose:2.7.0'
+ // COIL — Compose-native image loading
+ implementation libs.coil.compose
- implementation 'androidx.appcompat:appcompat:1.7.0'
- implementation 'com.google.android.material:material:1.12.0'
-
- // KOTLIN
- implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.0.20"
- implementation 'androidx.core:core-ktx:1.15.0'
+ // ANDROIDX CORE
+ implementation libs.androidx.core.ktx
// COROUTINES
- implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0"
- implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"
-
- // DI - HILT
- implementation "com.google.dagger:hilt-android:2.52"
- implementation "androidx.lifecycle:lifecycle-process:2.8.7"
- kapt "com.google.dagger:hilt-android-compiler:2.52"
- kapt "androidx.hilt:hilt-compiler:1.2.0"
-
- //DATA STORE
- implementation 'androidx.datastore:datastore-preferences:1.1.1'
-
- // UI libraries removed with the Compose migration: AestheticDialogs, DayNightSwitch,
- // recyclerview-animators, themed-toggle-button-group, bubblenavigation.aar, Lottie,
- // aboutlibraries, Glide, and androidx.paging (pagination is a plain nextPage cursor).
+ implementation libs.kotlinx.coroutines.core
+ implementation libs.kotlinx.coroutines.android
- // GSON
- implementation "com.google.code.gson:gson:2.10.1"
+ // DI — HILT
+ implementation libs.hilt.android
+ ksp libs.hilt.compiler
+ ksp libs.androidx.hilt.compiler
- // RETROFIT
- implementation "com.squareup.retrofit2:retrofit:2.11.0"
- implementation "com.squareup.retrofit2:converter-gson:2.11.0"
+ // DATA STORE
+ implementation libs.androidx.datastore.preferences
- // OK HTTP — pinned to the 4.x stable line; 5.0.0-alpha.2 was a pre-release
- implementation 'com.squareup.okhttp3:okhttp:4.12.0'
- implementation "com.squareup.okhttp3:logging-interceptor:4.12.0"
+ // NETWORK
+ implementation libs.gson
+ implementation libs.retrofit
+ implementation libs.retrofit.converter.gson
+ implementation libs.okhttp
+ implementation libs.okhttp.logging.interceptor
// ROOM
- implementation "androidx.room:room-ktx:2.6.1"
- implementation "androidx.room:room-runtime:2.6.1"
- kapt "androidx.room:room-compiler:2.6.1"
+ implementation libs.androidx.room.runtime
+ implementation libs.androidx.room.ktx
+ ksp libs.androidx.room.compiler
// WORKMANAGER — schedules the daily briefing
- implementation 'androidx.work:work-runtime-ktx:2.10.0'
- implementation 'androidx.hilt:hilt-work:1.2.0'
-
- // CUSTOM TABS — replaces the in-dialog WebView for article reading
- implementation 'androidx.browser:browser:1.8.0'
-
- testImplementation 'junit:junit:4.13.2'
- testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
- testImplementation 'app.cash.turbine:turbine:1.2.0'
- testImplementation 'io.mockk:mockk:1.13.13'
- testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
-
- androidTestImplementation 'androidx.test.ext:junit:1.2.1'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
+ implementation libs.androidx.work.runtime.ktx
+ implementation libs.androidx.hilt.work
+
+ // CUSTOM TABS — the article reader
+ implementation libs.androidx.browser
+
+ // Removed as unused, verified against the sources rather than assumed:
+ // appcompat and material — no View-system widget or theme remains;
+ // lifecycle-process — ProcessLifecycleOwner went with the old Application class;
+ // mockk — the tests use hand-written fakes;
+ // espresso, test-ext-junit, compose ui-test — there are no androidTest sources.
+ // Also gone with the Compose migration: Glide, Lottie, ViewPager2, ViewBinding,
+ // AestheticDialogs, RecyclerView Animators, DayNight Switch, aboutlibraries, paging.
+
+ testImplementation libs.junit
+ testImplementation libs.kotlinx.coroutines.test
+ testImplementation libs.turbine
+ testImplementation libs.okhttp.mockwebserver
}
diff --git a/app/src/main/java/com/thecode/infotify/designsystem/component/ArticleCard.kt b/app/src/main/java/com/thecode/infotify/designsystem/component/ArticleCard.kt
index 5f3a5fe..c00ed86 100644
--- a/app/src/main/java/com/thecode/infotify/designsystem/component/ArticleCard.kt
+++ b/app/src/main/java/com/thecode/infotify/designsystem/component/ArticleCard.kt
@@ -65,16 +65,25 @@ fun ArticleCard(
modifier = modifier.fillMaxWidth()
) {
Row(modifier = Modifier.padding(12.dp)) {
- ArticleThumbnail(
- imageUrl = article.imageUrl,
- modifier = Modifier
- .size(96.dp)
- .clip(MaterialTheme.shapes.small)
- )
+ // Same rule as the featured card: an image that cannot load leaves no
+ // placeholder behind. A grey square next to a headline reads as unfinished,
+ // and publishers' image hosts fail often enough for that to be common. With
+ // it gone the headline simply takes the full width, which looks deliberate.
+ var imageFailed by remember(article.url) { mutableStateOf(false) }
+
+ if (article.imageUrl != null && !imageFailed) {
+ ArticleThumbnail(
+ imageUrl = article.imageUrl,
+ onFailed = { imageFailed = true },
+ modifier = Modifier
+ .size(96.dp)
+ .clip(MaterialTheme.shapes.small)
+ )
+ }
Column(
modifier = Modifier
.weight(1f)
- .padding(start = 12.dp)
+ .padding(start = if (article.imageUrl != null && !imageFailed) 12.dp else 0.dp)
) {
SourceLine(article = article)
Text(
diff --git a/app/src/main/java/com/thecode/infotify/designsystem/component/RelativeTime.kt b/app/src/main/java/com/thecode/infotify/designsystem/component/RelativeTime.kt
index 7246b8a..fc66dba 100644
--- a/app/src/main/java/com/thecode/infotify/designsystem/component/RelativeTime.kt
+++ b/app/src/main/java/com/thecode/infotify/designsystem/component/RelativeTime.kt
@@ -1,34 +1,32 @@
package com.thecode.infotify.designsystem.component
import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import com.thecode.infotify.R
import java.time.Instant
import java.time.temporal.ChronoUnit
/**
- * "12 min", "3 h", "Yesterday", "12 Mar" — the phrasing a reader scans, not a raw date.
+ * "12 min", "3 h", "yesterday", "2 w" — the phrasing a reader scans, not a raw date.
*
- * The previous build printed publishedAt.split("T")[0], which gave every article from
+ * The previous build printed publishedAt.split("T")[0], which gave every article published
* today the same unhelpful "2026-09-02".
+ *
+ * Strings come from [stringResource] rather than LocalContext.current.getString: the latter
+ * is not configuration-aware, so a locale change would leave stale text on screen until the
+ * composable happened to be recreated for another reason.
*/
@Composable
fun relativeTime(instant: Instant, now: Instant = Instant.now()): String {
- val context = LocalContext.current
val minutes = ChronoUnit.MINUTES.between(instant, now)
+ val days = minutes / (60 * 24)
return when {
- minutes < 1 -> context.getString(R.string.time_just_now)
- minutes < 60 -> context.getString(R.string.time_minutes, minutes)
- minutes < 60 * 24 -> context.getString(R.string.time_hours, minutes / 60)
- minutes < 60 * 48 -> context.getString(R.string.time_yesterday)
- else -> {
- val days = minutes / (60 * 24)
- if (days < 7) {
- context.getString(R.string.time_days, days)
- } else {
- context.getString(R.string.time_weeks, days / 7)
- }
- }
+ minutes < 1 -> stringResource(R.string.time_just_now)
+ minutes < 60 -> stringResource(R.string.time_minutes, minutes)
+ minutes < 60 * 24 -> stringResource(R.string.time_hours, minutes / 60)
+ minutes < 60 * 48 -> stringResource(R.string.time_yesterday)
+ days < 7 -> stringResource(R.string.time_days, days)
+ else -> stringResource(R.string.time_weeks, days / 7)
}
}
diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml
index 2e19007..c36c47d 100644
--- a/app/src/main/res/drawable/ic_notification.xml
+++ b/app/src/main/res/drawable/ic_notification.xml
@@ -1,14 +1,15 @@
+ android:viewportHeight="24">
diff --git a/build.gradle b/build.gradle
index 86bae57..6b12066 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,30 +1,13 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
-
-buildscript {
-
- repositories {
- google()
- mavenCentral()
- maven { url 'https://maven.google.com' }
- maven { url 'https://plugins.gradle.org/m2/' }
- }
-
- dependencies {
- classpath 'com.android.tools.build:gradle:8.9.0'
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.20"
- classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.20"
- classpath "org.jetbrains.kotlin:kotlin-serialization:2.0.20"
- classpath "com.google.dagger:hilt-android-gradle-plugin:2.52"
- }
-}
-
-allprojects {
- repositories {
- google()
- mavenCentral()
- maven { url "https://jitpack.io" }
- maven { url 'https://maven.google.com' }
- }
+// Top-level build file.
+//
+// Plugins are declared here without applying them, and versioned in
+// gradle/libs.versions.toml — there are no version literals in this file by design.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.kotlin.serialization) apply false
+ alias(libs.plugins.hilt) apply false
}
tasks.register('clean', Delete) {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..93651ba
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,95 @@
+# Version catalog — the single place where a version number is written.
+#
+# This exists because versions drifted. The Kotlin toolchain ended up declared as 2.0.20 in
+# the root build file, 2.4.10 for two compiler plugins and 2.2.10 for the standard library,
+# across two files, which cannot compile. Inline literals make that failure easy and silent;
+# a catalog makes it impossible.
+#
+# The three Kotlin compiler plugins — Compose, serialization, KSP — are pinned together on
+# purpose: they must track the Kotlin the build uses, not float independently.
+
+[versions]
+agp = "9.2.1"
+kotlin = "2.3.21"
+ksp = "2.3.11"
+
+hilt = "2.60.1"
+# androidx.hilt (hilt-work, hilt-navigation-compose, hilt-compiler) versions separately
+# from Dagger Hilt itself.
+hiltAndroidx = "1.4.0"
+
+composeBom = "2026.08.00"
+activityCompose = "1.13.0"
+lifecycle = "2.11.0"
+navigation = "2.10.0"
+serializationJson = "1.11.0"
+splashscreen = "1.2.0"
+coreKtx = "1.19.0"
+coroutines = "1.11.0"
+datastore = "1.2.1"
+room = "2.8.4"
+work = "2.11.2"
+browser = "1.10.0"
+
+coil = "2.7.0"
+gson = "2.14.0"
+retrofit = "3.0.0"
+okhttp = "5.5.0"
+
+junit = "4.13.2"
+ktlint = "1.8.0"
+turbine = "1.2.1"
+
+[libraries]
+# Compose — versions come from the BOM, so these are deliberately unversioned.
+compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+compose-ui = { group = "androidx.compose.ui", name = "ui" }
+compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
+androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
+androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
+androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashscreen" }
+androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
+androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" }
+
+androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
+androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
+androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
+
+androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
+
+hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
+hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
+androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltAndroidx" }
+androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltAndroidx" }
+androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltAndroidx" }
+
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
+kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serializationJson" }
+
+coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
+gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
+retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
+retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
+okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
+okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
+okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
+
+junit = { module = "junit:junit", version.ref = "junit" }
+turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 1611f47..c6ed5c3 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
-#Mon Aug 19 10:33:16 CEST 2024
+#Thu Sep 03 15:10:33 WAT 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/ktlint.gradle b/ktlint.gradle
index ccfbcff..5dd3881 100644
--- a/ktlint.gradle
+++ b/ktlint.gradle
@@ -1,21 +1,29 @@
-repositories {
- mavenCentral()
-}
+// Kotlin style checking, run on demand: ./gradlew ktlint (or ktlintFormat to fix).
+//
+// No repositories block here: they are declared once in settings.gradle, and redeclaring
+// them per project is rejected by FAIL_ON_PROJECT_REPOS.
configurations {
ktlint
}
+
dependencies {
- ktlint 'com.pinterest:ktlint:0.50.0'
+ // ktlint-cli, not the old com.pinterest:ktlint coordinate, which stopped at 0.50.
+ ktlint "com.pinterest.ktlint:ktlint-cli:${libs.versions.ktlint.get()}"
}
+
tasks.register('ktlint', JavaExec) {
- description = "Check Kotlin code style."
+ group = 'verification'
+ description = 'Check Kotlin code style.'
classpath = configurations.ktlint
- main = "com.pinterest.ktlint.Main"
- args "src/**/*.kt"
+ // mainClass, not main: the `main` property was removed in Gradle 9.
+ mainClass = 'com.pinterest.ktlint.Main'
+ args 'src/**/*.kt'
}
+
tasks.register('ktlintFormat', JavaExec) {
- description = "Fix Kotlin code style deviations."
+ group = 'formatting'
+ description = 'Fix Kotlin code style deviations.'
classpath = configurations.ktlint
- main = "com.pinterest.ktlint.Main"
- args "-F", "src/**/*.kt"
-}
\ No newline at end of file
+ mainClass = 'com.pinterest.ktlint.Main'
+ args '-F', 'src/**/*.kt'
+}
diff --git a/playstore/README.md b/playstore/README.md
new file mode 100644
index 0000000..1e5cff7
--- /dev/null
+++ b/playstore/README.md
@@ -0,0 +1,50 @@
+# Play Store assets
+
+Everything Google Play asks for on the store listing, plus the script that produces it.
+
+| File | Size | Play field |
+|---|---|---|
+| `icon-512x512.png` | 512 × 512 | App icon |
+| `feature-graphic-1024x500.png` | 1024 × 500 | Feature graphic |
+| `screenshot-1-explore.png` … `screenshot-8-settings.png` | 1080 × 1920 | Phone screenshots |
+
+## Regenerating
+
+```bash
+python3 playstore/generate.py
+```
+
+Needs Pillow. It reads the raw captures from `captures/`, the fonts from
+`app/src/main/res/font/`, and overwrites the assets in place.
+
+## How these are made, and what stays true
+
+- **The icon is the launcher icon, rendered — not redrawn.** `generate.py` takes its geometry
+ from `app/src/main/res/drawable/ic_launcher_foreground.xml` (stem 41.5–60.5 × 52–82, dot at
+ (60, 34) r 9.5, on a 108-unit viewport), so the store icon and the installed icon are the
+ same drawing. They were not before: the previous 512 was a separate drawing whose stem sat
+ 15 px off-axis and 50 px too tall.
+- **The device chassis is drawn around each capture, never baked into it.** Replacing a
+ screenshot means dropping a new PNG into `captures/` and re-running the script; no frame
+ has to be redrawn, and no capture is ever stretched — the phone width is fixed and the
+ height follows the file's own ratio.
+- **The captures are real.** Nothing is mocked up or retouched. They come from the
+ `releaseTest` build — the R8-minified one — running on a Pixel 3a emulator (1080 × 2220,
+ API 34) against the live `infotify-api.nativia.co` proxy, with SystemUI demo mode on so
+ the status bar reads 9:00 with a full battery instead of leaking a real device's state.
+- **Only the gesture pill is cropped** (85 px, measured). The navigation bar and its labels
+ stay: they are the app's own structure, and they are what a store visitor reads the layout
+ from.
+
+## Known gaps
+
+- **Tablet screenshots are not here.** Play wants 7-inch and 10-inch sets before it will show
+ the app as tablet-ready. They have to be captured on tablet emulators; nothing here can be
+ upscaled into them honestly.
+- **`screenshot-2-for-you.png` shows one empty grey thumbnail.** That is the app's real
+ current behaviour, not a capture artefact: a publisher image that never resolves leaves the
+ placeholder box in place instead of collapsing the way `ArticleCard` intends. Worth fixing
+ before this screenshot is final — see the note in the commit that added these files.
+- **The screenshots are English only.** The app ships `values-fr` as well; a French set means
+ re-running the walkthrough with `adb shell cmd locale set-app-locales com.thecode.infotify
+ --locales fr-FR` and a translated `SHOTS` table.
diff --git a/playstore/feature-graphic-1024x500.png b/playstore/feature-graphic-1024x500.png
index bb27f5a..6ca8d1c 100644
Binary files a/playstore/feature-graphic-1024x500.png and b/playstore/feature-graphic-1024x500.png differ
diff --git a/playstore/generate.py b/playstore/generate.py
new file mode 100644
index 0000000..74599a0
--- /dev/null
+++ b/playstore/generate.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python3
+"""Generate the Play Store asset set for Infotify.
+
+Everything is drawn from the brand tokens the app itself uses, and every device shot is an
+unaltered capture: the phone chassis is drawn around the screenshot, never baked into it, so
+a capture can be replaced without redrawing a frame.
+"""
+import os
+from PIL import Image, ImageDraw, ImageFont, ImageFilter
+
+SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "captures")
+OUT = os.path.dirname(os.path.abspath(__file__))
+FONTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir,
+ "app", "src", "main", "res", "font")
+
+# Brand tokens — the same values Color.kt and site/style.css carry.
+PAPER = (250, 246, 242)
+INK = (19, 17, 16)
+INK_DEEP = (13, 11, 9)
+EMBER = (216, 80, 11)
+EMBER_BRIGHT = (255, 106, 56)
+MUTED_ON_PAPER = (107, 98, 91)
+MUTED_ON_INK = (162, 152, 144)
+PAPER_ON_INK = (246, 241, 236)
+CHASSIS = [(42, 39, 36), (22, 19, 15), (13, 11, 9)]
+
+
+def bricolage(size, weight=800, width=100):
+ f = ImageFont.truetype(f"{FONTS}/bricolage_grotesque.ttf", size)
+ # PIL orders the axes as declared in the font: opsz, wght, wdth.
+ f.set_variation_by_axes([96, weight, width])
+ return f
+
+
+def schibsted(size, weight=400):
+ f = ImageFont.truetype(f"{FONTS}/schibsted_grotesk.ttf", size)
+ f.set_variation_by_axes([weight])
+ return f
+
+
+def vertical_gradient(size, top, bottom):
+ w, h = size
+ grad = Image.new("RGB", (1, h))
+ px = grad.load()
+ for y in range(h):
+ t = y / max(h - 1, 1)
+ px[0, y] = tuple(round(top[i] + (bottom[i] - top[i]) * t) for i in range(3))
+ return grad.resize((w, h), Image.BILINEAR)
+
+
+def rounded_mask(size, radius, supersample=4):
+ """A rounded-rectangle mask drawn oversized and downsampled, so the corners are smooth."""
+ w, h = size
+ big = Image.new("L", (w * supersample, h * supersample), 0)
+ ImageDraw.Draw(big).rounded_rectangle(
+ (0, 0, w * supersample - 1, h * supersample - 1),
+ radius=radius * supersample,
+ fill=255,
+ )
+ return big.resize((w, h), Image.LANCZOS)
+
+
+def phone(screenshot, screen_w, bezel=13, radius=54):
+ """Wrap a capture in a device chassis. The capture keeps its own aspect ratio."""
+ sw, sh = screenshot.size
+ screen_h = round(screen_w * sh / sw)
+ screen = screenshot.resize((screen_w, screen_h), Image.LANCZOS).convert("RGB")
+
+ inner_radius = radius - bezel
+ screen.putalpha(rounded_mask((screen_w, screen_h), inner_radius))
+
+ body_w, body_h = screen_w + bezel * 2, screen_h + bezel * 2
+ body = vertical_gradient((body_w, body_h), CHASSIS[0], CHASSIS[2]).convert("RGBA")
+ body.putalpha(rounded_mask((body_w, body_h), radius))
+
+ # A hairline highlight along the top edge is what reads as machined metal rather than
+ # a flat grey outline.
+ edge = Image.new("RGBA", (body_w, body_h), (0, 0, 0, 0))
+ ImageDraw.Draw(edge).rounded_rectangle(
+ (0, 0, body_w - 1, body_h - 1), radius=radius, outline=(255, 255, 255, 34), width=2
+ )
+ body.alpha_composite(edge)
+ body.alpha_composite(screen, (bezel, bezel))
+ return body
+
+
+def drop_shadow(shape, blur=34, offset=(0, 22), opacity=118):
+ pad = blur * 3
+ w, h = shape.size
+ canvas = Image.new("RGBA", (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
+ silhouette = Image.new("RGBA", (w, h), (0, 0, 0, opacity))
+ silhouette.putalpha(
+ Image.eval(shape.getchannel("A"), lambda a: a * opacity // 255)
+ )
+ canvas.alpha_composite(silhouette, (pad + offset[0], pad + offset[1]))
+ return canvas.filter(ImageFilter.GaussianBlur(blur)), pad
+
+
+def wrap(draw, text, font, max_width):
+ """Honour explicit newlines first; only the remainder is wrapped to the measure."""
+ if "\n" in text:
+ out = []
+ for part in text.split("\n"):
+ out.extend(wrap(draw, part, font, max_width))
+ return out
+ lines, line = [], ""
+ for word in text.split():
+ probe = f"{line} {word}".strip()
+ if draw.textlength(probe, font=font) <= max_width or not line:
+ line = probe
+ else:
+ lines.append(line)
+ line = word
+ if line:
+ lines.append(line)
+ return lines
+
+
+def screenshot_asset(capture, headline, subline, theme, out_name):
+ W, H = 1080, 1920
+ dark = theme == "dark"
+ ember_ground = theme == "ember"
+
+ if ember_ground:
+ canvas = vertical_gradient((W, H), EMBER_BRIGHT, EMBER).convert("RGBA")
+ title_c, sub_c = (255, 255, 255), (255, 233, 222)
+ elif dark:
+ # An ink chassis on an ink ground loses its silhouette, and a drop shadow cannot
+ # separate black from black. A low ember bloom behind the device does, and it is the
+ # only place the accent appears on this tile.
+ canvas = vertical_gradient((W, H), (34, 30, 27), INK_DEEP).convert("RGBA")
+ bloom = Image.new("RGBA", (W, H), (0, 0, 0, 0))
+ ImageDraw.Draw(bloom).ellipse((90, 620, 990, 1700), fill=EMBER + (86,))
+ canvas.alpha_composite(bloom.filter(ImageFilter.GaussianBlur(150)))
+ title_c, sub_c = PAPER_ON_INK, MUTED_ON_INK
+ else:
+ canvas = vertical_gradient((W, H), (255, 253, 251), PAPER).convert("RGBA")
+ title_c, sub_c = INK, MUTED_ON_PAPER
+
+ draw = ImageDraw.Draw(canvas)
+ margin = 84
+ text_w = W - margin * 2
+
+ title_font = bricolage(70, weight=800)
+ sub_font = schibsted(35, weight=400)
+
+ y = 132
+ for line in wrap(draw, headline, title_font, text_w):
+ draw.text((margin, y), line, font=title_font, fill=title_c)
+ y += 84
+ if subline:
+ y += 14
+ for line in wrap(draw, subline, sub_font, text_w):
+ draw.text((margin, y), line, font=sub_font, fill=sub_c)
+ y += 48
+
+ # Only the gesture pill is cropped away — 85px, measured, not guessed: it belongs to the
+ # system rather than the app. The navigation bar and its labels stay, because they are
+ # the app's own structure and the one thing a store visitor uses to read the layout.
+ shot = Image.open(capture)
+ shot = shot.crop((0, 0, shot.width, shot.height - 85))
+
+ device = phone(shot, screen_w=700)
+ shadow, pad = drop_shadow(device)
+ dx = (W - device.width) // 2
+ dy = max(y + 96, H - device.height + 10)
+
+ canvas.alpha_composite(shadow, (dx - pad, dy - pad))
+ canvas.alpha_composite(device, (dx, dy))
+
+ canvas.convert("RGB").save(os.path.join(OUT, out_name), optimize=True)
+ print(f" {out_name} {W}x{H}")
+
+
+def mark(height, supersample=4):
+ """The Infotify mark on a transparent ground, at any height.
+
+ One source of geometry for every asset: the numbers below are lifted from
+ ic_launcher_foreground.xml, so the store icon, the feature graphic and the installed
+ launcher icon are the same drawing rather than three that resemble each other.
+ Bounding box in viewport units: x 41.5..69.5, y 24.5..82 — 28 wide by 57.5 tall.
+ """
+ unit = height * supersample / 57.5
+ w = round(28 * unit)
+ h = round(57.5 * unit)
+ img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
+ d = ImageDraw.Draw(img)
+ ox, oy = 41.5, 24.5 # move the viewport origin to the mark's own top-left
+ d.rounded_rectangle(
+ ((41.5 - ox) * unit, (52 - oy) * unit, (60.5 - ox) * unit, (82 - oy) * unit),
+ radius=2.5 * unit,
+ fill=(251, 248, 245, 255),
+ )
+ d.ellipse(
+ ((50.5 - ox) * unit, (24.5 - oy) * unit, (69.5 - ox) * unit, (43.5 - oy) * unit),
+ fill=(255, 106, 31, 255),
+ )
+ return img.resize((round(w / supersample), round(h / supersample)), Image.LANCZOS)
+
+
+def icon():
+ """The 512 store icon: the launcher vector, rendered — not redrawn.
+
+ The previous file was a different drawing (stem 15px off-axis and 50px too tall), so the
+ store icon and the installed icon did not match. They do now.
+ """
+ S = 512
+ img = Image.new("RGBA", (S, S), (22, 19, 15, 255))
+ m = mark(round(57.5 * S / 108))
+ img.alpha_composite(m, ((S - m.width) // 2 + round(3 * S / 108), (S - m.height) // 2))
+ img.convert("RGB").save(os.path.join(OUT, "icon-512x512.png"), optimize=True)
+ print(f" icon-512x512.png {S}x{S}")
+
+
+def feature_graphic():
+ """1024x500. Play crops this banner hard on some surfaces, so the lockup sits inside the
+ left two-thirds and the ember disc is decoration that is allowed to be cut.
+
+ No standalone mark here: the wordmark opens on the same "i" the mark is drawn from, so
+ setting them side by side reads as the letter twice rather than as a lockup.
+ """
+ W, H = 1024, 500
+ canvas = vertical_gradient((W, H), (28, 25, 22), INK_DEEP).convert("RGBA")
+
+ glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
+ ImageDraw.Draw(glow).ellipse((854, -128, 1178, 196), fill=EMBER + (255,))
+ canvas.alpha_composite(glow.filter(ImageFilter.GaussianBlur(1.5)))
+
+ draw = ImageDraw.Draw(canvas)
+ x = 92
+ draw.text((x, 150), "Infotify", font=bricolage(112, weight=800), fill=(251, 248, 245))
+ draw.text(
+ (x + 5, 290),
+ "The news that matters to you",
+ font=schibsted(40, weight=500),
+ fill=(222, 214, 207),
+ )
+ draw.text(
+ (x + 5, 348),
+ "Choose your subjects. Read offline. One briefing a day.",
+ font=schibsted(29, weight=400),
+ fill=(150, 141, 134),
+ )
+ canvas.convert("RGB").save(
+ os.path.join(OUT, "feature-graphic-1024x500.png"), optimize=True
+ )
+ print(f" feature-graphic-1024x500.png {W}x{H}")
+
+
+SHOTS = [
+ ('en_11_explore.png', 'Pick your subjects.\nSkip the rest.',
+ 'Fifteen topics, five at a time.', 'ember',
+ 'screenshot-1-explore.png'),
+ ('en_04_foryou.png', 'A front page that is\nactually yours',
+ 'Thousands of publishers, filtered to what you read.', 'light',
+ 'screenshot-2-for-you.png'),
+ ('en_03_briefing.png', 'One briefing a day',
+ 'A single notification, at a time you choose.', 'light',
+ 'screenshot-3-briefing.png'),
+ ('en_02_interests.png', 'Five subjects,\none tap each',
+ 'Change them whenever you like.', 'light',
+ 'screenshot-4-interests.png'),
+ ('en_06_search.png', 'Search every\npublisher at once',
+ 'One query, the whole wire.', 'light',
+ 'screenshot-5-search.png'),
+ ('en_05_saved.png', 'Save now,\nread later',
+ 'Saved stories open without a connection.', 'light',
+ 'screenshot-6-saved.png'),
+ ('en_10_feed_dark.png', 'Built for reading\nat night',
+ 'A true dark theme, not a dimmed white page.', 'dark',
+ 'screenshot-7-dark.png'),
+ ('en_07_settings.png', 'No account.\nNo tracking.',
+ 'Nothing to sign up for, and nothing to opt out of.', 'light',
+ 'screenshot-8-settings.png'),
+]
+
+if __name__ == "__main__":
+ os.makedirs(OUT, exist_ok=True)
+ print("Play Store assets:")
+ icon()
+ feature_graphic()
+ for capture, headline, subline, theme, name in SHOTS:
+ screenshot_asset(os.path.join(SRC, capture), headline, subline, theme, name)
diff --git a/playstore/icon-512x512.png b/playstore/icon-512x512.png
index 65293b6..2fbdf30 100644
Binary files a/playstore/icon-512x512.png and b/playstore/icon-512x512.png differ
diff --git a/playstore/listing.md b/playstore/listing.md
new file mode 100644
index 0000000..dd7583b
--- /dev/null
+++ b/playstore/listing.md
@@ -0,0 +1,137 @@
+# Play Store listing copy
+
+Ready to paste into the Play Console. Two locales: English (default) and French.
+
+Every claim here is checked against the code rather than written from memory —
+fifteen topics with a cap of five (`Topic.MAX_SELECTED`), four regions (`Region`),
+nine languages (`Language`), three permissions in the manifest, and no analytics or
+advertising library anywhere in `app/src/main`. Keep it that way: if the app changes,
+this file changes with it, and so does https://infotify.nativia.co/privacy.
+
+---
+
+## English — default listing
+
+### App name (30 max)
+
+```
+Infotify: News Without Noise
+```
+
+Alternative, if you would rather lead with the brand alone:
+
+```
+Infotify — Daily News Brief
+```
+
+### Short description (80 max)
+
+```
+Choose your subjects. One briefing a day. No account, no tracking.
+```
+
+### Full description (4000 max)
+
+```
+Infotify is a news reader built around one idea: you decide what is on your front page.
+
+CHOOSE YOUR SUBJECTS
+Pick up to five from fifteen — world, politics, business, technology, science, health, sport, entertainment, environment, education, crime, food, travel and lifestyle. Add a region if you want coverage from Africa, Europe, the Americas or Asia-Pacific. Change them whenever you like, in two taps.
+
+ONE BRIEFING A DAY
+A single notification, at a time you choose, and only when something new has appeared in your subjects. Not a stream of alerts. Turn it off entirely and the app works exactly the same.
+
+READ NOW OR READ LATER
+Save any story for when you have time. Saved articles live on your phone and open without a connection. Recently loaded headlines are cached too, so the app opens with something to read on a bad train line.
+
+SEARCH THE WHOLE WIRE
+One query across thousands of publishers, in nine languages: English, French, Spanish, German, Italian, Portuguese, Dutch, Russian and Arabic.
+
+MADE FOR READING
+A true dark theme rather than a dimmed white page. Typography chosen for headlines, not for interfaces. A layout that gives the top story more weight than the rest. Infotify follows your system light or dark setting, or you can pin either one.
+
+NO ACCOUNT. NO TRACKING.
+There is nothing to sign up for. Infotify never asks for a name, an email address or a phone number, and has nowhere to put one. There is no analytics library in the app and no advertising. Your subjects, your saved articles and your settings stay in the app's own storage on your device — uninstalling removes all of it, because there is no copy anywhere else.
+
+Articles open on the publisher's own site, so their work is read where they published it.
+
+Privacy policy: https://infotify.nativia.co/privacy
+Support: https://infotify.nativia.co/support
+
+Published by Nativia Solutions.
+```
+
+---
+
+## Français
+
+### Nom de l'application (30 max)
+
+```
+Infotify : l'info sans bruit
+```
+
+### Description courte (80 max)
+
+```
+Vos sujets, un briefing par jour. Sans compte, sans pistage.
+```
+
+### Description complète (4000 max)
+
+```
+Infotify est un lecteur d'actualités construit autour d'une idée : c'est vous qui décidez de ce qui fait votre une.
+
+CHOISISSEZ VOS SUJETS
+Cinq sujets au maximum, parmi quinze — monde, politique, économie, technologie, science, santé, sport, culture, environnement, éducation, justice, gastronomie, voyage et art de vivre. Ajoutez une région pour suivre l'Afrique, l'Europe, les Amériques ou l'Asie-Pacifique. Vous les modifiez quand vous voulez, en deux gestes.
+
+UN BRIEFING PAR JOUR
+Une seule notification, à l'heure que vous fixez, et uniquement quand quelque chose de neuf est paru dans vos sujets. Pas un flux d'alertes. Vous pouvez la désactiver entièrement : l'application fonctionne exactement pareil.
+
+LIRE MAINTENANT OU PLUS TARD
+Enregistrez un article pour le lire quand vous avez le temps. Vos articles enregistrés restent sur votre téléphone et s'ouvrent sans connexion. Les titres récemment chargés sont aussi mis en cache : l'application s'ouvre avec de quoi lire, même dans un train.
+
+CHERCHEZ DANS TOUTE LA DÉPÊCHE
+Une seule requête sur des milliers de sources, en neuf langues : français, anglais, espagnol, allemand, italien, portugais, néerlandais, russe et arabe.
+
+FAIT POUR LIRE
+Un vrai thème sombre, pas une page blanche assombrie. Une typographie choisie pour des titres, pas pour des interfaces. Une mise en page qui donne plus de poids à l'article principal qu'au reste. Infotify suit le réglage clair/sombre de votre système, ou vous fixez l'un des deux.
+
+SANS COMPTE. SANS PISTAGE.
+Il n'y a rien à créer. Infotify ne demande jamais de nom, d'adresse e-mail ni de numéro de téléphone, et n'a nulle part où les mettre. Aucune bibliothèque d'analyse dans l'application, aucune publicité. Vos sujets, vos articles enregistrés et vos réglages restent dans le stockage de l'application, sur votre appareil — désinstaller supprime tout, parce qu'il n'existe aucune copie ailleurs.
+
+Les articles s'ouvrent sur le site de l'éditeur : leur travail se lit là où ils l'ont publié.
+
+Politique de confidentialité : https://infotify.nativia.co/privacy
+Assistance : https://infotify.nativia.co/support
+
+Publié par Nativia Solutions.
+```
+
+---
+
+## Store settings, both locales
+
+| Field | Value |
+|---|---|
+| Category | News & Magazines / Actualités et magazines |
+| Website | https://infotify.nativia.co |
+| Privacy policy | https://infotify.nativia.co/privacy |
+| Support email | tekombo.gabriel@gmail.com |
+| Content rating | Everyone — but the questionnaire must declare that the app shows news from third-party publishers, whose content is not moderated |
+
+The privacy policy URL is the one that still has to change in the console: it points at
+the GitHub repository, and Play rejects a listing whose policy URL is dead or unrelated.
+
+## Data safety
+
+What the code supports saying: **the app itself collects nothing.** No analytics SDK, no
+advertising, no identifier, and three permissions — `INTERNET`, `ACCESS_NETWORK_STATE`,
+`POST_NOTIFICATIONS` — none of which is in a sensitive group.
+
+One question is genuinely open. `infotify-api.nativia.co` keeps a rate-limit counter per
+hashed IP address for under a day. Play only exempts data handled *ephemerally* — in
+memory, never written — and a file that survives a day is not that. Play also offers no
+"IP address" category to declare it under. This is a judgement call worth putting to Play
+support rather than guessing at; the privacy policy already describes the behaviour
+plainly, which is what matters if it is ever questioned.
diff --git a/playstore/screenshot-1-explore.png b/playstore/screenshot-1-explore.png
new file mode 100644
index 0000000..a8ca79f
Binary files /dev/null and b/playstore/screenshot-1-explore.png differ
diff --git a/playstore/screenshot-2-for-you.png b/playstore/screenshot-2-for-you.png
new file mode 100644
index 0000000..3fee76a
Binary files /dev/null and b/playstore/screenshot-2-for-you.png differ
diff --git a/playstore/screenshot-3-briefing.png b/playstore/screenshot-3-briefing.png
new file mode 100644
index 0000000..00a1835
Binary files /dev/null and b/playstore/screenshot-3-briefing.png differ
diff --git a/playstore/screenshot-4-interests.png b/playstore/screenshot-4-interests.png
new file mode 100644
index 0000000..560b886
Binary files /dev/null and b/playstore/screenshot-4-interests.png differ
diff --git a/playstore/screenshot-5-search.png b/playstore/screenshot-5-search.png
new file mode 100644
index 0000000..68b8183
Binary files /dev/null and b/playstore/screenshot-5-search.png differ
diff --git a/playstore/screenshot-6-saved.png b/playstore/screenshot-6-saved.png
new file mode 100644
index 0000000..f3347bc
Binary files /dev/null and b/playstore/screenshot-6-saved.png differ
diff --git a/playstore/screenshot-7-dark.png b/playstore/screenshot-7-dark.png
new file mode 100644
index 0000000..5aaf9e4
Binary files /dev/null and b/playstore/screenshot-7-dark.png differ
diff --git a/playstore/screenshot-8-settings.png b/playstore/screenshot-8-settings.png
new file mode 100644
index 0000000..0b3fbf2
Binary files /dev/null and b/playstore/screenshot-8-settings.png differ
diff --git a/settings.gradle b/settings.gradle
index 7a20974..f4f2f1d 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,2 +1,20 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ // Repositories are declared once, here, rather than per project. allprojects{} in the
+ // root build file is the older pattern and conflicts with this one.
+ repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = 'Infotify'
include ':app'
-rootProject.name='Infotify'
diff --git a/site/.htaccess b/site/.htaccess
index a4c542d..18e3827 100644
--- a/site/.htaccess
+++ b/site/.htaccess
@@ -33,4 +33,18 @@ DirectoryIndex index.html
Require all denied
+# Caching.
+#
+# HTML is never cached: a stale page kept serving old image filenames and made a fixed
+# deployment look unchanged. Assets are versioned in their filename instead, so they can
+# be cached hard and a new version simply has a new name.
+
+
+ Header set Cache-Control "no-cache, must-revalidate"
+
+
+ Header set Cache-Control "public, max-age=31536000"
+
+
+
ErrorDocument 404 /index.html
diff --git a/site/img/explore-2.png b/site/img/explore-2.png
new file mode 100644
index 0000000..2c53867
Binary files /dev/null and b/site/img/explore-2.png differ
diff --git a/site/img/explore.png b/site/img/explore.png
deleted file mode 100644
index 02bed94..0000000
Binary files a/site/img/explore.png and /dev/null differ
diff --git a/site/img/for-you-2.png b/site/img/for-you-2.png
new file mode 100644
index 0000000..84100ea
Binary files /dev/null and b/site/img/for-you-2.png differ
diff --git a/site/img/for-you.png b/site/img/for-you.png
deleted file mode 100644
index 806a4d8..0000000
Binary files a/site/img/for-you.png and /dev/null differ
diff --git a/site/index.html b/site/index.html
index bcf203a..1f054b9 100644
--- a/site/index.html
+++ b/site/index.html
@@ -13,7 +13,7 @@
-
+
@@ -21,7 +21,7 @@
-
+
Skip to content
@@ -54,7 +54,7 @@
The day’s news, the way you read it.
Android 8.0 and later.
-
+
@@ -71,7 +71,7 @@
A front page that is yours, not everybody’s.
-
+
diff --git a/site/privacy.html b/site/privacy.html
index 339f50e..ef10c86 100644
--- a/site/privacy.html
+++ b/site/privacy.html
@@ -15,7 +15,7 @@
-
+
Skip to content
diff --git a/site/style.css b/site/style.css
index b69026f..6eb1910 100644
--- a/site/style.css
+++ b/site/style.css
@@ -184,20 +184,59 @@ h1 {
/* --- phone --------------------------------------------------------------- */
+/* A device frame around the screenshot: an ink bezel with the screen inset inside it.
+ The frame is drawn entirely in CSS, so there is no chrome baked into the PNG and the
+ screenshots stay honest captures that can be replaced without redrawing anything. */
.phone {
- border-radius: 26px;
- overflow: hidden;
- border: 1px solid var(--line);
- box-shadow: var(--shadow);
- background: var(--surface);
+ position: relative;
+ padding: 9px;
+ border-radius: 42px;
+ background: linear-gradient(160deg, #2A2724 0%, #16130F 45%, #0D0B09 100%);
+ /* Two shadows: a tight one for the edge of the metal, a wide soft one for the drop. */
+ box-shadow:
+ 0 0 0 1px rgba(255, 255, 255, 0.07) inset,
+ 0 2px 4px rgba(22, 19, 15, 0.28),
+ 0 24px 48px -18px rgba(22, 19, 15, 0.55);
+}
+
+/* The speaker slot. Purely decorative, so it is hidden from assistive technology. */
+.phone::before {
+ content: "";
+ position: absolute;
+ top: 17px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 54px;
+ height: 4px;
+ border-radius: 2px;
+ background: rgba(251, 248, 245, 0.16);
+ z-index: 1;
+}
+
+/* The side button, on the right edge of the frame. */
+.phone::after {
+ content: "";
+ position: absolute;
+ top: 22%;
+ right: -2px;
+ width: 2px;
+ height: 8%;
+ border-radius: 0 2px 2px 0;
+ background: linear-gradient(180deg, #3A3632, #211E1A);
}
.phone img {
+ display: block;
width: 100%;
+ /* height: auto and nothing else. A declared aspect-ratio used to sit here to reserve
+ space before the image loaded, but a declared ratio overrides the file's own — so a
+ browser holding a screenshot of different dimensions squashed it into the declared
+ box and it looked stretched. The intrinsic ratio wins now; the width and height
+ attributes in the markup reserve the space instead. */
height: auto;
- /* Belt and braces: the ratio is declared so the space is reserved before the
- image loads, which also stops the page reflowing under the reader. */
- aspect-ratio: 620 / 1217;
+ /* Slightly tighter than the frame, which is what makes it read as a screen sitting
+ inside a bezel rather than a picture with a border. */
+ border-radius: 34px;
}
/* --- sections ------------------------------------------------------------ */
diff --git a/site/support.html b/site/support.html
index 598303b..273b78e 100644
--- a/site/support.html
+++ b/site/support.html
@@ -15,7 +15,7 @@
-
+
Skip to content