├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── dk │ │ └── nodes │ │ └── template │ │ └── ExampleInstrumentedTest.kt │ ├── debug │ └── res │ │ └── xml │ │ └── network_security_config.xml │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── dk │ │ │ └── nodes │ │ │ └── template │ │ │ ├── App.kt │ │ │ ├── inititializers │ │ │ └── AppInitializer.kt │ │ │ └── injection │ │ │ ├── components │ │ │ └── AppComponent.kt │ │ │ └── modules │ │ │ ├── AppModule.kt │ │ │ ├── InteractorModule.kt │ │ │ ├── OAuthModule.kt │ │ │ ├── RestModule.kt │ │ │ ├── RestRepositoryBinding.kt │ │ │ └── StorageBindingModule.kt │ └── res │ │ └── values │ │ └── nstack_keys.xml │ └── test │ └── java │ └── dk │ └── nodes │ └── template │ └── ExampleUnitTest.kt ├── build.gradle ├── data ├── .gitignore ├── build.gradle └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── dk │ └── nodes │ └── template │ └── data │ ├── network │ ├── Api.kt │ ├── RestPostRepository.kt │ ├── oauth │ │ ├── OAuthCallbackImpl.kt │ │ └── OAuthPreferencesRepository.kt │ └── util │ │ ├── BufferedSourceConverterFactory.kt │ │ ├── DateDeserializer.kt │ │ └── ItemTypeAdapterFactory.kt │ └── storage │ └── PrefManagerImpl.kt ├── docs ├── HOWWEWORK.md └── images │ └── arch.png ├── domain ├── .gitignore ├── build.gradle └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── dk │ └── nodes │ └── template │ └── domain │ ├── entities │ ├── Post.kt │ └── Theme.kt │ ├── extensions │ └── Extensions.kt │ ├── interactors │ ├── Interactor.kt │ ├── InteractorExtensions.kt │ ├── InteractorResult.kt │ ├── PostFlowInteractor.kt │ ├── PostsInteractor.kt │ └── SwitchThemeInteractor.kt │ ├── managers │ ├── PrefManager.kt │ ├── ThemeManager.kt │ └── ThemeManagerImpl.kt │ └── repositories │ ├── PostRepository.kt │ └── RepositoryException.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── presentation ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── dk │ │ └── nodes │ │ └── template │ │ └── presentation │ │ └── ExampleInstrumentedTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── assets │ │ ├── translations_0_da-DK.json │ │ ├── translations_1_en-GB.json │ │ ├── translations_2_es-MX.json │ │ └── translations_3_fr-FR.json │ ├── java │ │ └── dk │ │ │ └── nodes │ │ │ └── template │ │ │ └── presentation │ │ │ ├── extensions │ │ │ ├── LiveDataExtensions.kt │ │ │ └── ViewModelStoreOwnerExtensions.kt │ │ │ ├── injection │ │ │ ├── DaggerViewModelFactory.kt │ │ │ ├── PresentationModule.kt │ │ │ ├── ViewModelBuilder.kt │ │ │ └── ViewModelKey.kt │ │ │ ├── nstack │ │ │ ├── NStackPresenter.kt │ │ │ ├── RateReminderActions.kt │ │ │ └── Translation.java │ │ │ ├── ui │ │ │ ├── base │ │ │ │ ├── BaseActivity.kt │ │ │ │ ├── BaseFragment.kt │ │ │ │ └── BaseViewModel.kt │ │ │ ├── main │ │ │ │ ├── MainActivity+NStack.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── MainActivityBuilder.kt │ │ │ │ ├── MainActivityViewModel.kt │ │ │ │ └── MainActivityViewState.kt │ │ │ ├── sample │ │ │ │ ├── SampleAdapter.kt │ │ │ │ ├── SampleBuilder.kt │ │ │ │ ├── SampleFragment.kt │ │ │ │ ├── SampleViewModel.kt │ │ │ │ └── SampleViewState.kt │ │ │ └── splash │ │ │ │ ├── SplashActivity.kt │ │ │ │ ├── SplashBuilder.kt │ │ │ │ ├── SplashFragment.kt │ │ │ │ ├── SplashViewModel.kt │ │ │ │ └── SplashViewState.kt │ │ │ └── util │ │ │ ├── SharedElementHelper.kt │ │ │ ├── SingleEvent.kt │ │ │ ├── ThemeHelper.kt │ │ │ ├── ViewError.kt │ │ │ └── ViewErrorController.kt │ └── res │ │ ├── anim │ │ ├── slide_left_enter.xml │ │ ├── slide_left_exit.xml │ │ ├── slide_right_enter.xml │ │ └── slide_right_exit.xml │ │ ├── color │ │ └── selector_primary.xml │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_half_moon_and_sun.xml │ │ ├── font │ │ └── qwigley.ttf │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── activity_splash.xml │ │ ├── fragment_sample.xml │ │ ├── fragment_splash.xml │ │ ├── item_sample_list.xml │ │ └── row_sample.xml │ │ ├── menu │ │ └── menu_main.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 │ │ ├── navigation │ │ ├── main_nav_graph.xml │ │ └── splash_nav_graph.xml │ │ ├── values-night-v27 │ │ └── themes.xml │ │ ├── values-night │ │ └── themes.xml │ │ ├── values-v27 │ │ └── themes.xml │ │ └── values │ │ ├── attrs.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── ic_launcher_background.xml │ │ ├── nstack_keys.xml │ │ ├── shape.xml │ │ ├── strings.xml │ │ ├── styles.xml │ │ ├── themes.xml │ │ └── type.xml │ └── test │ └── java │ └── dk │ └── nodes │ └── template │ └── presentation │ └── ExampleUnitTest.java └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # Files for the ART/Dalvik VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # Generated files 12 | bin/ 13 | gen/ 14 | out/ 15 | 16 | # Gradle files 17 | .gradle/ 18 | build/ 19 | 20 | # Local configuration file (sdk path, etc) 21 | local.properties 22 | 23 | # Log Files 24 | *.log 25 | 26 | # Android Studio Navigation editor temp files 27 | .navigation/ 28 | 29 | # Android Studio captures folder 30 | captures/ 31 | 32 | # Intellij 33 | *.iml 34 | .idea/ 35 | projectFilesBackup/ 36 | 37 | # External native build folder generated in Android Studio 2.2 and later 38 | .externalNativeBuild 39 | 40 | # Windows thumbnail db 41 | Thumbs.db 42 | 43 | # OSX files 44 | .DS_Store 45 | 46 | #NDK 47 | obj/ 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 📖 Project description 2 | 3 | 4 | ## 🔧 Installation 5 | 1. `git clone ` 6 | 2. Run Gradle Sync 7 | 3. Rebuild to generate Dagger classes 8 | 9 | ## 🔗 Useful links 10 | * [Jira board]() 11 | 12 | ## 🌲 Branches 13 | * `master` - Latest version on Google Play. 14 | * `develop` - Updated work. Feature branches are merged in when complete and then deleted. 15 | 16 | ## ⚠️ Things to know 17 | 18 | 19 | ## 💻 Developers 20 | - [Name](https://github.com/) (@slackname) 21 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'com.github.gfx.ribbonizer' 5 | apply plugin: 'kotlin-kapt' 6 | 7 | android { 8 | compileSdkVersion sdks.compileSdkVersion 9 | buildToolsVersion sdks.buildToolsVersion 10 | flavorDimensions "default" 11 | 12 | defaultConfig { 13 | applicationId "dk.nodes.template" 14 | minSdkVersion sdks.minSdkVersion 15 | targetSdkVersion sdks.targetSdkVersion 16 | multiDexEnabled true 17 | versionCode 1 18 | versionName "2.0.3" 19 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 20 | 21 | manifestPlaceholders = [ 22 | appId : keys.appId, 23 | apiKey: keys.apiKey 24 | ] 25 | 26 | packagingOptions { 27 | pickFirst("META-INF/atomicfu.kotlin_module") 28 | } 29 | } 30 | 31 | // Uncomment the following if you include signingConfigs 32 | /* 33 | signingConfigs { 34 | staging { 35 | keyAlias 'androiddebugkey' 36 | keyPassword 'android' 37 | storeFile file('debug.keystore') 38 | storePassword 'android' 39 | } 40 | production { 41 | keyAlias 'androiddebugkey' 42 | keyPassword 'android' 43 | storeFile file('debug.keystore') 44 | storePassword 'android' 45 | } 46 | } 47 | */ 48 | 49 | buildTypes { 50 | debug { 51 | // uncomment the following line if you add a flavorbased signingConfig for debug builds 52 | // signingConfig null 53 | } 54 | release { 55 | buildConfigField "String", "API_URL", "\"https://jsonplaceholder.typicode.com\"" 56 | minifyEnabled false 57 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 58 | } 59 | } 60 | 61 | productFlavors { 62 | // The dev flavor is intended to be the same as staging, but with LeakCanary enabled 63 | dev { 64 | dimension "default" 65 | applicationIdSuffix ".dev" 66 | manifestPlaceholders = [APP_NAME: "KotlinTemplateDev"] 67 | buildConfigField "String", "API_URL", "\"https://jsonplaceholder.typicode.com\"" 68 | } 69 | staging { 70 | dimension "default" 71 | applicationIdSuffix ".staging" 72 | //signingConfig signingConfigs.staging 73 | manifestPlaceholders = [APP_NAME: "KotlinTemplateStaging"] 74 | buildConfigField "String", "API_URL", "\"https://jsonplaceholder.typicode.com\"" 75 | } 76 | production { 77 | dimension "default" 78 | applicationIdSuffix ".production" 79 | //signingConfig signingConfigs.production 80 | manifestPlaceholders = [APP_NAME: "KotlinTemplate"] 81 | buildConfigField "String", "API_URL", "\"https://jsonplaceholder.typicode.com\"" 82 | } 83 | } 84 | 85 | compileOptions { 86 | coreLibraryDesugaringEnabled true 87 | sourceCompatibility JavaVersion.VERSION_1_8 88 | targetCompatibility JavaVersion.VERSION_1_8 89 | } 90 | kotlinOptions { 91 | jvmTarget = "1.8" 92 | } 93 | } 94 | 95 | repositories { 96 | maven { url "https://jitpack.io" } 97 | mavenLocal() 98 | } 99 | 100 | dependencies { 101 | 102 | fileTree(dir: "libs", include: ["*.jar"]) 103 | androidTestImplementation("androidx.test.espresso:espresso-core:${versions.espresso}", { 104 | exclude group: "com.android.support", module: "support-annotations" 105 | }) 106 | implementation project (':domain') 107 | implementation project (':presentation') 108 | implementation project (':data') 109 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.kotlin}" 110 | implementation "org.jetbrains.kotlin:kotlin-reflect:${versions.kotlin}" 111 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.coroutines}" 112 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.coroutines}" 113 | 114 | implementation "androidx.appcompat:appcompat:$versions.appcompat" 115 | implementation 'androidx.recyclerview:recyclerview:1.1.0' 116 | implementation "com.google.android.material:material:$versions.material" 117 | implementation 'androidx.cardview:cardview:1.0.0' 118 | 119 | implementation "com.google.dagger:dagger-android:${versions.dagger}" 120 | implementation "com.google.dagger:dagger-android-support:${versions.dagger}" 121 | kapt "com.google.dagger:dagger-compiler:${versions.dagger}" 122 | kapt "com.google.dagger:dagger-android-processor:${versions.dagger}" 123 | 124 | implementation "com.jakewharton.timber:timber:${versions.timber}" 125 | 126 | implementation("com.squareup.retrofit2:retrofit:${versions.retrofit}") 127 | implementation("com.squareup.retrofit2:converter-gson:${versions.retrofit}") { 128 | exclude module: "retrofit:${versions.retrofit}" 129 | } 130 | 131 | // Nodes OkHTTP utilities 132 | implementation "dk.nodes.utils:okhttp:${versions.nodes_utils_okhttp}" 133 | 134 | implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}" 135 | implementation("com.squareup.okhttp3:logging-interceptor:${versions.okhttp}") 136 | 137 | implementation "dk.nodes.nstack:nstack-kotlin:${versions.nstack}" 138 | // implementation "dk.nodes.arch:base:${versions.nodes_arch}" 139 | 140 | // Testing 141 | testImplementation "junit:junit:${versions.junit}" 142 | // required if you want to use Mockito for unit tests 143 | testImplementation "org.mockito:mockito-core:${versions.mockito}" 144 | // required if you want to use Mockito for Android tests 145 | androidTestImplementation "org.mockito:mockito-android:${versions.mockito}" 146 | 147 | // For Espresso UI testing 148 | androidTestImplementation "androidx.test.espresso:espresso-core:${versions.espresso}" 149 | androidTestImplementation "androidx.test.espresso:espresso-intents:${versions.espresso}" 150 | androidTestImplementation "androidx.test.espresso:espresso-contrib:${versions.espresso}" 151 | 152 | // The following section is only used to force the latest version to resolve conflicts 153 | implementation "androidx.arch.core:core-common:$versions.archCore" 154 | implementation "androidx.arch.core:core-runtime:$versions.archCore" 155 | implementation "androidx.lifecycle:lifecycle-livedata-core:${versions.lifecycle}" 156 | implementation "androidx.lifecycle:lifecycle-runtime:${versions.lifecycle}" 157 | implementation "androidx.lifecycle:lifecycle-common-java8:${versions.lifecycle}" 158 | implementation "androidx.lifecycle:lifecycle-extensions:${versions.lifecycle}" 159 | implementation "androidx.lifecycle:lifecycle-viewmodel:${versions.lifecycle}" 160 | implementation "androidx.lifecycle:lifecycle-livedata:${versions.lifecycle}" 161 | implementation "androidx.core:core-ktx:${versions.ktx}" 162 | 163 | implementation "androidx.preference:preference-ktx:${versions.preferences}" 164 | 165 | // Chucker (like Charles but on device) 166 | devImplementation "com.github.ChuckerTeam.Chucker:library-no-op:${versions.chucker}" 167 | stagingImplementation "com.github.ChuckerTeam.Chucker:library:${versions.chucker}" 168 | productionImplementation "com.github.ChuckerTeam.Chucker:library-no-op:${versions.chucker}" 169 | 170 | // LeakCanary 171 | devImplementation "com.squareup.leakcanary:leakcanary-android:${versions.leak_canary}" 172 | 173 | // Java 8+ API desugaring support 174 | coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$versions.desugaring" 175 | 176 | } 177 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in D:\programs\Android\sdk/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | 19 | # Uncomment this to preserve the line number information for 20 | # debugging stack traces. 21 | #-keepattributes SourceFile,LineNumberTable 22 | 23 | # If you keep the line number information, uncomment this to 24 | # hide the original source file name. 25 | #-renamesourcefileattribute SourceFile 26 | 27 | -keep class androidx.core.app.CoreComponentFactory { *; } 28 | -------------------------------------------------------------------------------- /app/src/androidTest/java/dk/nodes/template/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template 2 | 3 | import androidx.test.InstrumentationRegistry 4 | import androidx.test.runner.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.assertEquals 10 | 11 | /** 12 | * Instrumentation test, which will execute on an Android device. 13 | * 14 | * @see [Testing documentation](http://d.android.com/tools/testing) 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | @Throws(Exception::class) 20 | fun useAppContext() { 21 | // Context of the app under test. 22 | val appContext = InstrumentationRegistry.getTargetContext() 23 | assertEquals("dk.bison.wt", appContext.packageName) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/debug/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 10 | 11 | 19 | 20 | 21 | 25 | 29 | 30 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 47 | 48 | -------------------------------------------------------------------------------- /app/src/main/java/dk/nodes/template/App.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template 2 | 3 | import dagger.android.AndroidInjector 4 | import dagger.android.DaggerApplication 5 | import dk.nodes.template.inititializers.AppInitializer 6 | import dk.nodes.template.injection.components.DaggerAppComponent 7 | import javax.inject.Inject 8 | 9 | class App : DaggerApplication() { 10 | 11 | @Inject lateinit var initializer: AppInitializer 12 | override fun onCreate() { 13 | super.onCreate() 14 | initializer.init(this) 15 | } 16 | 17 | override fun applicationInjector(): AndroidInjector { 18 | return DaggerAppComponent.factory().create(this) 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/dk/nodes/template/inititializers/AppInitializer.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.inititializers 2 | 3 | import android.app.Application 4 | import android.util.Log 5 | import com.chuckerteam.chucker.api.ChuckerCollector 6 | import dk.nodes.nstack.kotlin.NStack 7 | import dk.nodes.template.BuildConfig 8 | import dk.nodes.template.domain.managers.ThemeManager 9 | import dk.nodes.template.presentation.nstack.Translation 10 | import dk.nodes.template.presentation.util.ThemeHelper 11 | import timber.log.Timber 12 | import javax.inject.Inject 13 | 14 | interface AppInitializer { 15 | fun init(app: Application) 16 | } 17 | 18 | class AppInitializerImpl @Inject constructor( 19 | private val themeManager: ThemeManager, 20 | private val chuckerCollector: ChuckerCollector 21 | ) : AppInitializer { 22 | override fun init(app: Application) { 23 | NStack.translationClass = Translation::class.java 24 | NStack.init(app, BuildConfig.DEBUG) 25 | if (BuildConfig.DEBUG) { 26 | NStack.enableLiveEdit(app) 27 | Timber.plant(Timber.DebugTree(), chuckerTree()) 28 | } 29 | ThemeHelper.applyTheme(themeManager.theme) 30 | } 31 | 32 | private fun chuckerTree() = object : Timber.Tree() { 33 | override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { 34 | if (priority >= Log.ERROR) { 35 | chuckerCollector.onError( 36 | tag ?: BuildConfig.APPLICATION_ID, 37 | t ?: Throwable("Unknown Error") 38 | ) 39 | } 40 | } 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /app/src/main/java/dk/nodes/template/injection/components/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.injection.components 2 | 3 | import dagger.Component 4 | import dagger.android.AndroidInjector 5 | import dagger.android.support.AndroidSupportInjectionModule 6 | import dk.nodes.template.App 7 | import dk.nodes.template.injection.modules.* 8 | import dk.nodes.template.presentation.injection.PresentationModule 9 | import dk.nodes.template.presentation.injection.ViewModelBuilder 10 | import javax.inject.Singleton 11 | 12 | @Component( 13 | modules = [ 14 | AndroidSupportInjectionModule::class, 15 | ViewModelBuilder::class, 16 | PresentationModule::class, 17 | AppModule::class, 18 | InteractorModule::class, 19 | RestModule::class, 20 | RestRepositoryBinding::class, 21 | StorageBindingModule::class, 22 | OAuthModule::class 23 | ] 24 | ) 25 | @Singleton 26 | interface AppComponent : AndroidInjector { 27 | @Component.Factory 28 | abstract class Factory : AndroidInjector.Factory 29 | } -------------------------------------------------------------------------------- /app/src/main/java/dk/nodes/template/injection/modules/AppModule.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.injection.modules 2 | 3 | import android.content.Context 4 | import dagger.Binds 5 | import dagger.Module 6 | import dagger.Provides 7 | import dk.nodes.template.App 8 | import dk.nodes.template.inititializers.AppInitializer 9 | import dk.nodes.template.inititializers.AppInitializerImpl 10 | 11 | @Module 12 | abstract class AppModule { 13 | 14 | @Binds 15 | abstract fun bindAppInitalizer(initializer: AppInitializerImpl): AppInitializer 16 | 17 | @Module 18 | companion object { 19 | @JvmStatic 20 | @Provides 21 | fun provideContext(application: App): Context = application.applicationContext 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/dk/nodes/template/injection/modules/InteractorModule.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.injection.modules 2 | 3 | import dagger.Module 4 | 5 | @Module 6 | class InteractorModule -------------------------------------------------------------------------------- /app/src/main/java/dk/nodes/template/injection/modules/OAuthModule.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.injection.modules 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.Provides 6 | import dk.nodes.okhttputils.oauth.OAuthAuthenticator 7 | import dk.nodes.okhttputils.oauth.OAuthCallback 8 | import dk.nodes.okhttputils.oauth.OAuthInterceptor 9 | import dk.nodes.okhttputils.oauth.OAuthRepository 10 | import dk.nodes.okhttputils.oauth.entities.OAuthHeader 11 | import dk.nodes.template.data.network.oauth.OAuthCallbackImpl 12 | import dk.nodes.template.data.network.oauth.OAuthPreferencesRepository 13 | import javax.inject.Singleton 14 | 15 | @Module(includes = [OAuthModule.BindingModule::class]) 16 | class OAuthModule { 17 | 18 | @Module 19 | interface BindingModule { 20 | 21 | @Binds 22 | @Singleton 23 | fun bindOAuthRepository(repository: OAuthPreferencesRepository): OAuthRepository 24 | 25 | @Binds 26 | @Singleton 27 | fun bindOAuthCallback(oAuthCallback: OAuthCallbackImpl): OAuthCallback 28 | } 29 | 30 | @Provides 31 | @Singleton 32 | fun provideOAuthHeader(): OAuthHeader { 33 | // modify header type & name here 34 | return OAuthHeader() 35 | } 36 | 37 | @Provides 38 | @Singleton 39 | fun provideOAuthInterceptor(repository: OAuthRepository, oAuthHeader: OAuthHeader): OAuthInterceptor { 40 | return OAuthInterceptor(repository, oAuthHeader) 41 | } 42 | 43 | @Provides 44 | @Singleton 45 | fun provideOAuthAuthenticator( 46 | repository: OAuthRepository, 47 | oAuthHeader: OAuthHeader, 48 | oAuthCallback: OAuthCallback 49 | ): OAuthAuthenticator { 50 | return OAuthAuthenticator(repository, oAuthCallback, oAuthHeader) 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/java/dk/nodes/template/injection/modules/RestModule.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.injection.modules 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import com.chuckerteam.chucker.api.ChuckerCollector 6 | import com.chuckerteam.chucker.api.ChuckerInterceptor 7 | import com.chuckerteam.chucker.api.RetentionManager 8 | import com.google.gson.Gson 9 | import com.google.gson.GsonBuilder 10 | import dagger.Module 11 | import dagger.Provides 12 | import dk.nodes.nstack.kotlin.NStack 13 | import dk.nodes.nstack.kotlin.provider.NMetaInterceptor 14 | import dk.nodes.okhttputils.oauth.OAuthAuthenticator 15 | import dk.nodes.okhttputils.oauth.OAuthInterceptor 16 | import dk.nodes.template.BuildConfig 17 | import dk.nodes.template.data.network.Api 18 | import dk.nodes.template.data.network.util.BufferedSourceConverterFactory 19 | import dk.nodes.template.data.network.util.DateDeserializer 20 | import dk.nodes.template.data.network.util.ItemTypeAdapterFactory 21 | import okhttp3.OkHttpClient 22 | import retrofit2.Converter 23 | import retrofit2.Retrofit 24 | import retrofit2.converter.gson.GsonConverterFactory 25 | import java.util.Date 26 | import java.util.concurrent.TimeUnit 27 | import javax.inject.Named 28 | import javax.inject.Singleton 29 | 30 | @Module 31 | class RestModule { 32 | @Provides 33 | fun provideTypeFactory(): ItemTypeAdapterFactory { 34 | return ItemTypeAdapterFactory() 35 | } 36 | 37 | @Provides 38 | fun provideDateDeserializer(): DateDeserializer { 39 | return DateDeserializer() 40 | } 41 | 42 | @Provides 43 | @Singleton 44 | fun provideGson(typeFactory: ItemTypeAdapterFactory, dateDeserializer: DateDeserializer): Gson { 45 | return GsonBuilder() 46 | .registerTypeAdapterFactory(typeFactory) 47 | .registerTypeAdapter(Date::class.java, dateDeserializer) 48 | .setDateFormat(DateDeserializer.DATE_FORMATS[0]) 49 | .create() 50 | } 51 | 52 | @Provides 53 | @Named("NAME_BASE_URL") 54 | fun provideBaseUrlString(): String { 55 | return BuildConfig.API_URL 56 | } 57 | 58 | @Provides 59 | @Singleton 60 | fun provideGsonConverter(gson: Gson): Converter.Factory { 61 | return GsonConverterFactory.create(gson) 62 | } 63 | 64 | @Provides 65 | @Singleton 66 | fun provideChuckerCollector(context: Context) = ChuckerCollector( 67 | context = context, 68 | // Toggles visibility of the push notification 69 | showNotification = true, 70 | // Allows to customize the retention period of collected data 71 | retentionPeriod = RetentionManager.Period.ONE_HOUR 72 | ) 73 | 74 | @Provides 75 | @Singleton 76 | fun provideChuckerInterceptor( 77 | context: Context, 78 | chuckerCollector: ChuckerCollector 79 | ) = ChuckerInterceptor( 80 | context = context, 81 | // The previously created Collector 82 | collector = chuckerCollector, 83 | // The max body content length in bytes, after this responses will be truncated. 84 | maxContentLength = 250000L 85 | // List of headers to replace with ** in the Chucker UI 86 | // headersToRedact = setOf("Auth-Token") 87 | ) 88 | 89 | @Provides 90 | @Singleton 91 | fun provideHttpClient(chuckerInterceptor: ChuckerInterceptor): OkHttpClient { 92 | val clientBuilder = OkHttpClient.Builder() 93 | .connectTimeout(45, TimeUnit.SECONDS) 94 | .readTimeout(60, TimeUnit.SECONDS) 95 | .writeTimeout(60, TimeUnit.SECONDS) 96 | .addInterceptor(chuckerInterceptor) 97 | .addInterceptor( 98 | NMetaInterceptor( 99 | NStack.env, 100 | NStack.appClientInfo.versionName, 101 | Build.VERSION.RELEASE, 102 | Build.MODEL 103 | ) 104 | ) 105 | 106 | if (BuildConfig.DEBUG) { 107 | val logging = okhttp3.logging.HttpLoggingInterceptor() 108 | logging.level = okhttp3.logging.HttpLoggingInterceptor.Level.BODY 109 | clientBuilder.addInterceptor(logging) 110 | } 111 | 112 | return clientBuilder.build() 113 | } 114 | 115 | @Provides 116 | @Singleton 117 | fun provideRetrofit( 118 | client: OkHttpClient, 119 | converter: Converter.Factory, 120 | @Named("NAME_BASE_URL") baseUrl: String 121 | ): Retrofit { 122 | return Retrofit.Builder() 123 | .client(client) 124 | .baseUrl(baseUrl) 125 | .addConverterFactory(BufferedSourceConverterFactory()) 126 | .addConverterFactory(converter) 127 | .build() 128 | } 129 | 130 | @Provides 131 | @Singleton 132 | fun provideApi(retrofit: Retrofit): Api { 133 | return retrofit.create(Api::class.java) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /app/src/main/java/dk/nodes/template/injection/modules/RestRepositoryBinding.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.injection.modules 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dk.nodes.template.data.network.RestPostRepository 6 | import dk.nodes.template.domain.repositories.PostRepository 7 | import javax.inject.Singleton 8 | 9 | @Module 10 | abstract class RestRepositoryBinding { 11 | @Binds 12 | @Singleton 13 | abstract fun bindPostRepository(repository: RestPostRepository): PostRepository 14 | } -------------------------------------------------------------------------------- /app/src/main/java/dk/nodes/template/injection/modules/StorageBindingModule.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.injection.modules 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dk.nodes.template.domain.managers.PrefManager 6 | import dk.nodes.template.domain.managers.ThemeManager 7 | import dk.nodes.template.domain.managers.ThemeManagerImpl 8 | import dk.nodes.template.data.storage.PrefManagerImpl 9 | import javax.inject.Singleton 10 | 11 | @Module 12 | abstract class StorageBindingModule { 13 | 14 | @Binds 15 | @Singleton 16 | abstract fun bindPrefManager(manager: PrefManagerImpl): PrefManager 17 | 18 | @Binds 19 | @Singleton 20 | abstract fun bindThemeManager(manager: ThemeManagerImpl): ThemeManager 21 | } -------------------------------------------------------------------------------- /app/src/main/res/values/nstack_keys.xml: -------------------------------------------------------------------------------- 1 | 2 | {default_ok} 3 | {default_cancel} 4 | {default_no} 5 | {default_yes} 6 | {default_retry} 7 | {default_edit} 8 | {default_save} 9 | {default_back} 10 | {default_settings} 11 | {default_later} 12 | {default_next} 13 | {default_previous} 14 | {default_skip} 15 | {error_authenticationError} 16 | {error_connectionError} 17 | {error_errorTitle} 18 | {error_unknownError} 19 | {rateReminder_title} 20 | {rateReminder_body} 21 | {rateReminder_yesBtn} 22 | {rateReminder_laterBtn} 23 | {rateReminder_noBtn} 24 | {versionControl_updateHeader} 25 | {versionControl_forceHeader} 26 | {versionControl_negativeBtn} 27 | {versionControl_positiveBtn} 28 | {versionControl_newInVersionHeader} 29 | {versionControl_okBtn} 30 | -------------------------------------------------------------------------------- /app/src/test/java/dk/nodes/template/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template 2 | 3 | import org.junit.Test 4 | 5 | /** 6 | * Example local unit test, which will execute on the development machine (host). 7 | * 8 | * @see [Testing documentation](http://d.android.com/tools/testing) 9 | */ 10 | class ExampleUnitTest { 11 | @Test 12 | @Throws(Exception::class) 13 | fun addition_isCorrect() { 14 | org.junit.Assert.assertEquals(4, (2 + 2).toLong()) 15 | } 16 | } -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | 5 | ext.versions = [ 6 | kotlin : '1.3.72', 7 | coroutines : '1.3.7', 8 | constraint_layout : '2.0.0-beta6', 9 | dagger : '2.28', 10 | lifecycle : '2.2.0', 11 | timber : "4.7.1", 12 | junit : '4.13', 13 | espresso : '3.2.0', 14 | mockito : '3.3.3', 15 | nstack : '3.2.5', 16 | nodes_arch : '2.3.5', 17 | retrofit : '2.9.0', 18 | okhttp : '4.6.0', 19 | ktx : '1.3.0', 20 | material : '1.2.0-beta01', 21 | archCore : '2.1.0', 22 | navigation : '2.2.2', 23 | nodes_utils : '1.0.0', 24 | nodes_utils_okhttp : '0.12.2', 25 | preferences : '1.1.1', 26 | chucker : '3.2.0', 27 | leak_canary : '2.3', 28 | desugaring : '1.0.5' 29 | ] 30 | 31 | ext.keys = [ 32 | appId : 'bOdrNuZd4syxuAz6gyCb3xwBCjA8U4h4IcQI', 33 | apiKey : 'X0ENl5QpKI51tS9CzKSt1PGwfZeq2gBMTU58', 34 | acceptHeader: "da-DK" 35 | ] 36 | 37 | ext.sdks = [ 38 | compileSdkVersion: 29, 39 | buildToolsVersion: '29.0.0', 40 | minSdkVersion : 21, 41 | targetSdkVersion : 29 42 | ] 43 | 44 | repositories { 45 | mavenCentral() 46 | google() 47 | jcenter() 48 | mavenLocal() 49 | } 50 | dependencies { 51 | classpath 'com.android.tools.build:gradle:4.0.0' 52 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" 53 | classpath "dk.nodes.nstack:translation:${versions.nstack}" 54 | classpath "com.github.gfx.ribbonizer:ribbonizer-plugin:2.1.0" 55 | } 56 | } 57 | 58 | plugins { 59 | id "com.diffplug.gradle.spotless" version "3.27.2" 60 | id 'com.github.ben-manes.versions' version "0.28.0" 61 | } 62 | 63 | allprojects { 64 | repositories { 65 | mavenCentral() 66 | maven { url 'https://maven.google.com' } 67 | jcenter() 68 | mavenLocal() 69 | } 70 | } 71 | 72 | 73 | subprojects { 74 | apply plugin: 'com.diffplug.gradle.spotless' 75 | spotless { 76 | format 'misc', { 77 | target '**/*.gradle' 78 | trimTrailingWhitespace() 79 | indentWithSpaces(4) // or spaces. Takes an integer argument if you don't like 4 80 | endWithNewline() 81 | } 82 | kotlin { 83 | target "**/*.kt" 84 | ktlint('0.33.0') 85 | targetExclude("**/RateReminderActions.kt") 86 | } 87 | java { 88 | target "**/*.java" 89 | targetExclude '**/Translation.java' 90 | googleJavaFormat('1.1').aosp() 91 | } 92 | } 93 | 94 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { 95 | kotlinOptions { 96 | // Treat all Kotlin warnings as errors, don't go full retarded 97 | // allWarningsAsErrors = true 98 | 99 | // Enable experimental coroutines APIs, including Flow 100 | freeCompilerArgs += "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi" 101 | freeCompilerArgs += "-Xuse-experimental=kotlinx.coroutines.FlowPreview" 102 | 103 | // Set JVM target to 1.8 104 | jvmTarget = "1.8" 105 | } 106 | } 107 | } 108 | 109 | dependencyUpdates.resolutionStrategy { 110 | componentSelection { rules -> 111 | rules.all { ComponentSelection selection -> 112 | boolean rejected = ['alpha', 'beta', 'cr', 'm', 'preview'].any { qualifier -> 113 | selection.candidate.version ==~ /(?i).*[.-]${qualifier}[.\d-]*/ 114 | } 115 | if (rejected) { 116 | selection.reject('Release candidate') 117 | } 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /data/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | 4 | android { 5 | compileSdkVersion sdks.compileSdkVersion 6 | defaultConfig { 7 | minSdkVersion sdks.minSdkVersion 8 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 9 | } 10 | 11 | compileOptions { 12 | coreLibraryDesugaringEnabled true 13 | sourceCompatibility JavaVersion.VERSION_1_8 14 | targetCompatibility JavaVersion.VERSION_1_8 15 | } 16 | kotlinOptions { 17 | jvmTarget = "1.8" 18 | } 19 | } 20 | 21 | dependencies { 22 | implementation project (':domain') 23 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.kotlin}" 24 | implementation "androidx.core:core-ktx:${versions.ktx}" 25 | implementation "androidx.preference:preference-ktx:${versions.preferences}" 26 | implementation("com.squareup.retrofit2:retrofit:${versions.retrofit}") 27 | implementation("com.squareup.retrofit2:converter-gson:${versions.retrofit}") { 28 | exclude module: "retrofit:${versions.retrofit}" 29 | } 30 | // Nodes OkHTTP utilities 31 | implementation "dk.nodes.utils:okhttp:${versions.nodes_utils_okhttp}" 32 | 33 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.coroutines}" 34 | implementation group: 'javax.inject', name: 'javax.inject', version: '1' 35 | coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$versions.desugaring" 36 | } 37 | -------------------------------------------------------------------------------- /data/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/src/main/java/dk/nodes/template/data/network/Api.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.data.network 2 | 3 | import dk.nodes.template.domain.entities.Post 4 | import retrofit2.Response 5 | import retrofit2.http.GET 6 | 7 | interface Api { 8 | @GET("posts") 9 | suspend fun getPosts(): Response> 10 | } 11 | -------------------------------------------------------------------------------- /data/src/main/java/dk/nodes/template/data/network/RestPostRepository.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.data.network 2 | 3 | import dk.nodes.template.domain.entities.Post 4 | import dk.nodes.template.domain.repositories.PostRepository 5 | import dk.nodes.template.domain.repositories.RepositoryException 6 | import kotlinx.coroutines.channels.BroadcastChannel 7 | import kotlinx.coroutines.channels.Channel 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.asFlow 10 | import javax.inject.Inject 11 | 12 | class RestPostRepository @Inject constructor(private val api: Api) : PostRepository { 13 | 14 | private val postsChannel = BroadcastChannel>(Channel.CONFLATED) 15 | .also { 16 | it.offer(listOf()) 17 | } 18 | 19 | override fun getPostsFlow(): Flow> { 20 | return postsChannel.asFlow() 21 | } 22 | 23 | override suspend fun getPosts(): List { 24 | val response = api.getPosts() 25 | if (response.isSuccessful) { 26 | 27 | return response.body() 28 | ?.also { 29 | postsChannel.send(it) 30 | } 31 | ?: throw(RepositoryException( 32 | response.code(), 33 | response.errorBody()?.string(), 34 | response.message() 35 | )) 36 | } else { 37 | throw(RepositoryException( 38 | response.code(), 39 | response.errorBody()?.string(), 40 | response.message() 41 | )) 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /data/src/main/java/dk/nodes/template/data/network/oauth/OAuthCallbackImpl.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.data.network.oauth 2 | 3 | import dk.nodes.okhttputils.oauth.OAuthCallback 4 | import dk.nodes.okhttputils.oauth.entities.OAuthInfo 5 | import dk.nodes.okhttputils.oauth.entities.OAuthResult 6 | import javax.inject.Inject 7 | 8 | class OAuthCallbackImpl @Inject constructor() : OAuthCallback { 9 | override fun provideAuthInfo(refreshToken: String?): OAuthResult { 10 | return OAuthResult.Success(OAuthInfo( 11 | accessToken = "newAccessToken", 12 | refreshToken = "new/old refresh token" 13 | )) 14 | } 15 | } -------------------------------------------------------------------------------- /data/src/main/java/dk/nodes/template/data/network/oauth/OAuthPreferencesRepository.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.data.network.oauth 2 | 3 | import dk.nodes.okhttputils.oauth.OAuthRepository 4 | import dk.nodes.template.domain.managers.PrefManager 5 | import javax.inject.Inject 6 | 7 | class OAuthPreferencesRepository @Inject constructor( 8 | private val prefManager: PrefManager 9 | ) : OAuthRepository { 10 | 11 | override fun getAccessToken(): String? { 12 | return prefManager.getString(PREF_ACCESS_TOKEN, null) 13 | } 14 | 15 | override fun getRefreshToken(): String? { 16 | return prefManager.getString(PREF_REFRESH_TOKEN, null) 17 | } 18 | 19 | override fun setAccessToken(accessToken: String?) { 20 | accessToken?.let { token -> 21 | prefManager.setString(PREF_ACCESS_TOKEN, token) 22 | } 23 | } 24 | 25 | override fun setRefreshToken(refreshToken: String?) { 26 | refreshToken?.let { token -> 27 | prefManager.setString(PREF_REFRESH_TOKEN, token) 28 | } 29 | } 30 | 31 | override fun clear() { 32 | prefManager.remove(PREF_ACCESS_TOKEN) 33 | prefManager.remove(PREF_REFRESH_TOKEN) 34 | } 35 | 36 | companion object { 37 | private const val PREF_REFRESH_TOKEN = "pref_oauth_refresh_token" 38 | private const val PREF_ACCESS_TOKEN = "pref_oauth_access_token" 39 | } 40 | } -------------------------------------------------------------------------------- /data/src/main/java/dk/nodes/template/data/network/util/BufferedSourceConverterFactory.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.data.network.util 2 | 3 | import okhttp3.ResponseBody 4 | import okio.BufferedSource 5 | import retrofit2.Converter 6 | import retrofit2.Retrofit 7 | import java.lang.reflect.Type 8 | 9 | /* 10 | This is necessary to get general purpose disk caching from Store library to work 11 | with retrofit. (Store expects okio BufferedSource) 12 | */ 13 | class BufferedSourceConverterFactory : Converter.Factory() { 14 | override fun responseBodyConverter( 15 | type: Type?, 16 | annotations: Array?, 17 | retrofit: Retrofit? 18 | ): Converter? { 19 | return if (BufferedSource::class.java != type) { 20 | null 21 | } else Converter { value -> value.source() } 22 | } 23 | } -------------------------------------------------------------------------------- /data/src/main/java/dk/nodes/template/data/network/util/DateDeserializer.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.data.network.util 2 | 3 | import com.google.gson.JsonDeserializationContext 4 | import com.google.gson.JsonDeserializer 5 | import com.google.gson.JsonElement 6 | import com.google.gson.JsonParseException 7 | import java.lang.reflect.Type 8 | import java.text.ParseException 9 | import java.text.SimpleDateFormat 10 | import java.util.Arrays 11 | import java.util.Date 12 | import java.util.HashMap 13 | import java.util.Locale 14 | 15 | class DateDeserializer : JsonDeserializer { 16 | @Throws(JsonParseException::class) 17 | override fun deserialize( 18 | jsonElement: JsonElement, 19 | typeOF: Type, 20 | context: JsonDeserializationContext 21 | ): Date { 22 | for (format in DATE_FORMATS) { 23 | if (!formatters.containsKey(format)) { 24 | formatters.put(format, SimpleDateFormat(format, Locale.getDefault())) 25 | } 26 | 27 | try { 28 | return formatters[format]?.parse(jsonElement.asString) ?: Date() 29 | } catch (e: ParseException) { 30 | } 31 | } 32 | throw JsonParseException( 33 | "Unparseable date: \"" + jsonElement.asString + 34 | "\". Supported formats: " + Arrays.toString(DATE_FORMATS) 35 | ) 36 | } 37 | 38 | // replacement for a static member 39 | companion object { 40 | private val formatters = HashMap() 41 | val DATE_FORMATS = arrayOf( 42 | "yyyy-MM-dd'T'HH:mm:ss.SSSZ", 43 | "yyyy-MM-dd'T'HH:mm:ss'Z'", 44 | "yyyy-MM-dd'T'HH:mm:ssZ", 45 | "yyyy-MM-dd", 46 | "MMM dd, yyyy hh:mm:ss aaa" 47 | ) 48 | } 49 | } -------------------------------------------------------------------------------- /data/src/main/java/dk/nodes/template/data/network/util/ItemTypeAdapterFactory.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.data.network.util 2 | 3 | import com.google.gson.Gson 4 | import com.google.gson.JsonElement 5 | import com.google.gson.TypeAdapter 6 | import com.google.gson.TypeAdapterFactory 7 | import com.google.gson.reflect.TypeToken 8 | import com.google.gson.stream.JsonReader 9 | import com.google.gson.stream.JsonWriter 10 | import java.io.IOException 11 | 12 | class ItemTypeAdapterFactory : TypeAdapterFactory { 13 | var rootContainerNames = listOf("data") 14 | 15 | override fun create(gson: Gson, type: TypeToken): TypeAdapter { 16 | 17 | val delegate = gson.getDelegateAdapter(this, type) 18 | val elementAdapter = gson.getAdapter(JsonElement::class.java) 19 | 20 | return object : TypeAdapter() { 21 | 22 | @Throws(IOException::class) 23 | override fun write(out: JsonWriter, value: T) { 24 | delegate.write(out, value) 25 | } 26 | 27 | @Throws(IOException::class) 28 | override fun read(`in`: JsonReader): T { 29 | 30 | var jsonElement = elementAdapter.read(`in`) 31 | if (jsonElement.isJsonObject) { 32 | // Log.e("debug", "parsing element " + jsonElement.toString()) 33 | val jsonObject = jsonElement.asJsonObject 34 | val entry_set = jsonObject.entrySet() 35 | if (entry_set.size == 1) { 36 | val key: String = entry_set.iterator().next().key ?: "" 37 | val ele: JsonElement = entry_set.iterator().next().value 38 | if (rootContainerNames.contains(key)) { 39 | // Log.e("debug", "Doing deserialization workaround") 40 | return delegate.fromJsonTree(ele) 41 | } 42 | } 43 | } 44 | return delegate.fromJsonTree(jsonElement) 45 | } 46 | }.nullSafe() 47 | } 48 | } -------------------------------------------------------------------------------- /data/src/main/java/dk/nodes/template/data/storage/PrefManagerImpl.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.data.storage 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import androidx.core.content.edit 6 | import androidx.preference.PreferenceManager 7 | import dk.nodes.template.domain.managers.PrefManager 8 | import javax.inject.Inject 9 | 10 | class PrefManagerImpl @Inject constructor(context: Context) : PrefManager { 11 | private var sharedPrefs: SharedPreferences = 12 | PreferenceManager.getDefaultSharedPreferences(context) 13 | 14 | override fun getInt(key: String, defaultValue: Int): Int { 15 | return sharedPrefs.getInt(key, defaultValue) 16 | } 17 | 18 | override fun setInt(key: String, value: Int) { 19 | sharedPrefs.edit(commit = true) { 20 | putInt(key, value) 21 | } 22 | } 23 | 24 | override fun getLong(key: String, defaultValue: Long): Long = 25 | sharedPrefs.getLong(key, defaultValue) 26 | 27 | override fun setLong(key: String, value: Long) { 28 | sharedPrefs.edit(commit = true) { 29 | putLong(key, value) 30 | } 31 | } 32 | 33 | override fun getBoolean(key: String, defaultValue: Boolean): Boolean = 34 | sharedPrefs.getBoolean(key, defaultValue) 35 | 36 | override fun setBoolean(key: String, value: Boolean) { 37 | sharedPrefs.edit(commit = true) { 38 | putBoolean(key, value) 39 | } 40 | } 41 | 42 | override fun getFloat(key: String, defaultValue: Float): Float = 43 | sharedPrefs.getFloat(key, defaultValue) 44 | 45 | override fun setFloat(key: String, value: Float) { 46 | sharedPrefs.edit(commit = true) { 47 | putFloat(key, value) 48 | } 49 | } 50 | 51 | override fun getString(key: String, defaultValue: String?): String? = 52 | sharedPrefs.getString(key, defaultValue) 53 | 54 | override fun setString(key: String, value: String) { 55 | sharedPrefs.edit(commit = true) { 56 | putString(key, value) 57 | } 58 | } 59 | 60 | override fun remove(key: String) { 61 | sharedPrefs.edit(true) { 62 | remove(key) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /docs/HOWWEWORK.md: -------------------------------------------------------------------------------- 1 | # Kotlin Template 2 | 3 | [Short project description] 4 | 5 | ## Day-to-day work 6 | 7 | 1. All new work resigns in `feature/*` branches. If some new interfaces were introduced, remember to cover them with unit-tests. 8 | 2. After your work on the feature branch is complete, open the PR and ask someone who is working on this project (or worked previously) to review it. 9 | 3. When PR is opened, Bitrise will automatically run all the code quality checks and unit-tests and will generate a report. 10 | 4. When PR is reviewed, merged and all the checks have passed, remember to make a new builds. 11 | 12 | > Pro-tip: When opening pull-requests and/or creating branches remember to reference the JIRA issue/ticket 13 | 14 | ## Enterprise level projects 15 | 16 | ### Code quaulity 17 | Enterprise level projects also contain `lintrules` module with custom linting rules to maintain code quaulity. Moreover, each module uses `detect` and `klint` gradle plugins to maintain code style and cleanliness. Every pull request made, will launch these checks to make sure develop/master branches are always "clean" and release-ready. You can also run these checks manually using `gradle` commands like so: 18 | ``` 19 | ./gradlew detekt 20 | ./gradlew lint 21 | ./gradlew ktlintCheck 22 | ``` 23 | 24 | ### Unit-test coverage 25 | Enterprise level project are required to have **80% code-coverage** with the most important piece being application's **business logic** (100% coverage) and Presentation/UI layers. Android uses `JaCoCo` gradle plugin to generate coverage reports for all the modules and ties them all together. If there are some files you wish to exclude from coverage-report (Dagger files for example) you can update `jacoco.gradle` to with additional files like so: 26 | ```groovy 27 | def toExclude = ['**/R.class', 28 | '**/R$*.class', 29 | '**/*$$ViewBinder*.*', 30 | '**/injection/*', 31 | '**/**Builder.*' 32 | ``` 33 | When it comes to writing unit-test itself please refer to our extensive guide for unit testing: https://github.com/nodes-android/guidelines/blob/master/unittesting.md 34 | 35 | ## Android Project 36 | 37 | Android project is based on our newest template for client projects that uses [Google's ViewModels](https://developer.android.com/topic/libraries/architecture/viewmodel) with a lightweight ViewState approach, similar to MVI and follows multi-modular approach to support clean architecture principles. 38 | 39 | 40 | 41 | ### Build Configuration 42 | Due to the modular architecture approach top-level `build.gradle` should provides all dependencies versions, android API level, and API keys that could be shared across different modules using extensions. 43 | ```groovy 44 | 45 | ext.sdks = [ 46 | compileSdkVersion: [latest SDK version], 47 | buildToolsVersion: [latest Build Tools version] 48 | minSdkVersion : 21, 49 | targetSdkVersion : [latest SDK version] 50 | ] 51 | 52 | ext.versions = [ 53 | kotlin : '[Latest Kotlin version]', 54 | coroutines : '[Latest Kotlin coroutines version]', 55 | ... 56 | ] 57 | ``` 58 | When it comes to flavours, projects usually have two application variants, that are defined in the `app` build.gradle. Thats also the place where all flavour-dependent variables should be specified 59 | - `stating` - points to test environment, builds are debuggable 60 | - `production` - points to production environment, signed with the the release keystore 61 | 62 | 63 | 64 | ### Flow of control 65 | 66 |

