├── .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 | [](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 | }
--------------------------------------------------------------------------------