{"id":48547347,"url":"https://github.com/po4yka/bite-size-reader-client","last_synced_at":"2026-04-08T07:03:01.853Z","repository":{"id":324513487,"uuid":"1097471656","full_name":"po4yka/bite-size-reader-client","owner":"po4yka","description":"KMP + Compose Multiplatform mobile client for AI-powered article and video summaries. Offline-first, shared UI across Android \u0026 iOS. Carbon Design System.","archived":false,"fork":false,"pushed_at":"2026-03-27T18:41:15.000Z","size":1886,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-28T02:18:45.781Z","etag":null,"topics":["compose-multiplatform","kotlin-multiplatform","reader-app","summarization"],"latest_commit_sha":null,"homepage":"","language":"Kotlin","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"bsd-3-clause","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/po4yka.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"docs/SECURITY.md","support":null,"governance":null,"roadmap":"ROADMAP.md","authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-11-16T08:41:44.000Z","updated_at":"2026-03-27T18:41:19.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/po4yka/bite-size-reader-client","commit_stats":null,"previous_names":["po4yka/bite-size-reader-client"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/po4yka/bite-size-reader-client","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/po4yka%2Fbite-size-reader-client","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/po4yka%2Fbite-size-reader-client/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/po4yka%2Fbite-size-reader-client/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/po4yka%2Fbite-size-reader-client/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/po4yka","download_url":"https://codeload.github.com/po4yka/bite-size-reader-client/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/po4yka%2Fbite-size-reader-client/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31544090,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-07T16:28:08.000Z","status":"online","status_checked_at":"2026-04-08T02:00:06.127Z","response_time":54,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["compose-multiplatform","kotlin-multiplatform","reader-app","summarization"],"created_at":"2026-04-08T07:02:59.626Z","updated_at":"2026-04-08T07:03:01.841Z","avatar_url":"https://github.com/po4yka.png","language":"Kotlin","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Bite-Size Reader Mobile Client\n\n[![PR Validation](https://github.com/po4yka/bite-size-reader-client/actions/workflows/pr-validation.yml/badge.svg)](https://github.com/po4yka/bite-size-reader-client/actions/workflows/pr-validation.yml)\n[![CI](https://github.com/po4yka/bite-size-reader-client/actions/workflows/ci.yml/badge.svg)](https://github.com/po4yka/bite-size-reader-client/actions/workflows/ci.yml)\n[![Code Quality](https://github.com/po4yka/bite-size-reader-client/actions/workflows/code-quality.yml/badge.svg)](https://github.com/po4yka/bite-size-reader-client/actions/workflows/code-quality.yml)\n\nCompose Multiplatform client for [Bite-Size Reader](https://github.com/po4yka/bite-size-reader) - a service that summarizes web articles and YouTube videos using LLM.\n\n## Overview\n\nThis is a **Kotlin Multiplatform + Compose Multiplatform** app that provides a shared UI stack across Android, iOS, and Desktop while sharing ~80-90% of business logic code. The app allows users to:\n\n- Browse and read saved article/video summaries\n- Submit new URLs for AI-powered summarization\n- Search summaries by topic, content, or tags\n- Work offline with automatic sync\n- Track reading progress and organize content\n\n### Architecture Philosophy\n\n**KMP + Compose Multiplatform UI:**\n- **Shared Code (80-90%)**: Infrastructure in `core/*`, feature logic in `feature/*`, navigation contracts in `core/navigation`, and shell composition in `composeApp/`\n- **Shared UI**: Compose Multiplatform screens rendered on Android, iOS, and Desktop (with native host hooks where needed)\n- **Offline-First**: Local SQLite database with session-based sync to the backend API\n- **Boundary-Driven**: Domain/UI code stays free of transport DTOs and routed screens receive dependencies from components\n\n## CI/CD\n\nThis project features **comprehensive CI/CD automation** using GitHub Actions:\n\n-  **Automated Testing**: All PRs run Android, iOS, and modular KMP test suites\n-  **Multi-Platform Builds**: Parallel builds on Ubuntu (Android) and macOS (iOS)\n-  **Automated Releases**: Tag-based releases with automatic APK/IPA generation\n-  **Code Quality**: Linting, security scanning, and dependency checks\n-  **Dependabot**: Automatic dependency updates with grouped PRs\n-  **Cost Optimized**: Conditional builds and smart caching reduce CI minutes by ~60%\n\n**Quick Start:**\n- PRs automatically validate on both platforms\n- Add `skip-ios` label to skip expensive macOS builds for Android-only changes\n- Create releases: `git tag v1.0.0 \u0026\u0026 git push --tags`\n\nSee **[docs/CICD.md](docs/CICD.md)** for complete documentation including setup, secrets configuration, and troubleshooting.\n\n## Tech Stack\n\n### Shared Kotlin Multiplatform (commonMain)\n\n| Category | Technology | Purpose |\n|----------|-----------|---------|\n| **Navigation** | [Decompose](https://github.com/arkivanov/Decompose) | Lifecycle-aware navigation and state preservation |\n| **Networking** | [Ktor Client 3.0](https://ktor.io/docs/client.html) | HTTP client with async/await support |\n| **Data Layer** | Feature repositories + Ktor APIs | Repository pattern with local persistence and sync |\n| **Database** | [SQLDelight 2.0](https://cashapp.github.io/sqldelight/) | Type-safe SQL with coroutines support |\n| **Serialization** | [kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization) | JSON parsing and data classes |\n| **DI** | [Koin 4.1+](https://insert-koin.io/) | Dependency injection |\n| **Coroutines** | [kotlinx.coroutines](https://github.com/Kotlin/kotlinx.coroutines) | Async/await and Flow streams |\n| **Date/Time** | [kotlinx-datetime](https://github.com/Kotlin/kotlinx-datetime) | ISO 8601 parsing and timezone handling |\n| **Logging** | [kotlin-logging](https://github.com/oshai/kotlin-logging) | Structured multiplatform logging |\n| **Icons** | Custom Carbon Icons | IBM Carbon Design System icons as ImageVectors |\n| **Dev Tools** | [Compose Hot Reload](https://github.com/JetBrains/compose-hot-reload) | Instant UI updates without restarts |\n\n### iOS (Compose Multiplatform host)\n\n- **Compose Multiplatform** - Shared UI rendered via `MainViewController`\n- **SwiftUI shell** - Hosts Compose UI and bridges Telegram login via native WebView\n- **SKIE** - Configured in Gradle but currently disabled for the active Kotlin version\n- **Keychain** - Secure JWT token storage\n- **Share Extension** - Submit URLs from Safari/other apps\n- **WidgetKit** - Home screen widget for recent summaries\n\n### Android (Jetpack Compose)\n\n- **Jetpack Compose** - Modern declarative UI (100% Compose)\n- **Material 3** - Material Design components\n- **Koin Android** - Activity/Composable injection\n- **Tink AEAD + DataStore** - Secure JWT storage\n- **WorkManager** - Background sync jobs\n- **App Widgets** - Home screen widget\n\n## Project Structure\n\n```\nbite-size-reader-client/\n androidApp/                      # Android app host, widgets, workers, manifest/resources\n composeApp/                      # Compose Multiplatform UI + navigation shell + CocoaPods export\n    src/iosMain/kotlin/          # Compose UIViewController for iOS host\n    src/desktopMain/kotlin/      # Desktop preview entrypoint\n    src/commonMain/kotlin/       # Shared Compose UI/theme/navigation\n core/\n    common/                       # Shared domain, config, base presentation primitives\n    data/                         # Shared networking/bootstrap, SQLDelight, persistence, secure storage\n    navigation/                   # Route contracts and navigator interfaces\n    ui/                           # Shared non-feature UI primitives\n feature/\n    auth/                         # Auth/session contracts, APIs, and flows\n    collections/                  # Collections, tags, RSS, import/export\n    digest/                       # Digest and custom digest flows\n    settings/                     # Settings, stats, reading goals, account\n    summary/                      # Summary list/detail, search, submit URL, recommendations\n    sync/                         # Sync orchestration and public sync contracts\n iosApp/                          # iOS app shell (SwiftUI hosting Compose)\n    iosApp/\n       Auth/                   # Native Telegram login sheet\n       Config/                 # Platform config\n       iOSApp.swift            # Entry point hosting Compose UI\n    ShareExtension/             # Native share-extension source\n    RecentSummariesWidget/      # Native widget source\n    Info.plist                  # Main app config\n    Podfile                     # CocoaPods dependencies\n gradle/\n    libs.versions.toml          # Version catalog\n README.md                        # This file\n docs/                            # Reference documentation\n```\n\n## Backend API Integration\n\nThis client connects to the [bite-size-reader](https://github.com/po4yka/bite-size-reader) FastAPI backend.\n\n**Base URL**: Configurable via `local.properties` (default: `https://bitsizereaderapi.po4yka.com`)\n\n**API Version**: v1\n\n**Content-Type**: `application/json`\n\n**Authentication**: JWT Bearer tokens in `Authorization` header\n\n### API Endpoints Reference\n\n#### Authentication API\n\n##### POST `/v1/auth/telegram-login`\n\nAuthenticate user with Telegram and receive JWT tokens.\n\n**Request Body**:\n```json\n{\n  \"id\": 123456789,\n  \"hash\": \"abc123...\",\n  \"auth_date\": 1705234567,\n  \"username\": \"johndoe\",\n  \"first_name\": \"John\",\n  \"last_name\": \"Doe\",\n  \"photo_url\": \"https://...\",\n  \"client_id\": \"android-app-v1.0\"\n}\n```\n\n**Response** (200 OK):\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"access_token\": \"eyJhbGciOiJIUzI1NiIs...\",\n    \"refresh_token\": \"eyJhbGciOiJIUzI1NiIs...\",\n    \"token_type\": \"Bearer\",\n    \"expires_in\": 3600,\n    \"user\": {\n      \"id\": 123456789,\n      \"username\": \"johndoe\",\n      \"first_name\": \"John\",\n      \"last_name\": \"Doe\"\n    }\n  },\n  \"meta\": {\n    \"timestamp\": \"2025-01-14T12:00:00Z\",\n    \"version\": \"1.0\"\n  }\n}\n```\n\n**Implementation**:\n```kotlin\n// data/remote/dto/AuthRequestDto.kt\n@Serializable\ndata class TelegramLoginRequest(\n    @SerialName(\"id\") val telegramUserId: Long,\n    @SerialName(\"hash\") val authHash: String,\n    @SerialName(\"auth_date\") val authDate: Long,\n    val username: String? = null,\n    @SerialName(\"first_name\") val firstName: String? = null,\n    @SerialName(\"last_name\") val lastName: String? = null,\n    @SerialName(\"photo_url\") val photoUrl: String? = null,\n    @SerialName(\"client_id\") val clientId: String\n)\n\n@Serializable\ndata class AuthResponse(\n    @SerialName(\"access_token\") val accessToken: String,\n    @SerialName(\"refresh_token\") val refreshToken: String,\n    @SerialName(\"token_type\") val tokenType: String,\n    @SerialName(\"expires_in\") val expiresIn: Int,\n    val user: UserDto\n)\n\n// data/remote/AuthApi.kt\ninterface AuthApi {\n    suspend fun loginWithTelegram(request: TelegramLoginRequest): ApiResponse\u003cAuthResponse\u003e\n}\n\nclass AuthApiImpl(private val client: HttpClient) : AuthApi {\n    override suspend fun loginWithTelegram(request: TelegramLoginRequest): ApiResponse\u003cAuthResponse\u003e {\n        return client.post(\"/v1/auth/telegram-login\") {\n            contentType(ContentType.Application.Json)\n            setBody(request)\n        }.body()\n    }\n}\n```\n\n##### POST `/v1/auth/refresh`\n\nRefresh expired access token using refresh token.\n\n**Request Body**:\n```json\n{\n  \"refresh_token\": \"eyJhbGciOiJIUzI1NiIs...\"\n}\n```\n\n**Response** (200 OK):\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"access_token\": \"eyJhbGciOiJIUzI1NiIs...\",\n    \"token_type\": \"Bearer\",\n    \"expires_in\": 3600\n  },\n  \"meta\": {\n    \"timestamp\": \"2025-01-14T12:00:00Z\"\n  }\n}\n```\n\n**Implementation**:\n```kotlin\nsuspend fun refreshToken(refreshToken: String): ApiResponse\u003cTokenRefreshResponse\u003e {\n    return client.post(\"/v1/auth/refresh\") {\n        contentType(ContentType.Application.Json)\n        setBody(mapOf(\"refresh_token\" to refreshToken))\n    }.body()\n}\n```\n\n##### GET `/v1/auth/me`\n\nGet current authenticated user information.\n\n**Headers**: `Authorization: Bearer \u003caccess_token\u003e`\n\n**Response** (200 OK):\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"id\": 123456789,\n    \"username\": \"johndoe\",\n    \"first_name\": \"John\",\n    \"is_owner\": true\n  }\n}\n```\n\n#### Summaries API\n\n##### GET `/v1/summaries`\n\nList summaries with pagination and filters.\n\n**Headers**: `Authorization: Bearer \u003caccess_token\u003e`\n\n**Query Parameters**:\n- `limit` (int, default: 20): Number of results per page\n- `offset` (int, default: 0): Pagination offset\n- `is_read` (bool, optional): Filter by read status\n- `lang` (string, optional): Filter by language (en, ru)\n- `from_date` (string, optional): ISO 8601 date (e.g., \"2025-01-01T00:00:00Z\")\n- `to_date` (string, optional): ISO 8601 date\n- `sort_by` (string, optional): Sort field (created_at, reading_time)\n- `sort_order` (string, optional): asc or desc (default: desc)\n\n**Response** (200 OK):\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"summaries\": [\n      {\n        \"id\": 1,\n        \"request_id\": 42,\n        \"title\": \"Understanding Kotlin Multiplatform\",\n        \"domain\": \"example.com\",\n        \"url\": \"https://example.com/article\",\n        \"tldr\": \"Kotlin Multiplatform allows sharing code...\",\n        \"summary_250\": \"Brief summary in 250 chars...\",\n        \"reading_time_min\": 5,\n        \"topic_tags\": [\"#kotlin\", \"#mobile\", \"#tech\"],\n        \"is_read\": false,\n        \"lang\": \"en\",\n        \"created_at\": \"2025-01-14T12:00:00Z\"\n      }\n    ],\n    \"pagination\": {\n      \"total\": 150,\n      \"limit\": 20,\n      \"offset\": 0,\n      \"has_more\": true\n    }\n  }\n}\n```\n\n**Implementation**:\n```kotlin\n// data/remote/dto/SummaryDto.kt\n@Serializable\ndata class SummaryCompactDto(\n    val id: Int,\n    @SerialName(\"request_id\") val requestId: Int,\n    val title: String,\n    val domain: String? = null,\n    val url: String,\n    val tldr: String,\n    @SerialName(\"summary_250\") val summary250: String,\n    @SerialName(\"reading_time_min\") val readingTimeMin: Int,\n    @SerialName(\"topic_tags\") val topicTags: List\u003cString\u003e,\n    @SerialName(\"is_read\") val isRead: Boolean,\n    val lang: String,\n    @SerialName(\"created_at\") val createdAt: String\n)\n\n@Serializable\ndata class PaginationInfo(\n    val total: Int,\n    val limit: Int,\n    val offset: Int,\n    @SerialName(\"has_more\") val hasMore: Boolean\n)\n\n@Serializable\ndata class SummaryListResponse(\n    val summaries: List\u003cSummaryCompactDto\u003e,\n    val pagination: PaginationInfo\n)\n\n// data/remote/SummariesApi.kt\ninterface SummariesApi {\n    suspend fun getSummaries(\n        limit: Int = 20,\n        offset: Int = 0,\n        isRead: Boolean? = null,\n        lang: String? = null,\n        fromDate: String? = null,\n        toDate: String? = null,\n        sortBy: String? = null,\n        sortOrder: String? = null\n    ): ApiResponse\u003cSummaryListResponse\u003e\n}\n\nclass SummariesApiImpl(private val client: HttpClient) : SummariesApi {\n    override suspend fun getSummaries(\n        limit: Int,\n        offset: Int,\n        isRead: Boolean?,\n        lang: String?,\n        fromDate: String?,\n        toDate: String?,\n        sortBy: String?,\n        sortOrder: String?\n    ): ApiResponse\u003cSummaryListResponse\u003e {\n        return client.get(\"/v1/summaries\") {\n            parameter(\"limit\", limit)\n            parameter(\"offset\", offset)\n            isRead?.let { parameter(\"is_read\", it) }\n            lang?.let { parameter(\"lang\", it) }\n            fromDate?.let { parameter(\"from_date\", it) }\n            toDate?.let { parameter(\"to_date\", it) }\n            sortBy?.let { parameter(\"sort_by\", it) }\n            sortOrder?.let { parameter(\"sort_order\", it) }\n        }.body()\n    }\n}\n```\n\n##### GET `/v1/summaries/{id}`\n\nGet full summary details by ID.\n\n**Headers**: `Authorization: Bearer \u003caccess_token\u003e`\n\n**Path Parameters**: `id` (int) - Summary ID\n\n**Response** (200 OK):\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"id\": 1,\n    \"request_id\": 42,\n    \"title\": \"Understanding Kotlin Multiplatform\",\n    \"url\": \"https://example.com/article\",\n    \"summary_250\": \"Brief summary...\",\n    \"summary_1000\": \"Extended summary in 1000 chars...\",\n    \"tldr\": \"Kotlin Multiplatform allows sharing code...\",\n    \"key_ideas\": [\n      \"Share business logic across platforms\",\n      \"Native UI for each platform\",\n      \"Reduce code duplication\"\n    ],\n    \"topic_tags\": [\"#kotlin\", \"#mobile\"],\n    \"entities\": {\n      \"people\": [\"John Doe\"],\n      \"organizations\": [\"JetBrains\"],\n      \"locations\": [\"Prague\"]\n    },\n    \"estimated_reading_time_min\": 5,\n    \"key_stats\": [\n      {\n        \"label\": \"Code sharing\",\n        \"value\": 70.0,\n        \"unit\": \"%\",\n        \"source_excerpt\": \"Share up to 70% of code\"\n      }\n    ],\n    \"answered_questions\": [\"What is KMP?\", \"How does it work?\"],\n    \"readability\": {\n      \"method\": \"Flesch-Kincaid\",\n      \"score\": 12.4,\n      \"level\": \"College\"\n    },\n    \"seo_keywords\": [\"kotlin\", \"multiplatform\", \"mobile\"],\n    \"is_read\": false,\n    \"lang\": \"en\",\n    \"created_at\": \"2025-01-14T12:00:00Z\"\n  }\n}\n```\n\n**Implementation**:\n```kotlin\n@Serializable\ndata class SummaryDetailDto(\n    val id: Int,\n    @SerialName(\"request_id\") val requestId: Int,\n    val title: String,\n    val url: String,\n    @SerialName(\"summary_250\") val summary250: String,\n    @SerialName(\"summary_1000\") val summary1000: String,\n    val tldr: String,\n    @SerialName(\"key_ideas\") val keyIdeas: List\u003cString\u003e,\n    @SerialName(\"topic_tags\") val topicTags: List\u003cString\u003e,\n    val entities: EntitiesDto,\n    @SerialName(\"estimated_reading_time_min\") val readingTimeMin: Int,\n    @SerialName(\"key_stats\") val keyStats: List\u003cKeyStatDto\u003e,\n    @SerialName(\"answered_questions\") val answeredQuestions: List\u003cString\u003e,\n    val readability: ReadabilityDto,\n    @SerialName(\"seo_keywords\") val seoKeywords: List\u003cString\u003e,\n    @SerialName(\"is_read\") val isRead: Boolean,\n    val lang: String,\n    @SerialName(\"created_at\") val createdAt: String\n)\n\nsuspend fun getSummaryById(id: Int): ApiResponse\u003cSummaryDetailDto\u003e {\n    return client.get(\"/v1/summaries/$id\").body()\n}\n```\n\n##### PATCH `/v1/summaries/{id}`\n\nUpdate summary metadata (mark as read/unread).\n\n**Headers**: `Authorization: Bearer \u003caccess_token\u003e`\n\n**Path Parameters**: `id` (int) - Summary ID\n\n**Request Body**:\n```json\n{\n  \"is_read\": true\n}\n```\n\n**Response** (200 OK):\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"id\": 1,\n    \"is_read\": true,\n    \"updated_at\": \"2025-01-14T12:00:00Z\"\n  }\n}\n```\n\n**Implementation**:\n```kotlin\nsuspend fun updateSummary(id: Int, isRead: Boolean): ApiResponse\u003cSummaryUpdateResponse\u003e {\n    return client.patch(\"/v1/summaries/$id\") {\n        contentType(ContentType.Application.Json)\n        setBody(mapOf(\"is_read\" to isRead))\n    }.body()\n}\n```\n\n#### Requests API\n\n##### POST `/v1/requests`\n\nSubmit new URL for summarization.\n\n**Headers**: `Authorization: Bearer \u003caccess_token\u003e`\n\n**Request Body**:\n```json\n{\n  \"type\": \"url\",\n  \"input_url\": \"https://example.com/article\",\n  \"lang_preference\": \"auto\"\n}\n```\n\n**Response** (201 Created):\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"request_id\": 42,\n    \"status\": \"pending\",\n    \"stage\": \"content_extraction\",\n    \"progress\": 0,\n    \"created_at\": \"2025-01-14T12:00:00Z\"\n  }\n}\n```\n\n**Implementation**:\n```kotlin\n@Serializable\ndata class SubmitURLRequest(\n    val type: String = \"url\",\n    @SerialName(\"input_url\") val inputUrl: String,\n    @SerialName(\"lang_preference\") val langPreference: String = \"auto\"\n)\n\n@Serializable\ndata class RequestResponse(\n    @SerialName(\"request_id\") val requestId: Int,\n    val status: String,\n    val stage: String?,\n    val progress: Int,\n    @SerialName(\"created_at\") val createdAt: String\n)\n\nsuspend fun submitURL(url: String, langPreference: String = \"auto\"): ApiResponse\u003cRequestResponse\u003e {\n    return client.post(\"/v1/requests\") {\n        contentType(ContentType.Application.Json)\n        setBody(SubmitURLRequest(inputUrl = url, langPreference = langPreference))\n    }.body()\n}\n```\n\n##### GET `/v1/requests/{id}/status`\n\nPoll request processing status.\n\n**Headers**: `Authorization: Bearer \u003caccess_token\u003e`\n\n**Path Parameters**: `id` (int) - Request ID\n\n**Response** (200 OK):\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"request_id\": 42,\n    \"status\": \"processing\",\n    \"stage\": \"llm_summarization\",\n    \"progress\": 50,\n    \"estimated_seconds_remaining\": 15,\n    \"error_message\": null,\n    \"can_retry\": false,\n    \"summary_id\": null\n  }\n}\n```\n\n**Status Values**: `pending`, `processing`, `completed`, `error`\n\n**Stage Values**: `content_extraction`, `llm_summarization`, `validation`, `done`\n\n**Progress**: 0-100 (percentage)\n\n**Implementation**:\n```kotlin\n@Serializable\ndata class RequestStatusDto(\n    @SerialName(\"request_id\") val requestId: Int,\n    val status: String,\n    val stage: String?,\n    val progress: Int,\n    @SerialName(\"estimated_seconds_remaining\") val estimatedSecondsRemaining: Int?,\n    @SerialName(\"error_message\") val errorMessage: String?,\n    @SerialName(\"can_retry\") val canRetry: Boolean,\n    @SerialName(\"summary_id\") val summaryId: Int?\n)\n\nsuspend fun getRequestStatus(requestId: Int): ApiResponse\u003cRequestStatusDto\u003e {\n    return client.get(\"/v1/requests/$requestId/status\").body()\n}\n```\n\n##### POST `/v1/requests/{id}/retry`\n\nRetry failed request.\n\n**Headers**: `Authorization: Bearer \u003caccess_token\u003e`\n\n**Path Parameters**: `id` (int) - Request ID\n\n**Response** (200 OK):\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"request_id\": 42,\n    \"status\": \"pending\"\n  }\n}\n```\n\n#### Search API\n\n##### GET `/v1/search`\n\nFull-text search across summaries.\n\n**Headers**: `Authorization: Bearer \u003caccess_token\u003e`\n\n**Query Parameters**:\n- `q` (string, required): Search query\n- `limit` (int, default: 20): Number of results\n- `offset` (int, default: 0): Pagination offset\n\n**Response** (200 OK):\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"results\": [\n      {\n        \"id\": 1,\n        \"title\": \"Understanding Kotlin Multiplatform\",\n        \"url\": \"https://example.com/article\",\n        \"snippet\": \"...Kotlin Multiplatform allows...\",\n        \"relevance_score\": 0.95,\n        \"topic_tags\": [\"#kotlin\", \"#mobile\"]\n      }\n    ],\n    \"total\": 5,\n    \"query\": \"kotlin multiplatform\"\n  }\n}\n```\n\n**Implementation**:\n```kotlin\n@Serializable\ndata class SearchResultDto(\n    val id: Int,\n    val title: String,\n    val url: String,\n    val snippet: String,\n    @SerialName(\"relevance_score\") val relevanceScore: Double,\n    @SerialName(\"topic_tags\") val topicTags: List\u003cString\u003e\n)\n\n@Serializable\ndata class SearchResponse(\n    val results: List\u003cSearchResultDto\u003e,\n    val total: Int,\n    val query: String\n)\n\nsuspend fun search(query: String, limit: Int = 20, offset: Int = 0): ApiResponse\u003cSearchResponse\u003e {\n    return client.get(\"/v1/search\") {\n        parameter(\"q\", query)\n        parameter(\"limit\", limit)\n        parameter(\"offset\", offset)\n    }.body()\n}\n```\n\n#### Sync API\n\n##### GET `/v1/sync/delta`\n\nGet incremental updates since last sync.\n\n**Headers**: `Authorization: Bearer \u003caccess_token\u003e`\n\n**Query Parameters**:\n- `since` (string, required): ISO 8601 timestamp of last sync\n\n**Response** (200 OK):\n```json\n{\n  \"success\": true,\n  \"data\": {\n    \"summaries\": [\n      {\n        \"id\": 1,\n        \"action\": \"update\",\n        \"data\": { /* full summary object */ }\n      }\n    ],\n    \"deleted_ids\": [42, 43],\n    \"sync_timestamp\": \"2025-01-14T12:00:00Z\"\n  }\n}\n```\n\n**Implementation**:\n```kotlin\n@Serializable\ndata class SyncDeltaResponse(\n    val summaries: List\u003cSyncChangeDto\u003e,\n    @SerialName(\"deleted_ids\") val deletedIds: List\u003cInt\u003e,\n    @SerialName(\"sync_timestamp\") val syncTimestamp: String\n)\n\n@Serializable\ndata class SyncChangeDto(\n    val id: Int,\n    val action: String, // \"update\" or \"delete\"\n    val data: SummaryDetailDto?\n)\n\nsuspend fun getDeltaSync(since: String): ApiResponse\u003cSyncDeltaResponse\u003e {\n    return client.get(\"/v1/sync/delta\") {\n        parameter(\"since\", since)\n    }.body()\n}\n```\n\n### API Client Setup with Ktor\n\n**Complete Ktor HttpClient configuration**:\n\n```kotlin\n// core/data/.../data/remote/ApiClient.kt\nimport io.ktor.client.*\nimport io.ktor.client.engine.*\nimport io.ktor.client.plugins.*\nimport io.ktor.client.plugins.auth.*\nimport io.ktor.client.plugins.auth.providers.*\nimport io.ktor.client.plugins.contentnegotiation.*\nimport io.ktor.client.plugins.logging.*\nimport io.ktor.http.*\nimport io.ktor.serialization.kotlinx.json.*\nimport kotlinx.serialization.json.Json\n\nclass ApiClient(\n    private val baseUrl: String,\n    private val tokenProvider: TokenProvider,\n    private val engine: HttpClientEngine\n) {\n    val httpClient = HttpClient(engine) {\n        // JSON serialization\n        install(ContentNegotiation) {\n            json(Json {\n                ignoreUnknownKeys = true\n                isLenient = true\n                encodeDefaults = true\n                prettyPrint = false\n            })\n        }\n\n        // Authentication with JWT\n        install(Auth) {\n            bearer {\n                loadTokens {\n                    val tokens = tokenProvider.getTokens()\n                    BearerTokens(\n                        accessToken = tokens.accessToken,\n                        refreshToken = tokens.refreshToken\n                    )\n                }\n\n                refreshTokens {\n                    val newTokens = tokenProvider.refreshToken()\n                    BearerTokens(\n                        accessToken = newTokens.accessToken,\n                        refreshToken = newTokens.refreshToken\n                    )\n                }\n            }\n        }\n\n        // Default request configuration\n        defaultRequest {\n            url(baseUrl)\n            contentType(ContentType.Application.Json)\n        }\n\n        // Logging (debug builds only)\n        install(Logging) {\n            logger = Logger.DEFAULT\n            level = LogLevel.INFO\n            filter { request -\u003e\n                request.url.host.contains(baseUrl)\n            }\n        }\n\n        // Timeout configuration\n        install(HttpTimeout) {\n            requestTimeoutMillis = 30_000\n            connectTimeoutMillis = 10_000\n            socketTimeoutMillis = 30_000\n        }\n\n        // Response validation\n        HttpResponseValidator {\n            validateResponse { response -\u003e\n                when (response.status.value) {\n                    in 300..399 -\u003e throw RedirectException(response)\n                    in 400..499 -\u003e throw ClientRequestException(response)\n                    in 500..599 -\u003e throw ServerResponseException(response)\n                }\n            }\n        }\n    }\n}\n\ninterface TokenProvider {\n    suspend fun getTokens(): AuthTokens\n    suspend fun refreshToken(): AuthTokens\n}\n\ndata class AuthTokens(\n    val accessToken: String,\n    val refreshToken: String\n)\n```\n\n### Generic API Response Wrapper\n\nAll API responses follow this structure:\n\n```kotlin\n// data/remote/dto/ApiResponseDto.kt\n@Serializable\ndata class ApiResponse\u003cT\u003e(\n    val success: Boolean,\n    val data: T? = null,\n    val error: ErrorDetail? = null,\n    val meta: MetaInfo\n)\n\n@Serializable\ndata class ErrorDetail(\n    val code: String,\n    val message: String,\n    val details: Map\u003cString, String\u003e? = null,\n    @SerialName(\"correlation_id\") val correlationId: String? = null\n)\n\n@Serializable\ndata class MetaInfo(\n    val timestamp: String,\n    val version: String? = null\n)\n```\n\n### Error Handling\n\n**Common Error Codes**:\n\n| Code | HTTP Status | Description |\n|------|------------|-------------|\n| `invalid_token` | 401 | JWT token expired or invalid |\n| `unauthorized` | 401 | User not authenticated |\n| `forbidden` | 403 | User not authorized for this resource |\n| `not_found` | 404 | Resource not found |\n| `validation_error` | 422 | Request validation failed |\n| `rate_limit_exceeded` | 429 | Too many requests |\n| `server_error` | 500 | Internal server error |\n\n**Implementation**:\n\n```kotlin\n// domain/model/ApiError.kt\nsealed class ApiError {\n    data class NetworkError(val message: String) : ApiError()\n    data class ServerError(val code: String, val message: String) : ApiError()\n    data class Unauthorized(val message: String) : ApiError()\n    data class NotFound(val message: String) : ApiError()\n    data class ValidationError(val fields: Map\u003cString, String\u003e) : ApiError()\n    data class Unknown(val message: String) : ApiError()\n}\n\n// data/remote/ApiErrorHandler.kt\nsuspend fun \u003cT\u003e safeApiCall(\n    apiCall: suspend () -\u003e HttpResponse\n): Result\u003cT\u003e {\n    return try {\n        val response = apiCall()\n        val body: ApiResponse\u003cT\u003e = response.body()\n\n        if (body.success \u0026\u0026 body.data != null) {\n            Result.success(body.data)\n        } else {\n            Result.failure(\n                ApiException(body.error?.message ?: \"Unknown error\")\n            )\n        }\n    } catch (e: RedirectResponseException) {\n        Result.failure(ApiError.NetworkError(\"Redirect: ${e.message}\"))\n    } catch (e: ClientRequestException) {\n        when (e.response.status.value) {\n            401 -\u003e Result.failure(ApiError.Unauthorized(\"Please login again\"))\n            404 -\u003e Result.failure(ApiError.NotFound(\"Resource not found\"))\n            422 -\u003e {\n                val errorBody: ApiResponse\u003cNothing\u003e = e.response.body()\n                Result.failure(\n                    ApiError.ValidationError(errorBody.error?.details ?: emptyMap())\n                )\n            }\n            else -\u003e Result.failure(ApiError.ServerError(\n                code = \"client_error\",\n                message = e.message ?: \"Client error\"\n            ))\n        }\n    } catch (e: ServerResponseException) {\n        Result.failure(ApiError.ServerError(\n            code = \"server_error\",\n            message = \"Server error: ${e.response.status.value}\"\n        ))\n    } catch (e: Exception) {\n        Result.failure(ApiError.Unknown(e.message ?: \"Unknown error\"))\n    }\n}\n```\n\n### Authentication Flow Implementation\n\nComplete Telegram authentication flow:\n\n```kotlin\n// domain/usecase/LoginWithTelegramUseCase.kt\nclass LoginWithTelegramUseCase(\n    private val authRepository: AuthRepository\n) {\n    suspend operator fun invoke(\n        telegramUserId: Long,\n        authHash: String,\n        authDate: Long,\n        username: String?,\n        firstName: String?,\n        lastName: String?,\n        photoUrl: String?,\n        clientId: String\n    ): Result\u003cUser\u003e {\n        // 1. Submit Telegram auth data to backend\n        val loginResult = authRepository.loginWithTelegram(\n            TelegramLoginRequest(\n                telegramUserId = telegramUserId,\n                authHash = authHash,\n                authDate = authDate,\n                username = username,\n                firstName = firstName,\n                lastName = lastName,\n                photoUrl = photoUrl,\n                clientId = clientId\n            )\n        )\n\n        // 2. Store tokens securely\n        return loginResult.mapCatching { authResponse -\u003e\n            authRepository.storeTokens(\n                accessToken = authResponse.accessToken,\n                refreshToken = authResponse.refreshToken,\n                expiresIn = authResponse.expiresIn\n            )\n            authResponse.user.toDomain()\n        }\n    }\n}\n```\n\nSee [docs/DEVELOPMENT.md](./docs/DEVELOPMENT.md) for setup and workflow guidance.\n\n## Getting Started\n\n### Prerequisites\n\n#### Development Tools\n\n- **Xcode 15+** (for iOS development, macOS only)\n- **Android Studio Ladybug+** (2024.2.1 or later)\n- **JDK 17+** (for Gradle)\n- **CocoaPods** (for iOS dependencies)\n\n#### Backend Service\n\nThe mobile client requires the [bite-size-reader](https://github.com/po4yka/bite-size-reader) FastAPI backend service.\n\n**Backend Requirements**:\n- **Python 3.13+**\n- **Docker** (optional, recommended)\n- **Required Environment Variables**:\n  - `JWT_SECRET_KEY` - 32+ character secret (generate: `openssl rand -hex 32`)\n  - `BOT_TOKEN` - Telegram bot token (for auth verification)\n  - `ALLOWED_USER_IDS` - Comma-separated Telegram user IDs\n  - `ALLOWED_CLIENT_IDS` - Optional client ID whitelist\n  - `ALLOWED_ORIGINS` - CORS allowed origins (for mobile API)\n  - `OPENROUTER_API_KEY` - For LLM summarization\n  - `FIRECRAWL_API_KEY` - For content extraction\n\n**Quick Backend Setup**:\n\n```bash\n# Clone backend repository\ncd ..\ngit clone https://github.com/po4yka/bite-size-reader.git\ncd bite-size-reader\n\n# Configure environment\ncp .env.example .env\n# Edit .env with your API keys\n\n# Start with Docker (recommended)\ndocker-compose up -d\n\n# Verify backend is running\ncurl http://localhost:8000/health\n# Expected: {\"status\":\"ok\"}\n```\n\n**Backend API Documentation**: http://localhost:8000/docs\n\nFor detailed backend setup, see [docs/DEVELOPMENT.md](./docs/DEVELOPMENT.md#backend-setup).\n\n### Clone Repository\n\n```bash\ngit clone https://github.com/po4yka/bite-size-reader-client.git\ncd bite-size-reader-client\n```\n\n### Configuration\n\nCreate `local.properties` in project root:\n\n```properties\n# Backend API base URL\napi.base.url=http://localhost:8000\n\n# Telegram Bot Token (for auth verification)\ntelegram.bot.token=YOUR_BOT_TOKEN_HERE\n\n# Client ID (identifies this app to backend)\nclient.id=android-app-v1.0\n```\n\n**Note**: Do NOT commit `local.properties` - it's in `.gitignore`.\n\n### Build \u0026 Run\n\n#### Desktop (Hot Reload for UI Development)\n\n```bash\n# Run with Compose Hot Reload for rapid UI development\n./gradlew :composeApp:runDesktop\n\n# Edit any Compose UI file and see changes instantly!\n```\n\n**Note**: Desktop target is for development only. See [docs/COMPOSE_HOT_RELOAD.md](docs/COMPOSE_HOT_RELOAD.md) for details.\n\n#### Android\n\n```bash\n# Open in Android Studio\nopen -a \"Android Studio\" .\n\n# Or build from command line\n./gradlew :androidApp:assembleDebug\n\n# Install on connected device/emulator\n./gradlew :androidApp:installDebug\n```\n\n#### iOS\n\n```bash\n# Install CocoaPods dependencies\ncd iosApp\npod install\ncd ..\n\n# Open Xcode workspace\nopen iosApp/iosApp.xcworkspace\n\n# Or build from command line\nxcodebuild -workspace iosApp/iosApp.xcworkspace \\\n           -scheme iosApp \\\n           -configuration Debug \\\n           -sdk iphonesimulator\n```\n\n### Running Tests\n\n```bash\n# Run the module tests you changed\n./gradlew :core:common:allTests :core:data:allTests\n./gradlew :feature:summary:allTests :feature:settings:allTests\n\n# Android tests\n./gradlew :composeApp:testDebugUnitTest\n```\n\n## Development\n\n### Code Style\n\n- **Kotlin**: [Official Kotlin style guide](https://kotlinlang.org/docs/coding-conventions.html)\n- **Swift**: [Swift API Design Guidelines](https://swift.org/documentation/api-design-guidelines/)\n- **Formatting**: Use IDE auto-formatting (Cmd+Opt+L / Ctrl+Alt+L)\n\n### Dependency Management\n\nAll versions are managed in `gradle/libs.versions.toml`:\n\n```toml\n[versions]\nkotlin = \"2.2.20\"\nktor = \"3.0.2\"\nsqldelight = \"2.0.2\"\ndecompose = \"3.2.0\"\nstore = \"5.1.0\"\nkoin = \"3.5.6\"\n\n[libraries]\nktor-client-core = { module = \"io.ktor:ktor-client-core\", version.ref = \"ktor\" }\n# ... more dependencies\n```\n\n### Adding Dependencies\n\n1. Add version to `[versions]` section in `libs.versions.toml`\n2. Add library to `[libraries]` section\n3. Reference in `build.gradle.kts`: `implementation(libs.ktor.client.core)`\n\n### Architecture Patterns\n\n#### MVI (Model-View-Intent)\n\n```kotlin\n// State\ndata class SummaryListState(\n    val summaries: List\u003cSummary\u003e = emptyList(),\n    val isLoading: Boolean = false,\n    val error: String? = null\n)\n\n// Intent/Event\nsealed class SummaryListEvent {\n    data object LoadSummaries : SummaryListEvent()\n    data class MarkAsRead(val id: Int) : SummaryListEvent()\n}\n\n// ViewModel\nclass SummaryListViewModel(\n    private val getSummariesUseCase: GetSummariesUseCase\n) {\n    private val _state = MutableStateFlow(SummaryListState())\n    val state: StateFlow\u003cSummaryListState\u003e = _state.asStateFlow()\n\n    fun onEvent(event: SummaryListEvent) {\n        when (event) {\n            is SummaryListEvent.LoadSummaries -\u003e loadSummaries()\n            is SummaryListEvent.MarkAsRead -\u003e markAsRead(event.id)\n        }\n    }\n}\n```\n\n#### Repository Pattern (with Store)\n\n```kotlin\nclass SummaryRepositoryImpl(\n    private val store: Store\u003cString, List\u003cSummary\u003e\u003e\n) : SummaryRepository {\n\n    override fun getSummaries(): Flow\u003cList\u003cSummary\u003e\u003e =\n        store.stream(StoreReadRequest.cached(key = \"summaries\", refresh = true))\n            .map { it.dataOrNull() ?: emptyList() }\n}\n```\n\n#### Decompose Navigation\n\n```kotlin\ninterface RootComponent {\n    val stack: Value\u003cChildStack\u003c*, Child\u003e\u003e\n\n    sealed class Child {\n        class SummaryList(val component: SummaryListComponent) : Child()\n        class SummaryDetail(val component: SummaryDetailComponent) : Child()\n    }\n}\n```\n\n## Key Features\n\n### Offline-First Architecture\n\n- **Local SQLite Database**: All summaries cached locally\n- **Background Sync**: Automatic delta sync on app launch\n- **Optimistic Updates**: Instant UI updates with background sync\n- **Conflict Resolution**: Server wins for reads, local changes uploaded\n\n### Telegram Authentication\n\n1. User taps \"Login with Telegram\"\n2. Opens Telegram Login Widget (WebView on iOS, Custom Tab on Android)\n3. User authorizes in Telegram app\n4. Callback receives auth data\n5. App exchanges auth data for JWT tokens\n6. Tokens stored securely (Keychain/Tink AEAD + DataStore)\n\n### URL Submission Flow\n\n1. User pastes URL or shares from another app\n2. Client validates URL format\n3. POST to `/v1/requests` with URL\n4. Receive `request_id`\n5. Poll `/v1/requests/{id}/status` every 2 seconds\n6. Show progress: content_extraction → llm_summarization → validation → done\n7. Fetch final summary and display\n\n### Search\n\n- **Local FTS**: SQLite FTS5 for offline search\n- **Remote API**: Full-corpus search on backend\n- **Merged Results**: Combine local + remote with deduplication\n- **Topic Tags**: Filter by hashtags (#technology, #ai, etc.)\n\n## Performance\n\n### Optimizations\n\n- **Lazy Loading**: Pagination (20 items per page)\n- **Image Caching**: Coil (Android) / Kingfisher (iOS) for thumbnails\n- **Database Indexing**: Indexes on `is_read`, `created_at`, FTS\n- **Memory Management**: Weak references, proper lifecycle handling\n- **Background Sync**: WorkManager (Android) / Background Tasks (iOS)\n\n### Benchmarks\n\n- **App Launch**: \u003c2 seconds cold start\n- **Summary List**: 60 FPS scrolling with 1000+ items\n- **Search**: \u003c200ms for local FTS, \u003c500ms for remote\n- **Sync**: \u003c5 seconds for 100 summaries delta sync\n\n## Troubleshooting\n\n### Common Issues\n\n**iOS build fails with \"Framework not found ComposeApp\":**\n```bash\n./gradlew :composeApp:syncFramework \\\n  -Pkotlin.native.cocoapods.platform=iphonesimulator \\\n  -Pkotlin.native.cocoapods.archs=arm64 \\\n  -Pkotlin.native.cocoapods.configuration=Debug\ncd iosApp \u0026\u0026 pod install\n```\n\n**Android build fails with SQLDelight errors:**\n```bash\n./gradlew :core:data:generateCommonMainDatabaseInterface --rerun-tasks\n```\n\n**API connection refused:**\n- Ensure backend is running: `cd ../bite-size-reader \u0026\u0026 docker-compose up`\n- Check `api.base.url` in `local.properties`\n- On Android emulator, use `http://10.0.2.2:8000` instead of `localhost:8000`\n\n**Telegram auth fails:**\n- Verify `telegram.bot.token` in `local.properties`\n- Check backend logs for HMAC validation errors\n- Ensure timestamp is within 15-minute window\n\n### Debug Logging\n\nEnable verbose logging in `local.properties`:\n\n```properties\nlog.level=DEBUG\n```\n\nOr at runtime:\n\n```kotlin\n// Set Kermit log level\nLogger.setMinSeverity(Severity.Debug)\n```\n\n## Documentation\n\nSee [docs/INDEX.md](docs/INDEX.md) for the complete documentation index.\n\n### Quick Links\n\n| Topic | Document |\n|-------|----------|\n| Architecture | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |\n| Sync Strategy | [docs/SYNC_STRATEGY.md](docs/SYNC_STRATEGY.md) |\n| API Reference | [docs/API.md](docs/API.md) |\n| Authentication | [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) |\n| ViewModel Guide | [docs/VIEWMODEL_GUIDE.md](docs/VIEWMODEL_GUIDE.md) |\n| Use Case Guide | [docs/USE_CASE_GUIDE.md](docs/USE_CASE_GUIDE.md) |\n| Component Library | [docs/COMPONENT_LIBRARY.md](docs/COMPONENT_LIBRARY.md) |\n| CI/CD | [docs/CICD.md](docs/CICD.md) |\n\nFor AI-assisted development guidance, see [CLAUDE.md](CLAUDE.md).\n\n## License\n\nBSD 3-Clause License - see [LICENSE](./LICENSE) file.\n\nCopyright (c) 2025, Nikita Pochaev\n\n## Related Projects\n\n- **Backend Service**: [bite-size-reader](https://github.com/po4yka/bite-size-reader) - FastAPI backend with Telegram bot\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpo4yka%2Fbite-size-reader-client","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpo4yka%2Fbite-size-reader-client","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpo4yka%2Fbite-size-reader-client/lists"}