Skip to content

Commit

Permalink
Merge pull request #99 from futuredapp/feature/v3
Browse files Browse the repository at this point in the history
Architectural Revamp
  • Loading branch information
matejsemancik authored Jan 10, 2025
2 parents cbf8ede + a1f18db commit e5cc299
Show file tree
Hide file tree
Showing 162 changed files with 2,591 additions and 1,919 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/android_enterprise_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@ jobs:
needs: detect-changes
if: ${{ needs.detect-changes.outputs.androidFiles == 'true' }}
env:
# TODO Verify app distribution groups
# TODO PROJECT-SETUP Verify app distribution groups
APP_DISTRIBUTION_GROUPS: futured-qa, devs
APP_DISTRIBUTION_ARTIFACT_TYPE: APK
FIREBASE_CREDENTIALS_FILE: firebase_credentials.json
# TODO Platform-specific slack channel name for notifications, eg. "gmlh-android"
# TODO PROJECT-SETUP Platform-specific slack channel name for notifications, eg. "gmlh-android"
SLACK_CHANNEL: project-slack-channel-name
# TODO verify product flavor configuration
# TODO PROJECT-SETUP verify product flavor configuration
# Specifies API environment for KMP build.
# One of [dev|prod] as per configuration of Buildkonfig plugin in :shared:network:* Gradle module.
KMP_FLAVOR: dev
Expand Down
14 changes: 7 additions & 7 deletions .github/workflows/android_google_play_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ jobs:
runs-on: [ ubuntu-latest ]
env:
EXCLUDE_AAB_FILTER: .*intermediate
# TODO Platform-specific slack channel name for notifications, eg. "gmlh-android"
# TODO PROJECT-SETUP Platform-specific slack channel name for notifications, eg. "gmlh-android"
SLACK_CHANNEL: project-slack-channel-name
# TODO verify product flavor configuration
# TODO PROJECT-SETUP verify product flavor configuration
# Specifies API environment for KMP build.
# One of [dev|prod] as per configuration of Buildkonfig plugin in :shared:network:* Gradle module.
KMP_FLAVOR: prod
Expand All @@ -38,9 +38,9 @@ jobs:
- name: Generate app bundle
shell: bash
env:
# TODO Set up `ANDROID_RELEASE_KEYSTORE_PASSWORD` secret for this GitHub repository
# TODO Set up `ANDROID_RELEASE_KEY_PASSWORD` secret for this GitHub repository
# TODO Set up `ANDROID_RELEASE_KEY_ALIAS` secret for this GitHub repository
# TODO PROJECT-SETUP Set up `ANDROID_RELEASE_KEYSTORE_PASSWORD` secret for this GitHub repository
# TODO PROJECT-SETUP Set up `ANDROID_RELEASE_KEY_PASSWORD` secret for this GitHub repository
# TODO PROJECT-SETUP Set up `ANDROID_RELEASE_KEY_ALIAS` secret for this GitHub repository
RELEASE_KEYSTORE_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
RELEASE_KEY_PASS: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
Expand All @@ -54,9 +54,9 @@ jobs:
- name: Upload Android Release to Play Store
uses: r0adkll/[email protected]
with:
# TODO Set up `GOOGLE_PLAY_PUBLISH_SERVICE_ACCOUNT` as plaintext JSON for this GitHub repository
# TODO PROJECT-SETUP Set up `GOOGLE_PLAY_PUBLISH_SERVICE_ACCOUNT` as plaintext JSON for this GitHub repository
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_PUBLISH_SERVICE_ACCOUNT }}
# TODO This has to be applicationId
# TODO PROJECT-SETUP This has to be applicationId
packageName: app.futured.kmptemplate.android
releaseFiles: ${{ steps.artifacts.outputs.aab_file }}
track: internal
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/android_pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
needs: detect-changes
if: ${{ needs.detect-changes.outputs.androidFiles == 'true' }}
env:
# TODO Platform-specific slack channel name for notifications, eg. "gmlh-android"
# TODO PROJECT-SETUP Platform-specific slack channel name for notifications, eg. "gmlh-android"
SLACK_CHANNEL: project-slack-channel-name
steps:
- name: Checkout
Expand Down
54 changes: 34 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@ To give you a short overview of our stack, we use:

- Native UI on both platforms. Jetpack Compose on Android and SwiftUI on iOS. The rest of the application is shared in KMP.
- [Decompose](https://github.com/arkivanov/Decompose) for sharing presentation logic and navigation state.
- The app follows the MVVM design pattern, ViewModels are built around the Decompose InstanceKeeper feature.
- The presentation layer follows the MVI-like design pattern.
- [Koin](https://insert-koin.io/) for dependency injection.
- [SKIE](https://skie.touchlab.co/) for better Kotlin->Swift interop (exhaustive enums, sealed classes, Coroutines support).
- [moko-resources](https://github.com/icerockdev/moko-resources) for sharing string, color and image resources.
- [apollo-kotlin](https://github.com/apollographql/apollo-kotlin) client for apps that call GraphQL APIs.
- [ktorfit](https://github.com/Foso/Ktorfit) client for apps that call plain HTTP APIs.
- [moko-resources](https://github.com/icerockdev/moko-resources) for sharing string (and other types of) resources.
- [apollo-kotlin](https://github.com/apollographql/apollo-kotlin) network client for apps that call GraphQL APIs.
- [ktorfit](https://github.com/Foso/Ktorfit) network client for apps that call plain HTTP APIs.
- [Jetpack DataStore](https://developer.android.com/jetpack/androidx/releases/datastore) as a simple preferences storage (we have JSON-based and primitive implementations).
- [iOS-templates](https://github.com/futuredapp/iOS-templates) as template which generates a new iOS scene using MVVM-C architecture.

The template is a sample app with several screens to let you kick off the project with everything set up including navigation and some API calls.
The template is a sample app with several screens to let you kick off the project with everything set up, incl. navigation and some API calls.

-------8<------- CUT HERE AFTER CLONING -------8<-------

Expand Down Expand Up @@ -101,17 +101,33 @@ This project complies with ~~Standard (F0), High (F1), Highest (F2)~~ security s
## Navigation Structure

The app utilizes [Decompose](https://arkivanov.github.io/Decompose/) to share presentation logic and navigation state in KMP.
The following meta-description provides an overview of the Decompose navigation tree:
The following meta-description provides an overview of Decompose navigation tree:

```kotlin
Navigation("RootNavigation") {
Navigation("RootNavHost") {
Slot {
Screen("LoginScreen")
Navigation("HomeNavigation") {
Navigation("SignedInNavHost") {
// Bottom navigation stack
Stack {
Screen("FirstScreen")
Screen("SecondScreen")
Screen("ThirdScreen")
// Home tab
Navigation("HomeNavHost") {
Stack {
Screen("FirstScreen")
Screen("SecondScreen") {
Slot {
Screen("Picker")
}
}
Screen("ThirdScreen")
}
}
// Profile tab
Navigation("ProfileNavHost") {
Stack {
Screen("ProfileScreen")
}
}
}
}
}
Expand Down Expand Up @@ -195,13 +211,11 @@ ${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/uplo

## Deep Linking

Deep links are provided by each platform to common code and processed using `DeepLinkResolver` and `DeepLinkNavigator` classes.
The (example) app currently supports the following scheme: `kmptemplate` and the following links:
Deep links are provided by each platform to common code and parsed using `DeepLinkResolver` class.
The (sample) app currently supports the following url scheme: `kmptemplate` and the following links:

- `kmptemplate://login` -- Navigates to login screen
- `kmptemplate://a` -- Navigates to bottom navigation tab A
- `kmptemplate://b` -- Navigates to bottom navigation tab B
- `kmptemplate://c` -- Navigates to bottom navigation tab C
- `kmptemplate://b/third` -- Navigates to third example screen on tab B.
- `kmptemplate://b/secret?arg={OptionalArgument}` -- Navigates to secret screen reachable only by deep
link with optional argument `arg` on tab B
- `kmptemplate://home` -- Opens Home tab with default stack.
- `kmptemplate://profile` -- Opens Profile tab with default stack.
- `kmptemplate://home/second` -- Opens SecondScreen in Home tab.
- `kmptemplate://home/third?arg={argument}` -- Opens ThirdScreen in Home tab with provided argument. The `argument` is mandatory.
- `kmptemplate://home/third/{argument}` -- Opens ThirdScreen in Home tab with provided argument. The `argument` is mandatory.
2 changes: 1 addition & 1 deletion androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ plugins {

alias(libs.plugins.compose.compiler)
alias(libs.plugins.androidx.baselineprofile)
// TODO enable after providing google-services.json
// TODO PROJECT-SETUP enable after providing google-services.json
// alias(libs.plugins.google.services)
alias(libs.plugins.firebase.distribution)
}
Expand Down
6 changes: 3 additions & 3 deletions androidApp/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@
kotlinx.serialization.KSerializer serializer(...);
}

# TODO update package name
# TODO PROJECT-SETUP update package name
-keep,includedescriptorclasses class app.futured.kmptemplate.**$$serializer { *; }
# TODO update package name
# TODO PROJECT-SETUP update package name
-keepclassmembers class app.futured.kmptemplate.** {
*** Companion;
}
# TODO update package name
# TODO PROJECT-SETUP update package name
-keepclasseswithmembers class app.futured.kmptemplate.** {
kotlinx.serialization.KSerializer serializer(...);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,22 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import app.futured.kmptemplate.android.ui.navigation.RootNavGraph
import app.futured.kmptemplate.feature.DefaultAppComponentContext
import app.futured.kmptemplate.feature.navigation.root.RootNavigation
import app.futured.kmptemplate.feature.navigation.root.RootNavigationFactory
import com.arkivanov.decompose.defaultComponentContext
import app.futured.kmptemplate.android.ui.navigation.RootNavHostUi
import app.futured.kmptemplate.feature.navigation.root.RootNavHost
import app.futured.kmptemplate.feature.navigation.root.RootNavHostFactory
import app.futured.kmptemplate.feature.ui.base.DefaultAppComponentContext
import com.arkivanov.decompose.retainedComponent

class MainActivity : ComponentActivity() {

private lateinit var rootNavigation: RootNavigation
private lateinit var rootNavHost: RootNavHost

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
rootNavigation = RootNavigationFactory.create(DefaultAppComponentContext(defaultComponentContext()))
rootNavigation.openDeepLinkIfNeeded(intent)
rootNavHost = retainedComponent { retainedContext ->
RootNavHostFactory.create(DefaultAppComponentContext(retainedContext))
}
rootNavHost.handleIntent(intent)

enableEdgeToEdge()
setContent {
Expand All @@ -33,24 +35,24 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
RootNavGraph(rootNavigation = rootNavigation)
RootNavHostUi(navHost = rootNavHost)
}
}
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
rootNavigation.openDeepLinkIfNeeded(intent)
rootNavHost.handleIntent(intent)
}

private fun RootNavigation.openDeepLinkIfNeeded(intent: Intent?) {
private fun RootNavHost.handleIntent(intent: Intent?) {
if (intent == null) {
return
}

val uri = intent.dataString ?: return
actions.openDeepLink(uri)
actions.onDeepLink(uri)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import app.futured.kmptemplate.platform.binding.PlatformFirebaseCrashlytics
class PlatformFirebaseCrashlyticsImpl : PlatformFirebaseCrashlytics {

override fun logMessage(message: String) {
// TODO Uncomment when Firebase Crashlytics is added as dependency
// TODO PROJECT-SETUP Uncomment when Firebase Crashlytics is added as dependency
// Firebase.crashlytics.log(message)
}

override fun sendNonFatalException(error: Throwable) {
// TODO Uncomment when Firebase Crashlytics is added as dependency
// TODO PROJECT-SETUP Uncomment when Firebase Crashlytics is added as dependency
// Firebase.crashlytics.recordException(error)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package app.futured.kmptemplate.android.ui.navigation

import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.futured.kmptemplate.android.ui.screen.FirstScreenUi
import app.futured.kmptemplate.android.ui.screen.SecondScreenUi
import app.futured.kmptemplate.android.ui.screen.ThirdScreenUi
import app.futured.kmptemplate.feature.navigation.home.HomeChild
import app.futured.kmptemplate.feature.navigation.home.HomeConfig
import app.futured.kmptemplate.feature.navigation.home.HomeNavHost
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.androidPredictiveBackAnimatable
import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation
import com.arkivanov.decompose.router.stack.ChildStack

@OptIn(ExperimentalDecomposeApi::class)
@Composable
fun HomeNavHostUi(
navHost: HomeNavHost,
modifier: Modifier = Modifier,
) {
val stack: ChildStack<HomeConfig, HomeChild> by navHost.stack.collectAsStateWithLifecycle()
val actions = navHost.actions

Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.navigationBars,
content = { paddings ->
Children(
stack = stack,
modifier = Modifier.padding(paddings),
animation = predictiveBackAnimation(
backHandler = navHost.backHandler,
onBack = actions::pop,
selector = { backEvent, _, _ -> androidPredictiveBackAnimatable(backEvent) },
),
) { child ->
when (val childInstance = child.instance) {
is HomeChild.First -> FirstScreenUi(screen = childInstance.screen, modifier = Modifier.fillMaxSize())
is HomeChild.Second -> SecondScreenUi(screen = childInstance.screen, modifier = Modifier.fillMaxSize())
is HomeChild.Third -> ThirdScreenUi(screen = childInstance.screen, modifier = Modifier.fillMaxSize())
}
}
},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package app.futured.kmptemplate.android.ui.navigation

import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.futured.kmptemplate.android.ui.screen.ProfileScreenUi
import app.futured.kmptemplate.feature.navigation.profile.ProfileChild
import app.futured.kmptemplate.feature.navigation.profile.ProfileConfig
import app.futured.kmptemplate.feature.navigation.profile.ProfileNavHost
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.androidPredictiveBackAnimatable
import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation
import com.arkivanov.decompose.router.stack.ChildStack

@OptIn(ExperimentalDecomposeApi::class)
@Composable
fun ProfileNavHostUi(
navHost: ProfileNavHost,
modifier: Modifier = Modifier,
) {
val stack: ChildStack<ProfileConfig, ProfileChild> by navHost.stack.collectAsStateWithLifecycle()
val actions = navHost.actions

Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.navigationBars,
content = { paddings ->
Children(
stack = stack,
modifier = Modifier.padding(paddings),
animation = predictiveBackAnimation(
backHandler = navHost.backHandler,
onBack = actions::pop,
selector = { backEvent, _, _ -> androidPredictiveBackAnimatable(backEvent) },
),
) { child ->
when (val childInstance = child.instance) {
is ProfileChild.Profile -> ProfileScreenUi(screen = childInstance.screen, modifier = Modifier.fillMaxSize())
}
}
},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package app.futured.kmptemplate.android.ui.navigation

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.futured.kmptemplate.android.ui.screen.LoginScreenUi
import app.futured.kmptemplate.feature.navigation.root.RootChild
import app.futured.kmptemplate.feature.navigation.root.RootConfig
import app.futured.kmptemplate.feature.navigation.root.RootNavHost
import com.arkivanov.decompose.router.slot.ChildSlot

@Composable
fun RootNavHostUi(
navHost: RootNavHost,
modifier: Modifier = Modifier,
) {
val slot: ChildSlot<RootConfig, RootChild> by navHost.slot.collectAsStateWithLifecycle()

Box(modifier.background(MaterialTheme.colorScheme.background)) {
when (val childInstance = slot.child?.instance) {
is RootChild.Login -> LoginScreenUi(screen = childInstance.screen, modifier = Modifier.fillMaxSize())
is RootChild.SignedIn -> SignedInNavHostUi(navHost = childInstance.navHost, modifier = Modifier.fillMaxSize())
null -> ApplicationLoading(Modifier.fillMaxSize())
}
}
}

@Composable
private fun ApplicationLoading(
modifier: Modifier = Modifier,
) = Box(modifier.background(MaterialTheme.colorScheme.background)) {
CircularProgressIndicator(
Modifier
.size(48.dp)
.align(Alignment.Center),
)
}
Loading

0 comments on commit e5cc299

Please sign in to comment.