├── .gitignore ├── LICENSE ├── README.md ├── androidApp ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── kotlin │ └── com │ │ └── github │ │ └── jetbrains │ │ └── rssreader │ │ └── androidApp │ │ ├── App.kt │ │ ├── AppActivity.kt │ │ ├── composeui │ │ ├── AppTheme.kt │ │ ├── Dialogs.kt │ │ ├── FeedIcon.kt │ │ ├── FeedList.kt │ │ ├── MainFeed.kt │ │ ├── PostList.kt │ │ ├── Previews.kt │ │ └── Screens.kt │ │ └── sync │ │ └── RefreshWorker.kt │ └── res │ ├── drawable │ ├── ic_add.xml │ ├── ic_edit.xml │ └── ic_launcher_foreground.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ └── values │ ├── ic_launcher_background.xml │ ├── strings.xml │ └── themes.xml ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── iosApp ├── iosApp.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ ├── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ │ └── Package.resolved │ │ └── xcuserdata │ │ │ └── konstantin.tskhovrebov.xcuserdatad │ │ │ └── UserInterfaceState.xcuserstate │ ├── xcshareddata │ │ └── xcschemes │ │ │ └── iosApp.xcscheme │ └── xcuserdata │ │ └── konstantin.tskhovrebov.xcuserdatad │ │ ├── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist │ │ └── xcschemes │ │ └── xcschememanagement.plist ├── iosApp │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── 100.png │ │ │ ├── 1024.png │ │ │ ├── 114.png │ │ │ ├── 120.png │ │ │ ├── 128.png │ │ │ ├── 144.png │ │ │ ├── 152.png │ │ │ ├── 16.png │ │ │ ├── 167.png │ │ │ ├── 172.png │ │ │ ├── 180.png │ │ │ ├── 196.png │ │ │ ├── 20.png │ │ │ ├── 216.png │ │ │ ├── 256.png │ │ │ ├── 29.png │ │ │ ├── 32.png │ │ │ ├── 40.png │ │ │ ├── 48.png │ │ │ ├── 50.png │ │ │ ├── 512.png │ │ │ ├── 55.png │ │ │ ├── 57.png │ │ │ ├── 58.png │ │ │ ├── 60.png │ │ │ ├── 64.png │ │ │ ├── 72.png │ │ │ ├── 76.png │ │ │ ├── 80.png │ │ │ ├── 87.png │ │ │ ├── 88.png │ │ │ └── Contents.json │ │ ├── Contents.json │ │ └── FeedPicker.colorset │ │ │ └── Contents.json │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── Info.plist │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── RSSApp.swift │ └── View │ │ ├── Basic │ │ ├── AlertView.swift │ │ └── NavigationLazyView.swift │ │ ├── FeedRow.swift │ │ ├── FeedsList.swift │ │ ├── MainFeedView.swift │ │ ├── PostRow.swift │ │ └── RootView.swift ├── iosAppTests │ ├── Info.plist │ └── iosAppTests.swift └── iosAppUITests │ ├── Info.plist │ └── iosAppUITests.swift ├── media ├── Android+iOS+Desktop.png ├── arch-details.jpg ├── basic-structure.png ├── desktop+web.png ├── ios+android.png └── top-level-arch.jpeg ├── settings.gradle.kts └── shared ├── build.gradle.kts └── src ├── androidMain ├── AndroidManifest.xml └── kotlin │ └── com │ └── github │ └── jetbrains │ └── rssreader │ └── core │ ├── AndroidFeedParser.kt │ ├── AndroidHttpClient.kt │ └── RssReader.kt ├── commonMain └── kotlin │ └── com │ └── github │ └── jetbrains │ └── rssreader │ ├── app │ ├── FeedStore.kt │ └── NanoRedux.kt │ └── core │ ├── RssReader.kt │ ├── Settings.kt │ ├── datasource │ ├── network │ │ ├── FeedLoader.kt │ │ └── FeedParser.kt │ └── storage │ │ └── FeedStorage.kt │ └── entity │ └── feed.kt └── iosMain └── kotlin └── com └── github └── jetbrains └── rssreader ├── app └── IosReduxUtils.kt └── core ├── CFlow.kt ├── IosFeedParser.kt ├── IosHttpClient.kt └── RssReader.kt /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | .idea 4 | .DS_Store 5 | build 6 | */build 7 | captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | xcuserdata/ 12 | Pods/ 13 | /androidApp/key 14 | *.jks 15 | *yarn.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 JetBrains s.r.o. and Kotlin Programming Language contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![official project](http://jb.gg/badges/official.svg)](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub) 2 | 3 | # KMM RSS Reader 4 | 5 | 6 | 7 | This is an open-source, mobile, cross-platform application built 8 | with [Kotlin Multiplatform Mobile](https://kotlinlang.org/lp/mobile/). 9 | 10 | ## Compose multiplatform experiment 11 | 12 | iOS and Desktop clients were implemented as experimental features and can be viewed [here](https://github.com/Kotlin/kmm-production-sample/tree/compose-app). 13 | 14 | 15 | 16 | ## Desktop and Web experiment 17 | 18 | Desktop and Web clients were implemented as experimental features and can be viewed [here](https://github.com/Kotlin/kmm-production-sample/tree/c6a0d9182802490d17729ae634fb59268f68a447). 19 | 20 | 21 | 22 | ## Project structure 23 | 24 | This repository contains a common Kotlin Multiplatform module, a Android project 25 | and an iOS project. The common module is connected with the Android project via the 26 | Gradle multi-project mechanism. For use in iOS applications, the shared module compiles into a 27 | framework that is exposed to the Xcode project via the internal integration Gradle task. This 28 | framework connects to the Xcode project that builds an iOS application. 29 | 30 | You can achieve the same structure by creating a project with 31 | the [KMM Plugin project wizard](https://plugins.jetbrains.com/plugin/14936-kotlin-multiplatform-mobile) 32 | or cloning the [basic sample project](https://github.com/Kotlin/kmm-sample/). 33 | 34 | 35 | 36 | ## Architecture 37 | 38 | Kotlin Multiplatform Mobile is a flexible technology that allows you to share only what you want to 39 | share, from the core layer to UI layers. 40 | 41 | This sample demonstrates sharing not only the data and domain layers of the app but also the 42 | application state: 43 | 44 | 45 | 46 | ### Shared data and domain layers 47 | 48 | There are two types of data sources. The network service is for getting RSS feed updates, while 49 | local storage is for caching the feed, which makes it possible to use the application 50 | offline. [Ktor HTTP Client](https://ktor.io/docs/client.html) is used for making API 51 | requests. [Kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization) is used to 52 | serialize feed data and store it locally 53 | with [MultiplaformSettings](https://github.com/russhwolf/multiplatform-settings). This logic is 54 | organized in the shared module of the `com.github.jetbrains.rssreader.core` package. 55 | 56 | ### Shared application state 57 | 58 | The Redux pattern is used for managing the application state. The simplified Redux architecture is 59 | implemented in the shared module. The `Store` class dispatches the **actions** that can be produced 60 | either by a user or by some async work, and generates the new state. It stores the actual **state** 61 | and facilitates subscription to state updates via Kotlin's `StateFlow`. To provide additional 62 | information about state updates, the `Store` class also produces **effects** that, for example, can 63 | be used to display this information via alerts. This logic is organized in the shared KMM module of 64 | the `com.github.jetbrains.rssreader.app` package. 65 | 66 | 67 | 68 | ### Native UI 69 | 70 | The UI layer is fully native and implemented using SwiftUI for iOS, Jetpack Compose for Android, 71 | Compose Multiplatform for Desktop and React.js for web browser. 72 | 73 | **On the iOS side,** the `Store` from the KMM library is wrapped into the `ObservableObject` and 74 | implements the state as a `@Published` wrapped property. This publishes changes whenever a 75 | dispatched action produces a new state after being reduced in the shared module. The store is 76 | injected as an `Environment Object` into the root view of the application, and is easily accessible 77 | from anywhere in the application. SwiftUI performs all aspects of diffing on the render pass when 78 | your state changes. 79 | 80 | For subscribing to state 81 | updates, [the simple wrapper](https://github.com/Kotlin/kmm-production-sample/blob/master/shared/src/iosMain/kotlin/com/github/jetbrains/rssreader/core/CFlow.kt) 82 | is used. This wrapper allows you to provide a callback that will be called when each new value (the 83 | state in our case) is emitted. 84 | 85 | ## Multiplatform features used 86 | 87 | **✅ Platform-specific API usage.** RSS feeds usually only support the XML format. 88 | The `kotlinx.serialization` library currently doesn't support parsing XML data, but there is no need 89 | to implement your own parser. Instead, platform libraries (`XmlPullParser` for 90 | Android and `NSXMLParser` for iOS) are used. The common `FeedParser` interface 91 | is declared in the `commonMain` source set. Platform implementations are placed in the 92 | corresponding `iOSMain` and `AndroidMain` source sets. They are injected into the 93 | RSSReader class (the KMM module entry point) via the `create` factory method, which is declared in 94 | the [RSSReader class companion object](https://github.com/Kotlin/kmm-production-sample/blob/master/shared/src/androidMain/kotlin/com/github/jetbrains/rssreader/core/RssReader.kt). 95 | -------------------------------------------------------------------------------- /androidApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties 2 | 3 | plugins { 4 | alias(libs.plugins.android.application) 5 | alias(libs.plugins.kotlin.parcelize) 6 | alias(libs.plugins.kotlin.android) 7 | alias(libs.plugins.compose.compiler) 8 | } 9 | 10 | android { 11 | namespace = "com.github.jetbrains.rssreader.androidApp" 12 | compileSdk = (findProperty("android.compileSdk") as String).toInt() 13 | 14 | defaultConfig { 15 | minSdk = (findProperty("android.minSdk") as String).toInt() 16 | targetSdk = (findProperty("android.targetSdk") as String).toInt() 17 | 18 | applicationId = "com.github.jetbrains.rssreader.androidApp" 19 | versionCode = 2 20 | versionName = "1.1" 21 | } 22 | 23 | signingConfigs { 24 | create("release") { 25 | storeFile = file("./key/key.jks") 26 | gradleLocalProperties(rootDir, providers).apply { 27 | storePassword = getProperty("storePwd") 28 | keyAlias = getProperty("keyAlias") 29 | keyPassword = getProperty("keyPwd") 30 | } 31 | } 32 | } 33 | 34 | buildTypes { 35 | create("debugPG") { 36 | isDebuggable = false 37 | isMinifyEnabled = true 38 | versionNameSuffix = " debugPG" 39 | matchingFallbacks.add("debug") 40 | 41 | proguardFiles( 42 | getDefaultProguardFile("proguard-android-optimize.txt"), 43 | file("proguard-rules.pro") 44 | ) 45 | } 46 | release { 47 | isMinifyEnabled = true 48 | signingConfig = signingConfigs.getByName("release") 49 | 50 | proguardFiles( 51 | getDefaultProguardFile("proguard-android-optimize.txt"), 52 | file("proguard-rules.pro") 53 | ) 54 | } 55 | } 56 | 57 | buildFeatures { 58 | compose = true 59 | buildConfig = true 60 | } 61 | compileOptions { 62 | // Flag to enable support for the new language APIs 63 | isCoreLibraryDesugaringEnabled = true 64 | sourceCompatibility = JavaVersion.VERSION_17 65 | targetCompatibility = JavaVersion.VERSION_17 66 | } 67 | kotlinOptions { 68 | jvmTarget = "17" 69 | } 70 | dependencies { 71 | implementation(project(":shared")) 72 | //desugar utils 73 | coreLibraryDesugaring(libs.desugar.jdk.libs) 74 | //Compose 75 | implementation(libs.androidx.compose.ui) 76 | implementation(libs.androidx.compose.ui.tooling) 77 | implementation(libs.androidx.compose.foundation) 78 | implementation(libs.androidx.compose.material) 79 | //Compose Utils 80 | implementation(libs.coil.compose) 81 | implementation(libs.activity.compose) 82 | implementation(libs.accompanist.swiperefresh) 83 | //Coroutines 84 | implementation(libs.kotlinx.coroutines.core) 85 | implementation(libs.kotlinx.coroutines.android) 86 | //DI 87 | implementation(libs.koin.core) 88 | implementation(libs.koin.android) 89 | //Navigation 90 | implementation(libs.voyager.navigator) 91 | //WorkManager 92 | implementation(libs.work.runtime.ktx) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /androidApp/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -dontwarn org.slf4j.** -------------------------------------------------------------------------------- /androidApp/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/com/github/jetbrains/rssreader/androidApp/App.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.androidApp 2 | 3 | import android.app.Application 4 | import com.github.jetbrains.rssreader.androidApp.sync.RefreshWorker 5 | import com.github.jetbrains.rssreader.app.FeedStore 6 | import com.github.jetbrains.rssreader.core.RssReader 7 | import com.github.jetbrains.rssreader.core.create 8 | import org.koin.android.ext.koin.androidContext 9 | import org.koin.android.ext.koin.androidLogger 10 | import org.koin.core.context.startKoin 11 | import org.koin.core.logger.Level 12 | import org.koin.dsl.module 13 | 14 | class App : Application() { 15 | 16 | override fun onCreate() { 17 | super.onCreate() 18 | initKoin() 19 | launchBackgroundSync() 20 | } 21 | 22 | private val appModule = module { 23 | single { RssReader.create(get(), BuildConfig.DEBUG) } 24 | single { FeedStore(get()) } 25 | } 26 | 27 | private fun initKoin() { 28 | startKoin { 29 | if (BuildConfig.DEBUG) androidLogger(Level.ERROR) 30 | 31 | androidContext(this@App) 32 | modules(appModule) 33 | } 34 | } 35 | 36 | private fun launchBackgroundSync() { 37 | RefreshWorker.enqueue(this) 38 | } 39 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/com/github/jetbrains/rssreader/androidApp/AppActivity.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.androidApp 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.material.Scaffold 8 | import androidx.compose.material.SnackbarHost 9 | import androidx.compose.material.rememberScaffoldState 10 | import androidx.compose.runtime.LaunchedEffect 11 | import androidx.compose.runtime.collectAsState 12 | import androidx.compose.ui.Modifier 13 | import cafe.adriel.voyager.navigator.Navigator 14 | import com.github.jetbrains.rssreader.androidApp.composeui.AppTheme 15 | import com.github.jetbrains.rssreader.androidApp.composeui.MainScreen 16 | import com.github.jetbrains.rssreader.app.FeedSideEffect 17 | import com.github.jetbrains.rssreader.app.FeedStore 18 | import kotlinx.coroutines.flow.* 19 | import org.koin.android.ext.android.inject 20 | 21 | class AppActivity : ComponentActivity() { 22 | override fun onCreate(savedInstanceState: Bundle?) { 23 | super.onCreate(savedInstanceState) 24 | setContent { 25 | AppTheme { 26 | val store: FeedStore by inject() 27 | val scaffoldState = rememberScaffoldState() 28 | val error = store.observeSideEffect() 29 | .filterIsInstance() 30 | .collectAsState(null) 31 | LaunchedEffect(error.value) { 32 | error.value?.let { 33 | scaffoldState.snackbarHostState.showSnackbar( 34 | it.error.message.toString() 35 | ) 36 | } 37 | } 38 | Box( 39 | Modifier.padding( 40 | WindowInsets.systemBars 41 | .only(WindowInsetsSides.Start + WindowInsetsSides.End) 42 | .asPaddingValues() 43 | ) 44 | ) { 45 | Scaffold( 46 | scaffoldState = scaffoldState, 47 | snackbarHost = { hostState -> 48 | SnackbarHost( 49 | hostState = hostState, 50 | modifier = Modifier.padding( 51 | WindowInsets.systemBars 52 | .only(WindowInsetsSides.Bottom) 53 | .asPaddingValues() 54 | ) 55 | ) 56 | } 57 | ) { 58 | Navigator(MainScreen()) 59 | } 60 | } 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/com/github/jetbrains/rssreader/androidApp/composeui/AppTheme.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.androidApp.composeui 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.Surface 6 | import androidx.compose.material.darkColors 7 | import androidx.compose.material.lightColors 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.graphics.Color 10 | 11 | private val Orange = Color(0xfff8873c) 12 | private val Purple = Color(0xff6b70fc) 13 | private val LightColors = lightColors( 14 | primary = Orange, 15 | primaryVariant = Orange, 16 | onPrimary = Color.White, 17 | secondary = Purple, 18 | onSecondary = Color.White 19 | ) 20 | private val DarkColors = darkColors( 21 | primary = Orange, 22 | primaryVariant = Orange, 23 | onPrimary = Color.White, 24 | secondary = Purple, 25 | onSecondary = Color.White 26 | ) 27 | 28 | @Composable 29 | fun AppTheme( 30 | content: @Composable () -> Unit 31 | ) { 32 | MaterialTheme( 33 | colors = if (isSystemInDarkTheme()) DarkColors else LightColors, 34 | content = { 35 | Surface(content = content) 36 | } 37 | ) 38 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/com/github/jetbrains/rssreader/androidApp/composeui/Dialogs.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.androidApp.composeui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.shape.RoundedCornerShape 6 | import androidx.compose.material.Button 7 | import androidx.compose.material.MaterialTheme 8 | import androidx.compose.material.Text 9 | import androidx.compose.material.TextField 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.res.stringResource 16 | import androidx.compose.ui.text.input.TextFieldValue 17 | import androidx.compose.ui.unit.dp 18 | import androidx.compose.ui.window.Dialog 19 | import com.github.jetbrains.rssreader.androidApp.R 20 | import com.github.jetbrains.rssreader.core.entity.Feed 21 | 22 | @Composable 23 | fun AddFeedDialog( 24 | onAdd: (String) -> Unit, 25 | onDismiss: () -> Unit 26 | ) = Dialog( 27 | onDismissRequest = onDismiss 28 | ) { 29 | Column( 30 | modifier = Modifier 31 | .fillMaxWidth() 32 | .background(MaterialTheme.colors.surface, shape = RoundedCornerShape(8.dp)) 33 | .padding(16.dp) 34 | ) { 35 | val input = remember { mutableStateOf(TextFieldValue()) } 36 | Text(text = stringResource(R.string.rss_feed_url)) 37 | TextField( 38 | maxLines = 3, 39 | value = input.value, 40 | onValueChange = { input.value = it } 41 | ) 42 | Spacer(modifier = Modifier.size(16.dp)) 43 | Button( 44 | modifier = Modifier.align(Alignment.End), 45 | onClick = { 46 | onAdd( 47 | input.value.text.replace("http://", "https://") 48 | ) 49 | } 50 | ) { 51 | Text(text = stringResource(R.string.add)) 52 | } 53 | } 54 | } 55 | 56 | @Composable 57 | fun DeleteFeedDialog( 58 | feed: Feed, 59 | onDelete: () -> Unit, 60 | onDismiss: () -> Unit 61 | ) = Dialog( 62 | onDismissRequest = onDismiss 63 | ) { 64 | Column( 65 | modifier = Modifier 66 | .fillMaxWidth() 67 | .background(MaterialTheme.colors.surface, shape = RoundedCornerShape(8.dp)) 68 | .padding(16.dp) 69 | ) { 70 | Text(text = feed.sourceUrl) 71 | Spacer(modifier = Modifier.size(16.dp)) 72 | Button( 73 | modifier = Modifier.align(Alignment.End), 74 | onClick = { onDelete() } 75 | ) { 76 | Text(text = stringResource(R.string.remove)) 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/com/github/jetbrains/rssreader/androidApp/composeui/FeedIcon.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.androidApp.composeui 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.foundation.shape.CircleShape 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.material.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.draw.clip 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.graphics.vector.ImageVector 19 | import androidx.compose.ui.res.stringResource 20 | import androidx.compose.ui.res.vectorResource 21 | import androidx.compose.ui.unit.dp 22 | import coil.compose.rememberAsyncImagePainter 23 | import com.github.jetbrains.rssreader.androidApp.R 24 | import com.github.jetbrains.rssreader.core.entity.Feed 25 | import java.util.* 26 | 27 | @Composable 28 | fun FeedIcon( 29 | feed: Feed?, 30 | isSelected: Boolean = false, 31 | onClick: (() -> Unit)? = null 32 | ) { 33 | val txtAll = stringResource(R.string.all) 34 | val shortName = remember(feed) { feed?.shortName() ?: txtAll } 35 | Box( 36 | modifier = Modifier 37 | .size(48.dp) 38 | .clip(CircleShape) 39 | .background( 40 | color = if (isSelected) MaterialTheme.colors.secondary else Color.Transparent 41 | ) 42 | ) { 43 | Box( 44 | modifier = Modifier 45 | .size(40.dp) 46 | .clip(CircleShape) 47 | .align(Alignment.Center) 48 | .background(color = MaterialTheme.colors.primary) 49 | .clickable(enabled = onClick != null, onClick = onClick ?: {}) 50 | ) { 51 | Text( 52 | modifier = Modifier.align(Alignment.Center), 53 | color = MaterialTheme.colors.onPrimary, 54 | text = shortName 55 | ) 56 | feed?.imageUrl?.let { url -> 57 | Image( 58 | painter = rememberAsyncImagePainter(url), 59 | modifier = Modifier.fillMaxSize(), 60 | contentDescription = null 61 | ) 62 | } 63 | } 64 | } 65 | } 66 | 67 | private fun Feed.shortName(): String = 68 | title 69 | .replace(" ", "") 70 | .take(2) 71 | .uppercase(Locale.getDefault()) 72 | 73 | @Composable 74 | fun EditIcon( 75 | onClick: () -> Unit 76 | ) { 77 | Box( 78 | modifier = Modifier 79 | .size(48.dp) 80 | .clip(CircleShape) 81 | .background(color = MaterialTheme.colors.secondary) 82 | .clickable(onClick = onClick) 83 | ) { 84 | Image( 85 | imageVector = ImageVector.vectorResource(R.drawable.ic_edit), 86 | modifier = Modifier.align(Alignment.Center), 87 | contentDescription = null 88 | ) 89 | } 90 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/com/github/jetbrains/rssreader/androidApp/composeui/FeedList.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.androidApp.composeui 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.itemsIndexed 8 | import androidx.compose.material.FloatingActionButton 9 | import androidx.compose.material.MaterialTheme 10 | import androidx.compose.material.Text 11 | import androidx.compose.runtime.* 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.vector.ImageVector 15 | import androidx.compose.ui.res.vectorResource 16 | import androidx.compose.ui.unit.dp 17 | import com.github.jetbrains.rssreader.androidApp.R 18 | import com.github.jetbrains.rssreader.app.FeedAction 19 | import com.github.jetbrains.rssreader.app.FeedStore 20 | import com.github.jetbrains.rssreader.core.entity.Feed 21 | 22 | @Composable 23 | fun FeedList(store: FeedStore) { 24 | Box( 25 | modifier = Modifier.fillMaxSize() 26 | ) { 27 | val state = store.observeState().collectAsState() 28 | val showAddDialog = remember { mutableStateOf(false) } 29 | val feedForDelete = remember> { mutableStateOf(null) } 30 | FeedItemList(feeds = state.value.feeds) { 31 | feedForDelete.value = it 32 | } 33 | FloatingActionButton( 34 | modifier = Modifier 35 | .align(Alignment.BottomEnd) 36 | .padding(16.dp) 37 | .navigationBarsPadding() 38 | .imePadding(), 39 | onClick = { showAddDialog.value = true } 40 | ) { 41 | Image( 42 | imageVector = ImageVector.vectorResource(R.drawable.ic_add), 43 | modifier = Modifier.align(Alignment.Center), 44 | contentDescription = null 45 | ) 46 | } 47 | if (showAddDialog.value) { 48 | AddFeedDialog( 49 | onAdd = { 50 | store.dispatch(FeedAction.Add(it)) 51 | showAddDialog.value = false 52 | }, 53 | onDismiss = { 54 | showAddDialog.value = false 55 | } 56 | ) 57 | } 58 | feedForDelete.value?.let { feed -> 59 | DeleteFeedDialog( 60 | feed = feed, 61 | onDelete = { 62 | store.dispatch(FeedAction.Delete(feed.sourceUrl)) 63 | feedForDelete.value = null 64 | }, 65 | onDismiss = { 66 | feedForDelete.value = null 67 | } 68 | ) 69 | } 70 | } 71 | } 72 | 73 | @Composable 74 | fun FeedItemList( 75 | feeds: List, 76 | onClick: (Feed) -> Unit 77 | ) { 78 | LazyColumn { 79 | itemsIndexed(feeds) { i, feed -> 80 | if (i == 0) Spacer(modifier = Modifier.windowInsetsTopHeight(WindowInsets.statusBars)) 81 | FeedItem(feed) { onClick(feed) } 82 | } 83 | } 84 | } 85 | 86 | @Composable 87 | fun FeedItem( 88 | feed: Feed, 89 | onClick: () -> Unit 90 | ) { 91 | Row( 92 | Modifier 93 | .clickable(onClick = onClick, enabled = !feed.isDefault) 94 | .padding(16.dp) 95 | ) { 96 | FeedIcon(feed = feed) 97 | Spacer(modifier = Modifier.size(16.dp)) 98 | Column { 99 | Text( 100 | style = MaterialTheme.typography.body1, 101 | text = feed.title 102 | ) 103 | Text( 104 | style = MaterialTheme.typography.body2, 105 | text = feed.desc 106 | ) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/com/github/jetbrains/rssreader/androidApp/composeui/MainFeed.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.androidApp.composeui 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.lazy.LazyRow 5 | import androidx.compose.foundation.lazy.items 6 | import androidx.compose.foundation.lazy.rememberLazyListState 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.collectAsState 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.runtime.rememberCoroutineScope 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.unit.dp 13 | import com.github.jetbrains.rssreader.app.FeedAction 14 | import com.github.jetbrains.rssreader.app.FeedStore 15 | import com.github.jetbrains.rssreader.core.entity.Feed 16 | import com.github.jetbrains.rssreader.core.entity.Post 17 | import kotlinx.coroutines.launch 18 | 19 | @Composable 20 | fun MainFeed( 21 | store: FeedStore, 22 | onPostClick: (Post) -> Unit, 23 | onEditClick: () -> Unit, 24 | ) { 25 | val state = store.observeState().collectAsState() 26 | val posts = remember(state.value.feeds, state.value.selectedFeed) { 27 | (state.value.selectedFeed?.posts ?: state.value.feeds.flatMap { it.posts }) 28 | .sortedByDescending { it.date } 29 | } 30 | Column { 31 | val coroutineScope = rememberCoroutineScope() 32 | val listState = rememberLazyListState() 33 | PostList( 34 | modifier = Modifier.weight(1f), 35 | posts = posts, 36 | listState = listState 37 | ) { post -> onPostClick(post) } 38 | MainFeedBottomBar( 39 | feeds = state.value.feeds, 40 | selectedFeed = state.value.selectedFeed, 41 | onFeedClick = { feed -> 42 | coroutineScope.launch { listState.scrollToItem(0) } 43 | store.dispatch(FeedAction.SelectFeed(feed)) 44 | }, 45 | onEditClick = onEditClick 46 | ) 47 | Spacer( 48 | Modifier 49 | .windowInsetsBottomHeight(WindowInsets.navigationBars) 50 | .fillMaxWidth() 51 | ) 52 | } 53 | } 54 | 55 | private sealed class Icons { 56 | object All : Icons() 57 | class FeedIcon(val feed: Feed) : Icons() 58 | object Edit : Icons() 59 | } 60 | 61 | @Composable 62 | fun MainFeedBottomBar( 63 | feeds: List, 64 | selectedFeed: Feed?, 65 | onFeedClick: (Feed?) -> Unit, 66 | onEditClick: () -> Unit 67 | ) { 68 | val items = buildList { 69 | add(Icons.All) 70 | addAll(feeds.map { Icons.FeedIcon(it) }) 71 | add(Icons.Edit) 72 | } 73 | LazyRow( 74 | modifier = Modifier.fillMaxWidth(), 75 | contentPadding = PaddingValues(16.dp) 76 | ) { 77 | this.items(items) { item -> 78 | when (item) { 79 | is Icons.All -> FeedIcon( 80 | feed = null, 81 | isSelected = selectedFeed == null, 82 | onClick = { onFeedClick(null) } 83 | ) 84 | is Icons.FeedIcon -> FeedIcon( 85 | feed = item.feed, 86 | isSelected = selectedFeed == item.feed, 87 | onClick = { onFeedClick(item.feed) } 88 | ) 89 | is Icons.Edit -> EditIcon(onClick = onEditClick) 90 | } 91 | Spacer(modifier = Modifier.size(16.dp)) 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/com/github/jetbrains/rssreader/androidApp/composeui/PostList.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.androidApp.composeui 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.LazyListState 8 | import androidx.compose.foundation.lazy.itemsIndexed 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.material.Card 11 | import androidx.compose.material.MaterialTheme 12 | import androidx.compose.material.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.text.style.TextOverflow 16 | import androidx.compose.ui.unit.dp 17 | import coil.compose.rememberAsyncImagePainter 18 | import com.github.jetbrains.rssreader.core.entity.Post 19 | import java.text.SimpleDateFormat 20 | import java.util.* 21 | 22 | @Composable 23 | fun PostList( 24 | modifier: Modifier, 25 | posts: List, 26 | listState: LazyListState, 27 | onClick: (Post) -> Unit 28 | ) { 29 | LazyColumn( 30 | modifier = modifier, 31 | contentPadding = PaddingValues(16.dp), 32 | state = listState 33 | ) { 34 | itemsIndexed(posts) { i, post -> 35 | if (i == 0) Spacer(Modifier.windowInsetsTopHeight(WindowInsets.statusBars)) 36 | PostItem(post) { onClick(post) } 37 | if (i != posts.size - 1) Spacer(modifier = Modifier.size(16.dp)) 38 | } 39 | } 40 | } 41 | 42 | private val dateFormatter = SimpleDateFormat("dd MMMM yyyy", Locale.getDefault()) 43 | 44 | @Composable 45 | fun PostItem( 46 | item: Post, 47 | onClick: () -> Unit 48 | ) { 49 | val padding = 16.dp 50 | Box { 51 | Card( 52 | elevation = 16.dp, 53 | shape = RoundedCornerShape(padding) 54 | ) { 55 | Column( 56 | modifier = Modifier.clickable(onClick = onClick) 57 | ) { 58 | Spacer(modifier = Modifier.size(padding)) 59 | Text( 60 | modifier = Modifier.padding(start = padding, end = padding), 61 | style = MaterialTheme.typography.h6, 62 | text = item.title 63 | ) 64 | item.imageUrl?.let { url -> 65 | Spacer(modifier = Modifier.size(padding)) 66 | Image( 67 | painter = rememberAsyncImagePainter(url), 68 | modifier = Modifier.height(180.dp).fillMaxWidth(), 69 | contentDescription = null 70 | ) 71 | } 72 | item.desc?.let { desc -> 73 | Spacer(modifier = Modifier.size(padding)) 74 | Text( 75 | modifier = Modifier.padding(start = padding, end = padding), 76 | style = MaterialTheme.typography.body1, 77 | maxLines = 5, 78 | overflow = TextOverflow.Ellipsis, 79 | text = desc 80 | ) 81 | } 82 | Spacer(modifier = Modifier.size(padding)) 83 | Text( 84 | modifier = Modifier.padding(start = padding, end = padding), 85 | style = MaterialTheme.typography.body2, 86 | color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f), 87 | text = dateFormatter.format(Date(item.date)) 88 | ) 89 | Spacer(modifier = Modifier.size(padding)) 90 | } 91 | } 92 | } 93 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/com/github/jetbrains/rssreader/androidApp/composeui/Previews.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.androidApp.composeui 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.tooling.preview.Preview 5 | import com.github.jetbrains.rssreader.core.entity.Feed 6 | import com.github.jetbrains.rssreader.core.entity.Post 7 | 8 | @Preview 9 | @Composable 10 | private fun FeedItemPreview() { 11 | AppTheme { 12 | FeedItem(feed = PreviewData.feed) {} 13 | } 14 | } 15 | 16 | @Preview 17 | @Composable 18 | private fun PostPreview() { 19 | AppTheme { 20 | PostItem(item = PreviewData.post, onClick = {}) 21 | } 22 | } 23 | 24 | @Preview 25 | @Composable 26 | private fun FeedIconPreview() { 27 | AppTheme { 28 | FeedIcon(feed = PreviewData.feed) 29 | } 30 | } 31 | 32 | @Preview 33 | @Composable 34 | private fun FeedIconSelectedPreview() { 35 | AppTheme { 36 | FeedIcon(feed = PreviewData.feed, true) 37 | } 38 | } 39 | 40 | private object PreviewData { 41 | val post = Post( 42 | title = "Productive Server-Side Development With Kotlin: Stories From The Industry", 43 | desc = "Kotlin was created as an alternative to Java, meaning that its application area within the JVM ecosystem was meant to be the same as Java’s. Obviously, this includes server-side development. We would love...", 44 | imageUrl = "https://blog.jetbrains.com/wp-content/uploads/2020/11/server.png", 45 | link = "https://blog.jetbrains.com/kotlin/2020/11/productive-server-side-development-with-kotlin-stories/", 46 | date = 42L 47 | ) 48 | val feed = Feed( 49 | title = "Kotlin Blog", 50 | link = "blog.jetbrains.com/kotlin/", 51 | desc = "blog.jetbrains.com/kotlin/", 52 | imageUrl = null, 53 | posts = listOf(post), 54 | sourceUrl = "https://blog.jetbrains.com/feed/", 55 | isDefault = true 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/com/github/jetbrains/rssreader/androidApp/composeui/Screens.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.androidApp.composeui 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.statusBarsPadding 7 | import androidx.compose.material.ExperimentalMaterialApi 8 | import androidx.compose.material.pullrefresh.PullRefreshIndicator 9 | import androidx.compose.material.pullrefresh.pullRefresh 10 | import androidx.compose.material.pullrefresh.rememberPullRefreshState 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.LaunchedEffect 13 | import androidx.compose.runtime.collectAsState 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.platform.LocalContext 18 | import cafe.adriel.voyager.core.screen.Screen 19 | import cafe.adriel.voyager.navigator.LocalNavigator 20 | import cafe.adriel.voyager.navigator.currentOrThrow 21 | import com.github.jetbrains.rssreader.app.FeedAction 22 | import com.github.jetbrains.rssreader.app.FeedStore 23 | import org.koin.core.component.KoinComponent 24 | import org.koin.core.component.inject 25 | 26 | class MainScreen : Screen, KoinComponent { 27 | @OptIn(ExperimentalMaterialApi::class) 28 | @Composable 29 | override fun Content() { 30 | val store: FeedStore by inject() 31 | val context = LocalContext.current 32 | val navigator = LocalNavigator.currentOrThrow 33 | val state by store.observeState().collectAsState() 34 | val refreshState = rememberPullRefreshState( 35 | refreshing = state.progress, 36 | onRefresh = { store.dispatch(FeedAction.Refresh(true)) } 37 | ) 38 | 39 | LaunchedEffect(Unit) { 40 | store.dispatch(FeedAction.Refresh(false)) 41 | } 42 | Box(modifier = Modifier.pullRefresh(refreshState)) { 43 | MainFeed( 44 | store = store, 45 | onPostClick = { post -> 46 | post.link?.let { url -> 47 | context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) 48 | } 49 | }, 50 | onEditClick = { 51 | navigator.push(FeedListScreen()) 52 | } 53 | ) 54 | PullRefreshIndicator( 55 | modifier = Modifier 56 | .align(Alignment.TopCenter) 57 | .statusBarsPadding(), 58 | refreshing = state.progress, 59 | state = refreshState, 60 | scale = true //https://github.com/google/accompanist/issues/572 61 | ) 62 | } 63 | } 64 | } 65 | 66 | class FeedListScreen : Screen, KoinComponent { 67 | @Composable 68 | override fun Content() { 69 | val store: FeedStore by inject() 70 | FeedList(store = store) 71 | } 72 | } -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/com/github/jetbrains/rssreader/androidApp/sync/RefreshWorker.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.androidApp.sync 2 | 3 | import android.content.Context 4 | import androidx.work.* 5 | import com.github.jetbrains.rssreader.core.RssReader 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.withContext 8 | import org.koin.core.component.KoinComponent 9 | import org.koin.core.component.inject 10 | import java.util.concurrent.TimeUnit 11 | 12 | class RefreshWorker( 13 | appContext: Context, 14 | workerParams: WorkerParameters 15 | ) : CoroutineWorker(appContext, workerParams), KoinComponent { 16 | private val rssReader: RssReader by inject() 17 | 18 | override suspend fun doWork(): Result = withContext(Dispatchers.Main) { 19 | rssReader.getAllFeeds(true) 20 | Result.success() 21 | } 22 | 23 | companion object { 24 | private const val WORK_NAME = "refresh_work_name" 25 | fun enqueue(context: Context) { 26 | WorkManager.getInstance(context).enqueueUniquePeriodicWork( 27 | WORK_NAME, 28 | ExistingPeriodicWorkPolicy.KEEP, 29 | PeriodicWorkRequestBuilder(1, TimeUnit.HOURS).build() 30 | ) 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /androidApp/src/main/res/drawable/ic_add.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /androidApp/src/main/res/drawable/ic_edit.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /androidApp/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 15 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #626AD8 4 | -------------------------------------------------------------------------------- /androidApp/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | RSS reader 4 | Rss feed url 5 | Add 6 | Remove 7 | All 8 | -------------------------------------------------------------------------------- /androidApp/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application).apply(false) 3 | alias(libs.plugins.android.library).apply(false) 4 | alias(libs.plugins.kotlinx.serialization).apply(false) 5 | alias(libs.plugins.kotlin.multiplatform).apply(false) 6 | alias(libs.plugins.kotlin.android).apply(false) 7 | alias(libs.plugins.kotlin.parcelize).apply(false) 8 | alias(libs.plugins.dependencyUpdates).apply(false) 9 | alias(libs.plugins.compose.compiler).apply(false) 10 | } 11 | 12 | allprojects { 13 | // ./gradlew dependencyUpdates 14 | // Report: build/dependencyUpdates/report.txt 15 | apply(plugin = "com.github.ben-manes.versions") 16 | } 17 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | #Gradle 2 | org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" 3 | org.gradle.caching=true 4 | org.gradle.configuration-cache=true 5 | 6 | #Kotlin 7 | kotlin.code.style=official 8 | 9 | #Android 10 | android.useAndroidX=true 11 | android.compileSdk=34 12 | android.targetSdk=34 13 | android.minSdk=21 -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | agp = "8.4.2" 3 | kotlin = "2.0.0" 4 | dependencyUpdates = "0.51.0" 5 | 6 | androidx-compose = "1.6.8" 7 | androidx-compose-ui = "1.6.8" 8 | kotlinx-serialization = "1.6.2" 9 | kotlinx-coroutines = "1.8.0" 10 | ktor = "2.3.7" 11 | napier = "2.7.1" 12 | multiplatform-settings = "1.1.1" 13 | voyager = "1.0.0" 14 | koin = "3.5.3" 15 | accompanist = "0.32.0" 16 | coil = "2.5.0" 17 | activity-compose = "1.9.0" 18 | work-runtime-ktx = "2.9.0" 19 | desugar-jdk-libs = "2.0.4" 20 | 21 | [libraries] 22 | androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose-ui" } 23 | androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-compose-ui" } 24 | androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidx-compose" } 25 | androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose" } 26 | ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } 27 | ktor-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } 28 | kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } 29 | kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } 30 | napier = { module = "io.github.aakira:napier", version.ref = "napier" } 31 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } 32 | multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings" } 33 | ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } 34 | ktor-client-ios = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } 35 | voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } 36 | koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } 37 | koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } 38 | coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } 39 | activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } 40 | accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" } 41 | work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work-runtime-ktx" } 42 | desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar-jdk-libs" } 43 | 44 | [plugins] 45 | android-application = { id = "com.android.application", version.ref = "agp" } 46 | android-library = { id = "com.android.library", version.ref = "agp" } 47 | kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 48 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 49 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 50 | kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } 51 | dependencyUpdates = { id = "com.github.ben-manes.versions", version.ref = "dependencyUpdates" } 52 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Apr 06 10:19:50 MSK 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 210DA2BC255C229400897854 /* MainFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 210DA2BB255C229400897854 /* MainFeedView.swift */; }; 11 | 210DA2CD255C256D00897854 /* FeedsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 210DA2CC255C256D00897854 /* FeedsList.swift */; }; 12 | 210DA2D1255C26C400897854 /* FeedRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 210DA2D0255C26C400897854 /* FeedRow.swift */; }; 13 | 2147648C25D553B5002AEE9C /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2147648B25D553B5002AEE9C /* RootView.swift */; }; 14 | 214764BA25DACD6E002AEE9C /* NavigationLazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 214764B925DACD6E002AEE9C /* NavigationLazyView.swift */; }; 15 | 214764C025DACE94002AEE9C /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 214764BF25DACE94002AEE9C /* AlertView.swift */; }; 16 | 2183D291254585BD0021EE82 /* PostRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2183D290254585BD0021EE82 /* PostRow.swift */; }; 17 | 2183D2A3254B75F70021EE82 /* URLImage in Frameworks */ = {isa = PBXBuildFile; productRef = 2183D2A2254B75F70021EE82 /* URLImage */; }; 18 | 21B3FCA5255ED8360033CEFD /* RSSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21B3FCA4255ED8360033CEFD /* RSSApp.swift */; }; 19 | 21B9594E26BC4C57000C5150 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 21B9594D26BC4C57000C5150 /* Introspect */; }; 20 | 7555FF85242A565B00829871 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7555FF84242A565B00829871 /* Assets.xcassets */; }; 21 | 7555FF88242A565B00829871 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7555FF87242A565B00829871 /* Preview Assets.xcassets */; }; 22 | 7555FF8B242A565B00829871 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7555FF89242A565B00829871 /* LaunchScreen.storyboard */; }; 23 | 7555FF96242A565B00829871 /* iosAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF95242A565B00829871 /* iosAppTests.swift */; }; 24 | 7555FFA1242A565B00829871 /* iosAppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FFA0242A565B00829871 /* iosAppUITests.swift */; }; 25 | /* End PBXBuildFile section */ 26 | 27 | /* Begin PBXContainerItemProxy section */ 28 | 7555FF92242A565B00829871 /* PBXContainerItemProxy */ = { 29 | isa = PBXContainerItemProxy; 30 | containerPortal = 7555FF73242A565900829871 /* Project object */; 31 | proxyType = 1; 32 | remoteGlobalIDString = 7555FF7A242A565900829871; 33 | remoteInfo = iosApp; 34 | }; 35 | 7555FF9D242A565B00829871 /* PBXContainerItemProxy */ = { 36 | isa = PBXContainerItemProxy; 37 | containerPortal = 7555FF73242A565900829871 /* Project object */; 38 | proxyType = 1; 39 | remoteGlobalIDString = 7555FF7A242A565900829871; 40 | remoteInfo = iosApp; 41 | }; 42 | /* End PBXContainerItemProxy section */ 43 | 44 | /* Begin PBXFileReference section */ 45 | 210DA2BB255C229400897854 /* MainFeedView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainFeedView.swift; sourceTree = ""; }; 46 | 210DA2CC255C256D00897854 /* FeedsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedsList.swift; sourceTree = ""; }; 47 | 210DA2D0255C26C400897854 /* FeedRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedRow.swift; sourceTree = ""; }; 48 | 2147648B25D553B5002AEE9C /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 49 | 214764B925DACD6E002AEE9C /* NavigationLazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationLazyView.swift; sourceTree = ""; }; 50 | 214764BF25DACE94002AEE9C /* AlertView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertView.swift; sourceTree = ""; }; 51 | 2183D290254585BD0021EE82 /* PostRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRow.swift; sourceTree = ""; }; 52 | 21B3FCA4255ED8360033CEFD /* RSSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSApp.swift; sourceTree = ""; }; 53 | 7555FF7B242A565900829871 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 54 | 7555FF84242A565B00829871 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 55 | 7555FF87242A565B00829871 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 56 | 7555FF8A242A565B00829871 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 57 | 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 58 | 7555FF91242A565B00829871 /* iosAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iosAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 59 | 7555FF95242A565B00829871 /* iosAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iosAppTests.swift; sourceTree = ""; }; 60 | 7555FF97242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 61 | 7555FF9C242A565B00829871 /* iosAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iosAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 62 | 7555FFA0242A565B00829871 /* iosAppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iosAppUITests.swift; sourceTree = ""; }; 63 | 7555FFA2242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 64 | /* End PBXFileReference section */ 65 | 66 | /* Begin PBXFrameworksBuildPhase section */ 67 | 7555FF78242A565900829871 /* Frameworks */ = { 68 | isa = PBXFrameworksBuildPhase; 69 | buildActionMask = 2147483647; 70 | files = ( 71 | 2183D2A3254B75F70021EE82 /* URLImage in Frameworks */, 72 | 21B9594E26BC4C57000C5150 /* Introspect in Frameworks */, 73 | ); 74 | runOnlyForDeploymentPostprocessing = 0; 75 | }; 76 | 7555FF8E242A565B00829871 /* Frameworks */ = { 77 | isa = PBXFrameworksBuildPhase; 78 | buildActionMask = 2147483647; 79 | files = ( 80 | ); 81 | runOnlyForDeploymentPostprocessing = 0; 82 | }; 83 | 7555FF99242A565B00829871 /* Frameworks */ = { 84 | isa = PBXFrameworksBuildPhase; 85 | buildActionMask = 2147483647; 86 | files = ( 87 | ); 88 | runOnlyForDeploymentPostprocessing = 0; 89 | }; 90 | /* End PBXFrameworksBuildPhase section */ 91 | 92 | /* Begin PBXGroup section */ 93 | 210DA2A1255C08D600897854 /* View */ = { 94 | isa = PBXGroup; 95 | children = ( 96 | 210DA2C3255C23ED00897854 /* Basic */, 97 | 210DA2BB255C229400897854 /* MainFeedView.swift */, 98 | 2147648B25D553B5002AEE9C /* RootView.swift */, 99 | 2183D290254585BD0021EE82 /* PostRow.swift */, 100 | 210DA2CC255C256D00897854 /* FeedsList.swift */, 101 | 210DA2D0255C26C400897854 /* FeedRow.swift */, 102 | ); 103 | path = View; 104 | sourceTree = ""; 105 | }; 106 | 210DA2C3255C23ED00897854 /* Basic */ = { 107 | isa = PBXGroup; 108 | children = ( 109 | 214764BF25DACE94002AEE9C /* AlertView.swift */, 110 | 214764B925DACD6E002AEE9C /* NavigationLazyView.swift */, 111 | ); 112 | path = Basic; 113 | sourceTree = ""; 114 | }; 115 | 7555FF72242A565900829871 = { 116 | isa = PBXGroup; 117 | children = ( 118 | 7555FF7D242A565900829871 /* iosApp */, 119 | 7555FF94242A565B00829871 /* iosAppTests */, 120 | 7555FF9F242A565B00829871 /* iosAppUITests */, 121 | 7555FF7C242A565900829871 /* Products */, 122 | 7555FFB0242A642200829871 /* Frameworks */, 123 | ); 124 | sourceTree = ""; 125 | }; 126 | 7555FF7C242A565900829871 /* Products */ = { 127 | isa = PBXGroup; 128 | children = ( 129 | 7555FF7B242A565900829871 /* iosApp.app */, 130 | 7555FF91242A565B00829871 /* iosAppTests.xctest */, 131 | 7555FF9C242A565B00829871 /* iosAppUITests.xctest */, 132 | ); 133 | name = Products; 134 | sourceTree = ""; 135 | }; 136 | 7555FF7D242A565900829871 /* iosApp */ = { 137 | isa = PBXGroup; 138 | children = ( 139 | 210DA2A1255C08D600897854 /* View */, 140 | 21B3FCA4255ED8360033CEFD /* RSSApp.swift */, 141 | 7555FF84242A565B00829871 /* Assets.xcassets */, 142 | 7555FF89242A565B00829871 /* LaunchScreen.storyboard */, 143 | 7555FF8C242A565B00829871 /* Info.plist */, 144 | 7555FF86242A565B00829871 /* Preview Content */, 145 | ); 146 | path = iosApp; 147 | sourceTree = ""; 148 | }; 149 | 7555FF86242A565B00829871 /* Preview Content */ = { 150 | isa = PBXGroup; 151 | children = ( 152 | 7555FF87242A565B00829871 /* Preview Assets.xcassets */, 153 | ); 154 | path = "Preview Content"; 155 | sourceTree = ""; 156 | }; 157 | 7555FF94242A565B00829871 /* iosAppTests */ = { 158 | isa = PBXGroup; 159 | children = ( 160 | 7555FF95242A565B00829871 /* iosAppTests.swift */, 161 | 7555FF97242A565B00829871 /* Info.plist */, 162 | ); 163 | path = iosAppTests; 164 | sourceTree = ""; 165 | }; 166 | 7555FF9F242A565B00829871 /* iosAppUITests */ = { 167 | isa = PBXGroup; 168 | children = ( 169 | 7555FFA0242A565B00829871 /* iosAppUITests.swift */, 170 | 7555FFA2242A565B00829871 /* Info.plist */, 171 | ); 172 | path = iosAppUITests; 173 | sourceTree = ""; 174 | }; 175 | 7555FFB0242A642200829871 /* Frameworks */ = { 176 | isa = PBXGroup; 177 | children = ( 178 | ); 179 | name = Frameworks; 180 | sourceTree = ""; 181 | }; 182 | /* End PBXGroup section */ 183 | 184 | /* Begin PBXNativeTarget section */ 185 | 7555FF7A242A565900829871 /* iosApp */ = { 186 | isa = PBXNativeTarget; 187 | buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */; 188 | buildPhases = ( 189 | 7555FFB5242A651A00829871 /* Run Script */, 190 | 7555FF77242A565900829871 /* Sources */, 191 | 7555FF78242A565900829871 /* Frameworks */, 192 | 7555FF79242A565900829871 /* Resources */, 193 | ); 194 | buildRules = ( 195 | ); 196 | dependencies = ( 197 | ); 198 | name = iosApp; 199 | packageProductDependencies = ( 200 | 2183D2A2254B75F70021EE82 /* URLImage */, 201 | 21B9594D26BC4C57000C5150 /* Introspect */, 202 | ); 203 | productName = iosApp; 204 | productReference = 7555FF7B242A565900829871 /* iosApp.app */; 205 | productType = "com.apple.product-type.application"; 206 | }; 207 | 7555FF90242A565B00829871 /* iosAppTests */ = { 208 | isa = PBXNativeTarget; 209 | buildConfigurationList = 7555FFA8242A565B00829871 /* Build configuration list for PBXNativeTarget "iosAppTests" */; 210 | buildPhases = ( 211 | 7555FF8D242A565B00829871 /* Sources */, 212 | 7555FF8E242A565B00829871 /* Frameworks */, 213 | 7555FF8F242A565B00829871 /* Resources */, 214 | ); 215 | buildRules = ( 216 | ); 217 | dependencies = ( 218 | 7555FF93242A565B00829871 /* PBXTargetDependency */, 219 | ); 220 | name = iosAppTests; 221 | productName = iosAppTests; 222 | productReference = 7555FF91242A565B00829871 /* iosAppTests.xctest */; 223 | productType = "com.apple.product-type.bundle.unit-test"; 224 | }; 225 | 7555FF9B242A565B00829871 /* iosAppUITests */ = { 226 | isa = PBXNativeTarget; 227 | buildConfigurationList = 7555FFAB242A565B00829871 /* Build configuration list for PBXNativeTarget "iosAppUITests" */; 228 | buildPhases = ( 229 | 7555FF98242A565B00829871 /* Sources */, 230 | 7555FF99242A565B00829871 /* Frameworks */, 231 | 7555FF9A242A565B00829871 /* Resources */, 232 | ); 233 | buildRules = ( 234 | ); 235 | dependencies = ( 236 | 7555FF9E242A565B00829871 /* PBXTargetDependency */, 237 | ); 238 | name = iosAppUITests; 239 | productName = iosAppUITests; 240 | productReference = 7555FF9C242A565B00829871 /* iosAppUITests.xctest */; 241 | productType = "com.apple.product-type.bundle.ui-testing"; 242 | }; 243 | /* End PBXNativeTarget section */ 244 | 245 | /* Begin PBXProject section */ 246 | 7555FF73242A565900829871 /* Project object */ = { 247 | isa = PBXProject; 248 | attributes = { 249 | LastSwiftUpdateCheck = 1130; 250 | LastUpgradeCheck = 1220; 251 | ORGANIZATIONNAME = orgName; 252 | TargetAttributes = { 253 | 7555FF7A242A565900829871 = { 254 | CreatedOnToolsVersion = 11.3.1; 255 | }; 256 | 7555FF90242A565B00829871 = { 257 | CreatedOnToolsVersion = 11.3.1; 258 | TestTargetID = 7555FF7A242A565900829871; 259 | }; 260 | 7555FF9B242A565B00829871 = { 261 | CreatedOnToolsVersion = 11.3.1; 262 | TestTargetID = 7555FF7A242A565900829871; 263 | }; 264 | }; 265 | }; 266 | buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */; 267 | compatibilityVersion = "Xcode 9.3"; 268 | developmentRegion = en; 269 | hasScannedForEncodings = 0; 270 | knownRegions = ( 271 | en, 272 | Base, 273 | ); 274 | mainGroup = 7555FF72242A565900829871; 275 | packageReferences = ( 276 | 2183D2A1254B75F70021EE82 /* XCRemoteSwiftPackageReference "url-image" */, 277 | 21B9594C26BC4C57000C5150 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 278 | ); 279 | productRefGroup = 7555FF7C242A565900829871 /* Products */; 280 | projectDirPath = ""; 281 | projectRoot = ""; 282 | targets = ( 283 | 7555FF7A242A565900829871 /* iosApp */, 284 | 7555FF90242A565B00829871 /* iosAppTests */, 285 | 7555FF9B242A565B00829871 /* iosAppUITests */, 286 | ); 287 | }; 288 | /* End PBXProject section */ 289 | 290 | /* Begin PBXResourcesBuildPhase section */ 291 | 7555FF79242A565900829871 /* Resources */ = { 292 | isa = PBXResourcesBuildPhase; 293 | buildActionMask = 2147483647; 294 | files = ( 295 | 7555FF8B242A565B00829871 /* LaunchScreen.storyboard in Resources */, 296 | 7555FF88242A565B00829871 /* Preview Assets.xcassets in Resources */, 297 | 7555FF85242A565B00829871 /* Assets.xcassets in Resources */, 298 | ); 299 | runOnlyForDeploymentPostprocessing = 0; 300 | }; 301 | 7555FF8F242A565B00829871 /* Resources */ = { 302 | isa = PBXResourcesBuildPhase; 303 | buildActionMask = 2147483647; 304 | files = ( 305 | ); 306 | runOnlyForDeploymentPostprocessing = 0; 307 | }; 308 | 7555FF9A242A565B00829871 /* Resources */ = { 309 | isa = PBXResourcesBuildPhase; 310 | buildActionMask = 2147483647; 311 | files = ( 312 | ); 313 | runOnlyForDeploymentPostprocessing = 0; 314 | }; 315 | /* End PBXResourcesBuildPhase section */ 316 | 317 | /* Begin PBXShellScriptBuildPhase section */ 318 | 7555FFB5242A651A00829871 /* Run Script */ = { 319 | isa = PBXShellScriptBuildPhase; 320 | buildActionMask = 2147483647; 321 | files = ( 322 | ); 323 | inputFileListPaths = ( 324 | ); 325 | inputPaths = ( 326 | ); 327 | outputFileListPaths = ( 328 | ); 329 | outputPaths = ( 330 | ); 331 | runOnlyForDeploymentPostprocessing = 0; 332 | shellPath = /bin/sh; 333 | shellScript = "cd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; 334 | }; 335 | /* End PBXShellScriptBuildPhase section */ 336 | 337 | /* Begin PBXSourcesBuildPhase section */ 338 | 7555FF77242A565900829871 /* Sources */ = { 339 | isa = PBXSourcesBuildPhase; 340 | buildActionMask = 2147483647; 341 | files = ( 342 | 210DA2BC255C229400897854 /* MainFeedView.swift in Sources */, 343 | 210DA2D1255C26C400897854 /* FeedRow.swift in Sources */, 344 | 21B3FCA5255ED8360033CEFD /* RSSApp.swift in Sources */, 345 | 2147648C25D553B5002AEE9C /* RootView.swift in Sources */, 346 | 2183D291254585BD0021EE82 /* PostRow.swift in Sources */, 347 | 214764C025DACE94002AEE9C /* AlertView.swift in Sources */, 348 | 214764BA25DACD6E002AEE9C /* NavigationLazyView.swift in Sources */, 349 | 210DA2CD255C256D00897854 /* FeedsList.swift in Sources */, 350 | ); 351 | runOnlyForDeploymentPostprocessing = 0; 352 | }; 353 | 7555FF8D242A565B00829871 /* Sources */ = { 354 | isa = PBXSourcesBuildPhase; 355 | buildActionMask = 2147483647; 356 | files = ( 357 | 7555FF96242A565B00829871 /* iosAppTests.swift in Sources */, 358 | ); 359 | runOnlyForDeploymentPostprocessing = 0; 360 | }; 361 | 7555FF98242A565B00829871 /* Sources */ = { 362 | isa = PBXSourcesBuildPhase; 363 | buildActionMask = 2147483647; 364 | files = ( 365 | 7555FFA1242A565B00829871 /* iosAppUITests.swift in Sources */, 366 | ); 367 | runOnlyForDeploymentPostprocessing = 0; 368 | }; 369 | /* End PBXSourcesBuildPhase section */ 370 | 371 | /* Begin PBXTargetDependency section */ 372 | 7555FF93242A565B00829871 /* PBXTargetDependency */ = { 373 | isa = PBXTargetDependency; 374 | target = 7555FF7A242A565900829871 /* iosApp */; 375 | targetProxy = 7555FF92242A565B00829871 /* PBXContainerItemProxy */; 376 | }; 377 | 7555FF9E242A565B00829871 /* PBXTargetDependency */ = { 378 | isa = PBXTargetDependency; 379 | target = 7555FF7A242A565900829871 /* iosApp */; 380 | targetProxy = 7555FF9D242A565B00829871 /* PBXContainerItemProxy */; 381 | }; 382 | /* End PBXTargetDependency section */ 383 | 384 | /* Begin PBXVariantGroup section */ 385 | 7555FF89242A565B00829871 /* LaunchScreen.storyboard */ = { 386 | isa = PBXVariantGroup; 387 | children = ( 388 | 7555FF8A242A565B00829871 /* Base */, 389 | ); 390 | name = LaunchScreen.storyboard; 391 | sourceTree = ""; 392 | }; 393 | /* End PBXVariantGroup section */ 394 | 395 | /* Begin XCBuildConfiguration section */ 396 | 7555FFA3242A565B00829871 /* Debug */ = { 397 | isa = XCBuildConfiguration; 398 | buildSettings = { 399 | ALWAYS_SEARCH_USER_PATHS = NO; 400 | CLANG_ANALYZER_NONNULL = YES; 401 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 402 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 403 | CLANG_CXX_LIBRARY = "libc++"; 404 | CLANG_ENABLE_MODULES = YES; 405 | CLANG_ENABLE_OBJC_ARC = YES; 406 | CLANG_ENABLE_OBJC_WEAK = YES; 407 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 408 | CLANG_WARN_BOOL_CONVERSION = YES; 409 | CLANG_WARN_COMMA = YES; 410 | CLANG_WARN_CONSTANT_CONVERSION = YES; 411 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 412 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 413 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 414 | CLANG_WARN_EMPTY_BODY = YES; 415 | CLANG_WARN_ENUM_CONVERSION = YES; 416 | CLANG_WARN_INFINITE_RECURSION = YES; 417 | CLANG_WARN_INT_CONVERSION = YES; 418 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 419 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 420 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 421 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 422 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 423 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 424 | CLANG_WARN_STRICT_PROTOTYPES = YES; 425 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 426 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 427 | CLANG_WARN_UNREACHABLE_CODE = YES; 428 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 429 | COPY_PHASE_STRIP = NO; 430 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 431 | ENABLE_STRICT_OBJC_MSGSEND = YES; 432 | ENABLE_TESTABILITY = YES; 433 | GCC_C_LANGUAGE_STANDARD = gnu11; 434 | GCC_DYNAMIC_NO_PIC = NO; 435 | GCC_NO_COMMON_BLOCKS = YES; 436 | GCC_OPTIMIZATION_LEVEL = 0; 437 | GCC_PREPROCESSOR_DEFINITIONS = ( 438 | "DEBUG=1", 439 | "$(inherited)", 440 | ); 441 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 442 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 443 | GCC_WARN_UNDECLARED_SELECTOR = YES; 444 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 445 | GCC_WARN_UNUSED_FUNCTION = YES; 446 | GCC_WARN_UNUSED_VARIABLE = YES; 447 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 448 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 449 | MTL_FAST_MATH = YES; 450 | ONLY_ACTIVE_ARCH = YES; 451 | SDKROOT = iphoneos; 452 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 453 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 454 | }; 455 | name = Debug; 456 | }; 457 | 7555FFA4242A565B00829871 /* Release */ = { 458 | isa = XCBuildConfiguration; 459 | buildSettings = { 460 | ALWAYS_SEARCH_USER_PATHS = NO; 461 | CLANG_ANALYZER_NONNULL = YES; 462 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 463 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 464 | CLANG_CXX_LIBRARY = "libc++"; 465 | CLANG_ENABLE_MODULES = YES; 466 | CLANG_ENABLE_OBJC_ARC = YES; 467 | CLANG_ENABLE_OBJC_WEAK = YES; 468 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 469 | CLANG_WARN_BOOL_CONVERSION = YES; 470 | CLANG_WARN_COMMA = YES; 471 | CLANG_WARN_CONSTANT_CONVERSION = YES; 472 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 473 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 474 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 475 | CLANG_WARN_EMPTY_BODY = YES; 476 | CLANG_WARN_ENUM_CONVERSION = YES; 477 | CLANG_WARN_INFINITE_RECURSION = YES; 478 | CLANG_WARN_INT_CONVERSION = YES; 479 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 480 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 481 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 482 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 483 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 484 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 485 | CLANG_WARN_STRICT_PROTOTYPES = YES; 486 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 487 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 488 | CLANG_WARN_UNREACHABLE_CODE = YES; 489 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 490 | COPY_PHASE_STRIP = NO; 491 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 492 | ENABLE_NS_ASSERTIONS = NO; 493 | ENABLE_STRICT_OBJC_MSGSEND = YES; 494 | GCC_C_LANGUAGE_STANDARD = gnu11; 495 | GCC_NO_COMMON_BLOCKS = YES; 496 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 497 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 498 | GCC_WARN_UNDECLARED_SELECTOR = YES; 499 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 500 | GCC_WARN_UNUSED_FUNCTION = YES; 501 | GCC_WARN_UNUSED_VARIABLE = YES; 502 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 503 | MTL_ENABLE_DEBUG_INFO = NO; 504 | MTL_FAST_MATH = YES; 505 | SDKROOT = iphoneos; 506 | SWIFT_COMPILATION_MODE = wholemodule; 507 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 508 | VALIDATE_PRODUCT = YES; 509 | }; 510 | name = Release; 511 | }; 512 | 7555FFA6242A565B00829871 /* Debug */ = { 513 | isa = XCBuildConfiguration; 514 | buildSettings = { 515 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 516 | CODE_SIGN_STYLE = Automatic; 517 | DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; 518 | ENABLE_PREVIEWS = YES; 519 | FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; 520 | INFOPLIST_FILE = iosApp/Info.plist; 521 | LD_RUNPATH_SEARCH_PATHS = ( 522 | "$(inherited)", 523 | "@executable_path/Frameworks", 524 | ); 525 | OTHER_LDFLAGS = ( 526 | "$(inherited)", 527 | "-framework", 528 | RssReader, 529 | ); 530 | PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosApp; 531 | PRODUCT_NAME = "$(TARGET_NAME)"; 532 | SWIFT_VERSION = 5.0; 533 | TARGETED_DEVICE_FAMILY = "1,2"; 534 | }; 535 | name = Debug; 536 | }; 537 | 7555FFA7242A565B00829871 /* Release */ = { 538 | isa = XCBuildConfiguration; 539 | buildSettings = { 540 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 541 | CODE_SIGN_STYLE = Automatic; 542 | DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; 543 | ENABLE_PREVIEWS = YES; 544 | FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; 545 | INFOPLIST_FILE = iosApp/Info.plist; 546 | LD_RUNPATH_SEARCH_PATHS = ( 547 | "$(inherited)", 548 | "@executable_path/Frameworks", 549 | ); 550 | OTHER_LDFLAGS = ( 551 | "$(inherited)", 552 | "-framework", 553 | RssReader, 554 | ); 555 | PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosApp; 556 | PRODUCT_NAME = "$(TARGET_NAME)"; 557 | SWIFT_VERSION = 5.0; 558 | TARGETED_DEVICE_FAMILY = "1,2"; 559 | }; 560 | name = Release; 561 | }; 562 | 7555FFA9242A565B00829871 /* Debug */ = { 563 | isa = XCBuildConfiguration; 564 | buildSettings = { 565 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 566 | BUNDLE_LOADER = "$(TEST_HOST)"; 567 | CODE_SIGN_STYLE = Automatic; 568 | INFOPLIST_FILE = iosAppTests/Info.plist; 569 | IPHONEOS_DEPLOYMENT_TARGET = 13.2; 570 | LD_RUNPATH_SEARCH_PATHS = ( 571 | "$(inherited)", 572 | "@executable_path/Frameworks", 573 | "@loader_path/Frameworks", 574 | ); 575 | PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosAppTests; 576 | PRODUCT_NAME = "$(TARGET_NAME)"; 577 | SWIFT_VERSION = 5.0; 578 | TARGETED_DEVICE_FAMILY = "1,2"; 579 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/iosApp.app/iosApp"; 580 | }; 581 | name = Debug; 582 | }; 583 | 7555FFAA242A565B00829871 /* Release */ = { 584 | isa = XCBuildConfiguration; 585 | buildSettings = { 586 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 587 | BUNDLE_LOADER = "$(TEST_HOST)"; 588 | CODE_SIGN_STYLE = Automatic; 589 | INFOPLIST_FILE = iosAppTests/Info.plist; 590 | IPHONEOS_DEPLOYMENT_TARGET = 13.2; 591 | LD_RUNPATH_SEARCH_PATHS = ( 592 | "$(inherited)", 593 | "@executable_path/Frameworks", 594 | "@loader_path/Frameworks", 595 | ); 596 | PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosAppTests; 597 | PRODUCT_NAME = "$(TARGET_NAME)"; 598 | SWIFT_VERSION = 5.0; 599 | TARGETED_DEVICE_FAMILY = "1,2"; 600 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/iosApp.app/iosApp"; 601 | }; 602 | name = Release; 603 | }; 604 | 7555FFAC242A565B00829871 /* Debug */ = { 605 | isa = XCBuildConfiguration; 606 | buildSettings = { 607 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 608 | CODE_SIGN_STYLE = Automatic; 609 | INFOPLIST_FILE = iosAppUITests/Info.plist; 610 | LD_RUNPATH_SEARCH_PATHS = ( 611 | "$(inherited)", 612 | "@executable_path/Frameworks", 613 | "@loader_path/Frameworks", 614 | ); 615 | PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosAppUITests; 616 | PRODUCT_NAME = "$(TARGET_NAME)"; 617 | SWIFT_VERSION = 5.0; 618 | TARGETED_DEVICE_FAMILY = "1,2"; 619 | TEST_TARGET_NAME = iosApp; 620 | }; 621 | name = Debug; 622 | }; 623 | 7555FFAD242A565B00829871 /* Release */ = { 624 | isa = XCBuildConfiguration; 625 | buildSettings = { 626 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 627 | CODE_SIGN_STYLE = Automatic; 628 | INFOPLIST_FILE = iosAppUITests/Info.plist; 629 | LD_RUNPATH_SEARCH_PATHS = ( 630 | "$(inherited)", 631 | "@executable_path/Frameworks", 632 | "@loader_path/Frameworks", 633 | ); 634 | PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosAppUITests; 635 | PRODUCT_NAME = "$(TARGET_NAME)"; 636 | SWIFT_VERSION = 5.0; 637 | TARGETED_DEVICE_FAMILY = "1,2"; 638 | TEST_TARGET_NAME = iosApp; 639 | }; 640 | name = Release; 641 | }; 642 | /* End XCBuildConfiguration section */ 643 | 644 | /* Begin XCConfigurationList section */ 645 | 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = { 646 | isa = XCConfigurationList; 647 | buildConfigurations = ( 648 | 7555FFA3242A565B00829871 /* Debug */, 649 | 7555FFA4242A565B00829871 /* Release */, 650 | ); 651 | defaultConfigurationIsVisible = 0; 652 | defaultConfigurationName = Release; 653 | }; 654 | 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = { 655 | isa = XCConfigurationList; 656 | buildConfigurations = ( 657 | 7555FFA6242A565B00829871 /* Debug */, 658 | 7555FFA7242A565B00829871 /* Release */, 659 | ); 660 | defaultConfigurationIsVisible = 0; 661 | defaultConfigurationName = Release; 662 | }; 663 | 7555FFA8242A565B00829871 /* Build configuration list for PBXNativeTarget "iosAppTests" */ = { 664 | isa = XCConfigurationList; 665 | buildConfigurations = ( 666 | 7555FFA9242A565B00829871 /* Debug */, 667 | 7555FFAA242A565B00829871 /* Release */, 668 | ); 669 | defaultConfigurationIsVisible = 0; 670 | defaultConfigurationName = Release; 671 | }; 672 | 7555FFAB242A565B00829871 /* Build configuration list for PBXNativeTarget "iosAppUITests" */ = { 673 | isa = XCConfigurationList; 674 | buildConfigurations = ( 675 | 7555FFAC242A565B00829871 /* Debug */, 676 | 7555FFAD242A565B00829871 /* Release */, 677 | ); 678 | defaultConfigurationIsVisible = 0; 679 | defaultConfigurationName = Release; 680 | }; 681 | /* End XCConfigurationList section */ 682 | 683 | /* Begin XCRemoteSwiftPackageReference section */ 684 | 2183D2A1254B75F70021EE82 /* XCRemoteSwiftPackageReference "url-image" */ = { 685 | isa = XCRemoteSwiftPackageReference; 686 | repositoryURL = "https://github.com/dmytro-anokhin/url-image.git"; 687 | requirement = { 688 | kind = upToNextMajorVersion; 689 | minimumVersion = 2.1.1; 690 | }; 691 | }; 692 | 21B9594C26BC4C57000C5150 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { 693 | isa = XCRemoteSwiftPackageReference; 694 | repositoryURL = "https://github.com/siteline/SwiftUI-Introspect.git"; 695 | requirement = { 696 | kind = upToNextMajorVersion; 697 | minimumVersion = 0.1.3; 698 | }; 699 | }; 700 | /* End XCRemoteSwiftPackageReference section */ 701 | 702 | /* Begin XCSwiftPackageProductDependency section */ 703 | 2183D2A2254B75F70021EE82 /* URLImage */ = { 704 | isa = XCSwiftPackageProductDependency; 705 | package = 2183D2A1254B75F70021EE82 /* XCRemoteSwiftPackageReference "url-image" */; 706 | productName = URLImage; 707 | }; 708 | 21B9594D26BC4C57000C5150 /* Introspect */ = { 709 | isa = XCSwiftPackageProductDependency; 710 | package = 21B9594C26BC4C57000C5150 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; 711 | productName = Introspect; 712 | }; 713 | /* End XCSwiftPackageProductDependency section */ 714 | }; 715 | rootObject = 7555FF73242A565900829871 /* Project object */; 716 | } 717 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swiftui-introspect", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/siteline/SwiftUI-Introspect.git", 7 | "state" : { 8 | "revision" : "2e09be8af614401bc9f87d40093ec19ce56ccaf2", 9 | "version" : "0.1.3" 10 | } 11 | }, 12 | { 13 | "identity" : "url-image", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/dmytro-anokhin/url-image.git", 16 | "state" : { 17 | "revision" : "ca1792a46bd2d7d28728c7465ff90da07a8ed1c7", 18 | "version" : "2.1.1" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/konstantin.tskhovrebov.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/konstantin.tskhovrebov.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/xcuserdata/konstantin.tskhovrebov.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/xcuserdata/konstantin.tskhovrebov.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | iosApp.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/100.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/114.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/120.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/144.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/152.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/167.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/172.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/172.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/180.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/196.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/20.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/216.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/216.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/29.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/40.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/48.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/50.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/55.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/55.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/57.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/58.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/60.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/64.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/72.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/76.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/80.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/87.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/88.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/88.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "40.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "60.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "29.png", 17 | "idiom" : "iphone", 18 | "scale" : "1x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "58.png", 23 | "idiom" : "iphone", 24 | "scale" : "2x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "87.png", 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "29x29" 32 | }, 33 | { 34 | "filename" : "80.png", 35 | "idiom" : "iphone", 36 | "scale" : "2x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "120.png", 41 | "idiom" : "iphone", 42 | "scale" : "3x", 43 | "size" : "40x40" 44 | }, 45 | { 46 | "filename" : "57.png", 47 | "idiom" : "iphone", 48 | "scale" : "1x", 49 | "size" : "57x57" 50 | }, 51 | { 52 | "filename" : "114.png", 53 | "idiom" : "iphone", 54 | "scale" : "2x", 55 | "size" : "57x57" 56 | }, 57 | { 58 | "filename" : "120.png", 59 | "idiom" : "iphone", 60 | "scale" : "2x", 61 | "size" : "60x60" 62 | }, 63 | { 64 | "filename" : "180.png", 65 | "idiom" : "iphone", 66 | "scale" : "3x", 67 | "size" : "60x60" 68 | }, 69 | { 70 | "filename" : "20.png", 71 | "idiom" : "ipad", 72 | "scale" : "1x", 73 | "size" : "20x20" 74 | }, 75 | { 76 | "filename" : "40.png", 77 | "idiom" : "ipad", 78 | "scale" : "2x", 79 | "size" : "20x20" 80 | }, 81 | { 82 | "filename" : "29.png", 83 | "idiom" : "ipad", 84 | "scale" : "1x", 85 | "size" : "29x29" 86 | }, 87 | { 88 | "filename" : "58.png", 89 | "idiom" : "ipad", 90 | "scale" : "2x", 91 | "size" : "29x29" 92 | }, 93 | { 94 | "filename" : "40.png", 95 | "idiom" : "ipad", 96 | "scale" : "1x", 97 | "size" : "40x40" 98 | }, 99 | { 100 | "filename" : "80.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "40x40" 104 | }, 105 | { 106 | "filename" : "50.png", 107 | "idiom" : "ipad", 108 | "scale" : "1x", 109 | "size" : "50x50" 110 | }, 111 | { 112 | "filename" : "100.png", 113 | "idiom" : "ipad", 114 | "scale" : "2x", 115 | "size" : "50x50" 116 | }, 117 | { 118 | "filename" : "72.png", 119 | "idiom" : "ipad", 120 | "scale" : "1x", 121 | "size" : "72x72" 122 | }, 123 | { 124 | "filename" : "144.png", 125 | "idiom" : "ipad", 126 | "scale" : "2x", 127 | "size" : "72x72" 128 | }, 129 | { 130 | "filename" : "76.png", 131 | "idiom" : "ipad", 132 | "scale" : "1x", 133 | "size" : "76x76" 134 | }, 135 | { 136 | "filename" : "152.png", 137 | "idiom" : "ipad", 138 | "scale" : "2x", 139 | "size" : "76x76" 140 | }, 141 | { 142 | "filename" : "167.png", 143 | "idiom" : "ipad", 144 | "scale" : "2x", 145 | "size" : "83.5x83.5" 146 | }, 147 | { 148 | "filename" : "1024.png", 149 | "idiom" : "ios-marketing", 150 | "scale" : "1x", 151 | "size" : "1024x1024" 152 | }, 153 | { 154 | "filename" : "48.png", 155 | "idiom" : "watch", 156 | "role" : "notificationCenter", 157 | "scale" : "2x", 158 | "size" : "24x24", 159 | "subtype" : "38mm" 160 | }, 161 | { 162 | "filename" : "55.png", 163 | "idiom" : "watch", 164 | "role" : "notificationCenter", 165 | "scale" : "2x", 166 | "size" : "27.5x27.5", 167 | "subtype" : "42mm" 168 | }, 169 | { 170 | "filename" : "58.png", 171 | "idiom" : "watch", 172 | "role" : "companionSettings", 173 | "scale" : "2x", 174 | "size" : "29x29" 175 | }, 176 | { 177 | "filename" : "87.png", 178 | "idiom" : "watch", 179 | "role" : "companionSettings", 180 | "scale" : "3x", 181 | "size" : "29x29" 182 | }, 183 | { 184 | "filename" : "80.png", 185 | "idiom" : "watch", 186 | "role" : "appLauncher", 187 | "scale" : "2x", 188 | "size" : "40x40", 189 | "subtype" : "38mm" 190 | }, 191 | { 192 | "filename" : "88.png", 193 | "idiom" : "watch", 194 | "role" : "appLauncher", 195 | "scale" : "2x", 196 | "size" : "44x44", 197 | "subtype" : "40mm" 198 | }, 199 | { 200 | "filename" : "100.png", 201 | "idiom" : "watch", 202 | "role" : "appLauncher", 203 | "scale" : "2x", 204 | "size" : "50x50", 205 | "subtype" : "44mm" 206 | }, 207 | { 208 | "filename" : "172.png", 209 | "idiom" : "watch", 210 | "role" : "quickLook", 211 | "scale" : "2x", 212 | "size" : "86x86", 213 | "subtype" : "38mm" 214 | }, 215 | { 216 | "filename" : "196.png", 217 | "idiom" : "watch", 218 | "role" : "quickLook", 219 | "scale" : "2x", 220 | "size" : "98x98", 221 | "subtype" : "42mm" 222 | }, 223 | { 224 | "filename" : "216.png", 225 | "idiom" : "watch", 226 | "role" : "quickLook", 227 | "scale" : "2x", 228 | "size" : "108x108", 229 | "subtype" : "44mm" 230 | }, 231 | { 232 | "filename" : "1024.png", 233 | "idiom" : "watch-marketing", 234 | "scale" : "1x", 235 | "size" : "1024x1024" 236 | }, 237 | { 238 | "filename" : "16.png", 239 | "idiom" : "mac", 240 | "scale" : "1x", 241 | "size" : "16x16" 242 | }, 243 | { 244 | "filename" : "32.png", 245 | "idiom" : "mac", 246 | "scale" : "2x", 247 | "size" : "16x16" 248 | }, 249 | { 250 | "filename" : "32.png", 251 | "idiom" : "mac", 252 | "scale" : "1x", 253 | "size" : "32x32" 254 | }, 255 | { 256 | "filename" : "64.png", 257 | "idiom" : "mac", 258 | "scale" : "2x", 259 | "size" : "32x32" 260 | }, 261 | { 262 | "filename" : "128.png", 263 | "idiom" : "mac", 264 | "scale" : "1x", 265 | "size" : "128x128" 266 | }, 267 | { 268 | "filename" : "256.png", 269 | "idiom" : "mac", 270 | "scale" : "2x", 271 | "size" : "128x128" 272 | }, 273 | { 274 | "filename" : "256.png", 275 | "idiom" : "mac", 276 | "scale" : "1x", 277 | "size" : "256x256" 278 | }, 279 | { 280 | "filename" : "512.png", 281 | "idiom" : "mac", 282 | "scale" : "2x", 283 | "size" : "256x256" 284 | }, 285 | { 286 | "filename" : "512.png", 287 | "idiom" : "mac", 288 | "scale" : "1x", 289 | "size" : "512x512" 290 | }, 291 | { 292 | "filename" : "1024.png", 293 | "idiom" : "mac", 294 | "scale" : "2x", 295 | "size" : "512x512" 296 | } 297 | ], 298 | "info" : { 299 | "author" : "xcode", 300 | "version" : 1 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/FeedPicker.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "240", 9 | "green" : "240", 10 | "red" : "240" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.941", 27 | "green" : "0.941", 28 | "red" : "0.941" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /iosApp/iosApp/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /iosApp/iosApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | UIInterfaceOrientationLandscapeLeft 50 | UIInterfaceOrientationLandscapeRight 51 | 52 | UISupportedInterfaceOrientations~ipad 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationPortraitUpsideDown 56 | UIInterfaceOrientationLandscapeLeft 57 | UIInterfaceOrientationLandscapeRight 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /iosApp/iosApp/RSSApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // App.swift 3 | // iosApp 4 | // 5 | // Created by Ekaterina.Petrova on 13.11.2020. 6 | // Copyright © 2020 orgName. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import RssReader 12 | 13 | @main 14 | struct RSSApp: App { 15 | let rss: RssReader 16 | let store: ObservableFeedStore 17 | 18 | init() { 19 | rss = RssReader.Companion().create(withLog: true) 20 | store = ObservableFeedStore(store: FeedStore(rssReader: rss)) 21 | } 22 | 23 | var body: some Scene { 24 | WindowGroup { 25 | RootView().environmentObject(store) 26 | } 27 | } 28 | } 29 | 30 | class ObservableFeedStore: ObservableObject { 31 | @Published public var state: FeedState = FeedState(progress: false, feeds: [], selectedFeed: nil) 32 | @Published public var sideEffect: FeedSideEffect? 33 | 34 | let store: FeedStore 35 | 36 | var stateWatcher : Closeable? 37 | var sideEffectWatcher : Closeable? 38 | 39 | init(store: FeedStore) { 40 | self.store = store 41 | stateWatcher = self.store.watchState().watch { [weak self] state in 42 | self?.state = state 43 | } 44 | sideEffectWatcher = self.store.watchSideEffect().watch { [weak self] state in 45 | self?.sideEffect = state 46 | } 47 | } 48 | 49 | public func dispatch(_ action: FeedAction) { 50 | store.dispatch(action: action) 51 | } 52 | 53 | deinit { 54 | stateWatcher?.close() 55 | sideEffectWatcher?.close() 56 | } 57 | } 58 | 59 | public typealias DispatchFunction = (FeedAction) -> () 60 | 61 | public protocol ConnectedView: View { 62 | associatedtype Props 63 | associatedtype V: View 64 | 65 | func map(state: FeedState, dispatch: @escaping DispatchFunction) -> Props 66 | func body(props: Props) -> V 67 | } 68 | 69 | public extension ConnectedView { 70 | func render(state: FeedState, dispatch: @escaping DispatchFunction) -> V { 71 | let props = map(state: state, dispatch: dispatch) 72 | return body(props: props) 73 | } 74 | 75 | var body: StoreConnector { 76 | return StoreConnector(content: render) 77 | } 78 | } 79 | 80 | public struct StoreConnector: View { 81 | @EnvironmentObject var store: ObservableFeedStore 82 | let content: (FeedState, @escaping DispatchFunction) -> V 83 | 84 | public var body: V { 85 | return content(store.state, store.dispatch) 86 | } 87 | } 88 | 89 | -------------------------------------------------------------------------------- /iosApp/iosApp/View/Basic/AlertView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // 4 | // Created by Chris Eidhof on 20.04.20. 5 | // Copyright © 2020 objc.io. All rights reserved. 6 | // 7 | import SwiftUI 8 | import UIKit 9 | 10 | extension UIAlertController { 11 | convenience init(alert: TextAlert) { 12 | self.init(title: alert.title, message: nil, preferredStyle: .alert) 13 | addTextField { $0.placeholder = alert.placeholder } 14 | addAction(UIAlertAction(title: alert.cancel, style: .cancel) { _ in 15 | alert.action(nil) 16 | }) 17 | let textField = self.textFields?.first 18 | addAction(UIAlertAction(title: alert.accept, style: .default) { _ in 19 | alert.action(textField?.text) 20 | }) 21 | } 22 | } 23 | 24 | 25 | 26 | struct AlertWrapper: UIViewControllerRepresentable { 27 | @Binding var isPresented: Bool 28 | let alert: TextAlert 29 | let content: Content 30 | 31 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIHostingController { 32 | UIHostingController(rootView: content) 33 | } 34 | 35 | final class Coordinator { 36 | var alertController: UIAlertController? 37 | init(_ controller: UIAlertController? = nil) { 38 | self.alertController = controller 39 | } 40 | } 41 | 42 | func makeCoordinator() -> Coordinator { 43 | return Coordinator() 44 | } 45 | 46 | 47 | func updateUIViewController(_ uiViewController: UIHostingController, context: UIViewControllerRepresentableContext) { 48 | uiViewController.rootView = content 49 | if isPresented && uiViewController.presentedViewController == nil { 50 | var alert = self.alert 51 | alert.action = { 52 | self.isPresented = false 53 | self.alert.action($0) 54 | } 55 | context.coordinator.alertController = UIAlertController(alert: alert) 56 | uiViewController.present(context.coordinator.alertController!, animated: true) 57 | } 58 | if !isPresented && uiViewController.presentedViewController == context.coordinator.alertController { 59 | uiViewController.dismiss(animated: true) 60 | } 61 | } 62 | } 63 | 64 | public struct TextAlert { 65 | public var title: String 66 | public var placeholder: String = "" 67 | public var accept: String = "OK" 68 | public var cancel: String = "Cancel" 69 | public var action: (String?) -> () 70 | } 71 | 72 | extension View { 73 | public func alert(isPresented: Binding, _ alert: TextAlert) -> some View { 74 | AlertWrapper(isPresented: isPresented, alert: alert, content: self) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /iosApp/iosApp/View/Basic/NavigationLazyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationLazyView.swift 3 | // iosApp 4 | // 5 | // Created by Ekaterina.Petrova on 15.02.2021. 6 | // Copyright © 2021 orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct NavigationLazyView: View { 12 | let build: () -> Content 13 | init(_ build: @autoclosure @escaping () -> Content) { 14 | self.build = build 15 | } 16 | var body: Content { 17 | build() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /iosApp/iosApp/View/FeedRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedRow.swift 3 | // iosApp 4 | // 5 | // Created by Ekaterina.Petrova on 11.11.2020. 6 | // Copyright © 2020 orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import RssReader 11 | import URLImage 12 | 13 | struct FeedRow: View { 14 | let feed: Feed 15 | 16 | private enum Constants { 17 | static let imageWidth: CGFloat = 20.0 18 | } 19 | 20 | var body: some View { 21 | HStack { 22 | if let imageUrl = feed.imageUrl, let url = URL(string: imageUrl) { 23 | URLImage(url: url) { image in 24 | image 25 | .resizable() 26 | .aspectRatio(contentMode: .fill) 27 | 28 | } 29 | .frame(width: Constants.imageWidth, height: Constants.imageWidth) 30 | .clipped() 31 | .cornerRadius(Constants.imageWidth / 2.0) 32 | } 33 | VStack(alignment: .leading, spacing: 5.0) { 34 | Text(feed.title).bold().font(.title3).lineLimit(1) 35 | Text(feed.desc).font(.body) 36 | } 37 | } 38 | } 39 | } 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /iosApp/iosApp/View/FeedsList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedsList.swift 3 | // iosApp 4 | // 5 | // Created by Ekaterina.Petrova on 11.11.2020. 6 | // Copyright © 2020 orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import RssReader 11 | 12 | struct FeedsList: ConnectedView { 13 | 14 | struct Props { 15 | let defaultFeeds: [Feed] 16 | let userFeeds: [Feed] 17 | let onAdd: (String) -> () 18 | let onRemove: (String) -> () 19 | } 20 | 21 | func map(state: FeedState, dispatch: @escaping DispatchFunction) -> Props { 22 | return Props(defaultFeeds: state.feeds.filter { $0.isDefault }, 23 | userFeeds: state.feeds.filter { !$0.isDefault }, 24 | onAdd: { url in 25 | dispatch(FeedAction.Add(url: url)) 26 | }, onRemove: { url in 27 | dispatch(FeedAction.Delete(url: url)) 28 | }) 29 | } 30 | 31 | @SwiftUI.State var showsAlert: Bool = false 32 | 33 | func body(props: Props) -> some View { 34 | List { 35 | ForEach(props.defaultFeeds) { FeedRow(feed: $0) } 36 | ForEach(props.userFeeds) { FeedRow(feed: $0) } 37 | .onDelete( perform: { set in 38 | set.map { props.userFeeds[$0] }.forEach { props.onRemove($0.sourceUrl) } 39 | }) 40 | } 41 | .alert(isPresented: $showsAlert, TextAlert(title: "Title") { 42 | if let url = $0 { 43 | props.onAdd(url) 44 | } 45 | }) 46 | .navigationTitle("Feeds list") 47 | .navigationBarTitleDisplayMode(.inline) 48 | .navigationBarItems(trailing: Button(action: { 49 | showsAlert = true 50 | }) { 51 | Image(systemName: "plus.circle").imageScale(.large) 52 | }) 53 | } 54 | } 55 | 56 | extension Feed: Identifiable { } 57 | 58 | -------------------------------------------------------------------------------- /iosApp/iosApp/View/MainFeedView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import RssReader 3 | import URLImage 4 | 5 | struct MainFeedView: ConnectedView { 6 | 7 | struct Props { 8 | let loading: Bool 9 | let items: [Post] 10 | let feedOptions: [FeedPickerOption] 11 | let selectedFeedOption: FeedPickerOption 12 | 13 | let onReloadFeed: (Bool) -> Void 14 | let onSelectFeed: (Feed?) -> Void 15 | } 16 | 17 | enum FeedPickerOption: Hashable { 18 | case all, feed(Feed) 19 | 20 | var title: String { 21 | return String((self.feed?.title ?? "All").prefix(20)) 22 | } 23 | 24 | var feed: Feed? { 25 | switch self { 26 | case .all: 27 | return nil 28 | case .feed(let feed): 29 | return feed 30 | } 31 | } 32 | } 33 | 34 | func map(state: FeedState, dispatch: @escaping DispatchFunction) -> Props { 35 | let selectedFeedOption: FeedPickerOption 36 | if let selectedFeed = state.selectedFeed { 37 | selectedFeedOption = .feed(selectedFeed) 38 | } else { 39 | selectedFeedOption = .all 40 | } 41 | return Props(loading: state.progress, 42 | items: state.mainFeedPosts(), 43 | feedOptions: [.all] + state.feeds.map { FeedPickerOption.feed($0)}, 44 | selectedFeedOption: selectedFeedOption, 45 | onReloadFeed: { reload in 46 | dispatch(FeedAction.Refresh(forceLoad: reload)) 47 | }, 48 | onSelectFeed: { feed in 49 | dispatch(FeedAction.SelectFeed(feed: feed)) 50 | }) 51 | } 52 | 53 | 54 | @SwiftUI.State private var showSelectFeed = false 55 | 56 | init() { 57 | UITableView.appearance().backgroundColor = .white 58 | } 59 | 60 | func body(props: Props) -> some View { 61 | VStack { 62 | if showSelectFeed { 63 | feedPicker(props: props) 64 | } 65 | List(props.items, rowContent: PostRow.init) 66 | } 67 | .navigationBarTitleDisplayMode(.inline) 68 | .navigationBarItems(leading: refreshButton(props: props), trailing: editFeedLink) 69 | .toolbar { 70 | ToolbarItem(placement: .principal) { 71 | navigationTitle(props: props) 72 | } 73 | } 74 | .onAppear { 75 | props.onReloadFeed(true) 76 | } 77 | } 78 | 79 | var refreshButtionAnimation: Animation { 80 | Animation.linear(duration: 0.8).repeatForever(autoreverses: false) 81 | } 82 | 83 | func navigationTitle(props: Props) -> some View { 84 | VStack { 85 | HStack { 86 | Text("RSS Reader").font(.headline) 87 | Button(action: { 88 | withAnimation { showSelectFeed.toggle() } 89 | }) { 90 | Image(systemName: showSelectFeed ? "chevron.up" : "chevron.down").imageScale(.small) 91 | } 92 | } 93 | Text(props.selectedFeedOption.title).font(.subheadline).lineLimit(1) 94 | } 95 | } 96 | 97 | 98 | func feedPicker(props: Props) -> some View { 99 | let binding = Binding( 100 | get: { props.selectedFeedOption }, 101 | set: { props.onSelectFeed($0.feed) } 102 | ) 103 | return Picker("", selection: binding) { 104 | ForEach(props.feedOptions, id: \.self) { option in 105 | HStack { 106 | if let imageUrl = option.feed?.imageUrl, let url = URL(string: imageUrl) { 107 | 108 | URLImage(url: url) { image in 109 | image 110 | .resizable() 111 | .aspectRatio(contentMode: .fit) 112 | } 113 | .frame(width: 24, height: 24) 114 | .cornerRadius(12.0) 115 | .clipped() 116 | } 117 | Text(option.title) 118 | } 119 | } 120 | } 121 | .background(Color("FeedPicker")) 122 | .pickerStyle(.wheel) 123 | } 124 | 125 | func refreshButton(props: Props) -> some View { 126 | Button(action: { 127 | props.onReloadFeed(true) 128 | }) { 129 | Image(systemName: "arrow.clockwise") 130 | .imageScale(.large) 131 | .rotationEffect(Angle.degrees(props.loading ? 360 : 0)).animation( props.loading ? refreshButtionAnimation : .default) 132 | } 133 | } 134 | 135 | var editFeedLink: some View { 136 | NavigationLink(destination: NavigationLazyView(FeedsList())) { 137 | Image(systemName: "pencil.circle").imageScale(.large) 138 | } 139 | } 140 | 141 | } 142 | 143 | extension Post: Identifiable { } 144 | -------------------------------------------------------------------------------- /iosApp/iosApp/View/PostRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RssRow.swift 3 | // iosApp 4 | // 5 | // Created by Ekaterina.Petrova on 25.10.2020. 6 | // Copyright © 2020 orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import RssReader 11 | import URLImage 12 | 13 | struct PostRow: View { 14 | let post: Post 15 | 16 | var body: some View { 17 | if let postURL = post.linkURL { 18 | Link(destination: postURL) { 19 | content 20 | } 21 | .foregroundColor(.black) 22 | } else { 23 | content 24 | } 25 | } 26 | 27 | var content: some View { 28 | VStack(alignment: .leading, spacing: 10.0) { 29 | Text(post.title).bold().font(.title3) 30 | if let imageUrl = post.imageUrl, let url = URL(string: imageUrl) { 31 | URLImage(url: url) { image in 32 | image 33 | .resizable() 34 | .aspectRatio(contentMode: .fit) 35 | } 36 | .frame(minWidth: 0, maxWidth: .infinity) 37 | .clipped() 38 | } 39 | Text(post.desc ?? "").font(.body) 40 | HStack{ 41 | Spacer() 42 | Text(post.dateString).font(.footnote).foregroundColor(.gray) 43 | } 44 | } 45 | } 46 | } 47 | 48 | extension Post { 49 | static let dateFormatter: DateFormatter = { 50 | let formatter = DateFormatter() 51 | formatter.dateFormat = "E, MMM d HH:mm" 52 | return formatter 53 | }() 54 | 55 | var dateString: String { 56 | return Post.dateFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(date))) 57 | } 58 | 59 | var linkURL: URL? { 60 | if let link = link { 61 | return URL(string: link) 62 | } else { 63 | return nil 64 | } 65 | } 66 | } 67 | 68 | -------------------------------------------------------------------------------- /iosApp/iosApp/View/RootView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import RssReader 3 | 4 | struct RootView: View { 5 | @EnvironmentObject var store: ObservableFeedStore 6 | @SwiftUI.State var errorMessage: String? 7 | 8 | var body: some View { 9 | ZStack { 10 | NavigationView { 11 | MainFeedView() 12 | }.zIndex(0) 13 | if let errorMessage = self.errorMessage { 14 | VStack { 15 | Spacer() 16 | Text(errorMessage) 17 | .foregroundColor(.white) 18 | .padding(10.0) 19 | .background(Color.black) 20 | .cornerRadius(3.0) 21 | } 22 | .padding(.bottom, 10) 23 | .zIndex(1) 24 | .transition(.asymmetric(insertion: .move(edge: .bottom), removal: .opacity) ) 25 | } 26 | } 27 | .navigationViewStyle(StackNavigationViewStyle()) 28 | .onReceive(store.$sideEffect) { value in 29 | if let errorMessage = (value as? FeedSideEffect.Error)?.error.message { 30 | withAnimation { self.errorMessage = errorMessage } 31 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { 32 | withAnimation { self.errorMessage = nil } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /iosApp/iosAppTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /iosApp/iosAppTests/iosAppTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import iosApp 3 | 4 | class iosAppTests: XCTestCase { 5 | 6 | override func setUp() { 7 | // Put setup code here. This method is called before the invocation of each test method in the class. 8 | } 9 | 10 | override func tearDown() { 11 | // Put teardown code here. This method is called after the invocation of each test method in the class. 12 | } 13 | 14 | func testExample() { 15 | // This is an example of a functional test case. 16 | // Use XCTAssert and related functions to verify your tests produce the correct results. 17 | } 18 | 19 | func testPerformanceExample() { 20 | // This is an example of a performance test case. 21 | self.measure { 22 | // Put the code you want to measure the time of here. 23 | } 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /iosApp/iosAppUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /iosApp/iosAppUITests/iosAppUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class appNameUITests: XCTestCase { 4 | 5 | override func setUp() { 6 | // Put setup code here. This method is called before the invocation of each test method in the class. 7 | 8 | // In UI tests it is usually best to stop immediately when a failure occurs. 9 | continueAfterFailure = false 10 | 11 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 12 | } 13 | 14 | override func tearDown() { 15 | // Put teardown code here. This method is called after the invocation of each test method in the class. 16 | } 17 | 18 | func testExample() { 19 | // UI tests must launch the application that they test. 20 | let app = XCUIApplication() 21 | app.launch() 22 | 23 | // Use recording to get started writing UI tests. 24 | // Use XCTAssert and related functions to verify your tests produce the correct results. 25 | } 26 | 27 | func testLaunchPerformance() { 28 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { 29 | // This measures how long it takes to launch your application. 30 | measure(metrics: [XCTOSSignpostMetric.applicationLaunch]) { 31 | XCUIApplication().launch() 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /media/Android+iOS+Desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/media/Android+iOS+Desktop.png -------------------------------------------------------------------------------- /media/arch-details.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/media/arch-details.jpg -------------------------------------------------------------------------------- /media/basic-structure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/media/basic-structure.png -------------------------------------------------------------------------------- /media/desktop+web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/media/desktop+web.png -------------------------------------------------------------------------------- /media/ios+android.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/media/ios+android.png -------------------------------------------------------------------------------- /media/top-level-arch.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kotlin/kmp-production-sample/a621aab4068b78cdf24778794208bf4dee3adb6a/media/top-level-arch.jpeg -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "RssReader" 2 | 3 | pluginManagement { 4 | repositories { 5 | google() 6 | gradlePluginPortal() 7 | mavenCentral() 8 | } 9 | } 10 | 11 | dependencyResolutionManagement { 12 | repositories { 13 | google() 14 | mavenCentral() 15 | } 16 | } 17 | 18 | 19 | include(":shared") 20 | include(":androidApp") 21 | -------------------------------------------------------------------------------- /shared/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 2 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 3 | 4 | plugins { 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.kotlinx.serialization) 7 | alias(libs.plugins.kotlin.multiplatform) 8 | } 9 | 10 | kotlin { 11 | androidTarget { 12 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 13 | compilerOptions { 14 | jvmTarget.set(JvmTarget.JVM_17) 15 | } 16 | } 17 | listOf( 18 | iosX64(), 19 | iosArm64(), 20 | iosSimulatorArm64() 21 | ).forEach { 22 | it.binaries.framework { 23 | baseName = "RssReader" 24 | } 25 | } 26 | 27 | sourceSets { 28 | commonMain.dependencies { 29 | //Network 30 | implementation(libs.ktor.core) 31 | implementation(libs.ktor.logging) 32 | //Coroutines 33 | implementation(libs.kotlinx.coroutines.core) 34 | //Logger 35 | implementation(libs.napier) 36 | //JSON 37 | implementation(libs.kotlinx.serialization.json) 38 | //Key-Value storage 39 | implementation(libs.multiplatform.settings) 40 | // DI 41 | api(libs.koin.core) 42 | } 43 | 44 | androidMain.dependencies { 45 | //Network 46 | implementation(libs.ktor.client.okhttp) 47 | } 48 | 49 | iosMain.dependencies { 50 | //Network 51 | implementation(libs.ktor.client.ios) 52 | } 53 | } 54 | } 55 | 56 | android { 57 | namespace = "com.github.jetbrains.rssreader" 58 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") 59 | compileSdk = (findProperty("android.compileSdk") as String).toInt() 60 | 61 | defaultConfig { 62 | minSdk = (findProperty("android.minSdk") as String).toInt() 63 | } 64 | compileOptions { 65 | // Flag to enable support for the new language APIs 66 | isCoreLibraryDesugaringEnabled = true 67 | sourceCompatibility = JavaVersion.VERSION_17 68 | targetCompatibility = JavaVersion.VERSION_17 69 | } 70 | dependencies { 71 | coreLibraryDesugaring(libs.desugar.jdk.libs) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /shared/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/github/jetbrains/rssreader/core/AndroidFeedParser.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.core 2 | 3 | import android.util.Xml 4 | import com.github.jetbrains.rssreader.core.datasource.network.FeedParser 5 | import com.github.jetbrains.rssreader.core.entity.Feed 6 | import com.github.jetbrains.rssreader.core.entity.Post 7 | import io.github.aakira.napier.Napier 8 | import io.ktor.http.* 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.withContext 11 | import org.xmlpull.v1.XmlPullParser 12 | import java.time.ZonedDateTime 13 | import java.time.format.DateTimeFormatter 14 | import java.util.* 15 | 16 | internal class AndroidFeedParser : FeedParser { 17 | private val dateFormat = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US) 18 | 19 | override suspend fun parse(sourceUrl: String, xml: String, isDefault: Boolean): Feed = withContext(Dispatchers.IO) { 20 | val parser = Xml.newPullParser().apply { 21 | setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) 22 | } 23 | 24 | var feed: Feed 25 | 26 | xml.reader().use { reader -> 27 | parser.setInput(reader) 28 | 29 | var tag = parser.nextTag() 30 | while (tag != XmlPullParser.START_TAG && parser.name != "rss") { 31 | skip(parser) 32 | tag = parser.next() 33 | } 34 | parser.nextTag() 35 | 36 | feed = readFeed(sourceUrl, parser, isDefault) 37 | } 38 | 39 | return@withContext feed 40 | } 41 | 42 | private fun readFeed(sourceUrl: String, parser: XmlPullParser, isDefault: Boolean): Feed { 43 | parser.require(XmlPullParser.START_TAG, null, "channel") 44 | 45 | var title: String? = null 46 | var link: String? = null 47 | var description: String? = null 48 | var imageUrl: String? = null 49 | val posts = mutableListOf() 50 | 51 | while (parser.next() != XmlPullParser.END_TAG) { 52 | if (parser.eventType != XmlPullParser.START_TAG) continue 53 | when (parser.name) { 54 | "title" -> title = readTagText("title", parser) 55 | "link" -> link = readTagText("link", parser) 56 | "description" -> description = readTagText("description", parser) 57 | "image" -> imageUrl = readImageUrl(parser) 58 | "item" -> posts.add(readPost(title!!, parser)) 59 | else -> skip(parser) 60 | } 61 | } 62 | 63 | return Feed(title!!, link!!, description!!, imageUrl, posts, sourceUrl, isDefault) 64 | } 65 | 66 | private fun readImageUrl(parser: XmlPullParser): String? { 67 | parser.require(XmlPullParser.START_TAG, null, "image") 68 | 69 | var url: String? = null 70 | 71 | while (parser.next() != XmlPullParser.END_TAG) { 72 | if (parser.eventType != XmlPullParser.START_TAG) continue 73 | when (parser.name) { 74 | "url" -> url = readTagText("url", parser) 75 | else -> skip(parser) 76 | } 77 | } 78 | 79 | return url 80 | } 81 | 82 | private fun readPost(feedTitle: String, parser: XmlPullParser): Post { 83 | parser.require(XmlPullParser.START_TAG, null, "item") 84 | 85 | var title: String? = null 86 | var link: String? = null 87 | var description: String? = null 88 | var date: String? = null 89 | 90 | var content: String? = null 91 | 92 | while (parser.next() != XmlPullParser.END_TAG) { 93 | if (parser.eventType != XmlPullParser.START_TAG) continue 94 | when (parser.name) { 95 | "title" -> title = readTagText("title", parser) 96 | "link" -> link = readTagText("link", parser) 97 | "description" -> description = readTagText("description", parser) 98 | "content:encoded" -> content = readTagText("content:encoded", parser) 99 | "pubDate" -> date = readTagText("pubDate", parser) 100 | else -> skip(parser) 101 | } 102 | } 103 | 104 | val dateLong: Long = date?.let { 105 | try { 106 | ZonedDateTime.parse(date, dateFormat).toEpochSecond() * 1000 107 | } catch (e: Throwable) { 108 | Napier.e("Parse date error: ${e.message}") 109 | null 110 | } 111 | } ?: System.currentTimeMillis() 112 | 113 | return Post( 114 | title ?: feedTitle, 115 | link, 116 | FeedParser.cleanTextCompact(description), 117 | FeedParser.pullPostImageUrl(link, description, content), 118 | dateLong 119 | ) 120 | } 121 | 122 | private fun readTagText(tagName: String, parser: XmlPullParser): String { 123 | parser.require(XmlPullParser.START_TAG, null, tagName) 124 | val title = readText(parser) 125 | parser.require(XmlPullParser.END_TAG, null, tagName) 126 | return title 127 | } 128 | 129 | private fun readText(parser: XmlPullParser): String { 130 | var result = "" 131 | if (parser.next() == XmlPullParser.TEXT) { 132 | result = parser.text 133 | parser.nextTag() 134 | } 135 | return result 136 | } 137 | 138 | private fun skip(parser: XmlPullParser) { 139 | parser.require(XmlPullParser.START_TAG, null, null) 140 | var depth = 1 141 | while (depth != 0) { 142 | when (parser.next()) { 143 | XmlPullParser.END_TAG -> depth-- 144 | XmlPullParser.START_TAG -> depth++ 145 | } 146 | } 147 | } 148 | 149 | } -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/github/jetbrains/rssreader/core/AndroidHttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.core 2 | 3 | import io.github.aakira.napier.Napier 4 | import io.ktor.client.* 5 | import io.ktor.client.engine.okhttp.* 6 | import io.ktor.client.plugins.logging.* 7 | import java.util.concurrent.TimeUnit 8 | 9 | internal fun AndroidHttpClient(withLog: Boolean) = HttpClient(OkHttp) { 10 | engine { 11 | config { 12 | retryOnConnectionFailure(true) 13 | connectTimeout(5, TimeUnit.SECONDS) 14 | } 15 | } 16 | if (withLog) install(Logging) { 17 | level = LogLevel.HEADERS 18 | logger = object : Logger { 19 | override fun log(message: String) { 20 | Napier.v(tag = "AndroidHttpClient", message = message) 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/github/jetbrains/rssreader/core/RssReader.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.core 2 | 3 | import android.content.Context 4 | import com.github.jetbrains.rssreader.core.datasource.network.FeedLoader 5 | import com.github.jetbrains.rssreader.core.datasource.storage.FeedStorage 6 | import com.russhwolf.settings.SharedPreferencesSettings 7 | import io.github.aakira.napier.DebugAntilog 8 | import io.github.aakira.napier.Napier 9 | import kotlinx.serialization.json.Json 10 | 11 | fun RssReader.Companion.create(ctx: Context, withLog: Boolean) = RssReader( 12 | FeedLoader( 13 | AndroidHttpClient(withLog), 14 | AndroidFeedParser() 15 | ), 16 | FeedStorage( 17 | SharedPreferencesSettings(ctx.getSharedPreferences("rss_reader_pref", Context.MODE_PRIVATE)), 18 | Json { 19 | ignoreUnknownKeys = true 20 | isLenient = true 21 | encodeDefaults = false 22 | } 23 | ) 24 | ).also { 25 | if (withLog) Napier.base(DebugAntilog()) 26 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/github/jetbrains/rssreader/app/FeedStore.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.app 2 | 3 | import com.github.jetbrains.rssreader.core.RssReader 4 | import com.github.jetbrains.rssreader.core.entity.Feed 5 | import io.github.aakira.napier.Napier 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.MutableSharedFlow 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.StateFlow 12 | import kotlinx.coroutines.launch 13 | 14 | data class FeedState( 15 | val progress: Boolean, 16 | val feeds: List, 17 | val selectedFeed: Feed? = null //null means selected all 18 | ) : State 19 | 20 | fun FeedState.mainFeedPosts() = (selectedFeed?.posts ?: feeds.flatMap { it.posts }).sortedByDescending { it.date } 21 | 22 | sealed class FeedAction : Action { 23 | data class Refresh(val forceLoad: Boolean) : FeedAction() 24 | data class Add(val url: String) : FeedAction() 25 | data class Delete(val url: String) : FeedAction() 26 | data class SelectFeed(val feed: Feed?) : FeedAction() 27 | data class Data(val feeds: List) : FeedAction() 28 | data class Error(val error: Exception) : FeedAction() 29 | } 30 | 31 | sealed class FeedSideEffect : Effect { 32 | data class Error(val error: Exception) : FeedSideEffect() 33 | } 34 | 35 | class FeedStore( 36 | private val rssReader: RssReader 37 | ) : Store, 38 | CoroutineScope by CoroutineScope(Dispatchers.Main) { 39 | 40 | private val state = MutableStateFlow(FeedState(false, emptyList())) 41 | private val sideEffect = MutableSharedFlow() 42 | 43 | override fun observeState(): StateFlow = state 44 | 45 | override fun observeSideEffect(): Flow = sideEffect 46 | 47 | override fun dispatch(action: FeedAction) { 48 | Napier.d(tag = "FeedStore", message = "Action: $action") 49 | val oldState = state.value 50 | 51 | val newState = when (action) { 52 | is FeedAction.Refresh -> { 53 | if (oldState.progress) { 54 | launch { sideEffect.emit(FeedSideEffect.Error(Exception("In progress"))) } 55 | oldState 56 | } else { 57 | launch { loadAllFeeds(action.forceLoad) } 58 | oldState.copy(progress = true) 59 | } 60 | } 61 | is FeedAction.Add -> { 62 | if (oldState.progress) { 63 | launch { sideEffect.emit(FeedSideEffect.Error(Exception("In progress"))) } 64 | oldState 65 | } else { 66 | launch { addFeed(action.url) } 67 | FeedState(true, oldState.feeds) 68 | } 69 | } 70 | is FeedAction.Delete -> { 71 | if (oldState.progress) { 72 | launch { sideEffect.emit(FeedSideEffect.Error(Exception("In progress"))) } 73 | oldState 74 | } else { 75 | launch { deleteFeed(action.url) } 76 | FeedState(true, oldState.feeds) 77 | } 78 | } 79 | is FeedAction.SelectFeed -> { 80 | if (action.feed == null || oldState.feeds.contains(action.feed)) { 81 | oldState.copy(selectedFeed = action.feed) 82 | } else { 83 | launch { sideEffect.emit(FeedSideEffect.Error(Exception("Unknown feed"))) } 84 | oldState 85 | } 86 | } 87 | is FeedAction.Data -> { 88 | if (oldState.progress) { 89 | val selected = oldState.selectedFeed?.let { 90 | if (action.feeds.contains(it)) it else null 91 | } 92 | FeedState(false, action.feeds, selected) 93 | } else { 94 | launch { sideEffect.emit(FeedSideEffect.Error(Exception("Unexpected action"))) } 95 | oldState 96 | } 97 | } 98 | is FeedAction.Error -> { 99 | if (oldState.progress) { 100 | launch { sideEffect.emit(FeedSideEffect.Error(action.error)) } 101 | FeedState(false, oldState.feeds) 102 | } else { 103 | launch { sideEffect.emit(FeedSideEffect.Error(Exception("Unexpected action"))) } 104 | oldState 105 | } 106 | } 107 | } 108 | 109 | if (newState != oldState) { 110 | Napier.d(tag = "FeedStore", message = "NewState: $newState") 111 | state.value = newState 112 | } 113 | } 114 | 115 | private suspend fun loadAllFeeds(forceLoad: Boolean) { 116 | try { 117 | val allFeeds = rssReader.getAllFeeds(forceLoad) 118 | dispatch(FeedAction.Data(allFeeds)) 119 | } catch (e: Exception) { 120 | dispatch(FeedAction.Error(e)) 121 | } 122 | } 123 | 124 | private suspend fun addFeed(url: String) { 125 | try { 126 | rssReader.addFeed(url) 127 | val allFeeds = rssReader.getAllFeeds(false) 128 | dispatch(FeedAction.Data(allFeeds)) 129 | } catch (e: Exception) { 130 | dispatch(FeedAction.Error(e)) 131 | } 132 | } 133 | 134 | private suspend fun deleteFeed(url: String) { 135 | try { 136 | rssReader.deleteFeed(url) 137 | val allFeeds = rssReader.getAllFeeds(false) 138 | dispatch(FeedAction.Data(allFeeds)) 139 | } catch (e: Exception) { 140 | dispatch(FeedAction.Error(e)) 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/github/jetbrains/rssreader/app/NanoRedux.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.app 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.StateFlow 5 | 6 | interface State 7 | interface Action 8 | interface Effect 9 | 10 | interface Store { 11 | fun observeState(): StateFlow 12 | fun observeSideEffect(): Flow 13 | fun dispatch(action: A) 14 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/github/jetbrains/rssreader/core/RssReader.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.core 2 | 3 | import com.github.jetbrains.rssreader.core.datasource.network.FeedLoader 4 | import com.github.jetbrains.rssreader.core.datasource.storage.FeedStorage 5 | import com.github.jetbrains.rssreader.core.entity.Feed 6 | import kotlinx.coroutines.async 7 | import kotlinx.coroutines.awaitAll 8 | import kotlinx.coroutines.coroutineScope 9 | 10 | class RssReader internal constructor( 11 | private val feedLoader: FeedLoader, 12 | private val feedStorage: FeedStorage, 13 | private val settings: Settings = Settings(setOf("https://blog.jetbrains.com/kotlin/feed/")) 14 | ) { 15 | @Throws(Exception::class) 16 | suspend fun getAllFeeds( 17 | forceUpdate: Boolean = false 18 | ): List { 19 | var feeds = feedStorage.getAllFeeds() 20 | 21 | if (forceUpdate || feeds.isEmpty()) { 22 | val feedsUrls = if (feeds.isEmpty()) settings.defaultFeedUrls else feeds.map { it.sourceUrl } 23 | feeds = feedsUrls.mapAsync { url -> 24 | val new = feedLoader.getFeed(url, settings.isDefault(url)) 25 | feedStorage.saveFeed(new) 26 | new 27 | } 28 | } 29 | 30 | return feeds 31 | } 32 | 33 | @Throws(Exception::class) 34 | suspend fun addFeed(url: String) { 35 | val feed = feedLoader.getFeed(url, settings.isDefault(url)) 36 | feedStorage.saveFeed(feed) 37 | } 38 | 39 | @Throws(Exception::class) 40 | suspend fun deleteFeed(url: String) { 41 | feedStorage.deleteFeed(url) 42 | } 43 | 44 | private suspend fun Iterable.mapAsync(f: suspend (A) -> B): List = 45 | coroutineScope { map { async { f(it) } }.awaitAll() } 46 | 47 | companion object 48 | } 49 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/github/jetbrains/rssreader/core/Settings.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.core 2 | 3 | class Settings(val defaultFeedUrls: Set) { 4 | fun isDefault(feedUrl: String) = defaultFeedUrls.contains(feedUrl) 5 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/github/jetbrains/rssreader/core/datasource/network/FeedLoader.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.core.datasource.network 2 | 3 | import com.github.jetbrains.rssreader.core.entity.Feed 4 | import io.ktor.client.* 5 | import io.ktor.client.request.* 6 | import io.ktor.client.statement.* 7 | 8 | internal class FeedLoader( 9 | private val httpClient: HttpClient, 10 | private val parser: FeedParser 11 | ) { 12 | suspend fun getFeed(url: String, isDefault: Boolean): Feed { 13 | val xml = httpClient.get(url).bodyAsText() 14 | return parser.parse(url, xml, isDefault) 15 | } 16 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/github/jetbrains/rssreader/core/datasource/network/FeedParser.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.core.datasource.network 2 | 3 | import com.github.jetbrains.rssreader.core.entity.Feed 4 | import io.ktor.http.* 5 | 6 | interface FeedParser { 7 | suspend fun parse(sourceUrl: String, xml: String, isDefault: Boolean): Feed 8 | 9 | companion object { 10 | private val imgReg = Regex("]+\\bsrc=[\"']([^\"']+)[\"']") 11 | private val htmlTag = Regex("<.+?>") 12 | private val blankLine = Regex("(?m)^[ \t]*\r?\n") 13 | 14 | private fun findImageUrl(ownerLink: String, text: String): String? = 15 | imgReg.find(text)?.value?.let { v -> 16 | val i = v.indexOf("src=") + 5 //after src=" 17 | val url = v.substring(i, v.length - 1) 18 | if (url.startsWith("http")) url else { 19 | URLBuilder(ownerLink).apply { 20 | encodedPath = url 21 | }.buildString() 22 | } 23 | } 24 | 25 | internal fun cleanText(text: String?): String? = 26 | text?.replace(htmlTag, "") 27 | ?.replace(blankLine, "") 28 | ?.trim() 29 | 30 | internal fun cleanTextCompact(text: String?) = cleanText(text)?.take(300) 31 | 32 | internal fun pullPostImageUrl(postLink: String?, description: String?, content: String?): String? = 33 | postLink?.let { l -> 34 | description?.let { findImageUrl(l, it) } 35 | ?: content?.let { findImageUrl(l, it) } 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/github/jetbrains/rssreader/core/datasource/storage/FeedStorage.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.core.datasource.storage 2 | 3 | import com.github.jetbrains.rssreader.core.entity.Feed 4 | import com.russhwolf.settings.Settings 5 | import com.russhwolf.settings.set 6 | import kotlinx.serialization.builtins.ListSerializer 7 | import kotlinx.serialization.json.Json 8 | 9 | class FeedStorage( 10 | private val settings: Settings, 11 | private val json: Json 12 | ) { 13 | private companion object { 14 | private const val KEY_FEED_CACHE = "key_feed_cache" 15 | } 16 | 17 | private var diskCache: Map 18 | get() { 19 | return settings.getStringOrNull(KEY_FEED_CACHE)?.let { str -> 20 | json.decodeFromString(ListSerializer(Feed.serializer()), str) 21 | .associate { it.sourceUrl to it } 22 | } ?: mutableMapOf() 23 | } 24 | set(value) { 25 | val list = value.map { it.value } 26 | settings[KEY_FEED_CACHE] = 27 | json.encodeToString(ListSerializer(Feed.serializer()), list) 28 | } 29 | 30 | private val memCache: MutableMap by lazy { diskCache.toMutableMap() } 31 | 32 | suspend fun getFeed(url: String): Feed? = memCache[url] 33 | 34 | suspend fun saveFeed(feed: Feed) { 35 | memCache[feed.sourceUrl] = feed 36 | diskCache = memCache 37 | } 38 | 39 | suspend fun deleteFeed(url: String) { 40 | memCache.remove(url) 41 | diskCache = memCache 42 | } 43 | 44 | suspend fun getAllFeeds(): List = memCache.values.toList() 45 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/github/jetbrains/rssreader/core/entity/feed.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.core.entity 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | import kotlin.js.ExperimentalJsExport 6 | import kotlin.js.JsExport 7 | 8 | @Serializable 9 | data class Feed( 10 | @SerialName("title") val title: String, 11 | @SerialName("link") val link: String, 12 | @SerialName("description") val desc: String, 13 | @SerialName("imageUrl") val imageUrl: String?, 14 | @SerialName("posts") val posts: List, 15 | @SerialName("sourceUrl") val sourceUrl: String, 16 | @SerialName("isDefault") val isDefault: Boolean 17 | ) { 18 | override fun equals(other: Any?): Boolean { 19 | if (this === other) return true 20 | if (other == null || this::class != other::class) return false 21 | 22 | other as Feed 23 | 24 | if (sourceUrl != other.sourceUrl) return false 25 | 26 | return true 27 | } 28 | 29 | override fun hashCode(): Int { 30 | return sourceUrl.hashCode() 31 | } 32 | } 33 | 34 | @Serializable 35 | data class Post( 36 | @SerialName("title") val title: String, 37 | @SerialName("link") val link: String?, 38 | @SerialName("description") val desc: String?, 39 | @SerialName("imageUrl") val imageUrl: String?, 40 | @SerialName("date") val date: Long 41 | ) 42 | -------------------------------------------------------------------------------- /shared/src/iosMain/kotlin/com/github/jetbrains/rssreader/app/IosReduxUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.app 2 | 3 | import com.github.jetbrains.rssreader.core.wrap 4 | 5 | fun FeedStore.watchState() = observeState().wrap() 6 | fun FeedStore.watchSideEffect() = observeSideEffect().wrap() -------------------------------------------------------------------------------- /shared/src/iosMain/kotlin/com/github/jetbrains/rssreader/core/CFlow.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.core 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.Job 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.launchIn 8 | import kotlinx.coroutines.flow.onEach 9 | 10 | fun interface Closeable { 11 | fun close() 12 | } 13 | 14 | class CFlow internal constructor(private val origin: Flow) : Flow by origin { 15 | fun watch(block: (T) -> Unit): Closeable { 16 | val job = Job() 17 | 18 | onEach { 19 | block(it) 20 | }.launchIn(CoroutineScope(Dispatchers.Main + job)) 21 | 22 | return Closeable { job.cancel() } 23 | } 24 | } 25 | 26 | internal fun Flow.wrap(): CFlow = CFlow(this) 27 | 28 | -------------------------------------------------------------------------------- /shared/src/iosMain/kotlin/com/github/jetbrains/rssreader/core/IosFeedParser.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.core 2 | 3 | import com.github.jetbrains.rssreader.core.datasource.network.FeedParser 4 | import com.github.jetbrains.rssreader.core.entity.Feed 5 | import com.github.jetbrains.rssreader.core.entity.Post 6 | import io.github.aakira.napier.Napier 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.withContext 9 | import platform.Foundation.* 10 | import platform.darwin.NSObject 11 | import kotlin.coroutines.resume 12 | import kotlin.coroutines.suspendCoroutine 13 | 14 | internal class IosFeedParser : FeedParser { 15 | @Suppress("CAST_NEVER_SUCCEEDS") 16 | override suspend fun parse(sourceUrl: String, xml: String, isDefault: Boolean): Feed = 17 | withContext(Dispatchers.Default) { 18 | suspendCoroutine { continuation -> 19 | Napier.v(tag = "IosFeedParser", message = "Start parse $sourceUrl") 20 | NSXMLParser((xml as NSString).dataUsingEncoding(NSUTF8StringEncoding)!!).apply { 21 | delegate = RssFeedParser(sourceUrl, isDefault) { continuation.resume(it) } 22 | }.parse() 23 | } 24 | } 25 | 26 | private class RssFeedParser( 27 | private val sourceUrl: String, 28 | private val isDefault: Boolean, 29 | private val onEnd: (Feed) -> Unit 30 | ) : NSObject(), NSXMLParserDelegateProtocol { 31 | private val posts = mutableListOf() 32 | 33 | private var currentChannelData: MutableMap = mutableMapOf() 34 | private var currentItemData: MutableMap = mutableMapOf() 35 | private var currentData: MutableMap? = null 36 | private var currentElement: String? = null 37 | 38 | private val dateFormatter = NSDateFormatter().apply { 39 | dateFormat = "E, d MMM yyyy HH:mm:ss Z" 40 | } 41 | 42 | override fun parser( 43 | parser: NSXMLParser, 44 | didStartElement: String, 45 | namespaceURI: String?, 46 | qualifiedName: String?, 47 | attributes: Map 48 | ) { 49 | currentElement = didStartElement 50 | currentData = when (currentElement) { 51 | "channel" -> currentChannelData 52 | "item" -> currentItemData 53 | else -> currentData 54 | } 55 | } 56 | 57 | override fun parser(parser: NSXMLParser, foundCharacters: String) { 58 | val currentElement = currentElement ?: return 59 | val currentData = currentData ?: return 60 | currentData[currentElement] = (currentData[currentElement] ?: "") + foundCharacters 61 | } 62 | 63 | override fun parser( 64 | parser: NSXMLParser, 65 | didEndElement: String, 66 | namespaceURI: String?, 67 | qualifiedName: String? 68 | ) { 69 | if (didEndElement == "item") { 70 | posts.add(Post.withMap(currentItemData)) 71 | currentItemData.clear() 72 | } 73 | } 74 | 75 | override fun parserDidEndDocument(parser: NSXMLParser) { 76 | Napier.v(tag = "IosFeedParser", message = "end parse $sourceUrl") 77 | onEnd(Feed.withMap(currentChannelData, posts, sourceUrl, isDefault)) 78 | } 79 | 80 | private fun Post.Companion.withMap(rssMap: Map): Post { 81 | val pubDate = rssMap["pubDate"] 82 | val date = 83 | if (pubDate != null) 84 | dateFormatter.dateFromString(pubDate.trim())?.timeIntervalSince1970 85 | else 86 | null 87 | val link = rssMap["link"] 88 | val description = rssMap["description"] 89 | val content = rssMap["content:encoded"] 90 | return Post( 91 | FeedParser.cleanText(rssMap["title"])!!, 92 | FeedParser.cleanText(link), 93 | FeedParser.cleanTextCompact(description), 94 | FeedParser.pullPostImageUrl(link, description, content), 95 | date?.toLong() ?: 0 96 | ) 97 | } 98 | 99 | private fun Feed.Companion.withMap( 100 | rssMap: Map, 101 | posts: List, 102 | sourceUrl: String, 103 | isDefault: Boolean 104 | ) = Feed( 105 | rssMap["title"]!!, 106 | rssMap["link"]!!, 107 | rssMap["description"]!!, 108 | null, 109 | posts, 110 | sourceUrl, 111 | isDefault 112 | ) 113 | } 114 | } 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /shared/src/iosMain/kotlin/com/github/jetbrains/rssreader/core/IosHttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.core 2 | 3 | import io.github.aakira.napier.Napier 4 | import io.ktor.client.* 5 | import io.ktor.client.engine.darwin.* 6 | import io.ktor.client.plugins.logging.* 7 | 8 | internal fun IosHttpClient(withLog: Boolean) = HttpClient(Darwin) { 9 | engine { 10 | configureRequest { 11 | setAllowsCellularAccess(true) 12 | } 13 | } 14 | if (withLog) install(Logging) { 15 | level = LogLevel.HEADERS 16 | logger = object : Logger { 17 | override fun log(message: String) { 18 | Napier.v(tag = "IosHttpClient", message = message) 19 | } 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /shared/src/iosMain/kotlin/com/github/jetbrains/rssreader/core/RssReader.kt: -------------------------------------------------------------------------------- 1 | package com.github.jetbrains.rssreader.core 2 | 3 | import com.github.jetbrains.rssreader.core.datasource.network.FeedLoader 4 | import com.github.jetbrains.rssreader.core.datasource.storage.FeedStorage 5 | import com.russhwolf.settings.NSUserDefaultsSettings 6 | import io.github.aakira.napier.DebugAntilog 7 | import io.github.aakira.napier.Napier 8 | import kotlinx.serialization.json.Json 9 | import platform.Foundation.NSUserDefaults 10 | 11 | fun RssReader.Companion.create(withLog: Boolean) = RssReader( 12 | FeedLoader( 13 | IosHttpClient(withLog), 14 | IosFeedParser() 15 | ), 16 | FeedStorage( 17 | NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults()), 18 | Json { 19 | ignoreUnknownKeys = true 20 | isLenient = true 21 | encodeDefaults = false 22 | } 23 | ) 24 | ).also { 25 | if (withLog) Napier.base(DebugAntilog()) 26 | } --------------------------------------------------------------------------------