67 |    68 |

69 | 70 | 71 | Example: 72 | 1. View subscribes to ViewModel's LiveData instance(s). 73 | 2. User clicks a button that loads a list of posts in a view. 74 | 3. OnClickListener executes a Interactor/UseCase asynchronously in the business logic layer. 75 | 4. The Interactor runs in the background accessing a post repository which fetches a list of posts 76 | 5. ViewModel gets result from the Interactor and updates local view state, which triggers a LiveData update 77 | 6. View is updated since it's observing the LiveData instance from our ViewModel. 78 | 79 | 80 | 81 | ### Modules 82 | Android project follows multi-modular approach to support clean architecture principles 83 | 84 | #### App 85 | Main entry point with shared Application logic 86 | 87 | #### Data 88 | Contains data class models, repositories and network logic. Retrofit2/OkHttp3 is used for network logic. 89 | 90 | ```kotlin 91 | class RestPostRepository @Inject constructor(private val api: Api) : PostRepository { 92 | @Throws(RepositoryException::class) 93 | override suspend fun getPosts(cached: Boolean): List { 94 | val response = api.getPosts().execute() 95 | if (response.isSuccessful) { 96 | return response.body() 97 | ?: throw(RepositoryException( 98 | response.code(), 99 | response.message() 100 | )) 101 | } 102 | throw(RepositoryException(response.code(), response.message())) 103 | } 104 | } 105 | ``` 106 | 107 | #### Domain 108 | General shared business logic with interactors, extensions, managers, various utility code. 109 | 110 | An interactor usually returns a result via a suspend method. You can model the Result class as you like: 111 | 112 | ```kotlin 113 | sealed class Result { 114 | sealed class Success : Result() { 115 | data class StillFetching(val data: V) : Success() 116 | data class Cached(val data: V) : Success() 117 | data class FreshData(val data: V) : Success() 118 | object NoData() : Success() 119 | } 120 | 121 | // Error states 122 | } 123 | ``` 124 | Or the more simple version: 125 | ```kotlin 126 | sealed class Result { 127 | data class Success(val data: SomeData) : Result() 128 | data class Error(val e: Exception): Result() 129 | } 130 | ``` 131 | 132 | Interactors are the link to the outer layers of the domain layer, i.e. contacting the API or fetching/saving various state. 133 | 134 | ```kotlin 135 | class FetchPostsInteractor @Inject constructor( 136 | private val postRepository: PostRepository 137 | ) : BaseAsyncInteractor> { 138 | 139 | override suspend fun invoke(): List { 140 | return postRepository.getPosts() 141 | } 142 | } 143 | ``` 144 | 145 | #### Presentation 146 | Unsurprisingly holds the UI with matching ViewModels. This is usually the module branched out from if needed. Presentation layer provides various extension functions for your interactors so you could use different approaches when updating your `viewState`. 147 | 148 | ##### Result Interactor 149 | `ResultInteractor` will handle exception handling and produce a `CompleteResult` 150 | ```kotlin 151 | // Some random ViewModel 152 | 153 | // Wrap your basic intractors as a Result Interactor 154 | private val resultInteractor = getPostsInteractor.asResult() 155 | 156 | fun fetchPosts() = viewModelScope.launch(Dispatchers.Main) { 157 | state = mapResult(Loading()) 158 | val result = withContext(Dispatchers.IO) { resultInteractor.invoke() } 159 | state = mapResult(result) 160 | } 161 | ``` 162 | 163 | ##### LiveData Interactor 164 | `LiveDataInteactor` will produce a `LiveData>` that you can add as an additional state source 165 | 166 | ```kotlin 167 | // Wrap your basic interactor as LiveData 168 | private val liveDataInteractor = postsInteractor.asLiveData() 169 | 170 | fun fetchPosts() = viewModelScope.launch(Dispatchers.Main) { 171 | addStateSource(resultInteractor.liveData) { state = mapResult(it) } 172 | withContext(Dispatchers.IO) { 173 | resultInteractor() 174 | } 175 | } 176 | ``` 177 | 178 | ##### Flow Interactor 179 | `FlowInteractor` will produce a `Flow` to which you can subscribe to and map state accordingly 180 | ```kotlin 181 | 182 | // Wrap your basic interactor as a Flow 183 | private val flowInteractor = getPostsInteractor.asFlow() 184 | 185 | fun fetchPosts() = viewModelScope.launch(Dispatchers.Main) { 186 | resultInteractor.invoke() 187 | .flowOn(Dispatchers.IO) 188 | .collect { state = mapResult(it) } 189 | } 190 | ``` 191 | 192 | ##### RxInteractor 193 | `RxInteractor` will produce a `Flowable` that you can observe using `RxJava/RxKotlin` 194 | ```kotlin 195 | private val rxInteractor = postsInteractor.asRx() 196 | private val cd = CompositeDisposable() 197 | 198 | fun fetchPosts() = viewModelScope.launch { 199 | cd.add(rxInteractor.observe() 200 | .observeOn(AndroidSchedulers.mainThread()) 201 | .subscribeOn(Schedulers.io()) 202 | .subscribe { state = mapResult(it) }) 203 | rxInteractor.invoke() 204 | } 205 | ``` 206 | > Don't forget to clear `CompositeDisposable` in `onCleared()` 207 | 208 | 209 | #### Error Handling 210 | `ViewErrorController` is used to handle exceptions received from the intractors in the `ViewModel` and present the human-readable error message in the ui layer (i.e `Fragment`/`Activity`). 211 | When using it in `ViewModel`, `ViewErrorController` can be used to map throwable to a `ViewError` instance. You can also tweak `ViewErrorController` implementation to provide your own exception handling and etc. 212 | ```kotlin 213 | private fun mapResult(result: InteractorResult>): SampleViewState { 214 | return when (result) { 215 | is Fail -> state.copy( 216 | viewError = SingleEvent(ViewErrorController.mapThrowable(result.throwable)), 217 | isLoading = false 218 | ) 219 | } 220 | } 221 | ``` 222 | `ViewError` object is then can be used in Fragment/Activity to display Error Dialog or a Snackbar 223 | ```kotlin 224 | private fun showErrorMessage(state: SampleViewState) { 225 | defaultErrorController.get().showErrorSnackbar(requireView(), state.viewError?.consume() ?: return) { 226 | viewModel.fetchPosts() 227 | } 228 | } 229 | ``` 230 | 231 | Views are the consumers of the ViewModel's exposed LiveData. We want the view to be as dumb and small as possible, so only put UI code here 232 | 233 | ```kotlin 234 | override fun onCreate(savedInstanceState: Bundle?) { 235 | // ... 236 | viewModel.viewState.observeNonNull(this) { state -> 237 | showLoading(state) 238 | showPosts(state) 239 | showErrorMessage(state) 240 | } 241 | viewModel.loadPosts() 242 | } 243 | 244 | private fun showPosts(state: MainActivityViewState) { 245 | postsTextView.text = state.posts.joinToString { it.title + System.lineSeparator() } 246 | } 247 | 248 | private fun showLoading(state: MainActivityViewState) { 249 | postsProgressBar.isVisible = state.isLoading 250 | } 251 | 252 | private fun showErrorMessage(state: MainActivityViewState) { 253 | state.errorMessage?.let { 254 | if (it.consumed) return@let 255 | Snackbar.make( 256 | postsTextView, 257 | it.consume() ?: Translation.error.unknownError, 258 | Snackbar.LENGTH_SHORT 259 | ) 260 | } 261 | } 262 | ``` 263 | 264 | ## Injection 265 | 266 | This project is using Dagger for injection and scoping. Dagger is an annotation based dependency injection, which computes the dependency graph at compile time and verifies that everything is correctly injected at runtime. 267 | 268 | Dagger works by defining `@Component`s that hold the scope and lifetime of objects it creates. Each `@Component` can depend on other `@Component`s by being a `@Subcomponent`. 269 | 270 | ### Modules 271 | 272 | In Dagger Modules are the way to specify _how_ objects are created, where components are the once who decides the lifetime of those objects. 273 | 274 | There are different approaches to do this, given this class: 275 | ```kotlin 276 | class RestCityRepository @Inject constructor( 277 | private val api: Provider, 278 | private val gson: Gson 279 | ) : CityRepository { 280 | // ... 281 | } 282 | ``` 283 | 284 | 1) Full declaration in module via `@Provides` 285 | 286 | ```kotlin 287 | @Provides 288 | fun provideCityRepository(val api: Provider, val gson: Gson): CityRepository { 289 | return RestCityRepository(api, gson) 290 | } 291 | ``` 292 | 293 | 2) `@Bind`s and defining dependencies at implementation site 294 | 295 | ```kotlin 296 | @Binds 297 | abstract fun bindCityRepository(cityRepository: RestCityRepository): CityRepository 298 | ``` 299 | 300 | By method 2 we avoid having to mirror constructor dependencies and only have to define what implementation of the `CityRepository` we want to inject where needed. 301 | 302 | 303 | ### Scoping 304 | 305 | We define scopes via the components _or_ via scope annotations. In Careem we have two modes - component scope and @AppScope, which is similar to a singleton. 306 | 307 | If we wanted RestCityRepository to be a singleton, all we had to do was mark it as @AppScope; 308 | ```kotlin 309 | @AppScope 310 | class RestCityRepository @Inject constructor( 311 | private val api: Provider, 312 | private val gson: Gson 313 | ) : CityRepository { 314 | // ... 315 | } 316 | ``` 317 | 318 | 319 | ## Integrations 320 | List of important 3d party APIs and SDKs that are used in this project 321 | 322 | 323 | ## Live Templates 324 | The Kotlin template comes supported with a its own set of live templates which can be found at 325 | 326 | https://github.com/nodes-android/androidstudio-livetemplates 327 | 328 | It should make generating the boilerplate for activities and fragments easy. 329 | 330 | ## Nodes Architecture Library 331 | The Template uses components from our Architecture library so be sure to read up on how that is used as well 332 | 333 | https://github.com/nodes-android/nodes-architecture-android 334 | 335 | ## Inspired from the following sources: 336 | - [Clean Architecture by Uncle Bob](http://blog.8thlight.com/uncle-bob/2012/08/13/the-clean-architecture.html) 337 | - [Some dudes android implementation](https://medium.com/@dmilicic/a-detailed-guide-on-developing-android-apps-using-the-clean-architecture-pattern-d38d71e94029) 338 | - [Some other dudes implementation](https://fernandocejas.com/2014/09/03/architecting-android-the-clean-way) 339 | -------------------------------------------------------------------------------- /docs/images/arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/kotlin-template/6399bf37288bdb294a47e21fc73a621f7f356a0b/docs/images/arch.png -------------------------------------------------------------------------------- /domain/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /domain/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'kotlin' 2 | 3 | dependencies { 4 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.kotlin}" 5 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.coroutines}" 6 | implementation group: 'javax.inject', name: 'javax.inject', version: '1' 7 | } 8 | -------------------------------------------------------------------------------- /domain/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /domain/src/main/java/dk/nodes/template/domain/entities/Post.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.domain.entities 2 | 3 | data class Post( 4 | var userId: Int, 5 | var id: Int, 6 | var title: String, 7 | var body: String 8 | ) -------------------------------------------------------------------------------- /domain/src/main/java/dk/nodes/template/domain/entities/Theme.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.domain.entities 2 | 3 | enum class Theme { DARK, LIGHT, FOLLOW_SYSTEM } -------------------------------------------------------------------------------- /domain/src/main/java/dk/nodes/template/domain/extensions/Extensions.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.domain.extensions 2 | 3 | inline fun T.guard(block: T.() -> Unit): T { 4 | if (this == null) block(); return this 5 | } -------------------------------------------------------------------------------- /domain/src/main/java/dk/nodes/template/domain/interactors/Interactor.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.domain.interactors 2 | 3 | interface Interactor { 4 | suspend operator fun invoke(input: I): O 5 | } 6 | 7 | suspend operator fun NoInputInteractor.invoke() = invoke(Unit) 8 | 9 | typealias NoInputInteractor = Interactor 10 | 11 | typealias NoOutputInteractor = Interactor 12 | 13 | typealias EmptyInteractor = Interactor -------------------------------------------------------------------------------- /domain/src/main/java/dk/nodes/template/domain/interactors/InteractorExtensions.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.domain.interactors 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.channels.BroadcastChannel 6 | import kotlinx.coroutines.channels.Channel 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.asFlow 9 | import kotlinx.coroutines.launch 10 | import kotlinx.coroutines.withContext 11 | import kotlin.coroutines.CoroutineContext 12 | 13 | interface ResultInteractor : Interactor> 14 | 15 | interface FlowInteractor : NoOutputInteractor { 16 | fun flow(): Flow 17 | } 18 | 19 | private class ResultInteractorImpl(private val interactor: Interactor) : 20 | ResultInteractor { 21 | override suspend fun invoke(input: I): CompleteResult { 22 | return try { 23 | Success(interactor(input)) 24 | } catch (t: Throwable) { 25 | Fail(t) 26 | } 27 | } 28 | } 29 | 30 | private class FlowInteractorImpl(private val interactor: Interactor) : 31 | FlowInteractor> { 32 | 33 | private val channel = BroadcastChannel>(Channel.CONFLATED).also { 34 | it.offer(Uninitialized) 35 | } 36 | 37 | override fun flow(): Flow> { 38 | return channel.asFlow() 39 | } 40 | 41 | override suspend fun invoke(input: I) { 42 | channel.offer(Loading()) 43 | try { 44 | channel.offer(Success(interactor(input))) 45 | } catch (t: Throwable) { 46 | channel.offer(Fail(t)) 47 | } 48 | } 49 | } 50 | 51 | fun Interactor.asResult(): ResultInteractor { 52 | return ResultInteractorImpl(this) 53 | } 54 | 55 | fun Interactor.asFlow(): FlowInteractor> { 56 | return FlowInteractorImpl(this) 57 | } 58 | 59 | fun CoroutineScope.launchInteractor( 60 | interactor: NoOutputInteractor, 61 | input: I, 62 | coroutineContext: CoroutineContext = Dispatchers.IO 63 | ) { 64 | launch(coroutineContext) { interactor(input) } 65 | } 66 | 67 | fun CoroutineScope.launchInteractor( 68 | interactor: EmptyInteractor, 69 | coroutineContext: CoroutineContext = Dispatchers.IO 70 | ) { 71 | launchInteractor(interactor, Unit, coroutineContext) 72 | } 73 | 74 | suspend fun runInteractor( 75 | interactor: Interactor, 76 | input: I, 77 | coroutineContext: CoroutineContext = Dispatchers.IO 78 | ): T { 79 | return withContext(coroutineContext) { interactor(input) } 80 | } 81 | 82 | suspend fun runInteractor( 83 | interactor: NoInputInteractor, 84 | coroutineContext: CoroutineContext = Dispatchers.IO 85 | ): O { 86 | return withContext(coroutineContext) { interactor.invoke() } 87 | } 88 | 89 | fun InteractorResult.isSuccess(block: (T) -> R): InteractorResult { 90 | if (this is Success) { 91 | block(this.data) 92 | } 93 | return this 94 | } 95 | 96 | fun InteractorResult.isError(block: (throwable: Throwable) -> R): InteractorResult { 97 | if (this is Fail) { 98 | block(this.throwable) 99 | } 100 | return this 101 | } -------------------------------------------------------------------------------- /domain/src/main/java/dk/nodes/template/domain/interactors/InteractorResult.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.domain.interactors 2 | 3 | sealed class InteractorResult { 4 | 5 | override fun toString(): String { 6 | return when (this) { 7 | is Success<*> -> "Success[data=$data]" 8 | is Fail -> "Fail[throwable=$throwable]" 9 | is Loading -> "Loading" 10 | Uninitialized -> "Uninitialized" 11 | } 12 | } 13 | } 14 | 15 | sealed class IncompleteResult : InteractorResult() 16 | sealed class CompleteResult : InteractorResult() 17 | 18 | data class Success(val data: T) : CompleteResult() 19 | data class Fail(val throwable: Throwable) : CompleteResult() 20 | object Uninitialized : IncompleteResult() 21 | class Loading : IncompleteResult() -------------------------------------------------------------------------------- /domain/src/main/java/dk/nodes/template/domain/interactors/PostFlowInteractor.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.domain.interactors 2 | 3 | import dk.nodes.template.domain.entities.Post 4 | import dk.nodes.template.domain.repositories.PostRepository 5 | import kotlinx.coroutines.flow.Flow 6 | import javax.inject.Inject 7 | 8 | class PostFlowInteractor @Inject constructor(private val postRepository: PostRepository) : 9 | FlowInteractor> { 10 | override fun flow(): Flow> { 11 | return postRepository.getPostsFlow() 12 | } 13 | 14 | override suspend fun invoke(input: Unit) { 15 | postRepository.getPosts() 16 | } 17 | } -------------------------------------------------------------------------------- /domain/src/main/java/dk/nodes/template/domain/interactors/PostsInteractor.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.domain.interactors 2 | 3 | import dk.nodes.template.domain.entities.Post 4 | import dk.nodes.template.domain.repositories.PostRepository 5 | import javax.inject.Inject 6 | 7 | class PostsInteractor @Inject constructor( 8 | private val postRepository: PostRepository 9 | ) : NoInputInteractor> { 10 | 11 | override suspend fun invoke(input: Unit): List { 12 | return postRepository.getPosts() 13 | } 14 | } -------------------------------------------------------------------------------- /domain/src/main/java/dk/nodes/template/domain/interactors/SwitchThemeInteractor.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.domain.interactors 2 | 3 | import dk.nodes.template.domain.managers.ThemeManager 4 | import dk.nodes.template.domain.entities.Theme 5 | import javax.inject.Inject 6 | 7 | class SwitchThemeInteractor @Inject constructor( 8 | private val themeManager: ThemeManager 9 | ) : NoInputInteractor { 10 | override suspend fun invoke(input: Unit): Theme { 11 | themeManager.theme = if (themeManager.theme == Theme.LIGHT) Theme.DARK else Theme.LIGHT 12 | return themeManager.theme 13 | } 14 | } -------------------------------------------------------------------------------- /domain/src/main/java/dk/nodes/template/domain/managers/PrefManager.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.domain.managers 2 | 3 | /** 4 | * Created by bison on 11/10/17. 5 | * 6 | * The clean version of JosoPrefs :D (inject into and use from interactors) 7 | */ 8 | interface PrefManager { 9 | fun getInt(key: String, defaultValue: Int): Int 10 | fun setInt(key: String, value: Int) 11 | fun getLong(key: String, defaultValue: Long): Long 12 | fun setLong(key: String, value: Long) 13 | fun getBoolean(key: String, defaultValue: Boolean): Boolean 14 | fun setBoolean(key: String, value: Boolean) 15 | fun getFloat(key: String, defaultValue: Float): Float 16 | fun setFloat(key: String, value: Float) 17 | fun getString(key: String, defaultValue: String?): String? 18 | fun setString(key: String, value: String) 19 | fun remove(key: String) 20 | } -------------------------------------------------------------------------------- /domain/src/main/java/dk/nodes/template/domain/managers/ThemeManager.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.domain.managers 2 | 3 | import dk.nodes.template.domain.entities.Theme 4 | 5 | interface ThemeManager { 6 | var theme: Theme 7 | } -------------------------------------------------------------------------------- /domain/src/main/java/dk/nodes/template/domain/managers/ThemeManagerImpl.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.domain.managers 2 | 3 | import dk.nodes.template.domain.entities.Theme 4 | import javax.inject.Inject 5 | 6 | class ThemeManagerImpl @Inject constructor(private val prefManager: PrefManager) : ThemeManager { 7 | 8 | override var theme: Theme 9 | get() = getThemePref() 10 | set(value) { 11 | setThemePRef(value) 12 | } 13 | 14 | companion object { 15 | private const val PREF_THEME = "PREF_THEME" 16 | } 17 | 18 | private fun getThemePref(): Theme { 19 | val t = prefManager.getString(PREF_THEME, null) 20 | return if (t == null) Theme.LIGHT else Theme.valueOf(t) 21 | } 22 | 23 | private fun setThemePRef(theme: Theme) { 24 | prefManager.setString(PREF_THEME, theme.name) 25 | } 26 | } -------------------------------------------------------------------------------- /domain/src/main/java/dk/nodes/template/domain/repositories/PostRepository.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.domain.repositories 2 | 3 | import dk.nodes.template.domain.entities.Post 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | interface PostRepository { 7 | suspend fun getPosts(): List 8 | fun getPostsFlow(): Flow> 9 | } -------------------------------------------------------------------------------- /domain/src/main/java/dk/nodes/template/domain/repositories/RepositoryException.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.domain.repositories 2 | 3 | data class RepositoryException( 4 | val code: Int, 5 | val errorBody: String?, 6 | val msg: String 7 | ) : RuntimeException(msg) -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | org.gradle.jvmargs=-Xmx4g 13 | 14 | # When configured, Gradle will run in incubating parallel mode. 15 | # This option should only be used with decoupled projects. More details, visit 16 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 17 | # org.gradle.parallel=true 18 | android.useAndroidX=true 19 | android.enableJetifier=true 20 | 21 | org.gradle.parallel=true 22 | kapt.incremental.apt=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/kotlin-template/6399bf37288bdb294a47e21fc73a621f7f356a0b/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri May 29 10:35:29 CEST 2020 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.4.1-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # Attempt to set APP_HOME 46 | # Resolve links: $0 may be a link 47 | PRG="$0" 48 | # Need this for relative symlinks. 49 | while [ -h "$PRG" ] ; do 50 | ls=`ls -ld "$PRG"` 51 | link=`expr "$ls" : '.*-> \(.*\)$'` 52 | if expr "$link" : '/.*' > /dev/null; then 53 | PRG="$link" 54 | else 55 | PRG=`dirname "$PRG"`"/$link" 56 | fi 57 | done 58 | SAVED="`pwd`" 59 | cd "`dirname \"$PRG\"`/" >/dev/null 60 | APP_HOME="`pwd -P`" 61 | cd "$SAVED" >/dev/null 62 | 63 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 64 | 65 | # Determine the Java command to use to start the JVM. 66 | if [ -n "$JAVA_HOME" ] ; then 67 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 68 | # IBM's JDK on AIX uses strange locations for the executables 69 | JAVACMD="$JAVA_HOME/jre/sh/java" 70 | else 71 | JAVACMD="$JAVA_HOME/bin/java" 72 | fi 73 | if [ ! -x "$JAVACMD" ] ; then 74 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 75 | 76 | Please set the JAVA_HOME variable in your environment to match the 77 | location of your Java installation." 78 | fi 79 | else 80 | JAVACMD="java" 81 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 82 | 83 | Please set the JAVA_HOME variable in your environment to match the 84 | location of your Java installation." 85 | fi 86 | 87 | # Increase the maximum file descriptors if we can. 88 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 89 | MAX_FD_LIMIT=`ulimit -H -n` 90 | if [ $? -eq 0 ] ; then 91 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 92 | MAX_FD="$MAX_FD_LIMIT" 93 | fi 94 | ulimit -n $MAX_FD 95 | if [ $? -ne 0 ] ; then 96 | warn "Could not set maximum file descriptor limit: $MAX_FD" 97 | fi 98 | else 99 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 100 | fi 101 | fi 102 | 103 | # For Darwin, add options to specify how the application appears in the dock 104 | if $darwin; then 105 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 106 | fi 107 | 108 | # For Cygwin, switch paths to Windows format before running java 109 | if $cygwin ; then 110 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 111 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 112 | JAVACMD=`cygpath --unix "$JAVACMD"` 113 | 114 | # We build the pattern for arguments to be converted via cygpath 115 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 116 | SEP="" 117 | for dir in $ROOTDIRSRAW ; do 118 | ROOTDIRS="$ROOTDIRS$SEP$dir" 119 | SEP="|" 120 | done 121 | OURCYGPATTERN="(^($ROOTDIRS))" 122 | # Add a user-defined pattern to the cygpath arguments 123 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 124 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 125 | fi 126 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 127 | i=0 128 | for arg in "$@" ; do 129 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 130 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 131 | 132 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 133 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 134 | else 135 | eval `echo args$i`="\"$arg\"" 136 | fi 137 | i=$((i+1)) 138 | done 139 | case $i in 140 | (0) set -- ;; 141 | (1) set -- "$args0" ;; 142 | (2) set -- "$args0" "$args1" ;; 143 | (3) set -- "$args0" "$args1" "$args2" ;; 144 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 145 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 146 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 147 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 148 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 149 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 150 | esac 151 | fi 152 | 153 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 154 | function splitJvmOpts() { 155 | JVM_OPTS=("$@") 156 | } 157 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 158 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 159 | 160 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 161 | -------------------------------------------------------------------------------- /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 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 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 Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /presentation/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /presentation/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | apply plugin: 'kotlin-android-extensions' 5 | apply plugin: 'dk.nstack.translation.plugin' 6 | 7 | translation { 8 | appId = keys.appId 9 | apiKey = keys.apiKey 10 | acceptHeader = keys.acceptHeader 11 | } 12 | 13 | android { 14 | compileSdkVersion sdks.compileSdkVersion 15 | defaultConfig { 16 | minSdkVersion sdks.minSdkVersion 17 | targetSdkVersion sdks.targetSdkVersion 18 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 19 | } 20 | 21 | buildTypes { 22 | release { 23 | minifyEnabled false 24 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 25 | } 26 | } 27 | compileOptions { 28 | coreLibraryDesugaringEnabled true 29 | sourceCompatibility JavaVersion.VERSION_1_8 30 | targetCompatibility JavaVersion.VERSION_1_8 31 | } 32 | kotlinOptions { 33 | jvmTarget = "1.8" 34 | } 35 | 36 | } 37 | 38 | dependencies { 39 | implementation project(':domain') 40 | implementation "com.google.android.material:material:$versions.material" 41 | implementation "androidx.appcompat:appcompat:$versions.appcompat" 42 | implementation "androidx.arch.core:core-common:$versions.archCore" 43 | implementation "androidx.arch.core:core-runtime:$versions.archCore" 44 | implementation "androidx.constraintlayout:constraintlayout:${versions.constraint_layout}" 45 | 46 | implementation "androidx.lifecycle:lifecycle-livedata-core:${versions.lifecycle}" 47 | implementation "androidx.lifecycle:lifecycle-runtime:${versions.lifecycle}" 48 | implementation "androidx.lifecycle:lifecycle-common-java8:${versions.lifecycle}" 49 | implementation "androidx.lifecycle:lifecycle-extensions:${versions.lifecycle}" 50 | implementation "androidx.lifecycle:lifecycle-viewmodel:${versions.lifecycle}" 51 | implementation "androidx.lifecycle:lifecycle-livedata:${versions.lifecycle}" 52 | implementation "androidx.lifecycle:lifecycle-livedata-ktx:$versions.lifecycle" 53 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${versions.lifecycle}" 54 | 55 | // Utils & Extensions 56 | implementation "dk.nodes.utils:kotlin:${versions.nodes_utils}" 57 | implementation "dk.nodes.utils:android:${versions.nodes_utils}" 58 | 59 | // implementation "dk.nodes.arch:base:${versions.nodes_arch}" 60 | implementation "androidx.core:core-ktx:${versions.ktx}" 61 | implementation "com.google.dagger:dagger-android:${versions.dagger}" 62 | implementation "com.google.dagger:dagger-android-support:${versions.dagger}" 63 | kapt "com.google.dagger:dagger-compiler:${versions.dagger}" 64 | kapt "com.google.dagger:dagger-android-processor:${versions.dagger}" 65 | implementation "dk.nodes.nstack:nstack-kotlin:$versions.nstack" 66 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.coroutines}" 67 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.coroutines}" 68 | testImplementation "junit:junit:$versions.junit" 69 | implementation "androidx.core:core-ktx:${versions.ktx}" 70 | implementation "com.jakewharton.timber:timber:${versions.timber}" 71 | 72 | implementation "androidx.navigation:navigation-fragment-ktx:${versions.navigation}" 73 | implementation "androidx.navigation:navigation-ui-ktx:${versions.navigation}" 74 | 75 | coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$versions.desugaring" 76 | } 77 | -------------------------------------------------------------------------------- /presentation/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | -keep class androidx.core.app.CoreComponentFactory { *; } 24 | -------------------------------------------------------------------------------- /presentation/src/androidTest/java/dk/nodes/template/presentation/ExampleInstrumentedTest.java: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation; 2 | 3 | import static org.junit.Assert.*; 4 | 5 | import android.content.Context; 6 | import android.support.test.InstrumentationRegistry; 7 | import android.support.test.runner.AndroidJUnit4; 8 | import org.junit.Test; 9 | import org.junit.runner.RunWith; 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * @see Testing documentation 15 | */ 16 | @RunWith(AndroidJUnit4.class) 17 | public class ExampleInstrumentedTest { 18 | @Test 19 | public void useAppContext() { 20 | // Context of the app under test. 21 | Context appContext = InstrumentationRegistry.getTargetContext(); 22 | 23 | assertEquals("dk.nodes.template.presentation.test", appContext.getPackageName()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /presentation/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /presentation/src/main/assets/translations_0_da-DK.json: -------------------------------------------------------------------------------- 1 | {"default":{"hi":"Hej","cancel":"Annuller","no":"nej","yes":"Ja","edit":"Redigere","next":"N\u00e6ste","on":"T\u00e6ndt","off":"af","ok":"Ok"},"error":{"errorRandom":"Helt tilf\u00e6ldig fejl","errorTitle":"Fejl","authenticationError":"Login er udl\u00f8bet, login venligst ind igen.","connectionError":"Ingen eller d\u00e5rlig forbindelse, pr\u00f8v igen!","unknownError":"Ukendt fejl, pr\u00f8v igen."},"test":{"title":"NStack Demo","message":"Bacon ipsum dolor amet magna meatball jerky in, shank sunt do burgdoggen spare ribs. Lorem boudin eiusmod short ribs pastrami. Sausage bresaola do turkey, dolor qui tail ground round culpa boudin nulla minim sunt beef ribs ham. Cillum in pastrami adipisicing swine lorem, velit sunt meatloaf bresaola short loin fugiat tri-tip boudin.","subTitle":"Subtitle demo","on":"on","off":"off"}} -------------------------------------------------------------------------------- /presentation/src/main/assets/translations_1_en-GB.json: -------------------------------------------------------------------------------- 1 | {"default":{"hi":"Hi","cancel":"Cancel","no":"No","yes":"Yes","edit":"Edit","next":"Next","on":"On","off":"Off","ok":"Ok"},"error":{"errorRandom":"Totally random error","errorTitle":"Error","authenticationError":"Login expired, please login again.","connectionError":"No or bad connection, please try again.","unknownError":"Unknown error, please try again."},"test":{"title":"NStack Demo","message":"Bacon ipsum dolor amet magna meatball jerky in, shank sunt do burgdoggen spare ribs. Lorem boudin eiusmod short ribs pastrami. Sausage bresaola do turkey, dolor qui tail ground round culpa boudin nulla minim sunt beef ribs ham. Cillum in pastrami adipisicing swine lorem, velit sunt meatloaf bresaola short loin fugiat tri-tip boudin.","subTitle":"Subtitle demo","on":"on","off":"off"}} -------------------------------------------------------------------------------- /presentation/src/main/assets/translations_2_es-MX.json: -------------------------------------------------------------------------------- 1 | {"default":{"hi":"Hola","cancel":"Cancelar","no":"no","yes":"Si","edit":"Editar","next":"Siguiente","on":"Apprendido","off":"Apagado","ok":"__ok"},"error":{"errorRandom":"__errorRandom","errorTitle":"__errorTitle","authenticationError":"__authenticationError","connectionError":"__connectionError","unknownError":"__unknownError"},"test":{"title":"__title","message":"__message","subTitle":"__subTitle","on":"__on","off":"__off"}} -------------------------------------------------------------------------------- /presentation/src/main/assets/translations_3_fr-FR.json: -------------------------------------------------------------------------------- 1 | {"default":{"hi":"Salut","cancel":"Annuler","no":"Non","yes":"Oui","edit":"Modifier","next":"Prochain","on":"Allumez","off":"\u00c9teins","ok":"__ok"},"error":{"errorRandom":"__errorRandom","errorTitle":"__errorTitle","authenticationError":"__authenticationError","connectionError":"__connectionError","unknownError":"__unknownError"},"test":{"title":"__title","message":"__message","subTitle":"__subTitle","on":"__on","off":"__off"}} -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/extensions/LiveDataExtensions.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.extensions 2 | 3 | import androidx.lifecycle.LifecycleOwner 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.Observer 6 | import dk.nodes.template.presentation.util.EventObserver 7 | import dk.nodes.template.presentation.util.SingleEvent 8 | 9 | inline fun LiveData.observe( 10 | lifecycleOwner: LifecycleOwner, 11 | crossinline observer: (T?) -> Unit 12 | ) { 13 | this.observe(lifecycleOwner, Observer { 14 | observer(it) 15 | }) 16 | } 17 | 18 | inline fun LiveData.observeNonNull( 19 | lifecycleOwner: LifecycleOwner, 20 | crossinline observer: (T) -> Unit 21 | ) { 22 | this.observe(lifecycleOwner, Observer { 23 | it?.let(observer) 24 | }) 25 | } 26 | 27 | inline fun > LiveData.observeEvent( 28 | lifecycleOwner: LifecycleOwner, 29 | crossinline observer: (E) -> Unit 30 | ) { 31 | this.observe(lifecycleOwner, EventObserver { 32 | observer(it) 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/extensions/ViewModelStoreOwnerExtensions.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.extensions 2 | 3 | import androidx.fragment.app.Fragment 4 | import androidx.lifecycle.Lifecycle 5 | import androidx.lifecycle.LifecycleObserver 6 | import androidx.lifecycle.LifecycleOwner 7 | import androidx.lifecycle.OnLifecycleEvent 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.ViewModelProvider 10 | import androidx.lifecycle.ViewModelStoreOwner 11 | import java.io.Serializable 12 | 13 | inline fun ViewModelStoreOwner.getViewModel(factory: ViewModelProvider.Factory): VM { 14 | return ViewModelProvider(this, factory).get(VM::class.java) 15 | } 16 | 17 | inline fun Fragment.getSharedViewModel(factory: ViewModelProvider.Factory): VM { 18 | return ViewModelProvider(requireActivity(), factory).get(VM::class.java) 19 | } 20 | 21 | private object UninitializedValue 22 | 23 | /** 24 | * This was copied from SynchronizedLazyImpl but modified to automatically initialize in ON_CREATE. 25 | */ 26 | @Suppress("ClassName") 27 | class lifecycleAwareLazy(private val owner: LifecycleOwner, initializer: () -> T) : Lazy, 28 | Serializable { 29 | private var initializer: (() -> T)? = initializer 30 | @Volatile 31 | private var _value: Any? = UninitializedValue 32 | // final field is required to enable safe publication of constructed instance 33 | private val lock = this 34 | 35 | init { 36 | owner.lifecycle.addObserver(object : LifecycleObserver { 37 | @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) 38 | fun onStart() { 39 | if (!isInitialized()) value 40 | owner.lifecycle.removeObserver(this) 41 | } 42 | }) 43 | } 44 | 45 | @Suppress("LocalVariableName") 46 | override val value: T 47 | get() { 48 | val _v1 = _value 49 | if (_v1 !== UninitializedValue) { 50 | @Suppress("UNCHECKED_CAST") 51 | return _v1 as T 52 | } 53 | 54 | return synchronized(lock) { 55 | val _v2 = _value 56 | if (_v2 !== UninitializedValue) { 57 | @Suppress("UNCHECKED_CAST") (_v2 as T) 58 | } else { 59 | val typedValue = initializer!!() 60 | _value = typedValue 61 | initializer = null 62 | typedValue 63 | } 64 | } 65 | } 66 | 67 | override fun isInitialized(): Boolean = _value !== UninitializedValue 68 | 69 | override fun toString(): String = 70 | if (isInitialized()) value.toString() else "Lazy value not initialized yet." 71 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/injection/DaggerViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.injection 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import javax.inject.Inject 6 | import javax.inject.Provider 7 | 8 | internal class DaggerViewModelFactory @Inject constructor( 9 | private val creators: @JvmSuppressWildcards Map, Provider> 10 | ) : ViewModelProvider.Factory { 11 | override fun create(modelClass: Class): T { 12 | var creator: Provider? = creators[modelClass] 13 | if (creator == null) { 14 | for ((key, value) in creators) { 15 | if (modelClass.isAssignableFrom(key)) { 16 | creator = value 17 | break 18 | } 19 | } 20 | } 21 | if (creator == null) { 22 | throw IllegalArgumentException("Unknown model class: $modelClass") 23 | } 24 | try { 25 | @Suppress("UNCHECKED_CAST") 26 | return creator.get() as T 27 | } catch (e: Exception) { 28 | throw RuntimeException(e) 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/injection/PresentationModule.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.injection 2 | 3 | import dagger.Module 4 | import dk.nodes.template.presentation.ui.main.MainActivityBuilder 5 | import dk.nodes.template.presentation.ui.splash.SplashBuilder 6 | 7 | @Module(includes = [ 8 | MainActivityBuilder::class, 9 | SplashBuilder::class 10 | ]) 11 | class PresentationModule -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/injection/ViewModelBuilder.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.injection 2 | 3 | import androidx.lifecycle.ViewModelProvider 4 | import dagger.Binds 5 | import dagger.Module 6 | 7 | @Module 8 | abstract class ViewModelBuilder { 9 | 10 | @Binds 11 | internal abstract fun bindViewModelFactory(factory: DaggerViewModelFactory): ViewModelProvider.Factory 12 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/injection/ViewModelKey.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.injection 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.MapKey 5 | import kotlin.reflect.KClass 6 | 7 | @Target( 8 | AnnotationTarget.FUNCTION, 9 | AnnotationTarget.PROPERTY_GETTER, 10 | AnnotationTarget.PROPERTY_SETTER 11 | ) 12 | @Retention(AnnotationRetention.RUNTIME) 13 | @MapKey 14 | annotation class ViewModelKey(val value: KClass) -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/nstack/NStackPresenter.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.nstack 2 | 3 | import dk.nodes.nstack.kotlin.models.AppOpenData 4 | import dk.nodes.nstack.kotlin.models.AppUpdate 5 | import dk.nodes.nstack.kotlin.models.AppUpdateState 6 | import dk.nodes.nstack.kotlin.models.Message 7 | import dk.nodes.nstack.kotlin.models.RateReminder 8 | import dk.nodes.nstack.kotlin.models.state 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | 12 | @Singleton 13 | class NStackPresenter @Inject constructor() { 14 | 15 | private var appOpenData: AppOpenData? = null 16 | 17 | fun saveAppState(appUpdate: AppOpenData) { 18 | this.appOpenData = appUpdate 19 | } 20 | 21 | fun whenMessage(callback: (Message) -> Unit): NStackPresenter { 22 | appOpenData?.message?.let(callback) 23 | return this 24 | } 25 | 26 | fun whenChangelog(callback: (AppUpdate) -> Unit): NStackPresenter { 27 | if (appOpenData?.update?.state == AppUpdateState.CHANGELOG) { 28 | callback.invoke(appOpenData?.update ?: return this) 29 | } 30 | return this 31 | } 32 | 33 | fun whenRateReminder(callback: (RateReminder) -> Unit): NStackPresenter { 34 | appOpenData?.rateReminder?.let(callback) 35 | return this 36 | } 37 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/nstack/RateReminderActions.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.nstack 2 | 3 | import dk.nodes.nstack.kotlin.NStack 4 | 5 | /** 6 | * Generated by the NStack gradle plugin 7 | */ 8 | object RateReminderActions { 9 | 10 | private const val TEST = "test" 11 | 12 | suspend fun test() { 13 | send(TEST) 14 | } 15 | 16 | private suspend fun send(action: String) { 17 | NStack.RateReminder.action(action) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/nstack/Translation.java: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.nstack; 2 | 3 | /** 4 | * Created by nstack.io gradle translation plugin 5 | * Built from Accept Header: da-DK 6 | */ 7 | 8 | public class Translation { 9 | public final static class defaultSection { 10 | public static String hi = "Hej"; 11 | public static String cancel = "Annuller"; 12 | public static String no = "nej"; 13 | public static String yes = "Ja"; 14 | public static String edit = "Redigere"; 15 | public static String next = "N\u00E6ste"; 16 | public static String on = "T\u00E6ndt"; 17 | public static String off = "af"; 18 | public static String ok = "Ok"; 19 | } 20 | public final static class error { 21 | public static String errorRandom = "Helt tilf\u00E6ldig fejl"; 22 | public static String errorTitle = "Fejl"; 23 | public static String authenticationError = "Login er udl\u00F8bet, login venligst ind igen."; 24 | public static String connectionError = "Ingen eller d\u00E5rlig forbindelse, pr\u00F8v igen!"; 25 | public static String unknownError = "Ukendt fejl, pr\u00F8v igen."; 26 | } 27 | public final static class test { 28 | public static String title = "NStack Demo"; 29 | public static String message = "Bacon ipsum dolor amet magna meatball jerky in, shank sunt do burgdoggen spare ribs. Lorem boudin eiusmod short ribs pastrami. Sausage bresaola do turkey, dolor qui tail ground round culpa boudin nulla minim sunt beef ribs ham. Cillum in pastrami adipisicing swine lorem, velit sunt meatloaf bresaola short loin fugiat tri-tip boudin."; 30 | public static String subTitle = "Subtitle demo"; 31 | public static String on = "on"; 32 | public static String off = "off"; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/base/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.base 2 | 3 | import android.content.Context 4 | import android.os.Bundle 5 | import androidx.annotation.LayoutRes 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.lifecycle.ViewModel 8 | import androidx.lifecycle.ViewModelProvider 9 | import dagger.android.AndroidInjection 10 | import dagger.android.DispatchingAndroidInjector 11 | import dagger.android.HasAndroidInjector 12 | import dk.nodes.nstack.kotlin.inflater.NStackBaseContext 13 | import dk.nodes.template.presentation.extensions.getViewModel 14 | import dk.nodes.template.presentation.extensions.lifecycleAwareLazy 15 | import javax.inject.Inject 16 | 17 | abstract class BaseActivity : AppCompatActivity, HasAndroidInjector { 18 | 19 | constructor() : super() 20 | constructor(@LayoutRes resId: Int) : super(resId) 21 | 22 | @Inject 23 | lateinit var viewModelFactory: ViewModelProvider.Factory 24 | 25 | @Inject 26 | lateinit var androidInjector: DispatchingAndroidInjector 27 | 28 | override fun onCreate(savedInstanceState: Bundle?) { 29 | AndroidInjection.inject(this) 30 | super.onCreate(savedInstanceState) 31 | } 32 | 33 | override fun attachBaseContext(newBase: Context) { 34 | super.attachBaseContext(NStackBaseContext(newBase)) 35 | } 36 | 37 | protected inline fun getViewModel(): VM = 38 | getViewModel(viewModelFactory) 39 | 40 | protected inline fun viewModel(): Lazy { 41 | return lifecycleAwareLazy(this) { getViewModel() } 42 | } 43 | 44 | override fun androidInjector() = androidInjector 45 | } 46 | -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/base/BaseFragment.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.base 2 | 3 | import android.content.Context 4 | import androidx.annotation.LayoutRes 5 | import androidx.fragment.app.Fragment 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.ViewModelProvider 8 | import dagger.android.DispatchingAndroidInjector 9 | import dagger.android.HasAndroidInjector 10 | import dagger.android.support.AndroidSupportInjection 11 | import dk.nodes.template.presentation.extensions.getSharedViewModel 12 | import dk.nodes.template.presentation.extensions.getViewModel 13 | import dk.nodes.template.presentation.extensions.lifecycleAwareLazy 14 | import dk.nodes.template.presentation.util.ViewErrorController 15 | import javax.inject.Inject 16 | 17 | abstract class BaseFragment : Fragment, HasAndroidInjector { 18 | 19 | constructor() 20 | constructor(@LayoutRes resId: Int) : super(resId) 21 | 22 | @Inject 23 | lateinit var viewModelFactory: ViewModelProvider.Factory 24 | 25 | @Inject 26 | lateinit var defaultErrorController: dagger.Lazy 27 | 28 | @Inject 29 | lateinit var androidInjector: DispatchingAndroidInjector 30 | 31 | protected inline fun getViewModel(): VM = 32 | getViewModel(viewModelFactory) 33 | 34 | protected inline fun getSharedViewModel(): VM = 35 | getSharedViewModel(viewModelFactory) 36 | 37 | protected inline fun viewModel(): Lazy = lifecycleAwareLazy(this) { 38 | getViewModel() 39 | } 40 | 41 | protected inline fun sharedViewModel(): Lazy = 42 | lifecycleAwareLazy(this) { 43 | getSharedViewModel() 44 | } 45 | 46 | override fun onAttach(context: Context) { 47 | AndroidSupportInjection.inject(this) 48 | super.onAttach(context) 49 | } 50 | 51 | override fun androidInjector() = androidInjector 52 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/base/BaseViewModel.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.base 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MediatorLiveData 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.distinctUntilChanged 7 | 8 | abstract class BaseViewModel(initState: T) : ViewModel() { 9 | 10 | private val _viewState = MediatorLiveData().apply { value = initState } 11 | val viewState = _viewState.distinctUntilChanged() 12 | protected var state 13 | get() = _viewState.value!! 14 | set(value) { 15 | _viewState.value = value 16 | } 17 | 18 | protected var stateAsync: T = state 19 | set(value) { 20 | _viewState.postValue(value) // Sets the value asynchronously 21 | } 22 | 23 | protected fun addStateSource(source: LiveData, onChanged: (T) -> Unit) { 24 | _viewState.addSource(source, onChanged) 25 | } 26 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/main/MainActivity+NStack.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.main 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import androidx.appcompat.app.AlertDialog 6 | import androidx.lifecycle.lifecycleScope 7 | import dk.nodes.nstack.kotlin.NStack 8 | import dk.nodes.nstack.kotlin.models.AppUpdate 9 | import dk.nodes.nstack.kotlin.models.AppUpdateState 10 | import dk.nodes.nstack.kotlin.models.Message 11 | import dk.nodes.nstack.kotlin.models.RateReminder 12 | import dk.nodes.nstack.kotlin.models.Result 13 | import dk.nodes.nstack.kotlin.models.state 14 | import dk.nodes.nstack.kotlin.models.update 15 | import dk.nodes.template.presentation.nstack.Translation 16 | import kotlinx.coroutines.Dispatchers 17 | import kotlinx.coroutines.launch 18 | import kotlinx.coroutines.withContext 19 | 20 | fun MainActivity.setupNStack() { 21 | lifecycleScope.launch { 22 | when (val result = withContext(Dispatchers.IO) { NStack.appOpen() }) { 23 | is Result.Success -> { 24 | when (result.value.data.update.state) { 25 | AppUpdateState.NONE -> { /* Nothing to do */ 26 | } 27 | AppUpdateState.UPDATE -> showUpdateDialog(result.value.data.update) 28 | AppUpdateState.FORCE -> showForceDialog(result.value.data.update) 29 | AppUpdateState.CHANGELOG -> showChangelogDialog(result.value.data.update) 30 | } 31 | 32 | result.value.data.message?.let { showMessageDialog(it) } 33 | result.value.data.rateReminder?.let { showRateReminderDialog(it) } 34 | } 35 | is Result.Error -> { 36 | } 37 | } 38 | } 39 | } 40 | 41 | fun MainActivity.showRateReminderDialog(rateReminder: RateReminder) { 42 | AlertDialog.Builder(this) 43 | .setMessage(rateReminder.body) 44 | .setTitle(rateReminder.title) 45 | .setCancelable(false) 46 | .setPositiveButton(rateReminder.yesButton) { dialog, _ -> 47 | NStack.onRateReminderAction(true) 48 | startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(rateReminder.link))) 49 | dialog.dismiss() 50 | } 51 | .setNegativeButton(rateReminder.noButton) { dialog, _ -> 52 | NStack.onRateReminderAction(false) 53 | dialog.dismiss() 54 | } 55 | .setNeutralButton(rateReminder.laterButton) { dialog, _ -> 56 | dialog.dismiss() 57 | } 58 | .show() 59 | } 60 | 61 | fun MainActivity.showMessageDialog(message: Message) { 62 | AlertDialog.Builder(this) 63 | .setMessage(message.message) 64 | .setCancelable(false) 65 | .setPositiveButton(Translation.defaultSection.ok) { dialog, _ -> 66 | NStack.messageSeen(message) 67 | dialog.dismiss() 68 | } 69 | .show() 70 | } 71 | 72 | fun MainActivity.showUpdateDialog(appUpdate: AppUpdate) { 73 | AlertDialog.Builder(this) 74 | .setTitle(appUpdate.update?.translate?.title ?: return) 75 | .setMessage(appUpdate.update?.translate?.message ?: return) 76 | .setPositiveButton(appUpdate.update?.translate?.positiveButton) { dialog, _ -> 77 | dialog.dismiss() 78 | } 79 | .show() 80 | } 81 | 82 | fun MainActivity.showChangelogDialog(appUpdate: AppUpdate) { 83 | AlertDialog.Builder(this) 84 | .setTitle(appUpdate.update?.translate?.title ?: return) 85 | .setMessage(appUpdate.update?.translate?.message ?: return) 86 | .setNegativeButton(appUpdate.update?.translate?.negativeButton ?: return) { dialog, _ -> 87 | dialog.dismiss() 88 | } 89 | .show() 90 | } 91 | 92 | fun MainActivity.startPlayStore() { 93 | try { 94 | startActivity( 95 | Intent( 96 | Intent.ACTION_VIEW, 97 | Uri.parse("market://details?id=$packageName") 98 | ) 99 | ) 100 | } catch (anfe: android.content.ActivityNotFoundException) { 101 | startActivity( 102 | Intent( 103 | Intent.ACTION_VIEW, 104 | Uri.parse("https://play.google.com/store/apps/details?id=$packageName") 105 | ) 106 | ) 107 | } 108 | } 109 | 110 | fun MainActivity.showForceDialog(appUpdate: AppUpdate) { 111 | val dialog = AlertDialog.Builder(this) 112 | .setTitle(appUpdate.update?.translate?.title ?: return) 113 | .setMessage(appUpdate.update?.translate?.message ?: return) 114 | .setCancelable(false) 115 | .setPositiveButton(appUpdate.update?.translate?.positiveButton, null) 116 | .create() 117 | 118 | dialog.setOnShowListener { 119 | val b = dialog.getButton(AlertDialog.BUTTON_POSITIVE) 120 | b.setOnClickListener { 121 | startPlayStore() 122 | } 123 | } 124 | 125 | dialog.show() 126 | } 127 | -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/main/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.main 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import dk.nodes.template.domain.extensions.guard 7 | import dk.nodes.template.presentation.R 8 | import dk.nodes.template.presentation.extensions.observeNonNull 9 | import dk.nodes.template.presentation.ui.base.BaseActivity 10 | import dk.nodes.template.presentation.util.consume 11 | 12 | class MainActivity : BaseActivity(R.layout.activity_main) { 13 | 14 | private val viewModel by viewModel() 15 | 16 | override fun onCreate(savedInstanceState: Bundle?) { 17 | super.onCreate(savedInstanceState) 18 | viewModel.viewState.observeNonNull(this) { state -> 19 | handleNStack(state) 20 | } 21 | savedInstanceState.guard { viewModel.checkNStack() } 22 | } 23 | 24 | private fun handleNStack(viewState: MainActivityViewState) { 25 | viewState.nstackMessage.consume { showMessageDialog(it) } 26 | viewState.nstackRateReminder.consume { showRateReminderDialog(it) } 27 | viewState.nstackUpdate.consume { showChangelogDialog(it) } 28 | } 29 | 30 | companion object { 31 | fun createIntent(context: Context) = Intent(context, MainActivity::class.java) 32 | .apply { 33 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/main/MainActivityBuilder.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.main 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.Binds 5 | import dagger.Module 6 | import dagger.android.ContributesAndroidInjector 7 | import dagger.multibindings.IntoMap 8 | import dk.nodes.template.presentation.injection.ViewModelKey 9 | import dk.nodes.template.presentation.ui.sample.SampleBuilder 10 | 11 | @Module 12 | internal abstract class MainActivityBuilder { 13 | 14 | @Binds 15 | @IntoMap 16 | @ViewModelKey(MainActivityViewModel::class) 17 | abstract fun bindMainActivityViewMode(viewModel: MainActivityViewModel): ViewModel 18 | 19 | @ContributesAndroidInjector( 20 | modules = [ 21 | SampleBuilder::class 22 | ] 23 | ) 24 | internal abstract fun mainActivity(): MainActivity 25 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/main/MainActivityViewModel.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.main 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import dk.nodes.template.presentation.nstack.NStackPresenter 5 | import dk.nodes.template.presentation.ui.base.BaseViewModel 6 | import dk.nodes.template.presentation.util.SingleEvent 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.delay 9 | import kotlinx.coroutines.launch 10 | import kotlinx.coroutines.withContext 11 | import javax.inject.Inject 12 | 13 | class MainActivityViewModel @Inject constructor( 14 | private val nStackPresenter: NStackPresenter 15 | ) : BaseViewModel(MainActivityViewState()) { 16 | 17 | fun checkNStack() = viewModelScope.launch { 18 | // Delay popup a bit so it's not super intrusive 19 | withContext(Dispatchers.IO) { delay(1000) } 20 | nStackPresenter 21 | .whenChangelog { 22 | state = state.copy(nstackUpdate = SingleEvent(it)) 23 | }.whenMessage { 24 | state = state.copy(nstackMessage = SingleEvent(it)) 25 | }.whenRateReminder { 26 | state = state.copy(nstackRateReminder = SingleEvent(it)) 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/main/MainActivityViewState.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.main 2 | 3 | import dk.nodes.nstack.kotlin.models.AppUpdate 4 | import dk.nodes.nstack.kotlin.models.Message 5 | import dk.nodes.nstack.kotlin.models.RateReminder 6 | import dk.nodes.template.presentation.util.SingleEvent 7 | 8 | data class MainActivityViewState( 9 | val errorMessage: SingleEvent? = null, 10 | val isLoading: Boolean = false, 11 | val nstackMessage: SingleEvent? = null, 12 | val nstackRateReminder: SingleEvent? = null, 13 | val nstackUpdate: SingleEvent? = null 14 | ) -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/sample/SampleAdapter.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.sample 2 | 3 | import android.view.View 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.DiffUtil 6 | import androidx.recyclerview.widget.RecyclerView 7 | import dk.nodes.template.domain.entities.Post 8 | import dk.nodes.template.presentation.R 9 | import dk.nodes.utils.android.view.inflate 10 | import kotlinx.android.synthetic.main.row_sample.view.* 11 | 12 | class SampleAdapter : RecyclerView.Adapter() { 13 | 14 | private val list = mutableListOf() 15 | 16 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 17 | return ViewHolder(parent.inflate(R.layout.row_sample)) 18 | } 19 | 20 | override fun getItemCount() = list.size 21 | 22 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 23 | holder.bind(list[position]) 24 | } 25 | 26 | fun setData(list: List) { 27 | val diff = DiffUtil.calculateDiff(PostDiff(this.list, list)) 28 | this.list.clear() 29 | this.list += list 30 | diff.dispatchUpdatesTo(this) 31 | } 32 | 33 | class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 34 | 35 | fun bind(post: Post) { 36 | itemView.run { 37 | titleTv.text = post.title 38 | bodyTv.text = post.body 39 | } 40 | } 41 | } 42 | 43 | private inner class PostDiff( 44 | private val oldList: List, 45 | private val newList: List 46 | ) : DiffUtil.Callback() { 47 | override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { 48 | return newList[newItemPosition].id == oldList[oldItemPosition].id 49 | } 50 | 51 | override fun getOldListSize(): Int { 52 | return oldList.size 53 | } 54 | 55 | override fun getNewListSize(): Int { 56 | return newList.size 57 | } 58 | 59 | override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { 60 | return newList[newItemPosition] == oldList[oldItemPosition] 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/sample/SampleBuilder.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.sample 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.Binds 5 | import dagger.Module 6 | import dagger.android.ContributesAndroidInjector 7 | import dagger.multibindings.IntoMap 8 | import dk.nodes.template.presentation.injection.ViewModelKey 9 | 10 | @Module 11 | abstract class SampleBuilder { 12 | 13 | @ContributesAndroidInjector 14 | abstract fun sampleFragment(): SampleFragment 15 | 16 | @Binds 17 | @IntoMap 18 | @ViewModelKey(SampleViewModel::class) 19 | internal abstract fun bindSampleViewModel(viewModel: SampleViewModel): ViewModel 20 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/sample/SampleFragment.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.sample 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.core.view.isVisible 6 | import dk.nodes.template.presentation.R 7 | import dk.nodes.template.presentation.extensions.observeNonNull 8 | import dk.nodes.template.presentation.ui.base.BaseFragment 9 | import dk.nodes.template.presentation.util.consume 10 | import kotlinx.android.synthetic.main.fragment_sample.* 11 | 12 | class SampleFragment : BaseFragment(R.layout.fragment_sample) { 13 | 14 | private val viewModel by viewModel() 15 | private lateinit var adapter: SampleAdapter 16 | 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | super.onCreate(savedInstanceState) 19 | viewModel.fetchPosts() 20 | } 21 | 22 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 23 | super.onViewCreated(view, savedInstanceState) 24 | fragSampleFab.setOnClickListener { viewModel.switchTheme() } 25 | adapter = SampleAdapter().also(rv::setAdapter) 26 | } 27 | 28 | override fun onActivityCreated(savedInstanceState: Bundle?) { 29 | super.onActivityCreated(savedInstanceState) 30 | viewModel.viewState.observeNonNull(this) { state -> 31 | showLoading(state) 32 | showPosts(state) 33 | showErrorMessage(state) 34 | } 35 | } 36 | 37 | private fun showPosts(state: SampleViewState) { 38 | adapter.setData(state.posts) 39 | } 40 | 41 | private fun showLoading(state: SampleViewState) { 42 | postsProgressBar.isVisible = state.isLoading 43 | rv.isVisible = !state.isLoading 44 | } 45 | 46 | private fun showErrorMessage(state: SampleViewState) { 47 | state.viewError.consume { viewError -> 48 | defaultErrorController.get().showErrorSnackbar(requireView(), viewError) { 49 | viewModel.fetchPosts() 50 | } 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/sample/SampleViewModel.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.sample 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import dk.nodes.template.domain.interactors.Fail 5 | import dk.nodes.template.domain.interactors.InteractorResult 6 | import dk.nodes.template.domain.interactors.Loading 7 | import dk.nodes.template.domain.interactors.PostFlowInteractor 8 | import dk.nodes.template.domain.interactors.Success 9 | import dk.nodes.template.domain.interactors.SwitchThemeInteractor 10 | import dk.nodes.template.domain.interactors.asResult 11 | import dk.nodes.template.domain.interactors.invoke 12 | import dk.nodes.template.domain.interactors.runInteractor 13 | import dk.nodes.template.presentation.ui.base.BaseViewModel 14 | import dk.nodes.template.presentation.util.SingleEvent 15 | import dk.nodes.template.presentation.util.ThemeHelper 16 | import dk.nodes.template.presentation.util.ViewErrorController 17 | import kotlinx.coroutines.Dispatchers 18 | import kotlinx.coroutines.flow.collect 19 | import kotlinx.coroutines.launch 20 | import javax.inject.Inject 21 | 22 | class SampleViewModel @Inject constructor( 23 | private val postsInteractor: PostFlowInteractor, 24 | private val switchThemeInteractor: SwitchThemeInteractor 25 | ) : BaseViewModel(SampleViewState()) { 26 | 27 | init { 28 | viewModelScope.launch { 29 | postsInteractor.flow().collect { 30 | state = state.copy(posts = it) 31 | } 32 | } 33 | } 34 | 35 | fun fetchPosts() { 36 | viewModelScope.launch(Dispatchers.Main) { 37 | state = mapResult(Loading()) 38 | val result = runInteractor(postsInteractor.asResult()) 39 | state = mapResult(result) 40 | } 41 | } 42 | 43 | fun switchTheme() { 44 | viewModelScope.launch { 45 | ThemeHelper.applyTheme(switchThemeInteractor()) 46 | } 47 | } 48 | 49 | private fun mapResult(result: InteractorResult): SampleViewState { 50 | return when (result) { 51 | is Success -> state.copy(isLoading = false) 52 | is Loading -> state.copy(isLoading = true) 53 | is Fail -> state.copy( 54 | viewError = SingleEvent(ViewErrorController.mapThrowable(result.throwable)), 55 | isLoading = false 56 | ) 57 | else -> SampleViewState() 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/sample/SampleViewState.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.sample 2 | 3 | import dk.nodes.template.domain.entities.Post 4 | import dk.nodes.template.presentation.util.SingleEvent 5 | import dk.nodes.template.presentation.util.ViewError 6 | 7 | data class SampleViewState( 8 | val posts: List = emptyList(), 9 | val viewError: SingleEvent? = null, 10 | val isLoading: Boolean = false 11 | ) -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/splash/SplashActivity.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.splash 2 | 3 | import dk.nodes.template.presentation.R 4 | import dk.nodes.template.presentation.ui.base.BaseActivity 5 | 6 | class SplashActivity : BaseActivity(R.layout.activity_splash) -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/splash/SplashBuilder.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.splash 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.Binds 5 | import dagger.Module 6 | import dagger.android.ContributesAndroidInjector 7 | import dagger.multibindings.IntoMap 8 | import dk.nodes.template.presentation.injection.ViewModelKey 9 | 10 | @Module 11 | abstract class SplashBuilder { 12 | 13 | @Binds 14 | @IntoMap 15 | @ViewModelKey(SplashViewModel::class) 16 | internal abstract fun bindSplashViewModel(viewModel: SplashViewModel): ViewModel 17 | 18 | @ContributesAndroidInjector 19 | abstract fun splashActivity(): SplashActivity 20 | 21 | @ContributesAndroidInjector 22 | abstract fun splashFragment(): SplashFragment 23 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/splash/SplashFragment.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.splash 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.os.Bundle 6 | import android.view.View 7 | import androidx.appcompat.app.AlertDialog 8 | import dk.nodes.nstack.kotlin.models.AppUpdate 9 | import dk.nodes.nstack.kotlin.models.AppUpdateState 10 | import dk.nodes.nstack.kotlin.models.state 11 | import dk.nodes.nstack.kotlin.models.update 12 | import dk.nodes.template.presentation.R 13 | import dk.nodes.template.presentation.extensions.observeNonNull 14 | import dk.nodes.template.presentation.ui.base.BaseFragment 15 | import dk.nodes.template.presentation.ui.main.* 16 | import dk.nodes.template.presentation.util.consume 17 | 18 | class SplashFragment : BaseFragment(R.layout.fragment_splash) { 19 | 20 | private val viewModel by viewModel() 21 | 22 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 23 | super.onViewCreated(view, savedInstanceState) 24 | viewModel.viewState.observeNonNull(viewLifecycleOwner) { state -> 25 | handleNStack(state) 26 | handleNavigation(state) 27 | } 28 | viewModel.initAppState() 29 | } 30 | 31 | private fun handleNStack(state: SplashViewState) { 32 | state.nstackUpdateAvailable.consume { appUpdate -> 33 | when (appUpdate.state) { 34 | AppUpdateState.FORCE -> { 35 | showForceDialog(appUpdate) 36 | } 37 | // We handle the rest in MainActivity 38 | else -> { 39 | } 40 | } 41 | } 42 | } 43 | 44 | private fun handleNavigation(state: SplashViewState) { 45 | if (state.doneLoading && state.nstackUpdateAvailable?.peek()?.state != AppUpdateState.FORCE) { 46 | showApp() 47 | } 48 | } 49 | 50 | private fun showApp() { 51 | startActivity(MainActivity.createIntent(requireContext())) 52 | activity?.overridePendingTransition(0, 0) 53 | } 54 | 55 | private fun startPlayStore() { 56 | try { 57 | startActivity( 58 | Intent( 59 | Intent.ACTION_VIEW, 60 | Uri.parse("market://details?id=${context?.packageName}") 61 | ) 62 | ) 63 | } catch (anfe: android.content.ActivityNotFoundException) { 64 | startActivity( 65 | Intent( 66 | Intent.ACTION_VIEW, 67 | Uri.parse("https://play.google.com/store/apps/details?id=${context?.packageName}") 68 | ) 69 | ) 70 | } 71 | } 72 | 73 | private fun showForceDialog(appUpdate: AppUpdate) { 74 | val dialog = AlertDialog.Builder(context ?: return) 75 | .setTitle(appUpdate.update?.translate?.title ?: return) 76 | .setMessage(appUpdate.update?.translate?.message ?: return) 77 | .setCancelable(false) 78 | .setPositiveButton(appUpdate.update?.translate?.positiveButton, null) 79 | .create() 80 | 81 | dialog.setOnShowListener { 82 | val b = dialog.getButton(AlertDialog.BUTTON_POSITIVE) 83 | b.setOnClickListener { 84 | startPlayStore() 85 | } 86 | } 87 | 88 | dialog.show() 89 | } 90 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/splash/SplashViewModel.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.splash 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import dk.nodes.nstack.kotlin.NStack 5 | import dk.nodes.nstack.kotlin.models.Result 6 | import dk.nodes.template.presentation.nstack.NStackPresenter 7 | import dk.nodes.template.presentation.ui.base.BaseViewModel 8 | import dk.nodes.template.presentation.util.SingleEvent 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.async 11 | import kotlinx.coroutines.delay 12 | import kotlinx.coroutines.launch 13 | import timber.log.Timber 14 | import javax.inject.Inject 15 | 16 | class SplashViewModel @Inject constructor( 17 | private val nStackPresenter: NStackPresenter 18 | ) : BaseViewModel( 19 | SplashViewState( 20 | doneLoading = false, 21 | nstackUpdateAvailable = null 22 | ) 23 | ) { 24 | 25 | fun initAppState() { 26 | viewModelScope.launch { 27 | Timber.d("initAppState() - start") 28 | val deferredAppOpen = async(Dispatchers.IO) { NStack.appOpen() } 29 | // Other API calls that might be needed 30 | // ... 31 | // Splash should be shown for min. x milliseconds 32 | val deferredMinDelay = async(Dispatchers.IO) { delay(2000) } 33 | 34 | // Parallel execution, wait on both to finish 35 | val appOpenResult = deferredAppOpen.await() 36 | deferredMinDelay.await() 37 | 38 | Timber.d("initAppState() - end") 39 | state = when (appOpenResult) { 40 | is Result.Success -> { 41 | nStackPresenter.saveAppState(appOpenResult.value.data) 42 | state.copy( 43 | doneLoading = true, 44 | nstackUpdateAvailable = SingleEvent(appOpenResult.value.data.update) 45 | ) 46 | } 47 | else -> state.copy(doneLoading = true) 48 | } 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/ui/splash/SplashViewState.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.ui.splash 2 | 3 | import dk.nodes.nstack.kotlin.models.AppUpdate 4 | import dk.nodes.template.presentation.util.SingleEvent 5 | 6 | data class SplashViewState( 7 | val doneLoading: Boolean, 8 | val nstackUpdateAvailable: SingleEvent? 9 | ) -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/util/SharedElementHelper.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.util 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import android.os.Bundle 6 | import android.view.View 7 | import android.widget.ImageView 8 | import androidx.annotation.RequiresApi 9 | import androidx.core.app.ActivityOptionsCompat 10 | import androidx.core.util.Pair 11 | import androidx.fragment.app.FragmentTransaction 12 | import java.lang.ref.WeakReference 13 | 14 | class SharedElementHelper { 15 | private val sharedElementViews = mutableMapOf, String?>() 16 | private val transitionData = mutableMapOf() 17 | 18 | fun addSharedElementTransitionData(key: String, data: String) { 19 | transitionData[key] = data 20 | } 21 | 22 | @RequiresApi(Build.VERSION_CODES.LOLLIPOP) 23 | fun addSharedElement(view: View) { 24 | sharedElementViews[WeakReference(view)] = view.transitionName 25 | } 26 | 27 | fun addSharedElement(view: View, name: String) { 28 | sharedElementViews[WeakReference(view)] = name 29 | } 30 | 31 | fun applyToTransaction(tx: FragmentTransaction) { 32 | for ((viewRef, customTransitionName) in sharedElementViews) { 33 | viewRef.get()?.apply { 34 | tx.addSharedElement(this, customTransitionName!!) 35 | } 36 | } 37 | } 38 | 39 | fun applyToIntent(activity: Activity): Bundle? { 40 | return ActivityOptionsCompat.makeSceneTransitionAnimation( 41 | activity, 42 | *sharedElementViews.map { Pair(it.key.get(), it.value) }.toList().toTypedArray() 43 | ).toBundle() 44 | } 45 | 46 | fun isEmpty(): Boolean = sharedElementViews.isEmpty() 47 | 48 | fun hasExternalImageViews(): Boolean = sharedElementViews.any { it is ImageView } 49 | } 50 | 51 | fun sharedElements(vararg elements: kotlin.Pair): SharedElementHelper { 52 | return SharedElementHelper().apply { 53 | elements.forEach { 54 | addSharedElement(it.first, it.second) 55 | } 56 | } 57 | } 58 | 59 | @RequiresApi(Build.VERSION_CODES.LOLLIPOP) 60 | fun sharedElements(vararg elements: View): SharedElementHelper { 61 | return SharedElementHelper().apply { 62 | elements.forEach { 63 | addSharedElement(it) 64 | } 65 | } 66 | } 67 | 68 | @RequiresApi(Build.VERSION_CODES.LOLLIPOP) 69 | fun sharedElements(elements: List): SharedElementHelper { 70 | return sharedElements(*elements.toTypedArray()) 71 | } 72 | 73 | fun SharedElementHelper.withTransitionData(vararg transitionDataPairs: kotlin.Pair): SharedElementHelper { 74 | transitionDataPairs.forEach { 75 | addSharedElementTransitionData(it.first, it.second) 76 | } 77 | return this 78 | } 79 | 80 | fun SharedElementHelper.withTransitionData(data: Map): SharedElementHelper { 81 | return withTransitionData(*(data.entries.map { kotlin.Pair(it.key, it.value) }.toTypedArray())) 82 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/util/SingleEvent.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.util 2 | 3 | import androidx.lifecycle.Observer 4 | 5 | /** 6 | * Used as a wrapper for data that is exposed via a LiveData that represents an event. 7 | * 8 | * [Read more about this.](https://medium.com/google-developers/livedata-with-snackbar-navigation-and-other-events-the-singleliveevent-case-ac2622673150) 9 | */ 10 | open class SingleEvent(private val content: T) { 11 | 12 | var consumed = false 13 | private set // Allow external read but not write 14 | 15 | /** 16 | * Consumes the content if it's not been consumed yet. 17 | * @return The unconsumed content or `null` if it was consumed already. 18 | */ 19 | fun consume(): T? { 20 | return if (consumed) { 21 | null 22 | } else { 23 | consumed = true 24 | content 25 | } 26 | } 27 | 28 | /** 29 | * @return The content whether it's been handled or not. 30 | */ 31 | fun peek(): T = content 32 | 33 | override fun equals(other: Any?): Boolean { 34 | if (this === other) return true 35 | if (javaClass != other?.javaClass) return false 36 | 37 | other as SingleEvent<*> 38 | 39 | if (content != other.content) return false 40 | if (consumed != other.consumed) return false 41 | 42 | return true 43 | } 44 | 45 | override fun hashCode(): Int { 46 | var result = content?.hashCode() ?: 0 47 | result = 31 * result + consumed.hashCode() 48 | return result 49 | } 50 | } 51 | 52 | fun SingleEvent?.consume(block: (T) -> Unit) { 53 | this?.consume()?.let(block) 54 | } 55 | 56 | /** 57 | * An [Observer] for [SingleEvent]s, simplifying the pattern of checking if the [SingleEvent]'s content has 58 | * already been consumed. 59 | * 60 | * [onEventUnconsumedContent] is *only* called if the [SingleEvent]'s contents has not been consumed. 61 | */ 62 | 63 | class EventObserver(private val onEventUnconsumedContent: (T) -> Unit) : 64 | Observer> { 65 | override fun onChanged(event: SingleEvent?) { 66 | event?.consume()?.run(onEventUnconsumedContent) 67 | } 68 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/util/ThemeHelper.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.util 2 | 3 | import android.os.Build 4 | import androidx.appcompat.app.AppCompatDelegate 5 | import dk.nodes.template.domain.entities.Theme 6 | 7 | object ThemeHelper { 8 | 9 | fun applyTheme(theme: Theme) { 10 | val systemTheme = when (theme) { 11 | Theme.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO 12 | Theme.DARK -> AppCompatDelegate.MODE_NIGHT_YES 13 | else -> { 14 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 15 | AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM 16 | } else { 17 | AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY 18 | } 19 | } 20 | } 21 | AppCompatDelegate.setDefaultNightMode(systemTheme) 22 | } 23 | } -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/util/ViewError.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.util 2 | 3 | import java.io.Serializable 4 | 5 | data class ViewError( 6 | var title: String, 7 | var message: String, 8 | var code: Int = 400 9 | ) : Serializable 10 | -------------------------------------------------------------------------------- /presentation/src/main/java/dk/nodes/template/presentation/util/ViewErrorController.kt: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation.util 2 | 3 | import android.content.Context 4 | import android.view.View 5 | import androidx.appcompat.app.AlertDialog 6 | import com.google.android.material.snackbar.Snackbar 7 | import dk.nodes.template.presentation.nstack.Translation 8 | import dk.nodes.template.domain.repositories.RepositoryException 9 | import javax.inject.Inject 10 | 11 | class ViewErrorController @Inject constructor(val context: Context) { 12 | 13 | fun showErrorDialog(error: ViewError, cancelable: Boolean = true, dismissAction: (() -> Unit)? = null) { 14 | val builder = AlertDialog.Builder(context) 15 | builder.setTitle(error.title) 16 | builder.setMessage(error.message) 17 | builder.setPositiveButton(Translation.defaultSection.ok) { _, _ -> 18 | isShowingError = false 19 | } 20 | builder.setOnDismissListener { 21 | isShowingError = false 22 | dismissAction?.invoke() 23 | } 24 | if (!isShowingError) { 25 | isShowingError = true 26 | val dialog = builder.show() 27 | dialog.setCancelable(cancelable) 28 | dialog.setCanceledOnTouchOutside(cancelable) 29 | } 30 | } 31 | 32 | fun showErrorSnackbar(view: View, error: ViewError, showAction: Boolean = false, dismissAction: (() -> Unit)? = null) { 33 | val showLength = if (showAction) Snackbar.LENGTH_INDEFINITE else Snackbar.LENGTH_LONG 34 | val snackbar = Snackbar.make(view, error.message ?: Translation.error.errorRandom, showLength) 35 | if (showAction) { 36 | snackbar.setAction(Translation.defaultSection.ok) { 37 | isShowingError = false 38 | dismissAction?.invoke() 39 | } 40 | } 41 | if (!isShowingError) { 42 | isShowingError = true 43 | snackbar.show() 44 | } 45 | } 46 | 47 | companion object { 48 | var isShowingError = false 49 | 50 | fun mapThrowable(throwable: Throwable): ViewError { 51 | return when (throwable) { 52 | is RepositoryException -> { 53 | when (throwable.code) { 54 | 401, 403 -> { 55 | ViewError( 56 | title = Translation.error.errorTitle, 57 | message = Translation.error.authenticationError, 58 | code = -1 59 | ) 60 | } 61 | 402, in 404..500 -> { 62 | ViewError( 63 | title = Translation.error.errorTitle, 64 | message = Translation.error.unknownError, 65 | code = -1 66 | ) 67 | } 68 | in 500..600 -> { 69 | ViewError( 70 | title = Translation.error.errorTitle, 71 | message = Translation.error.unknownError, 72 | code = -1 73 | ) 74 | } 75 | else -> { 76 | ViewError( 77 | title = Translation.error.errorTitle, 78 | message = Translation.error.unknownError, 79 | code = -1 80 | ) 81 | } 82 | } 83 | } 84 | else -> { 85 | ViewError( 86 | title = Translation.error.errorTitle, 87 | message = Translation.error.connectionError, 88 | code = -1 89 | ) 90 | } 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /presentation/src/main/res/anim/slide_left_enter.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 15 | -------------------------------------------------------------------------------- /presentation/src/main/res/anim/slide_left_exit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 15 | -------------------------------------------------------------------------------- /presentation/src/main/res/anim/slide_right_enter.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 15 | -------------------------------------------------------------------------------- /presentation/src/main/res/anim/slide_right_exit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 15 | -------------------------------------------------------------------------------- /presentation/src/main/res/color/selector_primary.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /presentation/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 9 | 10 | 16 | 19 | 22 | 23 | 24 | 25 | 31 | -------------------------------------------------------------------------------- /presentation/src/main/res/drawable/ic_half_moon_and_sun.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /presentation/src/main/res/font/qwigley.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/kotlin-template/6399bf37288bdb294a47e21fc73a621f7f356a0b/presentation/src/main/res/font/qwigley.ttf -------------------------------------------------------------------------------- /presentation/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 17 | 18 | -------------------------------------------------------------------------------- /presentation/src/main/res/layout/activity_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 14 | 15 | -------------------------------------------------------------------------------- /presentation/src/main/res/layout/fragment_sample.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 18 | 19 | 27 | 28 | 40 | 41 | 49 | 50 | -------------------------------------------------------------------------------- /presentation/src/main/res/layout/fragment_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /presentation/src/main/res/layout/item_sample_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /presentation/src/main/res/layout/row_sample.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 14 | 15 | 21 | 22 | -------------------------------------------------------------------------------- /presentation/src/main/res/menu/menu_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 8 | -------------------------------------------------------------------------------- /presentation/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /presentation/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /presentation/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/kotlin-template/6399bf37288bdb294a47e21fc73a621f7f356a0b/presentation/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /presentation/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/kotlin-template/6399bf37288bdb294a47e21fc73a621f7f356a0b/presentation/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /presentation/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/kotlin-template/6399bf37288bdb294a47e21fc73a621f7f356a0b/presentation/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /presentation/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/kotlin-template/6399bf37288bdb294a47e21fc73a621f7f356a0b/presentation/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /presentation/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/kotlin-template/6399bf37288bdb294a47e21fc73a621f7f356a0b/presentation/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /presentation/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/kotlin-template/6399bf37288bdb294a47e21fc73a621f7f356a0b/presentation/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /presentation/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/kotlin-template/6399bf37288bdb294a47e21fc73a621f7f356a0b/presentation/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /presentation/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/kotlin-template/6399bf37288bdb294a47e21fc73a621f7f356a0b/presentation/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /presentation/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/kotlin-template/6399bf37288bdb294a47e21fc73a621f7f356a0b/presentation/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /presentation/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/kotlin-template/6399bf37288bdb294a47e21fc73a621f7f356a0b/presentation/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /presentation/src/main/res/navigation/main_nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /presentation/src/main/res/navigation/splash_nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /presentation/src/main/res/values-night-v27/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | -------------------------------------------------------------------------------- /presentation/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 35 | -------------------------------------------------------------------------------- /presentation/src/main/res/values-v27/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | -------------------------------------------------------------------------------- /presentation/src/main/res/values/attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /presentation/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | #bb86fc 46 | #6200ee 47 | #4b01d0 48 | #3700b3 49 | 50 | #03dac6 51 | #018786 52 | 53 | #cf6679 54 | #b00020 55 | 56 | 57 | 58 | #ffffff 59 | 60 | #121212 61 | 62 | 63 | #000000 64 | 65 | -------------------------------------------------------------------------------- /presentation/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 4dp 5 | 8dp 6 | 12dp 7 | 16dp 8 | 9 | 4dp 10 | 8dp 11 | 12dp 12 | 16dp 13 | 14 | -------------------------------------------------------------------------------- /presentation/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3DDC84 4 | -------------------------------------------------------------------------------- /presentation/src/main/res/values/nstack_keys.xml: -------------------------------------------------------------------------------- 1 | 2 | {default_hi} 3 | {default_cancel} 4 | {default_no} 5 | {default_yes} 6 | {default_edit} 7 | {default_next} 8 | {default_on} 9 | {default_off} 10 | {default_ok} 11 | {error_errorRandom} 12 | {error_errorTitle} 13 | {error_authenticationError} 14 | {error_connectionError} 15 | {error_unknownError} 16 | {test_title} 17 | {test_message} 18 | {test_subTitle} 19 | {test_on} 20 | {test_off} 21 | -------------------------------------------------------------------------------- /presentation/src/main/res/values/shape.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 40 | 41 | 42 | 45 | 46 | 47 | 50 | 51 | -------------------------------------------------------------------------------- /presentation/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Kotlin Template 3 | 4 | -------------------------------------------------------------------------------- /presentation/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 30 | 31 | 39 | 42 | 43 | 44 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /presentation/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 76 | 77 | 82 | 83 | -------------------------------------------------------------------------------- /presentation/src/main/res/values/type.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 43 | 44 | 47 | 48 | 51 | 52 | 55 | 56 | 59 | 60 | 63 | 64 | 67 | 68 | 71 | 72 | 75 | 76 | 79 | 80 | 83 | 84 | 87 | 88 | 91 | 92 | -------------------------------------------------------------------------------- /presentation/src/test/java/dk/nodes/template/presentation/ExampleUnitTest.java: -------------------------------------------------------------------------------- 1 | package dk.nodes.template.presentation; 2 | 3 | import static org.junit.Assert.*; 4 | 5 | import org.junit.Test; 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * @see Testing documentation 11 | */ 12 | public class ExampleUnitTest { 13 | @Test 14 | public void addition_isCorrect() { 15 | assertEquals(4, 2 + 2); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | include ':domain' 3 | include ':presentation' 4 | include ':data' 5 | --------------------------------------------------------------------------------