├── .github └── workflows │ ├── build.yml │ ├── static_code_analysis.yml │ └── tests.yml ├── .gitignore ├── README.md ├── android ├── app │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── akjaw │ │ │ │ └── fullerstack │ │ │ │ ├── composition │ │ │ │ └── modules │ │ │ │ │ ├── androidModule.kt │ │ │ │ │ ├── databaseModule.kt │ │ │ │ │ ├── navigatorModule.kt │ │ │ │ │ ├── networkModule.kt │ │ │ │ │ ├── presentationModule.kt │ │ │ │ │ └── socketModule.kt │ │ │ │ ├── helpers │ │ │ │ ├── logger │ │ │ │ │ ├── HyperlinkedDebugTree.kt │ │ │ │ │ └── LoggerExtensions.kt │ │ │ │ ├── network │ │ │ │ │ └── NetworkResponse.kt │ │ │ │ └── storage │ │ │ │ │ └── SharedPreferencesStorage.kt │ │ │ │ ├── notes │ │ │ │ ├── database │ │ │ │ │ ├── AppDatabase.kt │ │ │ │ │ ├── RoomNoteDao.kt │ │ │ │ │ └── TimestampConverter.kt │ │ │ │ ├── network │ │ │ │ │ ├── AuthenticationInterceptor.kt │ │ │ │ │ ├── NoteService.kt │ │ │ │ │ └── RetrofitNoteApi.kt │ │ │ │ └── socket │ │ │ │ │ ├── SessionCookieJar.kt │ │ │ │ │ ├── SocketListener.kt │ │ │ │ │ └── SocketWrapper.kt │ │ │ │ └── screens │ │ │ │ ├── common │ │ │ │ ├── FullerStackApp.kt │ │ │ │ ├── LiveEvent.kt │ │ │ │ ├── MainActivityAfterAuthenticationLauncher.kt │ │ │ │ ├── ParcelableNote.kt │ │ │ │ ├── ViewModelFactory.kt │ │ │ │ ├── base │ │ │ │ │ ├── BaseActivity.kt │ │ │ │ │ ├── BaseDialogFragment.kt │ │ │ │ │ └── BaseFragment.kt │ │ │ │ ├── composition │ │ │ │ │ └── mainActivityModule.kt │ │ │ │ ├── main │ │ │ │ │ ├── BottomNavigationHelper.kt │ │ │ │ │ ├── MainActivity.kt │ │ │ │ │ └── MainActivityViewModel.kt │ │ │ │ ├── navigation │ │ │ │ │ ├── DialogManager.kt │ │ │ │ │ ├── FragmentDialogManager.kt │ │ │ │ │ ├── MultiStack.kt │ │ │ │ │ ├── MultiStackFragmentStateChanger.kt │ │ │ │ │ ├── ScreenNavigator.kt │ │ │ │ │ ├── SimpleStackScreenNavigator.kt │ │ │ │ │ └── keys │ │ │ │ │ │ ├── MultiStackFragmentKey.kt │ │ │ │ │ │ ├── NoteEditorScreen.kt │ │ │ │ │ │ ├── NotesListScreen.kt │ │ │ │ │ │ ├── ProfileScreen.kt │ │ │ │ │ │ └── SettingsScreen.kt │ │ │ │ └── recyclerview │ │ │ │ │ └── SpacingItemDecoration.kt │ │ │ │ ├── editor │ │ │ │ ├── NoteEditorFragment.kt │ │ │ │ └── NoteEditorViewModel.kt │ │ │ │ ├── list │ │ │ │ ├── NotesListFragment.kt │ │ │ │ ├── NotesListViewModel.kt │ │ │ │ ├── dialog │ │ │ │ │ ├── DeleteNotesConfirmDialog.kt │ │ │ │ │ └── SortDialog.kt │ │ │ │ ├── recyclerview │ │ │ │ │ ├── NoteViewHolder.kt │ │ │ │ │ ├── NotesDiffCallback.kt │ │ │ │ │ ├── NotesListAdapter.kt │ │ │ │ │ ├── NotesListAdapterFactory.kt │ │ │ │ │ ├── NotesSelectionTracker.kt │ │ │ │ │ ├── NotesSelectionTrackerFactory.kt │ │ │ │ │ └── selection │ │ │ │ │ │ └── NotesListActionMode.kt │ │ │ │ └── view │ │ │ │ │ └── ActionRowView.kt │ │ │ │ ├── profile │ │ │ │ ├── ProfileFragment.kt │ │ │ │ └── ProfileViewModel.kt │ │ │ │ ├── settings │ │ │ │ └── SettingsFragment.kt │ │ │ │ └── splash │ │ │ │ └── SplashActivity.kt │ │ └── res │ │ │ ├── color │ │ │ └── bottom_navigation_selector.xml │ │ │ ├── drawable-night │ │ │ ├── ic_add_24dp.xml │ │ │ ├── ic_cached_24dp.xml │ │ │ ├── ic_close_24dp.xml │ │ │ ├── ic_delete_24dp.xml │ │ │ ├── ic_home_24dp.xml │ │ │ ├── ic_person_24dp.xml │ │ │ ├── ic_search_24dp.xml │ │ │ ├── ic_settings_24dp.xml │ │ │ ├── ic_sort_24dp.xml │ │ │ └── placeholder.png │ │ │ ├── drawable-v24 │ │ │ └── ic_launcher_foreground.xml │ │ │ ├── drawable │ │ │ ├── ic_add_24dp.xml │ │ │ ├── ic_cached_24dp.xml │ │ │ ├── ic_close_24dp.xml │ │ │ ├── ic_delete_24dp.xml │ │ │ ├── ic_home_24dp.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_person_24dp.xml │ │ │ ├── ic_search_24dp.xml │ │ │ ├── ic_settings_24dp.xml │ │ │ ├── ic_sort_24dp.xml │ │ │ ├── placeholder.png │ │ │ └── splash_screen.xml │ │ │ ├── layout │ │ │ ├── activity_main.xml │ │ │ ├── item_notes_list.xml │ │ │ ├── layout_note_editor.xml │ │ │ ├── layout_notes_list.xml │ │ │ ├── layout_profile.xml │ │ │ ├── layout_settings.xml │ │ │ ├── toolbar.xml │ │ │ ├── view_action_row.xml │ │ │ └── view_sort_radio.xml │ │ │ ├── menu │ │ │ ├── bottom_navigation_menu.xml │ │ │ ├── note_editor_add.xml │ │ │ ├── note_editor_update.xml │ │ │ └── note_list_selection.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── values-night │ │ │ ├── colors.xml │ │ │ ├── styles.xml │ │ │ └── themes.xml │ │ │ ├── values-v23 │ │ │ └── dimens.xml │ │ │ ├── values │ │ │ ├── attrs.xml │ │ │ ├── colors.xml │ │ │ ├── config.xml │ │ │ ├── dimens.xml │ │ │ ├── strings.xml │ │ │ ├── styles.xml │ │ │ └── themes.xml │ │ │ └── xml │ │ │ ├── network_security_config.xml │ │ │ └── settings_screen.xml │ │ └── test │ │ └── java │ │ └── com │ │ └── akjaw │ │ └── fullerstack │ │ ├── InstantExecutorExtension.kt │ │ ├── LiveDataTestUtil.kt │ │ ├── LiveEventTestUtil.kt │ │ └── screens │ │ ├── editor │ │ └── NoteEditorViewModelTest.kt │ │ └── list │ │ ├── NotesListViewModelTest.kt │ │ └── recyclerview │ │ └── NotesSelectionTrackerTest.kt ├── authentication │ ├── .gitignore │ ├── build.gradle.kts │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ └── com │ │ │ └── akjaw │ │ │ └── fullerstack │ │ │ └── authentication │ │ │ ├── ActivityAuthenticationLauncher.kt │ │ │ ├── Auth0UserAuthenticator.kt │ │ │ ├── AuthenticationLauncher.kt │ │ │ ├── GetUserProfile.kt │ │ │ ├── UserAuthenticator.kt │ │ │ ├── composition │ │ │ ├── activityScopedAuthenticationModule.kt │ │ │ ├── auth0Module.kt │ │ │ └── authenticationModule.kt │ │ │ ├── model │ │ │ ├── AccessToken.kt │ │ │ ├── Auth0Config.kt │ │ │ ├── AuthenticationResult.kt │ │ │ └── UserProfile.kt │ │ │ ├── navigation │ │ │ └── AfterAuthenticationLauncher.kt │ │ │ ├── presentation │ │ │ └── AuthenticationActivity.kt │ │ │ └── token │ │ │ └── TokenProvider.kt │ │ └── res │ │ ├── layout │ │ └── activity_authentication.xml │ │ └── values │ │ └── strings.xml └── framework │ ├── .gitignore │ ├── build.gradle.kts │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── com │ └── akjaw │ └── framework │ ├── composition │ └── LifecycleModule.kt │ ├── utility │ └── KeyboardCloser.kt │ └── view │ ├── DistinctTextWatcher.kt │ ├── ExtensionsKt.kt │ ├── SimpleAnimationListener.kt │ ├── SimpleAnimatorListener.kt │ └── ViewFader.kt ├── assets ├── android-home.png ├── apps-architecture.png ├── data-layer-implementations.png ├── react-home.png └── socket-update.png ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── Dependencies.kt ├── config ├── detekt │ ├── baseline.xml │ └── detekt.yml └── git │ └── pre-commit ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── ktor ├── Procfile ├── build.gradle.kts └── src │ └── main │ ├── kotlin │ └── server │ │ ├── Main.kt │ │ ├── composition │ │ ├── baseModule.kt │ │ ├── databaseModule.kt │ │ └── socketModule.kt │ │ ├── jwt │ │ └── TokenParser.kt │ │ ├── logger │ │ └── ApiLogger.kt │ │ ├── routes │ │ ├── notes │ │ │ ├── noteSocket.kt │ │ │ └── notesRoute.kt │ │ └── util.kt │ │ ├── socket │ │ ├── SocketHolder.kt │ │ ├── SocketNotifier.kt │ │ ├── SocketServer.kt │ │ └── UserSocketSession.kt │ │ └── storage │ │ ├── DatabaseCoroutineQuery.kt │ │ ├── DatabaseFactory.kt │ │ ├── ExposedDatabase.kt │ │ ├── H2Database.kt │ │ ├── NotesStorage.kt │ │ ├── PostgreSqlDatabase.kt │ │ └── model │ │ ├── EntityWithCreationTimestamp.kt │ │ ├── EntityWithLastModificationTimestamp.kt │ │ ├── NotesTable.kt │ │ └── User.kt │ └── resources │ └── application.conf ├── react ├── spa-app │ ├── build.gradle.kts │ ├── src │ │ └── main │ │ │ ├── kotlin │ │ │ ├── App.kt │ │ │ ├── ErrorBoundary.kt │ │ │ ├── appBar.kt │ │ │ ├── composition │ │ │ │ └── KodeinEntry.kt │ │ │ ├── features │ │ │ │ ├── editor │ │ │ │ │ ├── NoteEditor.kt │ │ │ │ │ ├── NoteEditorContainer.kt │ │ │ │ │ ├── NoteEditorSlice.kt │ │ │ │ │ ├── more │ │ │ │ │ │ ├── DeleteNoteButton.kt │ │ │ │ │ │ └── EditorMoreButton.kt │ │ │ │ │ └── thunk │ │ │ │ │ │ ├── AddNoteThunk.kt │ │ │ │ │ │ ├── DeleteNotesThunk.kt │ │ │ │ │ │ └── UpdateNoteThunk.kt │ │ │ │ ├── home │ │ │ │ │ └── HomePage.kt │ │ │ │ ├── list │ │ │ │ │ ├── ActionRow.kt │ │ │ │ │ ├── NotesList.kt │ │ │ │ │ ├── NotesListContainer.kt │ │ │ │ │ ├── NotesListItem.kt │ │ │ │ │ ├── NotesListSlice.kt │ │ │ │ │ └── thunk │ │ │ │ │ │ ├── GetNotesThunk.kt │ │ │ │ │ │ └── SynchronizeNotesThunk.kt │ │ │ │ └── settings │ │ │ │ │ ├── SettingsPage.kt │ │ │ │ │ ├── SettingsPageContainer.kt │ │ │ │ │ ├── SettingsSlice.kt │ │ │ │ │ └── thunk │ │ │ │ │ ├── ListenForNoteDateFormatThunk.kt │ │ │ │ │ └── SelectNoteDateFormatThunk.kt │ │ │ ├── helpers │ │ │ │ └── storage │ │ │ │ │ └── LocalStorage.kt │ │ │ ├── main.kt │ │ │ ├── network │ │ │ │ ├── HttpClientFactory.kt │ │ │ │ └── KtorClientNoteApi.kt │ │ │ ├── socket │ │ │ │ └── KtorNoteSocket.kt │ │ │ └── store │ │ │ │ ├── AppState.kt │ │ │ │ ├── RThunk.kt │ │ │ │ ├── Reducers.kt │ │ │ │ ├── ReduxImportsWrapper.kt │ │ │ │ └── Store.kt │ │ │ └── resources │ │ │ └── index.html │ └── webpack.config.d │ │ ├── externals.js │ │ └── webpack.config.js ├── spa-authentication │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ ├── Auth0.kt │ │ ├── AuthenticationConfig.kt │ │ ├── AuthenticationWrapper.kt │ │ ├── TokenProvider.kt │ │ ├── dukat │ │ ├── Auth0Client.module_@auth0_auth0-spa-js.kt │ │ ├── auth-state.tsx.module_@auth0_auth0-react.kt │ │ ├── auth0-context.tsx.module_@auth0_auth0-react.kt │ │ ├── auth0-provider.tsx.module_@auth0_auth0-react.kt │ │ ├── errors.tsx.module_@auth0_auth0-react.kt │ │ ├── global.module_@auth0_auth0-spa-js.kt │ │ ├── lib.dom.kt │ │ ├── lib.es2015.iterable.kt │ │ ├── lib.es5.kt │ │ ├── reducer.tsx.module_@auth0_auth0-react.kt │ │ ├── with-auth0.tsx.module_@auth0_auth0-react.kt │ │ └── with-authentication-required.tsx.module_@auth0_auth0-react.kt │ │ └── profile │ │ ├── ProfileText.kt │ │ └── UserProfile.kt └── spa-persistance │ ├── build.gradle.kts │ └── src │ └── main │ └── kotlin │ ├── DexieDatabase.kt │ ├── DexieNoteDao.kt │ ├── DexieNoteEntity.kt │ └── dukat │ ├── collection.module_dexie.kt │ ├── database.module_dexie.kt │ ├── db-events.module_dexie.kt │ ├── db-schema.module_dexie.kt │ ├── dbcore.module_dexie.kt │ ├── dexie-constructor.module_dexie.kt │ ├── dexie-dom-dependencies.module_dexie.kt │ ├── dexie-event-set.module_dexie.kt │ ├── dexie-event.module_dexie.kt │ ├── dexie.module_dexie.kt │ ├── errors.module_dexie.kt │ ├── index-spec.module_dexie.kt │ ├── index.module_dexie.kt │ ├── indexable-type.module_dexie.kt │ ├── lib.dom.kt │ ├── lib.es2015.iterable.kt │ ├── lib.es5.Intl.module_dukat.kt │ ├── lib.es5.kt │ ├── lib.scripthost.kt │ ├── middleware.module_dexie.kt │ ├── table-hooks.module_dexie.kt │ ├── table-schema.module_dexie.kt │ ├── table.module_dexie.kt │ ├── then-shortcut.module_dexie.kt │ ├── transaction-events.module_dexie.kt │ ├── transaction.module_dexie.kt │ ├── version.module_dexie.kt │ └── where-clause.module_dexie.kt ├── settings.gradle.kts ├── shared ├── build.gradle.kts ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── src │ ├── androidMain │ └── kotlin │ │ ├── base │ │ └── CommonDispatchers.kt │ │ ├── database │ │ └── NoteEntity.kt │ │ └── network │ │ └── safeApiCall.kt │ ├── androidTest │ └── kotlin │ │ └── runTest.kt │ ├── commonMain │ └── kotlin │ │ ├── base │ │ ├── CommonDispatchers.kt │ │ └── usecase │ │ │ └── Failure.kt │ │ ├── composition │ │ ├── CommonModule.kt │ │ └── UseCaseModule.kt │ │ ├── database │ │ ├── NoteDao.kt │ │ ├── NoteEntity.kt │ │ └── NoteEntityMapper.kt │ │ ├── feature │ │ ├── AddNote.kt │ │ ├── AddNotePayload.kt │ │ ├── DeleteNotePayload.kt │ │ ├── DeleteNotes.kt │ │ ├── GetNotes.kt │ │ ├── UpdateNote.kt │ │ ├── UpdateNotePayload.kt │ │ ├── local │ │ │ ├── search │ │ │ │ └── SearchNotes.kt │ │ │ └── sort │ │ │ │ ├── SortDirection.kt │ │ │ │ ├── SortNotes.kt │ │ │ │ ├── SortProperty.kt │ │ │ │ └── SortType.kt │ │ ├── socket │ │ │ ├── ListenToSocketUpdates.kt │ │ │ └── NoteSocket.kt │ │ └── synchronization │ │ │ ├── SynchronizeAddedNotes.kt │ │ │ ├── SynchronizeDeletedNotes.kt │ │ │ ├── SynchronizeNotes.kt │ │ │ └── SynchronizeUpdatedNotes.kt │ │ ├── helpers │ │ ├── Do.kt │ │ ├── date │ │ │ ├── KlockUnixTimestampProvider.kt │ │ │ ├── NoteDateFormat.kt │ │ │ ├── NotesDatePatternStorageKey.kt │ │ │ ├── PatternProvider.kt │ │ │ ├── PatternSaver.kt │ │ │ ├── PatternStorage.kt │ │ │ └── UnixTimestampProvider.kt │ │ ├── storage │ │ │ └── Storage.kt │ │ └── validation │ │ │ ├── NoteEditorInputValidator.kt │ │ │ └── NoteInputValidator.kt │ │ ├── model │ │ ├── CreationTimestamp.kt │ │ ├── LastModificationTimestamp.kt │ │ └── Note.kt │ │ ├── network │ │ ├── ApiUrl.kt │ │ ├── NetworkResponse.kt │ │ ├── NoteApi.kt │ │ ├── NoteSchema.kt │ │ ├── NoteSchemaMapper.kt │ │ └── safeApiCall.kt │ │ └── tests │ │ ├── NoteApiTestFake.kt │ │ ├── NoteDaoTestFake.kt │ │ ├── NoteSocketFake.kt │ │ └── README.md │ ├── commonTest │ └── kotlin │ │ ├── ExtenstionFunctionHelpers.kt │ │ ├── feature │ │ ├── AddNoteTest.kt │ │ ├── DeleteNotesTest.kt │ │ ├── GetNotesTest.kt │ │ ├── UpdateNoteTest.kt │ │ ├── local │ │ │ ├── search │ │ │ │ └── SearchNotesTest.kt │ │ │ └── sort │ │ │ │ └── SortNotesTest.kt │ │ ├── socket │ │ │ └── ListenToSocketUpdatesTest.kt │ │ └── synchronization │ │ │ ├── SynchronizationTestHelpers.kt │ │ │ ├── SynchronizeAddedNotesTest.kt │ │ │ ├── SynchronizeDeletedNotesTest.kt │ │ │ ├── SynchronizeNotesMock.kt │ │ │ ├── SynchronizeNotesTest.kt │ │ │ └── SynchronizeUpdatedNotesTest.kt │ │ ├── helpers │ │ ├── date │ │ │ └── UnixTimestampProviderFake.kt │ │ └── validation │ │ │ └── NoteEditorInputValidatorTest.kt │ │ └── runTest.kt │ ├── jsMain │ └── kotlin │ │ ├── base │ │ └── CommonDispatchers.kt │ │ ├── database │ │ └── NoteEntity.kt │ │ └── network │ │ └── safeApiCall.kt │ └── jsTest │ └── kotlin │ └── runTest.kt └── system.properties /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build all platforms 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build-android: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: eskatos/gradle-command-action@v1 12 | with: 13 | gradle-version: 6.1.1 14 | arguments: :android:app:assemble -x lintVitalRelease 15 | 16 | build-ktor: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: eskatos/gradle-command-action@v1 22 | with: 23 | gradle-version: 6.1.1 24 | arguments: :ktor:build 25 | 26 | build-react: 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - uses: actions/checkout@v2 31 | - uses: eskatos/gradle-command-action@v1 32 | with: 33 | gradle-version: 6.1.1 34 | arguments: :react:spa-app:build 35 | -------------------------------------------------------------------------------- /.github/workflows/static_code_analysis.yml: -------------------------------------------------------------------------------- 1 | name: Static code analysis 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | ktlint: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: eskatos/gradle-command-action@v1 12 | with: 13 | gradle-version: 6.1.1 14 | arguments: ktlintCheck 15 | 16 | detekt: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: eskatos/gradle-command-action@v1 22 | with: 23 | gradle-version: 6.1.1 24 | arguments: detekt 25 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test-android: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: eskatos/gradle-command-action@v1 12 | with: 13 | gradle-version: 6.1.1 14 | arguments: :android:app:testDebugUnitTest 15 | 16 | test-shared: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: eskatos/gradle-command-action@v1 22 | with: 23 | gradle-version: 6.1.1 24 | arguments: :shared:allTests 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fuller stack 2 | ![](https://github.com/AKJAW/fuller-stack-kotlin-multiplatform/workflows/Build%20all%20platforms/badge.svg) 3 | ![](https://github.com/AKJAW/fuller-stack-kotlin-multiplatform/workflows/Static%20code%20analysis/badge.svg) 4 | ![](https://github.com/AKJAW/fuller-stack-kotlin-multiplatform/workflows/Tests/badge.svg) 5 | 6 | 7 | 8 | 9 | A simple note taking app for Android and the Web. Both client platforms share code using Kotlin Multiplatform and use 10 | a Ktor server. 11 | 12 | The project is part of my [article series](https://akjaw.com/kotlin-multiplatform-for-android-and-the-web-part-1/) about 13 | Kotlin Multiplatform for Android and the Web. 14 | 15 | ## The client platform architecture 16 | ![Apps architecture](assets/apps-architecture.png) 17 | 18 | ![Apps architecture](assets/data-layer-implementations.png) 19 | 20 | ## How to change the server ip to the local server 21 | The server endpoint can be changed in the [commonMain/network.ApiUrl](shared/src/commonMain/kotlin/network/ApiUrl.kt) 22 | file. 23 | 24 | ## Ktor server 25 | 26 | ![Apps architecture](assets/socket-update.png) 27 | 28 | The gradle command for running the ktor server: 29 | ``` 30 | $ ./gradlew :ktor:ktorRun 31 | ``` 32 | The server will run on port 9000. 33 | 34 | Test account email: test@test.com 35 | Test account password: Test123123 36 | 37 | ## Android app 38 | 39 | Apps architecture 40 | 41 | 42 | The gradle command for installing the android app: 43 | ``` 44 | $ ./gradlew :android:app:installDebug 45 | ``` 46 | 47 | ## React app 48 | 49 | ![Apps architecture](assets/react-home.png) 50 | 51 | The gradle command for running the react app: 52 | ``` 53 | $ ./gradlew :react:spa-app:reactRun 54 | ``` 55 | The app will run on port 8080 56 | -------------------------------------------------------------------------------- /android/app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /android/app/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.kts. 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 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/composition/modules/androidModule.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.composition.modules 2 | 3 | import android.content.Context 4 | import android.content.Context.MODE_PRIVATE 5 | import com.akjaw.fullerstack.helpers.storage.SharedPreferencesStorage 6 | import com.akjaw.fullerstack.helpers.storage.SharedPreferencesStorage.Companion.PREFERENCES_NAME 7 | import helpers.storage.Storage 8 | import org.kodein.di.DI 9 | import org.kodein.di.bind 10 | import org.kodein.di.instance 11 | import org.kodein.di.singleton 12 | 13 | val androidModule = DI.Module("androidModule") { 14 | bind() with singleton { 15 | val sharedPrefs = instance().getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE) 16 | SharedPreferencesStorage(sharedPrefs) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/composition/modules/databaseModule.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.composition.modules 2 | 3 | import com.akjaw.fullerstack.notes.database.AppDatabase 4 | import database.NoteDao 5 | import org.kodein.di.DI 6 | import org.kodein.di.bind 7 | import org.kodein.di.instance 8 | import org.kodein.di.singleton 9 | 10 | val databaseModule = DI.Module("databaseModule") { 11 | bind() with singleton { AppDatabase.create(instance("ApplicationContext")) } 12 | bind() with singleton { instance().noteDao() } 13 | } 14 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/composition/modules/navigatorModule.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.composition.modules 2 | 3 | import androidx.fragment.app.FragmentActivity 4 | import com.akjaw.fullerstack.screens.common.main.BottomNavigationHelper 5 | import com.akjaw.fullerstack.screens.common.navigation.DialogManager 6 | import com.akjaw.fullerstack.screens.common.navigation.FragmentDialogManager 7 | import com.akjaw.fullerstack.screens.common.navigation.ScreenNavigator 8 | import com.akjaw.fullerstack.screens.common.navigation.SimpleStackScreenNavigator 9 | import org.kodein.di.DI 10 | import org.kodein.di.bind 11 | import org.kodein.di.instance 12 | import org.kodein.di.singleton 13 | 14 | val navigatorModule = DI.Module("navigatorModule") { 15 | bind() with singleton { SimpleStackScreenNavigator(instance()) } 16 | bind() with singleton { 17 | val fragmentActivity = instance() 18 | FragmentDialogManager(fragmentActivity.supportFragmentManager) 19 | } 20 | bind() with singleton { BottomNavigationHelper(instance()) } 21 | } 22 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/composition/modules/networkModule.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.composition.modules 2 | 3 | import com.akjaw.fullerstack.notes.network.AuthenticationInterceptor 4 | import com.akjaw.fullerstack.notes.network.NoteService 5 | import com.akjaw.fullerstack.notes.network.RetrofitNoteApi 6 | import com.akjaw.fullerstack.notes.socket.SessionCookieJar 7 | import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory 8 | import kotlinx.serialization.json.Json 9 | import network.ApiUrl 10 | import network.NoteApi 11 | import okhttp3.MediaType.Companion.toMediaType 12 | import okhttp3.OkHttpClient 13 | import okhttp3.logging.HttpLoggingInterceptor 14 | import org.kodein.di.DI 15 | import org.kodein.di.bind 16 | import org.kodein.di.instance 17 | import org.kodein.di.singleton 18 | import retrofit2.Retrofit 19 | 20 | val networkModule = DI.Module("networkModule") { 21 | bind() from singleton { AuthenticationInterceptor(instance()) } 22 | bind() from singleton { SessionCookieJar() } 23 | bind() from singleton { 24 | OkHttpClient.Builder() 25 | .addInterceptor(instance()) 26 | .addInterceptor(HttpLoggingInterceptor().apply { setLevel(HttpLoggingInterceptor.Level.BODY) }) 27 | .cookieJar(instance()) 28 | .build() 29 | } 30 | bind() with singleton { 31 | val contentType = "application/json".toMediaType() 32 | Retrofit.Builder() 33 | .baseUrl(ApiUrl.BASE_URL_WITH_PROTOCOL) 34 | .addConverterFactory(Json.asConverterFactory(contentType)) 35 | .client(instance()) 36 | .build() 37 | .create(NoteService::class.java) 38 | } 39 | bind() with singleton { RetrofitNoteApi(instance()) } 40 | } 41 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/composition/modules/socketModule.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.composition.modules 2 | 3 | import com.akjaw.fullerstack.notes.socket.SocketWrapper 4 | import network.ApiUrl 5 | import okhttp3.Request 6 | import org.kodein.di.DI 7 | import org.kodein.di.bind 8 | import org.kodein.di.instance 9 | import org.kodein.di.singleton 10 | 11 | val socketModule = DI.Module("socketModule") { 12 | bind("socketRequest") from singleton { 13 | Request.Builder() 14 | .url(ApiUrl.SOCKET_URL_WITH_PROTOCOL) 15 | .build() 16 | } 17 | bind() from singleton { SocketWrapper(instance(), instance(), instance("socketRequest")) } 18 | } 19 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/helpers/logger/HyperlinkedDebugTree.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.helpers.logger 2 | 3 | import timber.log.Timber 4 | 5 | // Credit https://proandroiddev.com/android-logging-on-steroids-clickable-logs-with-location-info-de1a5c16e86f 6 | class HyperlinkedDebugTree : Timber.DebugTree() { 7 | override fun createStackElementTag(element: StackTraceElement): String? { 8 | with(element) { 9 | return "($fileName:$lineNumber)$methodName()" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/helpers/logger/LoggerExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.helpers.logger 2 | 3 | import timber.log.Timber 4 | 5 | inline fun Any?.log(prefix: String = "object:") = Timber.d("$prefix: ${toString()}") 6 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/helpers/network/NetworkResponse.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.helpers.network 2 | 3 | sealed class NetworkResponse { 4 | class Success (val result: T) : NetworkResponse() 5 | object ApiError : NetworkResponse() 6 | object NetworkError : NetworkResponse() 7 | object UnknownError : NetworkResponse() 8 | } 9 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/helpers/storage/SharedPreferencesStorage.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.helpers.storage 2 | 3 | import android.content.SharedPreferences 4 | import helpers.storage.Storage 5 | 6 | class SharedPreferencesStorage(private val sharedPreferences: SharedPreferences) : Storage { 7 | 8 | companion object { 9 | const val PREFERENCES_NAME = "Storage" 10 | } 11 | 12 | override fun getString(key: String): String? = sharedPreferences.getString(key, null) 13 | 14 | override fun setString(key: String, value: String) { 15 | sharedPreferences.edit().putString(key, value).apply() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/notes/database/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.notes.database 2 | 3 | import android.content.Context 4 | import androidx.room.Database 5 | import androidx.room.Room 6 | import androidx.room.RoomDatabase 7 | import androidx.room.TypeConverters 8 | import androidx.room.migration.Migration 9 | import androidx.sqlite.db.SupportSQLiteDatabase 10 | import database.NoteEntity 11 | 12 | @Suppress("MagicNumber") 13 | @Database(entities = [NoteEntity::class], version = 4, exportSchema = false) 14 | @TypeConverters(TimestampConverter::class) 15 | abstract class AppDatabase : RoomDatabase() { 16 | companion object { 17 | fun create(applicationContext: Context): AppDatabase { 18 | return Room.databaseBuilder( 19 | applicationContext, 20 | AppDatabase::class.java, 21 | "fuller-stack" 22 | ).addMigrations(object : Migration(2, 3) { 23 | override fun migrate(database: SupportSQLiteDatabase) { 24 | database.execSQL("ALTER TABLE notes ADD wasDeleted INTEGER DEFAULT 0 NOT NULL") 25 | } 26 | }).build() 27 | } 28 | } 29 | 30 | abstract fun noteDao(): RoomNoteDao 31 | } 32 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/notes/database/TimestampConverter.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.notes.database 2 | 3 | import androidx.room.TypeConverter 4 | import model.CreationTimestamp 5 | import model.LastModificationTimestamp 6 | import model.toCreationTimestamp 7 | import model.toLastModificationTimestamp 8 | 9 | class TimestampConverter { 10 | 11 | @TypeConverter 12 | fun fromCreationTimestamp(value: Long): CreationTimestamp = value.toCreationTimestamp() 13 | 14 | @TypeConverter 15 | fun toCreationTimestamp(value: CreationTimestamp): Long = value.unix 16 | 17 | @TypeConverter 18 | fun fromLastModificationTimestamp(value: Long): LastModificationTimestamp = value.toLastModificationTimestamp() 19 | 20 | @TypeConverter 21 | fun toLastModificationTimestamp(value: LastModificationTimestamp): Long = value.unix 22 | } 23 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/notes/network/AuthenticationInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.notes.network 2 | 3 | import com.akjaw.fullerstack.authentication.token.TokenProvider 4 | import okhttp3.Interceptor 5 | import okhttp3.Response 6 | 7 | class AuthenticationInterceptor( 8 | private val tokenProvider: TokenProvider 9 | ) : Interceptor { 10 | override fun intercept(chain: Interceptor.Chain): Response { 11 | val requestBuilder = chain.request().newBuilder() 12 | val token = tokenProvider.getToken()?.jwt ?: "ERROR" 13 | requestBuilder.addHeader("Authorization", "Bearer $token") 14 | 15 | return chain.proceed(requestBuilder.build()) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/notes/network/NoteService.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.notes.network 2 | 3 | import feature.AddNotePayload 4 | import feature.DeleteNotePayload 5 | import feature.UpdateNotePayload 6 | import network.NoteSchema 7 | import retrofit2.http.Body 8 | import retrofit2.http.GET 9 | import retrofit2.http.HTTP 10 | import retrofit2.http.PATCH 11 | import retrofit2.http.POST 12 | 13 | interface NoteService { 14 | 15 | @GET("notes") 16 | suspend fun getNotes(): List 17 | 18 | @POST("notes") 19 | suspend fun addNote(@Body addNotePayload: AddNotePayload): Int 20 | 21 | @PATCH("notes") 22 | suspend fun updateNote(@Body updateNoteRequest: UpdateNotePayload) 23 | 24 | @HTTP(method = "DELETE", path = "notes", hasBody = true) 25 | suspend fun deleteNotes(@Body deleteNotePayloads: List) 26 | } 27 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/notes/socket/SessionCookieJar.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.notes.socket 2 | 3 | import com.akjaw.fullerstack.helpers.logger.log 4 | import okhttp3.Cookie 5 | import okhttp3.CookieJar 6 | import okhttp3.HttpUrl 7 | import java.util.* 8 | 9 | 10 | class SessionCookieJar : CookieJar { 11 | private var cookies: List? = null 12 | 13 | override fun saveFromResponse(url: HttpUrl, cookies: List) { 14 | url.log("Cookies url") 15 | cookies.log("Cookies cookies") 16 | val sessionCookie = cookies.firstOrNull { cookie -> 17 | cookie.name.toLowerCase(Locale.getDefault()) == "session" 18 | } 19 | if (sessionCookie != null) { 20 | "We got the cookie".log() 21 | this.cookies = listOf(sessionCookie) 22 | } 23 | } 24 | 25 | override fun loadForRequest(url: HttpUrl): List { 26 | return cookies ?: emptyList() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/notes/socket/SocketListener.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.notes.socket 2 | 3 | import com.akjaw.fullerstack.helpers.logger.log 4 | import okhttp3.Response 5 | import okhttp3.WebSocket 6 | import okhttp3.WebSocketListener 7 | import okio.ByteString 8 | 9 | class SocketListener( 10 | private val onData: (String) -> Unit, 11 | private val onError: (Throwable) -> Unit 12 | ) : WebSocketListener() { 13 | 14 | override fun onMessage(webSocket: WebSocket, text: String) { 15 | super.onMessage(webSocket, text) 16 | text.log("Socket string") 17 | onData(text) 18 | } 19 | 20 | override fun onMessage(webSocket: WebSocket, bytes: ByteString) { 21 | super.onMessage(webSocket, bytes) 22 | bytes.log("Socket bytes") 23 | } 24 | 25 | override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { 26 | super.onFailure(webSocket, t, response) 27 | t.log("Socket ERR") 28 | response.log("Socket ERR") 29 | onError(t) 30 | } 31 | 32 | override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { 33 | super.onClosed(webSocket, code, reason) 34 | code.log("Socket closed") 35 | reason.log("Socket closed") 36 | } 37 | 38 | override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { 39 | super.onClosing(webSocket, code, reason) 40 | code.log("Socket closing") 41 | reason.log("Socket closing") 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/notes/socket/SocketWrapper.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.notes.socket 2 | 3 | import com.akjaw.fullerstack.authentication.token.TokenProvider 4 | import feature.socket.NoteSocket 5 | import kotlinx.coroutines.cancel 6 | import kotlinx.coroutines.channels.awaitClose 7 | import kotlinx.coroutines.channels.sendBlocking 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.callbackFlow 10 | import kotlinx.serialization.builtins.ListSerializer 11 | import kotlinx.serialization.json.Json 12 | import network.NoteSchema 13 | import okhttp3.OkHttpClient 14 | import okhttp3.Request 15 | import okhttp3.WebSocket 16 | 17 | class SocketWrapper( 18 | private val okHttpClient: OkHttpClient, 19 | private val tokenProvider: TokenProvider, 20 | private val socketRequest: Request 21 | ) : NoteSocket { 22 | 23 | companion object { 24 | private const val NORMAL_CLOSE = 1000 25 | } 26 | 27 | private var socket: WebSocket? = null 28 | private var flow: Flow>? = null 29 | 30 | override fun close() { 31 | flow = null 32 | socket?.close(NORMAL_CLOSE, null) 33 | socket = null 34 | } 35 | 36 | override fun getNotesFlow(): Flow> { 37 | return flow ?: connect() 38 | } 39 | 40 | private fun connect(): Flow> = callbackFlow { 41 | val listener = SocketListener( 42 | onData = { json -> 43 | val noteSchema = Json.decodeFromString( 44 | ListSerializer(NoteSchema.serializer()), 45 | json 46 | ) 47 | sendBlocking(noteSchema) 48 | }, 49 | onError = { 50 | //TOOD find out what error is thrown, then decide 51 | cancel() 52 | } 53 | ) 54 | socket = okHttpClient.newWebSocket(socketRequest, listener) 55 | socket?.send("Bearer ${tokenProvider.getToken()?.jwt}") 56 | awaitClose { socket?.cancel() } 57 | }.apply { 58 | flow = this 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/LiveEvent.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import androidx.lifecycle.LifecycleObserver 5 | import androidx.lifecycle.LifecycleOwner 6 | import androidx.lifecycle.OnLifecycleEvent 7 | 8 | // TODO test memory leaks 9 | class LiveEvent { 10 | private val observers = mutableMapOf Unit>() 11 | 12 | fun observe(owner: LifecycleOwner, onChanged: (T) -> Unit) { 13 | if (owner.lifecycle.currentState == Lifecycle.State.DESTROYED) { 14 | return 15 | } 16 | LiveEventLifecycleObserver(owner) 17 | observers[owner] = onChanged 18 | } 19 | 20 | fun postValue(data: T) { 21 | observers.keys.forEach { owner -> 22 | val lifecycle = owner.lifecycle 23 | if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { 24 | observers[owner]?.invoke(data) 25 | } 26 | } 27 | } 28 | 29 | private inner class LiveEventLifecycleObserver(private val owner: LifecycleOwner) : LifecycleObserver { 30 | init { 31 | owner.lifecycle.addObserver(this) 32 | } 33 | 34 | @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) 35 | fun onDestroy() { 36 | observers.remove(owner) 37 | owner.lifecycle.removeObserver(this) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/MainActivityAfterAuthenticationLauncher.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import com.akjaw.fullerstack.authentication.navigation.AfterAuthenticationLauncher 6 | import com.akjaw.fullerstack.screens.common.main.MainActivity 7 | 8 | class MainActivityAfterAuthenticationLauncher: AfterAuthenticationLauncher { 9 | 10 | override fun launch(activity: Activity) { 11 | val intent = Intent(activity, MainActivity::class.java) 12 | activity.startActivity(intent) 13 | activity.finish() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/ParcelableNote.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common 2 | 3 | import android.os.Parcelable 4 | import kotlinx.android.parcel.Parcelize 5 | import model.Note 6 | 7 | @Parcelize 8 | data class ParcelableNote( 9 | val creationUnixTimestamp: Long, 10 | val title: String, 11 | val content: String 12 | ) : Parcelable 13 | 14 | fun Note.toParcelable(): ParcelableNote { 15 | return ParcelableNote( 16 | creationUnixTimestamp = this.creationTimestamp.unix, 17 | title = this.title, 18 | content = this.content 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/ViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import org.kodein.di.DirectDI 6 | import org.kodein.di.instanceOrNull 7 | 8 | class ViewModelFactory(private val di: DirectDI) : ViewModelProvider.Factory { 9 | 10 | override fun create(modelClass: Class): T { 11 | return di.instanceOrNull(tag = modelClass.simpleName) as T? 12 | ?: modelClass.newInstance() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/base/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.base 2 | 3 | import androidx.appcompat.app.AppCompatActivity 4 | import com.akjaw.framework.composition.lifecycleModule 5 | import com.akjaw.fullerstack.authentication.composition.activityScopedAuthenticationModule 6 | import org.kodein.di.DI 7 | import org.kodein.di.DIAware 8 | 9 | abstract class BaseActivity : AppCompatActivity(), DIAware { 10 | 11 | override val di: DI by DI.lazy { 12 | val customApplication = application as DIAware 13 | extend(customApplication.di) 14 | import(lifecycleModule()) 15 | import(activityScopedAuthenticationModule) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/base/BaseDialogFragment.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.base 2 | 3 | import androidx.fragment.app.DialogFragment 4 | import org.kodein.di.DI 5 | import org.kodein.di.DIAware 6 | import org.kodein.di.android.x.di 7 | 8 | abstract class BaseDialogFragment : DialogFragment(), DIAware { 9 | override val di: DI by di() 10 | } 11 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/base/BaseFragment.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.base 2 | 3 | import android.app.Activity 4 | import android.view.inputmethod.InputMethodManager 5 | import androidx.annotation.LayoutRes 6 | import com.akjaw.framework.utility.KeyboardCloser 7 | import com.zhuinden.simplestackextensions.fragments.KeyedFragment 8 | import org.kodein.di.DI 9 | import org.kodein.di.DIAware 10 | import org.kodein.di.android.x.di 11 | import org.kodein.di.instance 12 | 13 | abstract class BaseFragment : KeyedFragment, DIAware { 14 | constructor() : super() 15 | constructor(@LayoutRes layoutRes: Int) : super(layoutRes) 16 | 17 | override val di: DI by di() 18 | protected val keyboardCloser: KeyboardCloser by instance() 19 | 20 | fun hideKeyboard() { 21 | keyboardCloser.close() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/composition/mainActivityModule.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.composition 2 | 3 | import com.akjaw.fullerstack.composition.modules.navigatorModule 4 | import com.akjaw.fullerstack.composition.modules.presentationModule 5 | import org.kodein.di.DI 6 | 7 | val mainActivityModule = DI.Module("mainActivityModule") { 8 | import(navigatorModule) 9 | import(presentationModule) 10 | } 11 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/main/BottomNavigationHelper.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.main 2 | 3 | import android.view.MenuItem 4 | import androidx.fragment.app.FragmentManager 5 | import com.akjaw.fullerstack.android.R 6 | import com.akjaw.fullerstack.screens.common.navigation.MultiStack 7 | import com.akjaw.fullerstack.screens.common.navigation.MultiStackFragmentStateChanger 8 | import com.akjaw.fullerstack.screens.common.navigation.keys.MultiStackFragmentKey 9 | import com.google.android.material.bottomnavigation.BottomNavigationView 10 | 11 | class BottomNavigationHelper( 12 | private val multiStack: MultiStack 13 | ) { 14 | private var view: BottomNavigationView? = null 15 | 16 | fun initialize(fragmentManager: FragmentManager, view: BottomNavigationView) { 17 | this.view = view 18 | multiStack.setStateChanger(MultiStackFragmentStateChanger(fragmentManager, R.id.fragment_placeholder)) 19 | view.setOnNavigationItemSelectedListener(::handleNavigationItemSelected) 20 | } 21 | 22 | private fun handleNavigationItemSelected(item: MenuItem): Boolean { 23 | return when(item.itemId) { 24 | R.id.home -> { 25 | multiStack.setSelectedStack(MultiStackFragmentKey.RootFragments.HOME.name) 26 | true 27 | } 28 | R.id.profile -> { 29 | multiStack.setSelectedStack(MultiStackFragmentKey.RootFragments.PROFILE.name) 30 | true 31 | } 32 | R.id.settings -> { 33 | multiStack.setSelectedStack(MultiStackFragmentKey.RootFragments.SETTINGS.name) 34 | true 35 | } 36 | else -> false 37 | } 38 | } 39 | 40 | fun destroy() { 41 | view = null 42 | } 43 | 44 | fun show() { 45 | view?.animate()?.translationY(0f) 46 | } 47 | 48 | fun hide() { 49 | val height = view?.height ?: 0 50 | view?.animate()?.translationY(height.toFloat()) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/main/MainActivityViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.main 2 | 3 | import androidx.lifecycle.ViewModel 4 | import feature.socket.ListenToSocketUpdates 5 | 6 | class MainActivityViewModel( 7 | private val listenToSocketUpdates: ListenToSocketUpdates 8 | ) : ViewModel() { 9 | 10 | fun startNotesSocket() { 11 | listenToSocketUpdates.listenToSocketChanges() 12 | } 13 | 14 | override fun onCleared() { 15 | super.onCleared() 16 | listenToSocketUpdates.close() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/navigation/DialogManager.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.navigation 2 | 3 | import model.CreationTimestamp 4 | 5 | interface DialogManager { 6 | 7 | fun showDeleteNotesConfirmDialog( 8 | noteIdentifiers: List, 9 | onNotesDeleted: () -> Unit 10 | ) 11 | 12 | fun showSortDialog() 13 | } 14 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/navigation/FragmentDialogManager.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.navigation 2 | 3 | import androidx.fragment.app.FragmentManager 4 | import com.akjaw.fullerstack.screens.list.dialog.DeleteNotesConfirmDialog 5 | import com.akjaw.fullerstack.screens.list.dialog.SortDialog 6 | import model.CreationTimestamp 7 | 8 | class FragmentDialogManager( 9 | private val fragmentManager: FragmentManager 10 | ) : DialogManager { 11 | 12 | override fun showDeleteNotesConfirmDialog(noteIdentifiers: List, onNotesDeleted: () -> Unit) { 13 | val dialog = DeleteNotesConfirmDialog.newInstance(noteIdentifiers, onNotesDeleted) 14 | dialog.show(fragmentManager, "DeleteNotes") 15 | } 16 | 17 | override fun showSortDialog() { 18 | val dialog = SortDialog.newInstance() 19 | dialog.show(fragmentManager, "SortDialog") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/navigation/ScreenNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.navigation 2 | 3 | import com.akjaw.fullerstack.screens.common.ParcelableNote 4 | 5 | interface ScreenNavigator { 6 | 7 | fun openAddNoteScreen() 8 | 9 | fun goBack() 10 | 11 | fun openEditNoteScreen(note: ParcelableNote) 12 | } 13 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/navigation/SimpleStackScreenNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.navigation 2 | 3 | import com.akjaw.fullerstack.screens.common.ParcelableNote 4 | import com.akjaw.fullerstack.screens.common.navigation.keys.NoteEditorScreen 5 | 6 | class SimpleStackScreenNavigator( 7 | private val multiStack: MultiStack 8 | ) : ScreenNavigator { 9 | 10 | override fun openAddNoteScreen() { 11 | multiStack.getSelectedStack().goTo(NoteEditorScreen()) 12 | } 13 | 14 | override fun goBack() { 15 | multiStack.getSelectedStack().goBack() 16 | } 17 | 18 | override fun openEditNoteScreen(note: ParcelableNote) { 19 | multiStack.getSelectedStack().goTo(NoteEditorScreen(note)) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/navigation/keys/MultiStackFragmentKey.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.navigation.keys 2 | 3 | import android.os.Bundle 4 | import android.os.Parcelable 5 | import androidx.fragment.app.Fragment 6 | 7 | abstract class MultiStackFragmentKey : Parcelable { 8 | 9 | enum class RootFragments { 10 | HOME, 11 | PROFILE, 12 | SETTINGS 13 | } 14 | 15 | val fragmentTag 16 | get() = "${getKeyIdentifier()}_${toString()}" 17 | 18 | fun newFragment(): Fragment = instantiateFragment().apply { 19 | arguments = (arguments ?: Bundle()).also { bundle -> 20 | bundle.putParcelable("FRAGMENT_KEY", this@MultiStackFragmentKey) 21 | } 22 | } 23 | 24 | abstract fun getKeyIdentifier(): String 25 | 26 | abstract fun instantiateFragment(): Fragment 27 | } 28 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/navigation/keys/NoteEditorScreen.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.navigation.keys 2 | 3 | import com.akjaw.fullerstack.screens.common.ParcelableNote 4 | import com.akjaw.fullerstack.screens.common.base.BaseFragment 5 | import com.akjaw.fullerstack.screens.editor.NoteEditorFragment 6 | import kotlinx.android.parcel.Parcelize 7 | 8 | @Parcelize 9 | class NoteEditorScreen(private val note: ParcelableNote? = null) : MultiStackFragmentKey() { 10 | 11 | override fun getKeyIdentifier(): String = RootFragments.HOME.name 12 | 13 | override fun instantiateFragment(): BaseFragment = NoteEditorFragment.newInstance(note) 14 | } 15 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/navigation/keys/NotesListScreen.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.navigation.keys 2 | 3 | import com.akjaw.fullerstack.screens.common.base.BaseFragment 4 | import com.akjaw.fullerstack.screens.list.NotesListFragment 5 | import kotlinx.android.parcel.Parcelize 6 | 7 | @Parcelize 8 | class NotesListScreen : MultiStackFragmentKey() { 9 | 10 | override fun getKeyIdentifier(): String = RootFragments.HOME.name 11 | 12 | override fun instantiateFragment(): BaseFragment = NotesListFragment() 13 | } 14 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/navigation/keys/ProfileScreen.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.navigation.keys 2 | 3 | import com.akjaw.fullerstack.screens.common.base.BaseFragment 4 | import com.akjaw.fullerstack.screens.profile.ProfileFragment 5 | import kotlinx.android.parcel.Parcelize 6 | 7 | @Parcelize 8 | class ProfileScreen : MultiStackFragmentKey() { 9 | 10 | override fun getKeyIdentifier(): String = RootFragments.PROFILE.name 11 | 12 | override fun instantiateFragment(): BaseFragment = ProfileFragment() 13 | } 14 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/navigation/keys/SettingsScreen.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.navigation.keys 2 | 3 | import androidx.fragment.app.Fragment 4 | import com.akjaw.fullerstack.screens.settings.SettingsFragment 5 | import kotlinx.android.parcel.Parcelize 6 | 7 | @Parcelize 8 | class SettingsScreen : MultiStackFragmentKey() { 9 | 10 | override fun getKeyIdentifier(): String = RootFragments.SETTINGS.name 11 | 12 | override fun instantiateFragment(): Fragment = SettingsFragment() 13 | } 14 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/common/recyclerview/SpacingItemDecoration.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.common.recyclerview 2 | 3 | import android.graphics.Rect 4 | import android.view.View 5 | import androidx.recyclerview.widget.RecyclerView 6 | 7 | class SpacingItemDecoration(private val spacing: Int) : RecyclerView.ItemDecoration() { 8 | override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { 9 | outRect.apply { 10 | if (parent.getChildAdapterPosition(view) == 0) { 11 | top = spacing 12 | } 13 | right = spacing 14 | bottom = spacing 15 | left = spacing 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/list/recyclerview/NotesDiffCallback.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.list.recyclerview 2 | 3 | import androidx.recyclerview.widget.DiffUtil 4 | import model.Note 5 | 6 | class NotesDiffCallback( 7 | private val oldList: List, 8 | private val newList: List 9 | ) : DiffUtil.Callback() { 10 | 11 | override fun getOldListSize(): Int = oldList.count() 12 | 13 | override fun getNewListSize(): Int = newList.count() 14 | 15 | override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { 16 | return oldList[oldItemPosition].creationTimestamp == newList[newItemPosition].creationTimestamp 17 | } 18 | 19 | override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { 20 | return oldList[oldItemPosition] == newList[newItemPosition] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/list/recyclerview/NotesListAdapterFactory.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.list.recyclerview 2 | 3 | import model.Note 4 | import model.toCreationTimestamp 5 | 6 | class NotesListAdapterFactory( 7 | private val notesSelectionTrackerFactory: NotesSelectionTrackerFactory, 8 | ) { 9 | fun create( 10 | initialSelectedNotes: List?, 11 | onItemClicked: (Note) -> Unit, 12 | ): NotesListAdapter { 13 | val selectedNotes = initialSelectedNotes?.map { it.toCreationTimestamp() } ?: emptyList() 14 | 15 | return NotesListAdapter( 16 | initialSelectedNotes = selectedNotes, 17 | notesSelectionTrackerFactory = notesSelectionTrackerFactory, 18 | onItemClicked = onItemClicked, 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/list/recyclerview/NotesSelectionTrackerFactory.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.list.recyclerview 2 | 3 | import com.akjaw.fullerstack.screens.common.navigation.DialogManager 4 | import com.akjaw.fullerstack.screens.list.recyclerview.selection.NotesListActionMode 5 | import model.CreationTimestamp 6 | 7 | class NotesSelectionTrackerFactory( 8 | private val dialogManager: DialogManager, 9 | private val notesListActionMode: NotesListActionMode 10 | ) { 11 | fun create( 12 | initialSelectedNotes: List, 13 | onNoteChanged: (List) -> Unit 14 | ): NotesSelectionTracker { 15 | return NotesSelectionTracker( 16 | initialSelectedNotes = initialSelectedNotes, 17 | dialogManager = dialogManager, 18 | notesListActionMode = notesListActionMode, 19 | onNoteChanged = onNoteChanged 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/profile/ProfileViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.profile 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.liveData 6 | import androidx.lifecycle.viewModelScope 7 | import com.akjaw.fullerstack.authentication.AuthenticationLauncher 8 | import com.akjaw.fullerstack.authentication.GetUserProfile 9 | import com.akjaw.fullerstack.authentication.UserAuthenticator 10 | import com.akjaw.fullerstack.authentication.model.UserProfile 11 | import kotlinx.coroutines.launch 12 | 13 | class ProfileViewModel( 14 | private val userAuthenticationManager: UserAuthenticator, 15 | private val authenticationLauncher: AuthenticationLauncher, 16 | private val getUserProfile: GetUserProfile 17 | ) : ViewModel() { 18 | 19 | val userProfile: LiveData = liveData(viewModelScope.coroutineContext) { 20 | val profile = getUserProfile() ?: UserProfile() 21 | this.emit(profile) 22 | } 23 | 24 | fun signOut() = viewModelScope.launch{ 25 | userAuthenticationManager.signOutUser() 26 | authenticationLauncher.showAuthenticationScreen() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/akjaw/fullerstack/screens/splash/SplashActivity.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.screens.splash 2 | 3 | import android.os.Bundle 4 | import androidx.lifecycle.lifecycleScope 5 | import com.akjaw.fullerstack.android.R 6 | import com.akjaw.fullerstack.authentication.AuthenticationLauncher 7 | import com.akjaw.fullerstack.authentication.UserAuthenticator 8 | import com.akjaw.fullerstack.authentication.navigation.AfterAuthenticationLauncher 9 | import com.akjaw.fullerstack.authentication.token.TokenProvider 10 | import com.akjaw.fullerstack.screens.common.base.BaseActivity 11 | import org.kodein.di.instance 12 | 13 | class SplashActivity : BaseActivity() { 14 | 15 | private val userAuthenticator: UserAuthenticator by instance() 16 | private val authenticationLauncher: AuthenticationLauncher by instance() 17 | private val tokenProvider: TokenProvider by instance() 18 | private val afterAuthenticationLauncher: AfterAuthenticationLauncher by instance() 19 | 20 | override fun onCreate(savedInstanceState: Bundle?) { 21 | setTheme(R.style.AppTheme_Splash) 22 | super.onCreate(savedInstanceState) 23 | 24 | if (userAuthenticator.isUserAuthenticated()) { 25 | lifecycleScope.launchWhenResumed { 26 | tokenProvider.initializeToken() 27 | afterAuthenticationLauncher.launch(this@SplashActivity) 28 | finish() 29 | } 30 | } else { 31 | authenticationLauncher.showAuthenticationScreen() 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /android/app/src/main/res/color/bottom_navigation_selector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 9 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night/ic_add_24dp.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night/ic_cached_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night/ic_close_24dp.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night/ic_delete_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night/ic_home_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night/ic_person_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night/ic_search_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night/ic_settings_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night/ic_sort_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/android/app/src/main/res/drawable-night/placeholder.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_add_24dp.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_cached_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_close_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_delete_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_home_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_person_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_search_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_settings_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_sort_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/android/app/src/main/res/drawable/placeholder.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/splash_screen.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /android/app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 19 | 20 | 31 | 32 | -------------------------------------------------------------------------------- /android/app/src/main/res/layout/layout_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/layout/toolbar.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /android/app/src/main/res/menu/bottom_navigation_menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 15 | 21 | -------------------------------------------------------------------------------- /android/app/src/main/res/menu/note_editor_add.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/res/menu/note_editor_update.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/res/menu/note_list_selection.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #979A9C 4 | #101b22 5 | #F8BBD0 6 | #919191 7 | 8 | #636363 9 | #FFFFFF 10 | 11 | #587287 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-v23/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8dp 4 | 0dp 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #BBDEFB 4 | #8AACC8 5 | #F8BBD0 6 | 7 | #DFDFDF 8 | #587287 9 | 10 | #919191 11 | #3A3A3A 12 | 13 | @color/colorPrimary 14 | 15 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | NOTES_DATE_PATTERN 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4dp 4 | 2dp 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 20 | 21 | 25 | 26 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 15 | 16 | 21 | 22 | 25 | 26 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/settings_screen.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /android/app/src/test/java/com/akjaw/fullerstack/InstantExecutorExtension.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack 2 | 3 | import androidx.arch.core.executor.ArchTaskExecutor 4 | import androidx.arch.core.executor.TaskExecutor 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.test.resetMain 7 | import kotlinx.coroutines.test.setMain 8 | import org.junit.jupiter.api.extension.AfterEachCallback 9 | import org.junit.jupiter.api.extension.BeforeEachCallback 10 | import org.junit.jupiter.api.extension.ExtensionContext 11 | 12 | class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback { 13 | 14 | override fun beforeEach(context: ExtensionContext?) { 15 | Dispatchers.setMain(Dispatchers.Unconfined) 16 | ArchTaskExecutor.getInstance() 17 | .setDelegate(object : TaskExecutor() { 18 | override fun executeOnDiskIO(runnable: Runnable) = runnable.run() 19 | 20 | override fun postToMainThread(runnable: Runnable) = runnable.run() 21 | 22 | override fun isMainThread(): Boolean = true 23 | }) 24 | } 25 | 26 | override fun afterEach(context: ExtensionContext?) { 27 | Dispatchers.resetMain() 28 | ArchTaskExecutor.getInstance().setDelegate(null) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /android/app/src/test/java/com/akjaw/fullerstack/LiveDataTestUtil.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.Observer 5 | import java.util.concurrent.CountDownLatch 6 | import java.util.concurrent.TimeUnit 7 | import java.util.concurrent.TimeoutException 8 | 9 | /* Copyright 2019 Google LLC. 10 | SPDX-License-Identifier: Apache-2.0 */ 11 | fun LiveData.getOrAwaitValue( 12 | time: Long = 2, 13 | timeUnit: TimeUnit = TimeUnit.SECONDS 14 | ): T { 15 | var data: T? = null 16 | val latch = CountDownLatch(1) 17 | val observer = object : Observer { 18 | override fun onChanged(o: T?) { 19 | data = o 20 | latch.countDown() 21 | this@getOrAwaitValue.removeObserver(this) 22 | } 23 | } 24 | 25 | this.observeForever(observer) 26 | 27 | // Don't wait indefinitely if the LiveData is not set. 28 | if (!latch.await(time, timeUnit)) { 29 | throw TimeoutException("LiveData value was never set.") 30 | } 31 | 32 | @Suppress("UNCHECKED_CAST") 33 | return data as T 34 | } 35 | 36 | /* 37 | Copyright (C) 2019 The Android Open Source Project 38 | */ 39 | fun LiveData.observeForTesting(block: () -> Unit) { 40 | val observer = Observer { } 41 | try { 42 | observeForever(observer) 43 | block() 44 | } finally { 45 | removeObserver(observer) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /android/app/src/test/java/com/akjaw/fullerstack/LiveEventTestUtil.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import androidx.lifecycle.LifecycleObserver 5 | import androidx.lifecycle.LifecycleOwner 6 | import com.akjaw.fullerstack.screens.common.LiveEvent 7 | 8 | fun LiveEvent.testObserve(): () -> Boolean { 9 | var wasCalled = false 10 | 11 | val owner = LifecycleOwner { 12 | object : Lifecycle() { 13 | override fun addObserver(observer: LifecycleObserver) {} 14 | override fun removeObserver(observer: LifecycleObserver) {} 15 | override fun getCurrentState(): State = State.STARTED 16 | } 17 | } 18 | this.observe(owner) { wasCalled = true } 19 | 20 | return { wasCalled } 21 | } 22 | -------------------------------------------------------------------------------- /android/authentication/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /android/authentication/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | id("kotlin-android") 4 | id("kotlin-kapt") 5 | id("kotlin-android-extensions") 6 | id("de.mannodermaus.android-junit5") 7 | } 8 | 9 | android { 10 | defaultConfig { 11 | minSdkVersion(Versions.MIN_SDK_VERSION) 12 | targetSdkVersion(Versions.TARGET_SDK_VERSION) 13 | } 14 | compileSdkVersion(Versions.COMPILE_SDK_VERSION) 15 | compileOptions { 16 | sourceCompatibility = JavaVersion.VERSION_1_8 17 | targetCompatibility = JavaVersion.VERSION_1_8 18 | } 19 | defaultConfig { 20 | manifestPlaceholders = mapOf( 21 | "auth0Domain" to "@string/com_auth0_domain", 22 | "auth0Scheme" to "@string/com_auth0_schema" 23 | ) 24 | } 25 | kotlinOptions { 26 | jvmTarget = JavaVersion.VERSION_1_8.toString() 27 | } 28 | } 29 | 30 | dependencies { 31 | implementation(project(":shared")) 32 | implementation(project(":android:framework")) 33 | 34 | implementation(AndroidLibs.KOTLIN_JDK) 35 | 36 | implementation(AndroidLibs.AUTH0) 37 | 38 | implementation(AndroidLibs.APP_COMPAT) 39 | implementation(AndroidLibs.MATERIAL) 40 | implementation(AndroidLibs.CONSTRAINT_LAYOUT) 41 | implementation(AndroidLibs.LIFECYCLE_RUNTIME_KTX) 42 | implementation(AndroidLibs.LIFECYCLE_EXTENSTIONS) 43 | 44 | implementation(SharedLibs.COROUTINES_CORE) 45 | 46 | implementation(SharedLibs.KODEIN_DI) 47 | implementation(AndroidLibs.KODEIN_DI_FRAMEWORK_ANDROID_X) 48 | 49 | testImplementation(JVMTestingLibs.JUNIT5) 50 | testImplementation(JVMTestingLibs.MOCKK) 51 | testImplementation(JVMTestingLibs.COROUTINES_TEST) 52 | } 53 | -------------------------------------------------------------------------------- /android/authentication/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/android/authentication/consumer-rules.pro -------------------------------------------------------------------------------- /android/authentication/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 | -------------------------------------------------------------------------------- /android/authentication/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/authentication/src/main/java/com/akjaw/fullerstack/authentication/ActivityAuthenticationLauncher.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.authentication 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import com.akjaw.fullerstack.authentication.presentation.AuthenticationActivity 6 | 7 | internal class ActivityAuthenticationLauncher(private val activity: Activity) : AuthenticationLauncher { 8 | override fun showAuthenticationScreen() { 9 | val intent = Intent(activity, AuthenticationActivity::class.java) 10 | activity.startActivity(intent) 11 | activity.finish() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /android/authentication/src/main/java/com/akjaw/fullerstack/authentication/AuthenticationLauncher.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.authentication 2 | 3 | interface AuthenticationLauncher { 4 | 5 | fun showAuthenticationScreen() 6 | } 7 | -------------------------------------------------------------------------------- /android/authentication/src/main/java/com/akjaw/fullerstack/authentication/GetUserProfile.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.authentication 2 | 3 | import android.util.Log 4 | import com.akjaw.fullerstack.authentication.model.UserProfile 5 | import com.akjaw.fullerstack.authentication.token.TokenProvider 6 | import com.auth0.android.authentication.AuthenticationAPIClient 7 | import com.auth0.android.authentication.AuthenticationException 8 | import com.auth0.android.callback.BaseCallback 9 | import kotlin.coroutines.resume 10 | import kotlin.coroutines.suspendCoroutine 11 | import com.auth0.android.result.UserProfile as Auth0UserProfile 12 | 13 | class GetUserProfile( 14 | private val authenticationAPIClient: AuthenticationAPIClient, 15 | private val tokenProvider: TokenProvider 16 | ) { 17 | 18 | suspend operator fun invoke(): UserProfile? = suspendCoroutine { continuation -> 19 | val jwtToken = tokenProvider.getToken()?.jwt 20 | if (jwtToken == null) { 21 | continuation.resume(null) 22 | return@suspendCoroutine 23 | } 24 | authenticationAPIClient.userInfo(jwtToken) 25 | .start(object : BaseCallback { 26 | 27 | override fun onSuccess(payload: Auth0UserProfile?) { 28 | //TODO check these values 29 | val name = payload?.nickname.orEmpty() 30 | val email = payload?.email.orEmpty() 31 | val profilePictureUrl = payload?.pictureURL.orEmpty() 32 | val userProfile = UserProfile(name = name, email = email, profilePictureUrl = profilePictureUrl) 33 | continuation.resume(userProfile) 34 | } 35 | 36 | override fun onFailure(error: AuthenticationException) { 37 | Log.d("Auth", "userInfo failure $error") 38 | continuation.resume(null) 39 | } 40 | 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /android/authentication/src/main/java/com/akjaw/fullerstack/authentication/UserAuthenticator.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.authentication 2 | 3 | import com.akjaw.fullerstack.authentication.model.AuthenticationResult 4 | 5 | interface UserAuthenticator { 6 | 7 | fun isUserAuthenticated(): Boolean 8 | 9 | suspend fun signInUser(): AuthenticationResult 10 | 11 | suspend fun signOutUser(): AuthenticationResult 12 | } 13 | -------------------------------------------------------------------------------- /android/authentication/src/main/java/com/akjaw/fullerstack/authentication/composition/activityScopedAuthenticationModule.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.authentication.composition 2 | 3 | import com.akjaw.fullerstack.authentication.ActivityAuthenticationLauncher 4 | import com.akjaw.fullerstack.authentication.Auth0UserAuthenticator 5 | import com.akjaw.fullerstack.authentication.AuthenticationLauncher 6 | import com.akjaw.fullerstack.authentication.UserAuthenticator 7 | import org.kodein.di.DI 8 | import org.kodein.di.bind 9 | import org.kodein.di.instance 10 | import org.kodein.di.singleton 11 | 12 | val activityScopedAuthenticationModule = DI.Module("activityScopedAuthenticationModule") { 13 | bind() with singleton { ActivityAuthenticationLauncher(instance()) } 14 | bind() with singleton { 15 | Auth0UserAuthenticator( 16 | activity = instance(), 17 | auth0 = instance(), 18 | auth0Config = instance(), 19 | credentialsManager = instance() 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /android/authentication/src/main/java/com/akjaw/fullerstack/authentication/composition/auth0Module.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.authentication.composition 2 | 3 | import android.content.Context 4 | import com.auth0.android.Auth0 5 | import com.auth0.android.authentication.AuthenticationAPIClient 6 | import com.auth0.android.authentication.storage.SecureCredentialsManager 7 | import com.auth0.android.authentication.storage.SharedPreferencesStorage 8 | import org.kodein.di.DI 9 | import org.kodein.di.bind 10 | import org.kodein.di.instance 11 | import org.kodein.di.singleton 12 | 13 | internal val auth0Module = DI.Module("auth0Module") { 14 | bind() from singleton { 15 | Auth0(instance("ApplicationContext")).apply { 16 | isOIDCConformant = true 17 | } 18 | } 19 | bind() from singleton { AuthenticationAPIClient(instance()) } 20 | bind() from singleton { SharedPreferencesStorage(instance("ApplicationContext")) } 21 | bind() from singleton { 22 | SecureCredentialsManager( 23 | instance("ApplicationContext"), 24 | instance(), 25 | instance() 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /android/authentication/src/main/java/com/akjaw/fullerstack/authentication/composition/authenticationModule.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.authentication.composition 2 | 3 | import android.content.Context 4 | import com.akjaw.fullerstack.authentication.GetUserProfile 5 | import com.akjaw.fullerstack.authentication.R 6 | import com.akjaw.fullerstack.authentication.model.Auth0Config 7 | import com.akjaw.fullerstack.authentication.token.TokenProvider 8 | import org.kodein.di.DI 9 | import org.kodein.di.bind 10 | import org.kodein.di.instance 11 | import org.kodein.di.singleton 12 | 13 | val authenticationModule = DI.Module("authenticationModule") { 14 | import(auth0Module) 15 | bind() from singleton { 16 | val context = instance("ApplicationContext") 17 | Auth0Config( 18 | schema = context.getString(R.string.com_auth0_schema), 19 | apiIdentifier = context.getString(R.string.api_identifier), 20 | scope = "openid profile email offline_access" 21 | ) 22 | } 23 | bind() from singleton { GetUserProfile(instance(), instance()) } 24 | bind() from singleton { TokenProvider(instance()) } 25 | } 26 | -------------------------------------------------------------------------------- /android/authentication/src/main/java/com/akjaw/fullerstack/authentication/model/AccessToken.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.authentication.model 2 | 3 | data class AccessToken( 4 | val jwt: String 5 | ) 6 | -------------------------------------------------------------------------------- /android/authentication/src/main/java/com/akjaw/fullerstack/authentication/model/Auth0Config.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.authentication.model 2 | 3 | internal data class Auth0Config( 4 | val schema: String, 5 | val apiIdentifier: String, 6 | val scope: String 7 | ) 8 | -------------------------------------------------------------------------------- /android/authentication/src/main/java/com/akjaw/fullerstack/authentication/model/AuthenticationResult.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.authentication.model 2 | 3 | enum class AuthenticationResult { 4 | SUCCESS, 5 | FAILURE 6 | } 7 | -------------------------------------------------------------------------------- /android/authentication/src/main/java/com/akjaw/fullerstack/authentication/model/UserProfile.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.authentication.model 2 | 3 | data class UserProfile( 4 | val name: String = "", 5 | val email: String = "", 6 | val profilePictureUrl: String = "" 7 | ) 8 | -------------------------------------------------------------------------------- /android/authentication/src/main/java/com/akjaw/fullerstack/authentication/navigation/AfterAuthenticationLauncher.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.authentication.navigation 2 | 3 | import android.app.Activity 4 | 5 | interface AfterAuthenticationLauncher { 6 | 7 | fun launch(activity: Activity) 8 | } 9 | -------------------------------------------------------------------------------- /android/authentication/src/main/java/com/akjaw/fullerstack/authentication/token/TokenProvider.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.fullerstack.authentication.token 2 | 3 | import android.util.Log 4 | import com.akjaw.fullerstack.authentication.model.AccessToken 5 | import com.auth0.android.authentication.storage.CredentialsManagerException 6 | import com.auth0.android.authentication.storage.SecureCredentialsManager 7 | import com.auth0.android.callback.BaseCallback 8 | import com.auth0.android.result.Credentials 9 | import kotlin.coroutines.resume 10 | import kotlin.coroutines.suspendCoroutine 11 | 12 | class TokenProvider( 13 | private val credentialsManager: SecureCredentialsManager 14 | ) { 15 | 16 | enum class Result { 17 | SUCCESS, 18 | FAILURE 19 | } 20 | 21 | private var accessToken: AccessToken? = null 22 | 23 | fun getToken(): AccessToken? = accessToken 24 | 25 | //TODO handle failure case 26 | suspend fun initializeToken(): Result = suspendCoroutine { continuation -> 27 | credentialsManager.getCredentials(object :BaseCallback { 28 | 29 | override fun onSuccess(payload: Credentials?) { 30 | val jwt = payload?.accessToken 31 | if (jwt == null) { 32 | continuation.resume(Result.FAILURE) 33 | } else { 34 | accessToken = AccessToken(jwt) 35 | continuation.resume(Result.SUCCESS) 36 | } 37 | } 38 | 39 | override fun onFailure(error: CredentialsManagerException) { 40 | Log.d("Auth", "getCredentials failure $error") 41 | continuation.resume(Result.FAILURE) 42 | } 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /android/authentication/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | wz8gsvjVZ0xAWJ5unVuVvv1yiSSze596 4 | fuller-stack.eu.auth0.com 5 | https 6 | https://fuller-stack-ktor.herokuapp.com/ 7 | 8 | In order to use this app you have to be authenticated 9 | Sign in 10 | The was an error during authentication, please try again later 11 | -------------------------------------------------------------------------------- /android/framework/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /android/framework/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | id("kotlin-android") 4 | id("kotlin-kapt") 5 | id("de.mannodermaus.android-junit5") 6 | } 7 | 8 | android { 9 | defaultConfig { 10 | minSdkVersion(Versions.MIN_SDK_VERSION) 11 | targetSdkVersion(Versions.TARGET_SDK_VERSION) 12 | } 13 | compileSdkVersion(Versions.COMPILE_SDK_VERSION) 14 | compileOptions { 15 | sourceCompatibility = JavaVersion.VERSION_1_8 16 | targetCompatibility = JavaVersion.VERSION_1_8 17 | } 18 | kotlinOptions { 19 | jvmTarget = JavaVersion.VERSION_1_8.toString() 20 | } 21 | } 22 | 23 | dependencies { 24 | implementation(AndroidLibs.KOTLIN_JDK) 25 | 26 | implementation(AndroidLibs.APP_COMPAT) 27 | implementation(AndroidLibs.MATERIAL) 28 | implementation(AndroidLibs.CONSTRAINT_LAYOUT) 29 | implementation(AndroidLibs.LIFECYCLE_RUNTIME_KTX) 30 | implementation(AndroidLibs.LIFECYCLE_EXTENSTIONS) 31 | 32 | implementation(SharedLibs.COROUTINES_CORE) 33 | 34 | implementation(SharedLibs.KODEIN_DI) 35 | implementation(AndroidLibs.KODEIN_DI_FRAMEWORK_ANDROID_X) 36 | 37 | testImplementation(JVMTestingLibs.JUNIT5) 38 | testImplementation(JVMTestingLibs.MOCKK) 39 | } 40 | -------------------------------------------------------------------------------- /android/framework/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/android/framework/consumer-rules.pro -------------------------------------------------------------------------------- /android/framework/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 | -------------------------------------------------------------------------------- /android/framework/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /android/framework/src/main/java/com/akjaw/framework/composition/LifecycleModule.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.framework.composition 2 | 3 | import android.content.Context 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.fragment.app.FragmentActivity 6 | import org.kodein.di.DI 7 | import org.kodein.di.bind 8 | import org.kodein.di.singleton 9 | 10 | fun AppCompatActivity.lifecycleModule(): DI.Module = DI.Module("lifecycleModule") { 11 | bind() with singleton { this@lifecycleModule } 12 | bind("Context") with singleton { this@lifecycleModule } 13 | } 14 | -------------------------------------------------------------------------------- /android/framework/src/main/java/com/akjaw/framework/utility/KeyboardCloser.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.framework.utility 2 | 3 | import android.app.Activity 4 | import android.view.inputmethod.InputMethodManager 5 | 6 | class KeyboardCloser( 7 | private val activity: Activity 8 | ) { 9 | 10 | fun close() { 11 | val focusedView = activity.currentFocus ?: return 12 | val context = activity.baseContext ?: return 13 | val imm: InputMethodManager = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager 14 | imm.hideSoftInputFromWindow(focusedView.windowToken, 0) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /android/framework/src/main/java/com/akjaw/framework/view/DistinctTextWatcher.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.framework.view 2 | 3 | import android.text.Editable 4 | import android.text.TextWatcher 5 | import android.widget.TextView 6 | 7 | inline fun TextView.doAfterDistinctTextChange( 8 | crossinline afterTextChanged: (string: String) -> Unit = {} 9 | ): TextWatcher { 10 | val textWatcher = object : TextWatcher { 11 | private var previousText = "" 12 | 13 | override fun afterTextChanged(s: Editable?) { 14 | val text = s?.toString() 15 | if (text != null && text != previousText) { 16 | previousText = text 17 | afterTextChanged.invoke(text) 18 | } 19 | } 20 | 21 | override fun beforeTextChanged(text: CharSequence?, start: Int, count: Int, after: Int) { 22 | /* Empty */ 23 | } 24 | 25 | override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) { 26 | /* Empty */ 27 | } 28 | } 29 | 30 | addTextChangedListener(textWatcher) 31 | 32 | return textWatcher 33 | } 34 | -------------------------------------------------------------------------------- /android/framework/src/main/java/com/akjaw/framework/view/ExtensionsKt.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.framework.view 2 | 3 | import android.view.View 4 | 5 | fun View.show() { 6 | visibility = View.VISIBLE 7 | } 8 | 9 | fun View.hide() { 10 | visibility = View.GONE 11 | } 12 | 13 | fun View.makeInvisible() { 14 | visibility = View.INVISIBLE 15 | } 16 | -------------------------------------------------------------------------------- /android/framework/src/main/java/com/akjaw/framework/view/SimpleAnimationListener.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.framework.view 2 | 3 | import android.view.View 4 | import android.view.animation.Animation 5 | 6 | inline fun Animation.setOnAnimationEnd( 7 | crossinline onAnimationEnd: (p0: Animation?) -> Unit 8 | ): Animation.AnimationListener { 9 | val listener = object : Animation.AnimationListener { 10 | override fun onAnimationStart(p0: Animation?) { 11 | /* Empty */ 12 | } 13 | 14 | override fun onAnimationEnd(p0: Animation?) { 15 | onAnimationEnd(p0) 16 | } 17 | 18 | override fun onAnimationRepeat(p0: Animation?) { 19 | /* Empty */ 20 | } 21 | } 22 | 23 | setAnimationListener(listener) 24 | 25 | return listener 26 | } 27 | -------------------------------------------------------------------------------- /android/framework/src/main/java/com/akjaw/framework/view/SimpleAnimatorListener.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.framework.view 2 | 3 | import android.animation.Animator 4 | 5 | interface SimpleAnimatorListener : Animator.AnimatorListener { 6 | override fun onAnimationRepeat(animation: Animator?) { 7 | /* Empty */ 8 | } 9 | 10 | override fun onAnimationEnd(animation: Animator?) { 11 | /* Empty */ 12 | } 13 | 14 | override fun onAnimationCancel(animation: Animator?) { 15 | /* Empty */ 16 | } 17 | 18 | override fun onAnimationStart(animation: Animator?) { 19 | /* Empty */ 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /android/framework/src/main/java/com/akjaw/framework/view/ViewFader.kt: -------------------------------------------------------------------------------- 1 | package com.akjaw.framework.view 2 | 3 | import android.animation.Animator 4 | import android.view.View 5 | import androidx.lifecycle.LifecycleObserver 6 | 7 | class ViewFader : LifecycleObserver { 8 | 9 | private var views: List? = null 10 | 11 | fun fadeOutViews(durationMilliseconds: Long = 300) { 12 | if (areViewsShown() == true) { 13 | this.views?.forEach { it.fadeOut(durationMilliseconds) } 14 | } 15 | } 16 | 17 | fun fadeInViews(durationMilliseconds: Long = 300) { 18 | if (areViewsHidden() == true) { 19 | this.views?.forEach { it.fadeIn(durationMilliseconds) } 20 | } 21 | } 22 | 23 | fun setViews(views: List) { 24 | this.views = views 25 | } 26 | 27 | fun destroyViews() { 28 | views = null 29 | } 30 | 31 | private fun areViewsShown(): Boolean? = areViewsHidden()?.not() 32 | 33 | private fun areViewsHidden(): Boolean? = views?.none { it.visibility == View.VISIBLE } 34 | 35 | private fun View.fadeIn(durationMilliseconds: Long) { 36 | fade(0f, 1f, durationMilliseconds) 37 | } 38 | 39 | private fun View.fadeOut(durationMilliseconds: Long) { 40 | val listener = object : SimpleAnimatorListener { 41 | override fun onAnimationEnd(animation: Animator?) { 42 | visibility = View.GONE 43 | } 44 | } 45 | fade(1f, 0f, durationMilliseconds, listener) 46 | } 47 | 48 | private fun View.fade( 49 | startingAlpha: Float, 50 | endAlpha: Float, 51 | durationMilliseconds: Long, 52 | listener: SimpleAnimatorListener? = null 53 | ) { 54 | alpha = startingAlpha 55 | visibility = View.VISIBLE 56 | animate() 57 | .alpha(endAlpha) 58 | .setDuration(durationMilliseconds) 59 | .setListener(listener) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /assets/android-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/assets/android-home.png -------------------------------------------------------------------------------- /assets/apps-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/assets/apps-architecture.png -------------------------------------------------------------------------------- /assets/data-layer-implementations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/assets/data-layer-implementations.png -------------------------------------------------------------------------------- /assets/react-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/assets/react-home.png -------------------------------------------------------------------------------- /assets/socket-update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/assets/socket-update.png -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.gradle.kotlin.dsl.`kotlin-dsl` 2 | 3 | plugins { 4 | `kotlin-dsl` 5 | } 6 | 7 | repositories { 8 | jcenter() 9 | } -------------------------------------------------------------------------------- /config/detekt/baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NewLineAtEndOfFile:App.kt$.App.kt 6 | NewLineAtEndOfFile:CounterButton.kt$features.counterbutton.CounterButton.kt 7 | NewLineAtEndOfFile:CounterButtonProps.kt$features.counterbutton.CounterButtonProps.kt 8 | NewLineAtEndOfFile:CounterButtonState.kt$features.counterbutton.CounterButtonState.kt 9 | NewLineAtEndOfFile:KodeinEntry.kt$dependency_injection.KodeinEntry.kt 10 | NewLineAtEndOfFile:main.kt$.main.kt 11 | PackageNaming:CounterButton.kt$package features.counterbutton 12 | PackageNaming:CounterButtonProps.kt$package features.counterbutton 13 | PackageNaming:CounterButtonState.kt$package features.counterbutton 14 | PackageNaming:KodeinEntry.kt$package dependency_injection 15 | 16 | 17 | -------------------------------------------------------------------------------- /config/git/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "DETEKT HOOK RUNNING" 4 | 5 | #Needed for intelij pre hook bash execution 6 | JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64 7 | 8 | set -e 9 | set -o pipefail 10 | 11 | eval './gradlew detekt' 12 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | kotlin.mpp.enableGranularSourceSetsMetadata=true 5 | org.gradle.jvmargs=-Xms1024m -Xmx4096m -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AKJAW/fuller-stack-kotlin-multiplatform/52ce438698b72bb8276f0b6bcf7041c09e7e3620/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /ktor/Procfile: -------------------------------------------------------------------------------- 1 | web: java $JAVA_OPTS -Dserver.port=$PORT -jar ktor/build/libs/fuller-stack-ktor-0.0.1-all.jar -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/composition/baseModule.kt: -------------------------------------------------------------------------------- 1 | package server.composition 2 | 3 | import com.typesafe.config.Config 4 | import com.typesafe.config.ConfigFactory 5 | import org.kodein.di.DI 6 | import org.kodein.di.bind 7 | import org.kodein.di.instance 8 | import org.kodein.di.singleton 9 | import server.jwt.TokenParser 10 | import server.logger.ApiLogger 11 | 12 | val baseModule = DI.Module("baseModule") { 13 | bind() with singleton { ConfigFactory.load() } 14 | bind() from singleton { ApiLogger() } 15 | bind() from singleton { 16 | val config = instance() 17 | TokenParser(config.getString("ktor.domain"), instance()) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/composition/databaseModule.kt: -------------------------------------------------------------------------------- 1 | package server.composition 2 | 3 | import org.kodein.di.DI 4 | import org.kodein.di.bind 5 | import org.kodein.di.instance 6 | import org.kodein.di.singleton 7 | import server.storage.DatabaseFactory 8 | import server.storage.ExposedDatabase 9 | import server.storage.NotesStorage 10 | 11 | val databaseModule = DI.Module("databaseModule") { 12 | bind() with singleton { DatabaseFactory().create() } 13 | bind() from singleton { NotesStorage(instance(), instance()) } 14 | } 15 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/composition/socketModule.kt: -------------------------------------------------------------------------------- 1 | package server.composition 2 | 3 | import org.kodein.di.DI 4 | import org.kodein.di.bind 5 | import org.kodein.di.instance 6 | import org.kodein.di.singleton 7 | import server.socket.SocketNotifier 8 | import server.socket.SocketServer 9 | 10 | val socketModule = DI.Module("socketModule") { 11 | bind() from singleton { SocketServer(instance(), instance()) } 12 | bind() from singleton { SocketNotifier(instance(), instance()) } 13 | } 14 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/jwt/TokenParser.kt: -------------------------------------------------------------------------------- 1 | package server.jwt 2 | 3 | import com.auth0.jwk.UrlJwkProvider 4 | import com.auth0.jwt.JWT 5 | import com.auth0.jwt.algorithms.Algorithm 6 | import com.auth0.jwt.impl.JWTParser 7 | import server.logger.ApiLogger 8 | import java.security.interfaces.RSAPublicKey 9 | import java.util.* 10 | 11 | class TokenParser( 12 | private val domain: String, 13 | private val apiLogger: ApiLogger 14 | ) { 15 | 16 | fun getUserId(token: String): String? = try { 17 | val jwk = UrlJwkProvider(domain).get(JWT.decode(token).keyId) 18 | val algorithm = Algorithm.RSA256(jwk.publicKey as RSAPublicKey, null) 19 | val jwtVerifier = JWT.require(algorithm).build() 20 | val decoded = jwtVerifier.verify(token) 21 | val payloadString = String(Base64.getUrlDecoder().decode(decoded.payload)) 22 | val something = JWTParser().parsePayload(payloadString) 23 | something.getClaim("sub")?.asString() 24 | } catch (e: Throwable) { 25 | apiLogger.log("TokenParser", "error $e") 26 | null 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/logger/ApiLogger.kt: -------------------------------------------------------------------------------- 1 | package server.logger 2 | 3 | import org.slf4j.LoggerFactory 4 | 5 | class ApiLogger { 6 | private val logger = LoggerFactory.getLogger("ApiLogger") 7 | 8 | fun log(tag: String, text: String) { 9 | logger.info("$tag: $text") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/routes/util.kt: -------------------------------------------------------------------------------- 1 | package server.routes 2 | 3 | import io.ktor.application.ApplicationCall 4 | import io.ktor.auth.jwt.JWTPrincipal 5 | import io.ktor.auth.principal 6 | import io.ktor.request.receiveOrNull 7 | import io.ktor.sessions.get 8 | import io.ktor.sessions.sessions 9 | import kotlinx.serialization.SerializationException 10 | import server.NotesSession 11 | 12 | suspend inline fun getJsonData(call: ApplicationCall): T? = 13 | try { 14 | call.receiveOrNull() 15 | } catch (e: SerializationException) { 16 | null 17 | } 18 | 19 | fun ApplicationCall.getUserId(): String? { 20 | val jwtPrincipal = principal() 21 | return jwtPrincipal?.payload?.getClaim("sub")?.asString() 22 | } 23 | 24 | fun ApplicationCall.getSessionId(): String? { 25 | return sessions.get()?.id 26 | } 27 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/socket/SocketHolder.kt: -------------------------------------------------------------------------------- 1 | package server.socket 2 | 3 | import io.ktor.http.cio.websocket.WebSocketSession 4 | 5 | data class SocketHolder(val sessionId: String, val socketSession: WebSocketSession) 6 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/socket/SocketNotifier.kt: -------------------------------------------------------------------------------- 1 | package server.socket 2 | 3 | import io.ktor.application.ApplicationCall 4 | import kotlinx.coroutines.launch 5 | import server.logger.ApiLogger 6 | import server.routes.getSessionId 7 | import server.storage.model.User 8 | 9 | class SocketNotifier( 10 | private val apiLogger: ApiLogger, 11 | private val socketServer: SocketServer 12 | ) { 13 | 14 | fun notifySocketOfChange(call: ApplicationCall, user: User) { 15 | call.application.launch { 16 | val sessionId = call.getSessionId() 17 | apiLogger.log("Notify socket", "sessionId $sessionId") 18 | socketServer.sendUpdateToUser(user, sessionId) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/socket/UserSocketSession.kt: -------------------------------------------------------------------------------- 1 | package server.socket 2 | 3 | import server.jwt.TokenParser 4 | import server.storage.model.User 5 | 6 | class UserSocketSession(private val tokenParser: TokenParser) { 7 | 8 | private var userId: String? = null 9 | 10 | fun initialize(jwtToken: String?) { 11 | userId = jwtToken?.let { tokenParser.getUserId(it) } 12 | } 13 | 14 | fun getUser(): User? { 15 | val userId = userId ?: return null 16 | return User(userId) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/storage/DatabaseCoroutineQuery.kt: -------------------------------------------------------------------------------- 1 | package server.storage 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction 5 | 6 | suspend fun queryDatabase(block: suspend () -> T) = newSuspendedTransaction(Dispatchers.IO) { 7 | block() 8 | } 9 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/storage/DatabaseFactory.kt: -------------------------------------------------------------------------------- 1 | package server.storage 2 | 3 | class DatabaseFactory { 4 | fun create(): ExposedDatabase { 5 | val databaseUrl: String? = System.getenv("JDBC_DATABASE_URL") 6 | println("databaseUrl $databaseUrl") 7 | return when (databaseUrl) { 8 | null -> H2Database() 9 | else -> PostgreSqlDatabase(databaseUrl) 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/storage/ExposedDatabase.kt: -------------------------------------------------------------------------------- 1 | package server.storage 2 | 3 | import org.jetbrains.exposed.sql.Database 4 | 5 | interface ExposedDatabase { 6 | 7 | fun getDatabase(): Database 8 | } 9 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/storage/PostgreSqlDatabase.kt: -------------------------------------------------------------------------------- 1 | package server.storage 2 | 3 | import com.zaxxer.hikari.HikariConfig 4 | import com.zaxxer.hikari.HikariDataSource 5 | import org.jetbrains.exposed.sql.Database 6 | import org.jetbrains.exposed.sql.SchemaUtils 7 | import org.jetbrains.exposed.sql.transactions.transaction 8 | import server.storage.model.NotesTable 9 | 10 | @Suppress("MagicNumber") 11 | class PostgreSqlDatabase(databaseUrl: String) : ExposedDatabase { 12 | private val database: Database = Database.connect(hikari(databaseUrl)).apply { 13 | transaction { 14 | SchemaUtils.createMissingTablesAndColumns(NotesTable) 15 | } 16 | } 17 | 18 | private fun hikari(databaseUrl: String): HikariDataSource { 19 | val config = HikariConfig() 20 | config.driverClassName = "org.postgresql.Driver" 21 | config.jdbcUrl = databaseUrl 22 | config.maximumPoolSize = 3 23 | config.transactionIsolation = "TRANSACTION_REPEATABLE_READ" 24 | config.validate() 25 | return HikariDataSource(config) 26 | } 27 | 28 | override fun getDatabase(): Database = database 29 | } 30 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/storage/model/EntityWithCreationTimestamp.kt: -------------------------------------------------------------------------------- 1 | package server.storage.model 2 | 3 | import model.toCreationTimestamp 4 | import org.jetbrains.exposed.dao.IntEntity 5 | import org.jetbrains.exposed.dao.IntEntityClass 6 | import org.jetbrains.exposed.dao.id.EntityID 7 | 8 | class EntityWithCreationTimestamp(id: EntityID) : IntEntity(id) { 9 | var creationTimestamp by NotesTable.creationUnixTimestamp.transform( 10 | toColumn = { it.unix }, 11 | toReal = { it.toCreationTimestamp() } 12 | ) 13 | 14 | companion object : IntEntityClass(NotesTable, EntityWithCreationTimestamp::class.java) 15 | } 16 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/storage/model/EntityWithLastModificationTimestamp.kt: -------------------------------------------------------------------------------- 1 | package server.storage.model 2 | 3 | import model.toLastModificationTimestamp 4 | import org.jetbrains.exposed.dao.IntEntity 5 | import org.jetbrains.exposed.dao.IntEntityClass 6 | import org.jetbrains.exposed.dao.id.EntityID 7 | 8 | class EntityWithLastModificationTimestamp(id: EntityID) : IntEntity(id) { 9 | var lastModificationTimestamp by NotesTable.lastModificationUnixTimestamp.transform( 10 | toColumn = { it.unix }, 11 | toReal = { it.toLastModificationTimestamp() } 12 | ) 13 | 14 | companion object : IntEntityClass(NotesTable) 15 | } 16 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/storage/model/NotesTable.kt: -------------------------------------------------------------------------------- 1 | package server.storage.model 2 | 3 | import org.jetbrains.exposed.dao.id.IntIdTable 4 | 5 | @Suppress("MagicNumber") 6 | object NotesTable : IntIdTable("Notes") { 7 | val userId = varchar("userId", 2560) 8 | val title = varchar("title", 255) 9 | val content = varchar("content", 10485760) 10 | val lastModificationUnixTimestamp = long("lastModificationUnixTimestamp") 11 | val creationUnixTimestamp = long("creationUnixTimestamp") 12 | val wasDeleted = bool("wasDeleted") 13 | } 14 | -------------------------------------------------------------------------------- /ktor/src/main/kotlin/server/storage/model/User.kt: -------------------------------------------------------------------------------- 1 | package server.storage.model 2 | 3 | data class User(val id: String) 4 | -------------------------------------------------------------------------------- /ktor/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | ktor { 2 | domain = "https://fuller-stack.eu.auth0.com" 3 | audience = "https://fuller-stack-ktor.herokuapp.com/" 4 | } -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/App.kt: -------------------------------------------------------------------------------- 1 | 2 | import com.ccfraser.muirwik.components.mContainer 3 | import com.ccfraser.muirwik.components.styles.Breakpoint 4 | import composition.KodeinEntry 5 | import features.home.homePage 6 | import features.settings.settingsPageContainer 7 | import org.kodein.di.instance 8 | import react.RBuilder 9 | import react.RComponent 10 | import react.RProps 11 | import react.RState 12 | import react.child 13 | import react.router.dom.browserRouter 14 | import react.router.dom.route 15 | import react.router.dom.switch 16 | 17 | class App : RComponent() { 18 | private val tokenProvider by KodeinEntry.di.instance() 19 | 20 | override fun RBuilder.render() { 21 | child(authenticationWrapper) { 22 | attrs.tokenProvider = tokenProvider 23 | browserRouter { 24 | child(appBar) 25 | mContainer(maxWidth = Breakpoint.lg) { 26 | switch { 27 | //TODO redirect 404 to root 28 | route("/", exact = true) { 29 | child(homePage) 30 | } 31 | route("/profile") { 32 | settingsPageContainer {} 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/ErrorBoundary.kt: -------------------------------------------------------------------------------- 1 | import react.RBuilder 2 | import react.RComponent 3 | import react.RErrorInfo 4 | import react.RProps 5 | import react.RState 6 | import react.ReactElement 7 | import react.dom.h1 8 | 9 | interface ErrorBoundaryState : RState { 10 | var hasError: Boolean? 11 | } 12 | 13 | class ErrorBoundary : RComponent() { 14 | 15 | override fun componentDidCatch(error: Throwable, info: RErrorInfo) { 16 | console.error(error) 17 | console.error(info) 18 | } 19 | 20 | override fun RBuilder.render() { 21 | if (state.hasError == true) { 22 | h1 { + "Something went wrong" } 23 | } else { 24 | props.children() 25 | } 26 | } 27 | } 28 | 29 | fun RBuilder.errorBoundary(handler: RProps.() -> Unit): ReactElement { 30 | return child(ErrorBoundary::class) { 31 | this.attrs(handler) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/composition/KodeinEntry.kt: -------------------------------------------------------------------------------- 1 | package composition 2 | 3 | import DexieNoteDao 4 | import TokenProvider 5 | import database.NoteDao 6 | import feature.socket.NoteSocket 7 | import helpers.date.NotesDatePatternStorageKey 8 | import helpers.storage.LocalStorage 9 | import helpers.storage.Storage 10 | import io.ktor.client.HttpClient 11 | import network.HttpClientFactory 12 | import network.KtorClientNoteApi 13 | import network.NoteApi 14 | import org.kodein.di.DI 15 | import org.kodein.di.DIAware 16 | import org.kodein.di.bind 17 | import org.kodein.di.instance 18 | import org.kodein.di.singleton 19 | import socket.KtorNoteSocket 20 | 21 | object KodeinEntry : DIAware { 22 | override val di by DI.lazy { 23 | bind() with singleton { LocalStorage() } 24 | bind() with singleton { TokenProvider() } 25 | bind() with singleton { HttpClientFactory(instance()).create() } 26 | bind() with singleton { KtorNoteSocket(instance(), instance()) } 27 | bind() with singleton { KtorClientNoteApi(instance()) } 28 | bind() from singleton { DexieNoteDao() } 29 | bind() with singleton { instance() } 30 | bind() with singleton { 31 | NotesDatePatternStorageKey("NotesDatePatternStorageKey") 32 | } 33 | import(common) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/features/editor/NoteEditorSlice.kt: -------------------------------------------------------------------------------- 1 | package features.editor 2 | 3 | import features.editor.thunk.AddNoteThunk 4 | import features.editor.thunk.DeleteNotesThunk 5 | import features.editor.thunk.UpdateNoteThunk 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.SupervisorJob 8 | import model.CreationTimestamp 9 | import model.Note 10 | import redux.RAction 11 | import store.RThunk 12 | 13 | object NoteEditorSlice { 14 | data class State( 15 | val selectedNote: Note? = null, 16 | val isUpdating: Boolean = false 17 | ) 18 | 19 | private val noteEditorScope = CoroutineScope(SupervisorJob()) 20 | 21 | fun addNote(title: String, content: String): RThunk = 22 | AddNoteThunk(scope = noteEditorScope, title = title, content = content) 23 | 24 | fun updateNote( 25 | creationTimestamp: CreationTimestamp, 26 | title: String, 27 | content: String 28 | ): RThunk = 29 | UpdateNoteThunk(noteEditorScope, creationTimestamp, title, content) 30 | 31 | fun deleteNotes(creationTimestamps: List): RThunk = 32 | DeleteNotesThunk(noteEditorScope, creationTimestamps) 33 | 34 | data class OpenEditor(val note: Note?) : RAction 35 | 36 | class CloseEditor : RAction 37 | 38 | fun reducer(state: State = State(), action: RAction): State { 39 | return when (action) { 40 | is OpenEditor -> { 41 | val note = action.note ?: Note() 42 | state.copy(selectedNote = note, isUpdating = action.note != null) 43 | } 44 | is CloseEditor -> { 45 | state.copy(selectedNote = null, isUpdating = false) 46 | } 47 | else -> state 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/features/editor/more/DeleteNoteButton.kt: -------------------------------------------------------------------------------- 1 | package features.editor.more 2 | 3 | import com.ccfraser.muirwik.components.MColor 4 | import com.ccfraser.muirwik.components.button.mButton 5 | import com.ccfraser.muirwik.components.dialog.mDialog 6 | import com.ccfraser.muirwik.components.dialog.mDialogActions 7 | import com.ccfraser.muirwik.components.dialog.mDialogTitle 8 | import com.ccfraser.muirwik.components.menu.mMenuItemWithIcon 9 | import react.RProps 10 | import react.functionalComponent 11 | import react.useState 12 | import styled.styledDiv 13 | 14 | interface DeleteNoteButtonProps : RProps { 15 | var onDeleteClicked: () -> Unit 16 | } 17 | 18 | val deleteNoteButton = functionalComponent { props -> 19 | 20 | val (isDialogShown, setIsDialogShown) = useState(false) 21 | 22 | styledDiv { 23 | mMenuItemWithIcon("delete", "Delete", onClick = { setIsDialogShown(true) }) 24 | mDialog(isDialogShown, onClose = { _, _ -> setIsDialogShown(false) }) { 25 | mDialogTitle("Are you sure you want to delete this note?") 26 | mDialogActions { 27 | mButton("Cancel", MColor.primary, onClick = { setIsDialogShown(false) }) 28 | mButton("Yes", MColor.primary, onClick = { props.onDeleteClicked() }) 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/features/editor/more/EditorMoreButton.kt: -------------------------------------------------------------------------------- 1 | package features.editor.more 2 | 3 | import com.ccfraser.muirwik.components.button.MIconButtonSize 4 | import com.ccfraser.muirwik.components.button.mIconButton 5 | import com.ccfraser.muirwik.components.menu.mMenu 6 | import kotlinx.css.LinearDimension 7 | import kotlinx.css.padding 8 | import org.w3c.dom.Node 9 | import org.w3c.dom.events.Event 10 | import react.RProps 11 | import react.child 12 | import react.functionalComponent 13 | import react.useState 14 | import styled.StyleSheet 15 | import styled.css 16 | import styled.styledDiv 17 | 18 | interface EditorMoreButtonProps : RProps { 19 | var onDeleteClicked: () -> Unit 20 | } 21 | 22 | private object EditorMoreButtonClasses : StyleSheet("EditorMoreButton", isStatic = true) { 23 | val button by css { 24 | padding(LinearDimension("0")) 25 | } 26 | } 27 | 28 | val editorMoreButton = functionalComponent { props -> 29 | 30 | val (isMenuShown, setIsMenuShown) = useState(false) 31 | val (anchorEl, setAnchorEl) = useState(null) 32 | 33 | styledDiv { 34 | mIconButton( 35 | iconName = "more_vert", 36 | size = MIconButtonSize.medium, 37 | onClick = { event: Event -> 38 | setAnchorEl(event.currentTarget.asDynamic() as? Node) 39 | setIsMenuShown(isMenuShown.not()) 40 | } 41 | ) { 42 | css(EditorMoreButtonClasses.button) 43 | } 44 | mMenu(isMenuShown, anchorElement = anchorEl, onClose = { _, reason -> setIsMenuShown(false) }) { 45 | child(deleteNoteButton) { 46 | attrs.onDeleteClicked = { 47 | setIsMenuShown(false) 48 | setAnchorEl(null) 49 | props.onDeleteClicked() 50 | } 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/features/editor/thunk/AddNoteThunk.kt: -------------------------------------------------------------------------------- 1 | package features.editor.thunk 2 | 3 | import composition.KodeinEntry 4 | import feature.AddNote 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.launch 7 | import org.kodein.di.instance 8 | import redux.RAction 9 | import redux.WrapperAction 10 | import store.AppState 11 | import store.RThunk 12 | import store.nullAction 13 | 14 | class AddNoteThunk( 15 | private val scope: CoroutineScope, 16 | private val title: String, 17 | private val content: String 18 | ) : RThunk { 19 | private val addNote by KodeinEntry.di.instance() 20 | 21 | override fun invoke(dispatch: (RAction) -> WrapperAction, getState: () -> AppState): WrapperAction { 22 | scope.launch { 23 | addNote.executeAsync(title, content) 24 | } 25 | 26 | return nullAction 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/features/editor/thunk/DeleteNotesThunk.kt: -------------------------------------------------------------------------------- 1 | package features.editor.thunk 2 | 3 | import composition.KodeinEntry 4 | import feature.DeleteNotes 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.launch 7 | import model.CreationTimestamp 8 | import org.kodein.di.instance 9 | import redux.RAction 10 | import redux.WrapperAction 11 | import store.AppState 12 | import store.RThunk 13 | import store.nullAction 14 | 15 | class DeleteNotesThunk( 16 | private val scope: CoroutineScope, 17 | private val creationTimestamps: List 18 | ) : RThunk { 19 | private val deleteNotes by KodeinEntry.di.instance() 20 | 21 | override fun invoke(dispatch: (RAction) -> WrapperAction, getState: () -> AppState): WrapperAction { 22 | scope.launch { 23 | deleteNotes.executeAsync(creationTimestamps) 24 | } 25 | return nullAction 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/features/editor/thunk/UpdateNoteThunk.kt: -------------------------------------------------------------------------------- 1 | package features.editor.thunk 2 | 3 | import composition.KodeinEntry 4 | import feature.UpdateNote 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.launch 7 | import model.CreationTimestamp 8 | import org.kodein.di.instance 9 | import redux.RAction 10 | import redux.WrapperAction 11 | import store.AppState 12 | import store.RThunk 13 | import store.nullAction 14 | 15 | class UpdateNoteThunk( 16 | private val scope: CoroutineScope, 17 | private val creationTimestamp: CreationTimestamp, 18 | private val title: String, 19 | private val content: String 20 | ) : RThunk { 21 | private val updateNote by KodeinEntry.di.instance() 22 | 23 | override fun invoke(dispatch: (RAction) -> WrapperAction, getState: () -> AppState): WrapperAction { 24 | scope.launch { 25 | updateNote.executeAsync( 26 | creationTimestamp = creationTimestamp, 27 | title = title, 28 | content = content 29 | ) 30 | } 31 | return nullAction 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/features/home/HomePage.kt: -------------------------------------------------------------------------------- 1 | package features.home 2 | 3 | import com.ccfraser.muirwik.components.MGridSize 4 | import com.ccfraser.muirwik.components.MGridSpacing 5 | import com.ccfraser.muirwik.components.mGridContainer 6 | import com.ccfraser.muirwik.components.mGridItem 7 | import composition.KodeinEntry 8 | import feature.socket.ListenToSocketUpdates 9 | import features.editor.noteEditorContainer 10 | import features.list.notesListContainer 11 | import org.kodein.di.instance 12 | import react.RCleanup 13 | import react.RProps 14 | import react.functionalComponent 15 | import react.useEffectWithCleanup 16 | 17 | val homePage = functionalComponent { 18 | val listenToSocketUpdates by KodeinEntry.di.instance() 19 | val cleanUp: RCleanup = { listenToSocketUpdates.close() } 20 | 21 | useEffectWithCleanup(listOf()) { 22 | listenToSocketUpdates.listenToSocketChanges() 23 | cleanUp 24 | } 25 | mGridContainer(spacing = MGridSpacing.spacing2) { 26 | mGridItem(xs = MGridSize.cells12, md = MGridSize.cells6) { 27 | notesListContainer { } 28 | } 29 | mGridItem(xs = MGridSize.cells12, md = MGridSize.cells6) { 30 | noteEditorContainer { } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/features/list/thunk/GetNotesThunk.kt: -------------------------------------------------------------------------------- 1 | package features.list.thunk 2 | 3 | import DexieNoteDao 4 | import composition.KodeinEntry 5 | import feature.GetNotes 6 | import features.list.NotesListSlice 7 | import helpers.date.PatternProvider 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Job 10 | import kotlinx.coroutines.delay 11 | import kotlinx.coroutines.flow.collect 12 | import kotlinx.coroutines.flow.combine 13 | import kotlinx.coroutines.launch 14 | import org.kodein.di.instance 15 | import redux.RAction 16 | import redux.WrapperAction 17 | import store.AppState 18 | import store.RThunk 19 | import store.nullAction 20 | 21 | @Suppress("MagicNumber") 22 | class GetNotesThunk( 23 | private val scope: CoroutineScope, 24 | private val dexieNoteDao: DexieNoteDao 25 | ) : RThunk { 26 | private val getNotes by KodeinEntry.di.instance() 27 | private val patternProvider by KodeinEntry.di.instance() 28 | private var notesFlowJob: Job? = null 29 | 30 | // TODO should this be cancelled somewhere? 31 | override fun invoke(dispatch: (RAction) -> WrapperAction, getState: () -> AppState): WrapperAction { 32 | if (notesFlowJob != null) return nullAction 33 | 34 | scope.launch { 35 | while (dexieNoteDao.isInitialized.not()) { 36 | delay(100) 37 | } 38 | getNotes.executeAsync().combine(patternProvider.patternFlow) { notes, dateFormat -> 39 | notes.map { it.copy(dateFormat = dateFormat) } 40 | }.collect { notes -> 41 | val action = NotesListSlice.SetNotesList(notes.toTypedArray()) 42 | dispatch(action) 43 | } 44 | } 45 | 46 | return nullAction 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/features/list/thunk/SynchronizeNotesThunk.kt: -------------------------------------------------------------------------------- 1 | package features.list.thunk 2 | 3 | import DexieNoteDao 4 | import composition.KodeinEntry 5 | import feature.synchronization.SynchronizeNotes 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.delay 8 | import kotlinx.coroutines.launch 9 | import org.kodein.di.instance 10 | import redux.RAction 11 | import redux.WrapperAction 12 | import store.AppState 13 | import store.RThunk 14 | import store.nullAction 15 | 16 | @Suppress("MagicNumber") 17 | class SynchronizeNotesThunk( 18 | private val scope: CoroutineScope, 19 | private val dexieNoteDao: DexieNoteDao 20 | ) : RThunk { 21 | private val synchronizeNotes by KodeinEntry.di.instance() 22 | 23 | override fun invoke(dispatch: (RAction) -> WrapperAction, getState: () -> AppState): WrapperAction { 24 | scope.launch { 25 | while (dexieNoteDao.isInitialized.not()) { 26 | delay(100) 27 | } 28 | synchronizeNotes.executeAsync() 29 | } 30 | 31 | return nullAction 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/features/settings/SettingsSlice.kt: -------------------------------------------------------------------------------- 1 | package features.settings 2 | 3 | import com.soywiz.klock.DateFormat 4 | import composition.KodeinEntry 5 | import features.settings.thunk.ListenForNoteDateFormatThunk 6 | import features.settings.thunk.SelectNoteDateFormatThunk 7 | import helpers.date.NoteDateFormat 8 | import helpers.date.PatternProvider 9 | import helpers.date.PatternSaver 10 | import helpers.date.toDateFormat 11 | import helpers.date.toNoteDateFormat 12 | import kotlinx.coroutines.CoroutineScope 13 | import kotlinx.coroutines.SupervisorJob 14 | import org.kodein.di.instance 15 | import redux.RAction 16 | import store.RThunk 17 | 18 | object SettingsSlice { 19 | data class State( 20 | val selectedNoteDateFormat: NoteDateFormat = NoteDateFormat.Default 21 | ) 22 | 23 | private val patternProvider by KodeinEntry.di.instance() 24 | private val patternSaver by KodeinEntry.di.instance() 25 | 26 | private val settingsScope = CoroutineScope(SupervisorJob()) 27 | private val listenForNoteDateFormatThunk by lazy { 28 | ListenForNoteDateFormatThunk(settingsScope, patternProvider) 29 | } 30 | 31 | fun listenToNoteDateFormatChanges(): RThunk = listenForNoteDateFormatThunk 32 | 33 | fun changeNoteDateFormat(noteDateFormat: NoteDateFormat): RThunk = 34 | SelectNoteDateFormatThunk(patternSaver, noteDateFormat.toDateFormat()) 35 | 36 | data class ChangeNoteDateFormat(val dateFormat: DateFormat) : RAction 37 | 38 | fun reducer(state: State = State(), action: RAction): State { 39 | return when (action) { 40 | is ChangeNoteDateFormat -> { 41 | state.copy(selectedNoteDateFormat = action.dateFormat.toNoteDateFormat()) 42 | } 43 | else -> state 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/features/settings/thunk/ListenForNoteDateFormatThunk.kt: -------------------------------------------------------------------------------- 1 | package features.settings.thunk 2 | 3 | import features.settings.SettingsSlice 4 | import helpers.date.PatternProvider 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.flow.collect 7 | import kotlinx.coroutines.launch 8 | import redux.RAction 9 | import redux.WrapperAction 10 | import store.AppState 11 | import store.RThunk 12 | import store.nullAction 13 | 14 | class ListenForNoteDateFormatThunk( 15 | private val scope: CoroutineScope, 16 | private val patternProvider: PatternProvider 17 | ) : RThunk { 18 | 19 | override fun invoke(dispatch: (RAction) -> WrapperAction, getState: () -> AppState): WrapperAction { 20 | scope.launch { 21 | patternProvider.patternFlow.collect { dateFormat -> 22 | dispatch(SettingsSlice.ChangeNoteDateFormat(dateFormat)) 23 | } 24 | } 25 | 26 | return nullAction 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/features/settings/thunk/SelectNoteDateFormatThunk.kt: -------------------------------------------------------------------------------- 1 | package features.settings.thunk 2 | 3 | import com.soywiz.klock.DateFormat 4 | import helpers.date.PatternSaver 5 | import redux.RAction 6 | import redux.WrapperAction 7 | import store.AppState 8 | import store.RThunk 9 | import store.nullAction 10 | 11 | class SelectNoteDateFormatThunk( 12 | private val patternSaver: PatternSaver, 13 | private val dateFormat: DateFormat 14 | ) : RThunk { 15 | 16 | override fun invoke(dispatch: (RAction) -> WrapperAction, getState: () -> AppState): WrapperAction { 17 | patternSaver.setPattern(dateFormat) 18 | 19 | return nullAction 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/helpers/storage/LocalStorage.kt: -------------------------------------------------------------------------------- 1 | package helpers.storage 2 | 3 | import kotlinx.browser.window 4 | 5 | class LocalStorage : Storage { 6 | 7 | override fun getString(key: String): String? { 8 | return window.localStorage.getItem(key) 9 | } 10 | 11 | override fun setString(key: String, value: String) { 12 | window.localStorage.setItem(key, value) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/main.kt: -------------------------------------------------------------------------------- 1 | import com.ccfraser.muirwik.components.mThemeProvider 2 | import com.ccfraser.muirwik.components.styles.mStylesProvider 3 | import kotlinx.browser.document 4 | import kotlinx.browser.window 5 | import react.dom.render 6 | import react.redux.provider 7 | import store.myStore 8 | 9 | fun main() { 10 | render(document.getElementById("root")) { 11 | // errorBoundary { 12 | provider(myStore) { 13 | mStylesProvider("jss-insertion-point") { 14 | mThemeProvider { 15 | Auth0Provider { 16 | attrs.domain = AuthenticationConfig.domain 17 | attrs.clientId = AuthenticationConfig.clientId 18 | attrs.audience = AuthenticationConfig.audience 19 | attrs.redirectUri = window.location.origin 20 | attrs.onRedirectCallback = { } 21 | 22 | child(App::class) {} 23 | } 24 | } 25 | } 26 | } 27 | // } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/network/HttpClientFactory.kt: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import TokenProvider 4 | import io.ktor.client.HttpClient 5 | import io.ktor.client.engine.js.Js 6 | import io.ktor.client.features.defaultRequest 7 | import io.ktor.client.features.json.JsonFeature 8 | import io.ktor.client.features.json.serializer.KotlinxSerializer 9 | import io.ktor.client.features.websocket.WebSockets 10 | import io.ktor.client.request.header 11 | 12 | class HttpClientFactory( 13 | private val tokenProvider: TokenProvider 14 | ) { 15 | 16 | fun create(): HttpClient { 17 | return HttpClient(Js) { 18 | defaultRequest { 19 | header("Authorization", "Bearer ${tokenProvider.accessToken}") 20 | } 21 | install(WebSockets) 22 | install(JsonFeature) { 23 | serializer = KotlinxSerializer() 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/store/AppState.kt: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import features.editor.NoteEditorSlice 4 | import features.list.NotesListSlice 5 | import features.settings.SettingsSlice 6 | 7 | data class AppState( 8 | val notesListState: NotesListSlice.State = NotesListSlice.State(), 9 | val noteEditorState: NoteEditorSlice.State = NoteEditorSlice.State(), 10 | val settingsState: SettingsSlice.State = SettingsSlice.State(), 11 | ) 12 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/store/RThunk.kt: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import kotlinext.js.js 4 | import redux.RAction 5 | import redux.WrapperAction 6 | 7 | interface RThunk : RAction { 8 | operator fun invoke( 9 | dispatch: (RAction) -> WrapperAction, 10 | getState: () -> AppState 11 | ): WrapperAction 12 | } 13 | 14 | // Credit to https://github.com/AltmanEA/KotlinExamples 15 | fun rThunk() = 16 | applyMiddleware( 17 | { store -> 18 | { next -> 19 | { action -> 20 | if (action is RThunk) { 21 | action(store::dispatch, store::getState) 22 | } else { 23 | next(action) 24 | } 25 | } 26 | } 27 | } 28 | ) 29 | 30 | val nullAction = js {}.unsafeCast() 31 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/store/Reducers.kt: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import features.editor.NoteEditorSlice 4 | import features.list.NotesListSlice 5 | import features.settings.SettingsSlice 6 | import redux.Reducer 7 | import redux.combineReducers 8 | import kotlin.reflect.KProperty1 9 | 10 | fun combinedReducers() = combineReducersInferred( 11 | mapOf( 12 | AppState::notesListState to NotesListSlice::reducer, 13 | AppState::noteEditorState to NoteEditorSlice::reducer, 14 | AppState::settingsState to SettingsSlice::reducer, 15 | ) 16 | ) 17 | 18 | // credit https://github.com/JetBrains/kotlin-wrappers/blob/master/kotlin-redux/README.md 19 | fun combineReducersInferred(reducers: Map, Reducer<*, A>>): Reducer { 20 | return combineReducers(reducers.mapKeys { it.key.name }) 21 | } 22 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/store/ReduxImportsWrapper.kt: -------------------------------------------------------------------------------- 1 | @file:JsModule("redux") 2 | @file:JsNonModule 3 | 4 | package store 5 | 6 | import redux.Action 7 | import redux.Enhancer 8 | import redux.Middleware 9 | import redux.Reducer 10 | import redux.Store 11 | 12 | external fun createStore( 13 | reducer: Reducer, 14 | preloadedState: S, 15 | enhancer: Enhancer 16 | ): Store 17 | 18 | external fun compose(function1: (T2) -> R, function2: (T1) -> T2, function3: (A) -> T1): (A) -> R 19 | 20 | external fun applyMiddleware( 21 | vararg middlewares: Middleware 22 | ): Enhancer 23 | -------------------------------------------------------------------------------- /react/spa-app/src/main/kotlin/store/Store.kt: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import redux.RAction 4 | import redux.rEnhancer 5 | 6 | @Suppress("MaxLineLength") 7 | val myStore = createStore( 8 | combinedReducers(), 9 | AppState(), 10 | compose( 11 | rThunk(), 12 | rEnhancer(), 13 | js("if(window.__REDUX_DEVTOOLS_EXTENSION__ )window.__REDUX_DEVTOOLS_EXTENSION__ ();else(function(f){return f;});") 14 | ) 15 | ) 16 | -------------------------------------------------------------------------------- /react/spa-app/src/main/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Fuller stack 8 | 9 | 10 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /react/spa-app/webpack.config.d/externals.js: -------------------------------------------------------------------------------- 1 | config.externals = [ 2 | 'text-encoding', 3 | 'utf-8-validate', 4 | 'abort-controller', 5 | 'bufferutil', 6 | 'fs', 7 | 'node-fetch' 8 | ] -------------------------------------------------------------------------------- /react/spa-app/webpack.config.d/webpack.config.js: -------------------------------------------------------------------------------- 1 | config.devServer = Object.assign( 2 | {}, 3 | config.devServer || {}, 4 | { 5 | historyApiFallback: true, 6 | open: false 7 | } 8 | ) -------------------------------------------------------------------------------- /react/spa-authentication/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.jetbrains.kotlin.js") 3 | } 4 | 5 | repositories { 6 | maven("https://kotlin.bintray.com/kotlin-js-wrappers/") 7 | maven("https://dl.bintray.com/kotlin/kotlinx") 8 | maven("https://dl.bintray.com/cfraser/muirwik") 9 | } 10 | 11 | dependencies { 12 | implementation(project(":shared")) 13 | 14 | implementation(kotlin("stdlib-js")) 15 | 16 | // Kotlin wrappers 17 | implementation(ReactLibs.HTML_JS) 18 | implementation(ReactLibs.REACT) 19 | implementation(ReactLibs.REDUX) 20 | implementation(ReactLibs.REACT_REDUX) 21 | implementation(ReactLibs.REACT_DOM) 22 | implementation(ReactLibs.STYLED) 23 | implementation(ReactLibs.CSS_JS) 24 | 25 | // react libraries wrappers 26 | implementation(ReactLibs.MUIRWIK) 27 | } 28 | 29 | kotlin { 30 | target { 31 | browser() 32 | } 33 | 34 | sourceSets["main"].dependencies { 35 | implementation(npm("react", Versions.REACT)) 36 | implementation(npm("react-dom", Versions.REACT)) 37 | implementation(npm("@auth0/auth0-react", Versions.AUTH0)) 38 | 39 | implementation(npm("styled-components", "5.0.0")) 40 | implementation(npm("inline-style-prefixer", "6.0.0")) 41 | implementation(npm("@material-ui/core", Versions.NPM_MATERIAL_UI)) 42 | implementation(npm("@material-ui/icons", Versions.NPM_MATERIAL_UI_ICONS)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /react/spa-authentication/src/main/kotlin/Auth0.kt: -------------------------------------------------------------------------------- 1 | 2 | import react.RClass 3 | import react.RProps 4 | 5 | @JsModule("@auth0/auth0-react") 6 | @JsNonModule 7 | external val Auth0Module: dynamic 8 | 9 | val Auth0Provider: RClass = Auth0Module.Auth0Provider 10 | 11 | interface Auth0ProviderProps : RProps { 12 | var domain: String 13 | var clientId: String 14 | var audience: String 15 | var redirectUri: String 16 | var onRedirectCallback: (appState: AppState) -> Unit 17 | } 18 | 19 | val useAuth0: () -> Auth0ContextInterface = Auth0Module.useAuth0 20 | //TODO clean up unneeded dukat generated classes 21 | 22 | // fun RBuilder.auth0Provider( 23 | // domain: String, 24 | // clientId: String, 25 | // audience: String, 26 | // redirectUri: String, 27 | // onRedirectCallback: (appState: AppState) -> Unit, 28 | // handler: RHandler 29 | // ): ReactElement = child(Auth0Provider::class) { 30 | // // attrs.domain = domain 31 | // // attrs.clientId = clientId 32 | // // attrs.audience = audience 33 | // // attrs.redirectUri = redirectUri 34 | // // attrs.onRedirectCallback = onRedirectCallback 35 | // handler() 36 | // } 37 | -------------------------------------------------------------------------------- /react/spa-authentication/src/main/kotlin/AuthenticationConfig.kt: -------------------------------------------------------------------------------- 1 | 2 | object AuthenticationConfig { 3 | const val domain = "fuller-stack.eu.auth0.com" 4 | const val clientId = "JOtd5A5a3XItLWrEf0RV5DZsFGs8ToUS" 5 | const val audience = "https://fuller-stack-ktor.herokuapp.com/" 6 | } 7 | -------------------------------------------------------------------------------- /react/spa-authentication/src/main/kotlin/TokenProvider.kt: -------------------------------------------------------------------------------- 1 | class TokenProvider { 2 | var accessToken: String? = null 3 | private set 4 | 5 | fun initializeToken(token: String) { 6 | accessToken = token 7 | } 8 | 9 | fun revokeToken() { 10 | accessToken = null 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /react/spa-authentication/src/main/kotlin/dukat/auth-state.tsx.module_@auth0_auth0-react.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress( 2 | "INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", 3 | "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS" 4 | ) 5 | 6 | interface User { 7 | val name: String? 8 | val email: String? 9 | val picture: String? 10 | } 11 | 12 | external interface AuthState { 13 | var error: Error? 14 | get() = definedExternally 15 | set(value) = definedExternally 16 | var isAuthenticated: Boolean 17 | var isLoading: Boolean 18 | var user: User? 19 | get() = definedExternally 20 | set(value) = definedExternally 21 | } 22 | -------------------------------------------------------------------------------- /react/spa-authentication/src/main/kotlin/dukat/auth0-context.tsx.module_@auth0_auth0-react.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress( 2 | "INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", 3 | "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS", "MatchingDeclarationName" 4 | ) 5 | 6 | import kotlin.js.Promise 7 | 8 | @Suppress("MatchingDeclarationName") 9 | external interface Auth0ContextInterface : AuthState { 10 | var getAccessTokenSilently: (options: GetTokenSilentlyOptions) -> Promise 11 | var getAccessTokenWithPopup: (options: GetTokenWithPopupOptions) -> Promise 12 | var getIdTokenClaims: (options: GetIdTokenClaimsOptions) -> Promise 13 | var loginWithRedirect: (options: RedirectLoginOptions?) -> Promise 14 | var loginWithPopup: (options: PopupLoginOptions, config: PopupConfigOptions) -> Promise 15 | var logout: (options: LogoutOptions?) -> Unit 16 | } 17 | -------------------------------------------------------------------------------- /react/spa-authentication/src/main/kotlin/dukat/errors.tsx.module_@auth0_auth0-react.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress( 2 | "INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", 3 | "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS" 4 | ) 5 | 6 | typealias OAuthError = Error 7 | -------------------------------------------------------------------------------- /react/spa-authentication/src/main/kotlin/dukat/lib.dom.kt: -------------------------------------------------------------------------------- 1 | // @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", 2 | // "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 3 | // package tsstdlib 4 | // 5 | // import org.w3c.dom.events.Event 6 | // 7 | // external interface EventListenerObject { 8 | // fun handleEvent(evt: Event) 9 | // } 10 | -------------------------------------------------------------------------------- /react/spa-authentication/src/main/kotlin/dukat/lib.es2015.iterable.kt: -------------------------------------------------------------------------------- 1 | // @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", 2 | // "RETURN_TYPE_MISMATCH_ON_OVERRIDE", 3 | // "CONFLICTING_OVERLOADS") 4 | // package tsstdlib 5 | // 6 | // import kotlin.js.* 7 | // 8 | // external interface IteratorYieldResult { 9 | // var done: Boolean? 10 | // get() = definedExternally 11 | // set(value) = definedExternally 12 | // var value: TYield 13 | // } 14 | // 15 | // external interface IteratorReturnResult { 16 | // var done: Boolean 17 | // var value: TReturn 18 | // } 19 | // 20 | // external interface Iterator { 21 | // fun next(vararg args: dynamic /* JsTuple<> | JsTuple */): dynamic /* IteratorYieldResult | 22 | // IteratorReturnResult */ 23 | // val `return`: ((value: TReturn) -> dynamic)? 24 | // get() = definedExternally 25 | // val `throw`: ((e: Any) -> dynamic)? 26 | // get() = definedExternally 27 | // } 28 | // 29 | // external interface Iterable 30 | // 31 | // external interface PromiseConstructor { 32 | // var prototype: Promise 33 | // fun all(values: Any): Promise 34 | // | JsTuple | JsTuple | 35 | // JsTuple | JsTuple | 36 | // JsTuple | JsTuple | JsTuple 37 | // | JsTuple */> 38 | // fun all(values: Any): Promise> 39 | // fun race(values: Any): Promise 40 | // fun reject(reason: Any = definedExternally): Promise 41 | // fun resolve(value: T): Promise 42 | // fun resolve(value: PromiseLike): Promise 43 | // fun resolve(): Promise 44 | // fun all(values: Iterable */>): Promise> 45 | // fun race(values: Iterable): Promise 46 | // fun race(values: Iterable */>): Promise 47 | // } 48 | -------------------------------------------------------------------------------- /react/spa-authentication/src/main/kotlin/dukat/lib.es5.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress( 2 | "INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", 3 | "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS" 4 | ) 5 | package tsstdlib 6 | 7 | external interface ErrorConstructor { 8 | @nativeInvoke 9 | operator fun invoke(message: String = definedExternally): Error 10 | var prototype: Error 11 | } 12 | 13 | external interface PromiseLike { 14 | fun then( 15 | onfulfilled: ((value: T) -> dynamic)? = definedExternally, 16 | onrejected: ((reason: Any) -> dynamic)? = definedExternally 17 | ): PromiseLike 18 | } 19 | 20 | typealias Pick = Any 21 | 22 | typealias Exclude = Any 23 | 24 | typealias Omit = Any 25 | -------------------------------------------------------------------------------- /react/spa-authentication/src/main/kotlin/dukat/reducer.tsx.module_@auth0_auth0-react.kt: -------------------------------------------------------------------------------- 1 | // @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", 2 | // "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 3 | // 4 | // external interface `T$2` { 5 | // var type: String /* 'LOGIN_POPUP_STARTED' */ 6 | // } 7 | // 8 | // external interface `T$3` { 9 | // var type: String /* 'INITIALISED' | 'LOGIN_POPUP_COMPLETE' */ 10 | // var isAuthenticated: Boolean 11 | // var user: User? 12 | // get() = definedExternally 13 | // set(value) = definedExternally 14 | // } 15 | // 16 | // external interface `T$4` { 17 | // var type: String /* 'LOGOUT' */ 18 | // } 19 | // 20 | // external interface `T$5` { 21 | // var type: String /* 'ERROR' */ 22 | // var error: Error 23 | // } 24 | -------------------------------------------------------------------------------- /react/spa-authentication/src/main/kotlin/dukat/with-auth0.tsx.module_@auth0_auth0-react.kt: -------------------------------------------------------------------------------- 1 | // @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", 2 | // "RETURN_TYPE_MISMATCH_ON_OVERRIDE", 3 | // "CONFLICTING_OVERLOADS") 4 | // 5 | // external interface WithAuth0Props { 6 | // var auth0: Auth0ContextInterface 7 | // } 8 | -------------------------------------------------------------------------------- /react/spa-authentication/src/main/kotlin/dukat/with-authentication-required.tsx.module_@auth0_auth0-react.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress( 2 | "INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", 3 | "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS", "MatchingDeclarationName" 4 | ) 5 | 6 | external interface WithAuthenticationRequiredOptions { 7 | var returnTo: dynamic /* String? | (() -> String)? */ 8 | get() = definedExternally 9 | set(value) = definedExternally 10 | var onRedirecting: (() -> dynamic)? 11 | get() = definedExternally 12 | set(value) = definedExternally 13 | var loginOptions: RedirectLoginOptions? 14 | get() = definedExternally 15 | set(value) = definedExternally 16 | } 17 | -------------------------------------------------------------------------------- /react/spa-authentication/src/main/kotlin/profile/ProfileText.kt: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import com.ccfraser.muirwik.components.MTypographyVariant 4 | import com.ccfraser.muirwik.components.mTypography 5 | import kotlinx.css.Color 6 | import kotlinx.css.color 7 | import react.RProps 8 | import react.functionalComponent 9 | import styled.StyleSheet 10 | import styled.css 11 | import styled.styledDiv 12 | 13 | interface ProfileTextProps: RProps { 14 | var label: String 15 | var text: String? 16 | } 17 | 18 | @Suppress("MagicNumber") 19 | private object ProfileTextClasses : StyleSheet("ProfileText", isStatic = true) { 20 | val label by css { 21 | color = Color("#8e8e8e") 22 | } 23 | val text by css { 24 | 25 | } 26 | } 27 | 28 | val profileText = functionalComponent { props -> 29 | styledDiv { 30 | mTypography(text = "${props.label} ", variant = MTypographyVariant.h4, component = "span") { 31 | css(ProfileTextClasses.label) 32 | } 33 | val text = props.text ?: "Unavailable" 34 | mTypography(text = text, variant = MTypographyVariant.h4, component = "span") { 35 | css(ProfileTextClasses.text) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /react/spa-persistance/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.jetbrains.kotlin.js") 3 | } 4 | 5 | dependencies { 6 | implementation(project(":shared")) 7 | 8 | implementation(kotlin("stdlib-js")) 9 | 10 | // network 11 | implementation(SharedLibs.COROUTINES_CORE) 12 | } 13 | 14 | kotlin { 15 | target { 16 | browser() 17 | } 18 | 19 | sourceSets["main"].dependencies { } 20 | } 21 | 22 | tasks { 23 | val reactRun by registering { 24 | dependsOn("browserDevelopmentRun") 25 | 26 | doLast { 27 | println("running on http://localhost:8080/") 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/DexieDatabase.kt: -------------------------------------------------------------------------------- 1 | import dukat.DexieIndex 2 | import kotlin.js.json 3 | 4 | @Suppress("MaxLineLength") 5 | object DexieDatabase { 6 | private const val tableName = "testTable" 7 | val db = (js("new Dexie('test8')") as DexieIndex).apply { 8 | version(1).stores( 9 | json( 10 | tableName to "++localId,creationTimestamp,title,content,lastModificationTimestamp,hasSyncFailed,wasDeleted" 11 | ) 12 | ) 13 | } 14 | val noteTable: DexieIndex.Table = db.table(tableName) 15 | } 16 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/DexieNoteEntity.kt: -------------------------------------------------------------------------------- 1 | data class DexieNoteEntity( 2 | val localId: Int? = undefined, 3 | val title: String, 4 | val content: String, 5 | val lastModificationTimestamp: String, 6 | val creationTimestamp: String, 7 | val hasSyncFailed: Boolean = false, 8 | val wasDeleted: Boolean = false 9 | ) 10 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/database.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "MatchingDeclarationName", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | import kotlin.js.Promise 6 | 7 | external interface Database { 8 | var name: String 9 | var tables: Array 10 | fun table(tableName: String): Table 11 | fun transaction(mode: String /* 'readonly' | 'readwrite' | 'r' | 'r!' | 'r?' | 'rw' | 'rw!' | 'rw?' */, table: Table__0, scope: () -> dynamic): Promise 12 | fun transaction(mode: String /* 'readonly' | 'readwrite' | 'r' | 'r!' | 'r?' | 'rw' | 'rw!' | 'rw?' */, table: Table__0, table2: Table__0, scope: () -> dynamic): Promise 13 | fun transaction(mode: String /* 'readonly' | 'readwrite' | 'r' | 'r!' | 'r?' | 'rw' | 'rw!' | 'rw?' */, table: Table__0, table2: Table__0, table3: Table__0, scope: () -> dynamic): Promise 14 | fun transaction(mode: String /* 'readonly' | 'readwrite' | 'r' | 'r!' | 'r?' | 'rw' | 'rw!' | 'rw?' */, table: Table__0, table2: Table__0, table3: Table__0, table4: Table__0, scope: () -> dynamic): Promise 15 | fun transaction(mode: String /* 'readonly' | 'readwrite' | 'r' | 'r!' | 'r?' | 'rw' | 'rw!' | 'rw?' */, table: Table__0, table2: Table__0, table3: Table__0, table4: Table__0, table5: Table__0, scope: () -> dynamic): Promise 16 | fun transaction(mode: String /* 'readonly' | 'readwrite' | 'r' | 'r!' | 'r?' | 'rw' | 'rw!' | 'rw?' */, tables: Array, scope: () -> dynamic): Promise 17 | } 18 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/db-events.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | external interface DexieOnReadyEvent { 6 | fun subscribe(fn: () -> Any, bSticky: Boolean) 7 | fun unsubscribe(fn: () -> Any) 8 | fun fire(): Any 9 | } 10 | 11 | external interface DexieVersionChangeEvent { 12 | fun subscribe(fn: (event: IDBVersionChangeEvent) -> Any) 13 | fun unsubscribe(fn: (event: IDBVersionChangeEvent) -> Any) 14 | fun fire(event: IDBVersionChangeEvent): Any 15 | } 16 | 17 | external interface DexiePopulateEvent { 18 | fun subscribe(fn: (trans: Transaction) -> Any) 19 | fun unsubscribe(fn: (trans: Transaction) -> Any) 20 | fun fire(trans: Transaction): Any 21 | } 22 | 23 | external interface DbEvents : DexieEventSet { 24 | @nativeInvoke 25 | operator fun invoke(eventName: String /* 'ready' */, subscriber: () -> Any, bSticky: Boolean = definedExternally) 26 | @nativeInvoke 27 | operator fun invoke(eventName: String /* 'populate' */, subscriber: (trans: Transaction) -> Any) 28 | @nativeInvoke 29 | operator fun invoke(eventName: String /* 'blocked' | 'versionchange' */, subscriber: (event: IDBVersionChangeEvent) -> Any) 30 | var ready: DexieOnReadyEvent 31 | var populate: DexiePopulateEvent 32 | var blocked: DexieEvent 33 | var versionchange: DexieVersionChangeEvent 34 | } 35 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/db-schema.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | external interface DbSchema { 6 | @nativeGetter 7 | operator fun get(tableName: String): TableSchema? 8 | @nativeSetter 9 | operator fun set(tableName: String, value: TableSchema) 10 | } 11 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/dexie-dom-dependencies.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | external interface DexieDOMDependencies { 6 | var indexedDB: IDBFactory 7 | var IDBKeyRange: Any 8 | } 9 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/dexie-event-set.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | external interface `T$14` { 6 | @nativeGetter 7 | operator fun get(eventName: String): dynamic /* String | dynamic */ 8 | @nativeSetter 9 | operator fun set(eventName: String, value: String /* 'asap' */) 10 | @nativeSetter 11 | operator fun set(eventName: String, value: dynamic /* JsTuple<(f1: Function<*>, f2: Function<*>) -> Function<*>, Function<*>> */) 12 | } 13 | 14 | external interface DexieEventSet { 15 | @nativeInvoke 16 | operator fun invoke(eventName: String): DexieEvent 17 | fun addEventType(eventName: String, chainFunction: (f1: Function<*>, f2: Function<*>) -> Function<*> = definedExternally, defaultFunction: Function<*> = definedExternally): DexieEvent 18 | fun addEventType(events: `T$14`): DexieEvent 19 | } 20 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/dexie-event.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | external interface DexieEvent { 6 | var subscribers: Array> 7 | fun fire(vararg args: Any): Any 8 | fun subscribe(fn: (args: Any) -> Any) 9 | fun unsubscribe(fn: (args: Any) -> Any) 10 | } 11 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/index-spec.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | external interface IndexSpec { 6 | var name: String 7 | var keyPath: dynamic /* String? | Array? */ 8 | get() = definedExternally 9 | set(value) = definedExternally 10 | var unique: Boolean? 11 | var multi: Boolean? 12 | var auto: Boolean? 13 | var compound: Boolean? 14 | var src: String 15 | } 16 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/indexable-type.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | typealias IndexableTypeArray = Array> */> 6 | 7 | typealias IndexableTypeArrayReadonly = Array> */> 8 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/lib.scripthost.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | open external class VarDate { 6 | open var VarDate_typekey: VarDate 7 | } 8 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/middleware.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | external interface Middleware { 6 | var stack: Any 7 | var create: (down: TStack) -> Partial 8 | var level: Number? 9 | get() = definedExternally 10 | set(value) = definedExternally 11 | var name: String? 12 | get() = definedExternally 13 | set(value) = definedExternally 14 | } 15 | 16 | external interface DexieStacks { 17 | var dbcore: DBCore 18 | } 19 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/table-hooks.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | external interface CreatingHookContext { 6 | var onsuccess: ((primKey: Key) -> Unit)? 7 | get() = definedExternally 8 | set(value) = definedExternally 9 | var onerror: ((err: Any) -> Unit)? 10 | get() = definedExternally 11 | set(value) = definedExternally 12 | } 13 | 14 | external interface UpdatingHookContext { 15 | var onsuccess: ((updatedObj: T) -> Unit)? 16 | get() = definedExternally 17 | set(value) = definedExternally 18 | var onerror: ((err: Any) -> Unit)? 19 | get() = definedExternally 20 | set(value) = definedExternally 21 | } 22 | 23 | external interface DeletingHookContext { 24 | var onsuccess: (() -> Unit)? 25 | get() = definedExternally 26 | set(value) = definedExternally 27 | var onerror: ((err: Any) -> Unit)? 28 | get() = definedExternally 29 | set(value) = definedExternally 30 | } 31 | 32 | external interface TableHooks : DexieEventSet { 33 | @nativeInvoke 34 | operator fun invoke(eventName: String /* 'creating' */, subscriber: (self: CreatingHookContext, primKey: TKey, obj: T, transaction: Transaction) -> Any) 35 | @nativeInvoke 36 | operator fun invoke(eventName: String /* 'reading' */, subscriber: (obj: T) -> dynamic) 37 | @nativeInvoke 38 | operator fun invoke(eventName: String /* 'updating' */, subscriber: (self: UpdatingHookContext, modifications: Any, primKey: TKey, obj: T, transaction: Transaction) -> Any) 39 | @nativeInvoke 40 | operator fun invoke(eventName: String /* 'deleting' */, subscriber: (self: DeletingHookContext, primKey: TKey, obj: T, transaction: Transaction) -> Any) 41 | var creating: DexieEvent 42 | var reading: DexieEvent 43 | var updating: DexieEvent 44 | var deleting: DexieEvent 45 | } 46 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/table-schema.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | external interface `T$13` { 6 | @nativeGetter 7 | operator fun get(name: String): IndexSpec? 8 | @nativeSetter 9 | operator fun set(name: String, value: IndexSpec) 10 | } 11 | 12 | external interface TableSchema { 13 | var name: String 14 | var primKey: IndexSpec 15 | var indexes: Array 16 | var mappedClass: Function<*> 17 | var idxByName: `T$13` 18 | var readHook: ((x: Any) -> Any)? 19 | get() = definedExternally 20 | set(value) = definedExternally 21 | } 22 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/then-shortcut.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | typealias ThenShortcut = (value: T) -> dynamic 6 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/transaction-events.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | external interface TransactionEvents : DexieEventSet { 6 | @nativeInvoke 7 | operator fun invoke(eventName: String /* 'complete' | 'abort' */, subscriber: () -> Any) 8 | @nativeInvoke 9 | operator fun invoke(eventName: String /* 'error' */, subscriber: (error: Any) -> Any) 10 | var complete: DexieEvent 11 | var abort: DexieEvent 12 | var error: DexieEvent 13 | } 14 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/transaction.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | external interface Transaction { 6 | var db: Database 7 | var active: Boolean 8 | var mode: String /* "readonly" | "readwrite" | "versionchange" */ 9 | var storeNames: Array 10 | var parent: Transaction? 11 | get() = definedExternally 12 | set(value) = definedExternally 13 | var on: TransactionEvents 14 | fun abort() 15 | fun table(tableName: String): Table 16 | fun table(tableName: String): Table 17 | fun table(tableName: String): Table 18 | } 19 | -------------------------------------------------------------------------------- /react/spa-persistance/src/main/kotlin/dukat/version.module_dexie.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INTERFACE_WITH_SUPERCLASS", "OVERRIDING_FINAL_MEMBER", "RETURN_TYPE_MISMATCH_ON_OVERRIDE", "CONFLICTING_OVERLOADS") 2 | 3 | package dukat 4 | 5 | import kotlin.js.Json 6 | 7 | external interface Version { 8 | fun stores(schema: Json): Version 9 | fun upgrade(fn: (trans: Transaction) -> Unit): Version 10 | } 11 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "fuller_stack" 2 | 3 | enableFeaturePreview("GRADLE_METADATA") 4 | include( 5 | "shared", 6 | "ktor", 7 | "android:app", 8 | "android:authentication", 9 | "android:framework", 10 | "react:spa-app", 11 | "react:spa-authentication", 12 | "react:spa-persistance" 13 | ) 14 | -------------------------------------------------------------------------------- /shared/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/base/CommonDispatchers.kt: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.Dispatchers 5 | 6 | actual object CommonDispatchers { 7 | 8 | actual val BackgroundDispatcher: CoroutineDispatcher 9 | get() = Dispatchers.IO 10 | 11 | actual val MainDispatcher: CoroutineDispatcher 12 | get() = Dispatchers.Main 13 | } 14 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/database/NoteEntity.kt: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | import model.CreationTimestamp 6 | import model.LastModificationTimestamp 7 | 8 | @Entity(tableName = "notes") 9 | actual data class NoteEntity( 10 | @PrimaryKey(autoGenerate = true) actual val localId: Int = 0, 11 | actual val title: String, 12 | actual val content: String, 13 | actual val lastModificationTimestamp: LastModificationTimestamp, 14 | actual val creationTimestamp: CreationTimestamp, 15 | actual val hasSyncFailed: Boolean = false, 16 | actual val wasDeleted: Boolean = false 17 | ) 18 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/network/safeApiCall.kt: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import retrofit2.HttpException 4 | import java.io.IOException 5 | 6 | @Suppress("TooGenericExceptionCaught") 7 | actual suspend fun safeApiCall(block: suspend () -> T): NetworkResponse { 8 | return try { 9 | val result = block() 10 | NetworkResponse.Success(result) 11 | } catch (throwable: Throwable) { 12 | throwable.printStackTrace() 13 | when (throwable) { 14 | is IOException -> NetworkResponse.NetworkError 15 | is HttpException -> NetworkResponse.ApiError 16 | else -> NetworkResponse.UnknownError 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /shared/src/androidTest/kotlin/runTest.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.Dispatchers 2 | import kotlinx.coroutines.ExperimentalCoroutinesApi 3 | import kotlinx.coroutines.runBlocking 4 | import kotlinx.coroutines.test.setMain 5 | 6 | @ExperimentalCoroutinesApi 7 | actual fun runTest(block: suspend () -> T) { 8 | Dispatchers.setMain(Dispatchers.Default) 9 | runBlocking { block() } 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/base/CommonDispatchers.kt: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | 5 | expect object CommonDispatchers { 6 | 7 | val BackgroundDispatcher: CoroutineDispatcher 8 | 9 | val MainDispatcher: CoroutineDispatcher 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/base/usecase/Failure.kt: -------------------------------------------------------------------------------- 1 | package base.usecase 2 | 3 | sealed class Failure { 4 | object ApiError : Failure() 5 | object NetworkError : Failure() 6 | 7 | abstract class FeatureFailure : Failure() 8 | } 9 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/composition/CommonModule.kt: -------------------------------------------------------------------------------- 1 | package composition 2 | 3 | import base.CommonDispatchers 4 | import database.NoteEntityMapper 5 | import helpers.date.KlockUnixTimestampProvider 6 | import helpers.date.PatternProvider 7 | import helpers.date.PatternSaver 8 | import helpers.date.PatternStorage 9 | import helpers.date.UnixTimestampProvider 10 | import helpers.validation.NoteEditorInputValidator 11 | import helpers.validation.NoteInputValidator 12 | import kotlinx.coroutines.CoroutineDispatcher 13 | import kotlinx.coroutines.ExperimentalCoroutinesApi 14 | import network.NoteSchemaMapper 15 | import org.kodein.di.DI 16 | import org.kodein.di.bind 17 | import org.kodein.di.instance 18 | import org.kodein.di.singleton 19 | 20 | @ExperimentalCoroutinesApi 21 | val common = DI.Module("Common") { 22 | bind() from singleton { NoteSchemaMapper() } 23 | bind() from singleton { NoteEntityMapper() } 24 | bind(tag = "BackgroundDispatcher") with singleton { CommonDispatchers.BackgroundDispatcher } 25 | bind() with singleton { KlockUnixTimestampProvider() } 26 | bind() from singleton { PatternStorage(instance(), instance()) } 27 | bind() with singleton { instance() } 28 | bind() with singleton { instance() } 29 | bind() with singleton { NoteEditorInputValidator() } 30 | import(useCaseModule) 31 | } 32 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/database/NoteDao.kt: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import feature.AddNotePayload 4 | import feature.UpdateNotePayload 5 | import kotlinx.coroutines.flow.Flow 6 | import model.CreationTimestamp 7 | 8 | interface NoteDao { 9 | 10 | fun getAllNotes(): Flow> 11 | 12 | suspend fun addNote(addNotePayload: AddNotePayload): Int 13 | 14 | suspend fun updateNote(updateNotePayload: UpdateNotePayload) 15 | 16 | suspend fun updateSyncFailed(creationTimestamp: CreationTimestamp, hasSyncFailed: Boolean) 17 | 18 | suspend fun deleteNotes(creationTimestamps: List) 19 | 20 | suspend fun setWasDeleted( 21 | creationTimestamps: List, 22 | wasDeleted: Boolean, 23 | lastModificationTimestamp: Long 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/database/NoteEntity.kt: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import model.CreationTimestamp 4 | import model.LastModificationTimestamp 5 | 6 | expect class NoteEntity { 7 | val localId: Int 8 | val title: String 9 | val content: String 10 | val lastModificationTimestamp: LastModificationTimestamp 11 | val creationTimestamp: CreationTimestamp 12 | val hasSyncFailed: Boolean 13 | val wasDeleted: Boolean 14 | } 15 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/database/NoteEntityMapper.kt: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import model.Note 4 | 5 | class NoteEntityMapper { 6 | 7 | fun toNotes(notes: List): List = notes.map(::toNote) 8 | 9 | fun toNote(note: NoteEntity): Note = 10 | Note( 11 | title = note.title, 12 | content = note.content, 13 | lastModificationTimestamp = note.lastModificationTimestamp, 14 | creationTimestamp = note.creationTimestamp, 15 | hasSyncFailed = note.hasSyncFailed 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/feature/AddNote.kt: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import database.NoteDao 4 | import helpers.date.UnixTimestampProvider 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import kotlinx.coroutines.withContext 7 | import model.toCreationTimestamp 8 | import model.toLastModificationTimestamp 9 | import network.NetworkResponse 10 | import network.NoteApi 11 | import network.safeApiCall 12 | 13 | class AddNote( 14 | private val coroutineDispatcher: CoroutineDispatcher, 15 | private val noteDao: NoteDao, 16 | private val noteApi: NoteApi, 17 | private val unixTimestampProvider: UnixTimestampProvider 18 | ) { 19 | 20 | suspend fun executeAsync(title: String, content: String): Boolean = withContext(coroutineDispatcher) { 21 | val currentUnixTimestamp = unixTimestampProvider.now() 22 | val payload = AddNotePayload( 23 | title = title, 24 | content = content, 25 | lastModificationTimestamp = currentUnixTimestamp.toLastModificationTimestamp(), 26 | creationTimestamp = currentUnixTimestamp.toCreationTimestamp() 27 | ) 28 | noteDao.addNote(payload) 29 | val networkResponse = safeApiCall { noteApi.addNote(payload) } 30 | 31 | when (networkResponse) { 32 | is NetworkResponse.Success -> { 33 | true 34 | } 35 | else -> { 36 | noteDao.updateSyncFailed(payload.creationTimestamp, true) 37 | false 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/feature/AddNotePayload.kt: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import kotlinx.serialization.Serializable 4 | import model.CreationTimestamp 5 | import model.LastModificationTimestamp 6 | 7 | @Serializable 8 | data class AddNotePayload( 9 | val title: String, 10 | val content: String, 11 | val lastModificationTimestamp: LastModificationTimestamp, 12 | val creationTimestamp: CreationTimestamp 13 | ) 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/feature/DeleteNotePayload.kt: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import kotlinx.serialization.Serializable 4 | import model.CreationTimestamp 5 | import model.LastModificationTimestamp 6 | 7 | @Serializable 8 | data class DeleteNotePayload( 9 | val creationTimestamp: CreationTimestamp, 10 | val wasDeleted: Boolean, 11 | val lastModificationTimestamp: LastModificationTimestamp 12 | ) 13 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/feature/DeleteNotes.kt: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import database.NoteDao 4 | import helpers.date.UnixTimestampProvider 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import kotlinx.coroutines.withContext 7 | import model.CreationTimestamp 8 | import network.NetworkResponse 9 | import network.NoteApi 10 | import network.safeApiCall 11 | 12 | class DeleteNotes( 13 | private val coroutineDispatcher: CoroutineDispatcher, 14 | private val timestampProvider: UnixTimestampProvider, 15 | private val noteDao: NoteDao, 16 | private val noteApi: NoteApi 17 | ) { 18 | 19 | suspend fun executeAsync(creationTimestamps: List): Boolean = withContext(coroutineDispatcher) { 20 | val timestamp = timestampProvider.now() 21 | noteDao.setWasDeleted(creationTimestamps, true, timestamp) 22 | val result = safeApiCall { noteApi.deleteNotes(creationTimestamps, timestamp) } 23 | 24 | when (result) { 25 | is NetworkResponse.Success -> { 26 | noteDao.deleteNotes(creationTimestamps) 27 | true 28 | } 29 | else -> false 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/feature/GetNotes.kt: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import database.NoteDao 4 | import database.NoteEntityMapper 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.map 8 | import kotlinx.coroutines.withContext 9 | import model.Note 10 | 11 | class GetNotes( 12 | private val coroutineDispatcher: CoroutineDispatcher, 13 | private val noteDao: NoteDao, 14 | private val noteEntityMapper: NoteEntityMapper 15 | ) { 16 | 17 | suspend fun executeAsync(): Flow> = withContext(coroutineDispatcher) { 18 | return@withContext noteDao.getAllNotes() 19 | .map { entities -> 20 | val availableEntities = entities.filterNot { it.wasDeleted } 21 | noteEntityMapper.toNotes(availableEntities) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/feature/UpdateNote.kt: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import database.NoteDao 4 | import helpers.date.UnixTimestampProvider 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import kotlinx.coroutines.withContext 7 | import model.CreationTimestamp 8 | import model.toLastModificationTimestamp 9 | import network.NetworkResponse 10 | import network.NoteApi 11 | import network.safeApiCall 12 | 13 | class UpdateNote( 14 | private val coroutineDispatcher: CoroutineDispatcher, 15 | private val unixTimestampProvider: UnixTimestampProvider, 16 | private val noteDao: NoteDao, 17 | private val noteApi: NoteApi 18 | ) { 19 | 20 | suspend fun executeAsync( 21 | creationTimestamp: CreationTimestamp, 22 | title: String, 23 | content: String 24 | ): Boolean = withContext(coroutineDispatcher) { 25 | val unixTimestamp = unixTimestampProvider.now() 26 | val payload = UpdateNotePayload( 27 | title = title, 28 | content = content, 29 | lastModificationTimestamp = unixTimestamp.toLastModificationTimestamp(), 30 | creationTimestamp = creationTimestamp 31 | ) 32 | noteDao.updateNote(payload) 33 | val networkResponse = safeApiCall { noteApi.updateNote(payload) } 34 | 35 | when (networkResponse) { 36 | is NetworkResponse.Success -> { 37 | noteDao.updateSyncFailed(payload.creationTimestamp, false) 38 | true 39 | } 40 | else -> { 41 | noteDao.updateSyncFailed(payload.creationTimestamp, true) 42 | false 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/feature/UpdateNotePayload.kt: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import kotlinx.serialization.Serializable 4 | import model.CreationTimestamp 5 | import model.LastModificationTimestamp 6 | 7 | @Serializable 8 | data class UpdateNotePayload( 9 | val title: String, 10 | val content: String, 11 | val lastModificationTimestamp: LastModificationTimestamp, 12 | val creationTimestamp: CreationTimestamp 13 | ) 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/feature/local/search/SearchNotes.kt: -------------------------------------------------------------------------------- 1 | package feature.local.search 2 | 3 | import model.Note 4 | 5 | class SearchNotes { 6 | 7 | fun execute(notes: List, searchKeyword: String): List { 8 | val searchKeywordLower = searchKeyword.toLowerCase() 9 | return notes.filter { note -> 10 | note.title.toLowerCase().contains(searchKeywordLower) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/feature/local/sort/SortDirection.kt: -------------------------------------------------------------------------------- 1 | package feature.local.sort 2 | 3 | enum class SortDirection { 4 | ASCENDING, 5 | DESCENDING 6 | } 7 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/feature/local/sort/SortNotes.kt: -------------------------------------------------------------------------------- 1 | package feature.local.sort 2 | 3 | import model.Note 4 | 5 | class SortNotes { 6 | 7 | fun execute(notes: List, sortProperty: SortProperty): List { 8 | return notes.sortedWith(NotesComparator(sortProperty)) 9 | } 10 | 11 | private class NotesComparator(private val sortProperty: SortProperty) : Comparator { 12 | override fun compare(a: Note, b: Note): Int { 13 | return when (sortProperty.direction) { 14 | SortDirection.ASCENDING -> when (sortProperty.type) { 15 | SortType.NAME -> compareValues(a.title.toLowerCase(), b.title.toLowerCase()) 16 | SortType.CREATION_DATE -> compareValues(a.creationTimestamp.unix, b.creationTimestamp.unix) 17 | } 18 | SortDirection.DESCENDING -> when (sortProperty.type) { 19 | SortType.NAME -> compareValues(b.title.toLowerCase(), a.title.toLowerCase()) 20 | SortType.CREATION_DATE -> compareValues(b.creationTimestamp.unix, a.creationTimestamp.unix) 21 | } 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/feature/local/sort/SortProperty.kt: -------------------------------------------------------------------------------- 1 | package feature.local.sort 2 | 3 | data class SortProperty( 4 | val type: SortType, 5 | val direction: SortDirection 6 | ) { 7 | companion object { 8 | val DEFAULT = SortProperty(SortType.CREATION_DATE, SortDirection.DESCENDING) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/feature/local/sort/SortType.kt: -------------------------------------------------------------------------------- 1 | package feature.local.sort 2 | 3 | enum class SortType { 4 | CREATION_DATE, 5 | NAME 6 | } 7 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/feature/socket/ListenToSocketUpdates.kt: -------------------------------------------------------------------------------- 1 | package feature.socket 2 | 3 | import database.NoteDao 4 | import feature.synchronization.SynchronizeNotes 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Job 8 | import kotlinx.coroutines.cancel 9 | import kotlinx.coroutines.delay 10 | import kotlinx.coroutines.flow.collect 11 | import kotlinx.coroutines.flow.firstOrNull 12 | import kotlinx.coroutines.flow.retry 13 | import kotlinx.coroutines.launch 14 | 15 | class ListenToSocketUpdates( 16 | coroutineDispatcher: CoroutineDispatcher, 17 | private val noteDao: NoteDao, 18 | private val noteSocket: NoteSocket, 19 | private val synchronizeNotes: SynchronizeNotes 20 | ) { 21 | 22 | companion object { 23 | private const val SOCKET_RETRY_DELAY_MILLISECONDS = 5000L 24 | } 25 | 26 | private val scope: CoroutineScope = CoroutineScope(coroutineDispatcher) 27 | private var job: Job? = null 28 | 29 | fun listenToSocketChanges() { 30 | if (job != null) { 31 | return 32 | } 33 | job = scope.launch { 34 | noteSocket.getNotesFlow() 35 | .retry { 36 | delay(SOCKET_RETRY_DELAY_MILLISECONDS) 37 | true 38 | } 39 | .collect { apiNotes -> 40 | val localNotes = noteDao.getAllNotes().firstOrNull() 41 | if (localNotes != null) { 42 | synchronizeNotes.executeAsync(localNotes, apiNotes) 43 | } 44 | } 45 | } 46 | } 47 | 48 | fun close() { 49 | noteSocket.close() 50 | scope.cancel() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/feature/socket/NoteSocket.kt: -------------------------------------------------------------------------------- 1 | package feature.socket 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import network.NoteSchema 5 | 6 | interface NoteSocket { 7 | 8 | fun getNotesFlow(): Flow> 9 | 10 | fun close() 11 | } 12 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/helpers/Do.kt: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | object Do { 4 | inline infix fun exhaustive(any: T?) = any 5 | } 6 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/helpers/date/KlockUnixTimestampProvider.kt: -------------------------------------------------------------------------------- 1 | package helpers.date 2 | 3 | import com.soywiz.klock.DateTime 4 | 5 | class KlockUnixTimestampProvider : UnixTimestampProvider { 6 | override fun now(): Long = DateTime.nowUnixLong() 7 | } 8 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/helpers/date/NoteDateFormat.kt: -------------------------------------------------------------------------------- 1 | package helpers.date 2 | 3 | import com.soywiz.klock.DateFormat 4 | 5 | enum class NoteDateFormat(val value: String) { 6 | NOTES_LIST_ITEM_MONTH("dd.MM"), 7 | NOTES_LIST_ITEM_YEAR("dd.MM.yyyy"), 8 | NOTES_LIST_ITEM_MONTH_HOUR("HH:mm dd.MM"), 9 | NOTES_LIST_ITEM_YEAR_HOUR("HH:mm dd.MM.yyyy"); 10 | 11 | companion object { 12 | val Default: NoteDateFormat = NOTES_LIST_ITEM_MONTH 13 | } 14 | } 15 | 16 | fun NoteDateFormat.toDateFormat(): DateFormat = DateFormat(this.value) 17 | 18 | fun DateFormat.toNoteDateFormat(): NoteDateFormat = NoteDateFormat.values().first { it.value == this.toString() } 19 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/helpers/date/NotesDatePatternStorageKey.kt: -------------------------------------------------------------------------------- 1 | package helpers.date 2 | 3 | data class NotesDatePatternStorageKey(val value: String) 4 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/helpers/date/PatternProvider.kt: -------------------------------------------------------------------------------- 1 | package helpers.date 2 | 3 | import com.soywiz.klock.DateFormat 4 | import helpers.storage.Storage 5 | import kotlinx.coroutines.flow.Flow 6 | 7 | interface PatternProvider { 8 | 9 | val patternFlow: Flow 10 | 11 | fun getPattern(): DateFormat 12 | } 13 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/helpers/date/PatternSaver.kt: -------------------------------------------------------------------------------- 1 | package helpers.date 2 | 3 | import com.soywiz.klock.DateFormat 4 | 5 | interface PatternSaver { 6 | 7 | fun setPattern(dateFormat: DateFormat) 8 | } 9 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/helpers/date/PatternStorage.kt: -------------------------------------------------------------------------------- 1 | package helpers.date 2 | 3 | import com.soywiz.klock.DateFormat 4 | import helpers.storage.Storage 5 | import kotlinx.coroutines.flow.MutableStateFlow 6 | 7 | internal class PatternStorage( 8 | private val storage: Storage, 9 | private val notesDatePatternStorageKey: NotesDatePatternStorageKey 10 | ) : PatternProvider, PatternSaver { 11 | 12 | override val patternFlow: MutableStateFlow = MutableStateFlow(getSavedPattern()) 13 | 14 | override fun getPattern(): DateFormat = patternFlow.value 15 | 16 | override fun setPattern(dateFormat: DateFormat) { 17 | storage.setString(notesDatePatternStorageKey.value, dateFormat.toString()) 18 | patternFlow.value = dateFormat 19 | } 20 | 21 | private fun getSavedPattern(): DateFormat { 22 | val pattern = storage.getString(notesDatePatternStorageKey.value) ?: return initializeDefault() 23 | return DateFormat(pattern) 24 | } 25 | 26 | private fun initializeDefault(): DateFormat { 27 | val defaultFormat = NoteDateFormat.Default.toDateFormat() 28 | storage.setString(notesDatePatternStorageKey.value, defaultFormat.toString()) 29 | return defaultFormat 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/helpers/date/UnixTimestampProvider.kt: -------------------------------------------------------------------------------- 1 | package helpers.date 2 | 3 | interface UnixTimestampProvider { 4 | fun now(): Long 5 | } 6 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/helpers/storage/Storage.kt: -------------------------------------------------------------------------------- 1 | package helpers.storage 2 | 3 | interface Storage { 4 | 5 | fun getString(key: String): String? 6 | 7 | fun setString(key: String, value: String) 8 | } 9 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/helpers/validation/NoteEditorInputValidator.kt: -------------------------------------------------------------------------------- 1 | package helpers.validation 2 | 3 | import helpers.validation.NoteInputValidator.ValidationResult.Invalid 4 | import helpers.validation.NoteInputValidator.ValidationResult.Valid 5 | 6 | class NoteEditorInputValidator : NoteInputValidator { 7 | 8 | companion object { 9 | private const val MAX_TITLE_LENGTH = 30 10 | } 11 | 12 | override fun isTitleValid(title: String): NoteInputValidator.ValidationResult { 13 | return when { 14 | title.count() > MAX_TITLE_LENGTH -> Invalid("Title is too long") 15 | title.isBlank() -> Invalid("Title can't be empty") 16 | else -> Valid 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/helpers/validation/NoteInputValidator.kt: -------------------------------------------------------------------------------- 1 | package helpers.validation 2 | 3 | interface NoteInputValidator { 4 | 5 | sealed class ValidationResult { 6 | 7 | object Valid : ValidationResult() 8 | 9 | class Invalid(val errorMessage: String) : ValidationResult() 10 | } 11 | 12 | fun isTitleValid(title: String): ValidationResult 13 | } 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/model/CreationTimestamp.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class CreationTimestamp(val unix: Long) 7 | 8 | fun Long.toCreationTimestamp(): CreationTimestamp = CreationTimestamp(this) 9 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/model/LastModificationTimestamp.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class LastModificationTimestamp(val unix: Long) 7 | 8 | fun Long.toLastModificationTimestamp(): LastModificationTimestamp = LastModificationTimestamp(this) 9 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/model/Note.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import com.soywiz.klock.DateFormat 4 | import com.soywiz.klock.DateTime 5 | import helpers.date.NoteDateFormat 6 | import helpers.date.toDateFormat 7 | 8 | data class Note( 9 | val title: String = "", 10 | val content: String = "", 11 | val lastModificationTimestamp: LastModificationTimestamp = DateTime.nowUnixLong().toLastModificationTimestamp(), 12 | val creationTimestamp: CreationTimestamp = DateTime.nowUnixLong().toCreationTimestamp(), 13 | val dateFormat: DateFormat = NoteDateFormat.Default.toDateFormat(), 14 | val hasSyncFailed: Boolean = false 15 | ) 16 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/network/ApiUrl.kt: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | object ApiUrl { 4 | private const val PROTOCOL = "https://" 5 | const val BASE_URL = "fuller-stack-ktor.herokuapp.com" 6 | const val BASE_URL_WITH_PROTOCOL = "$PROTOCOL$BASE_URL" 7 | const val NOTES_URL_WITH_PROTOCOL = "$BASE_URL_WITH_PROTOCOL/notes" 8 | const val SOCKET_ENDPOINT = "notes/ws" 9 | const val SOCKET_URL_WITH_PROTOCOL = "$BASE_URL_WITH_PROTOCOL/notes/ws" 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/network/NetworkResponse.kt: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | sealed class NetworkResponse { 4 | class Success (val result: T) : NetworkResponse() 5 | object ApiError : NetworkResponse() 6 | object NetworkError : NetworkResponse() 7 | object UnknownError : NetworkResponse() 8 | } 9 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/network/NoteApi.kt: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import feature.AddNotePayload 4 | import feature.UpdateNotePayload 5 | import model.CreationTimestamp 6 | 7 | interface NoteApi { 8 | 9 | suspend fun getNotes(): List 10 | 11 | suspend fun addNote(addNotePayload: AddNotePayload): Int 12 | 13 | suspend fun updateNote(updatedNotePayload: UpdateNotePayload) 14 | 15 | suspend fun deleteNotes( 16 | creationTimestamps: List, 17 | lastModificationTimestamp: Long 18 | ) 19 | 20 | suspend fun restoreNotes( 21 | creationTimestamps: List, 22 | lastModificationTimestamp: Long 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/network/NoteSchema.kt: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import kotlinx.serialization.Serializable 4 | import model.CreationTimestamp 5 | import model.LastModificationTimestamp 6 | import model.toCreationTimestamp 7 | import model.toLastModificationTimestamp 8 | 9 | @Serializable 10 | data class NoteSchema( 11 | val apiId: Int = -1, 12 | val title: String = "", 13 | val content: String = "", 14 | val lastModificationTimestamp: LastModificationTimestamp = 0L.toLastModificationTimestamp(), 15 | val creationTimestamp: CreationTimestamp = 0L.toCreationTimestamp(), 16 | val wasDeleted: Boolean = false 17 | ) 18 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/network/NoteSchemaMapper.kt: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import model.Note 4 | 5 | class NoteSchemaMapper { 6 | 7 | fun toNotes(notes: List): List = notes.map(::toNote) 8 | 9 | fun toNote(note: NoteSchema): Note = 10 | Note( 11 | title = note.title, 12 | content = note.content, 13 | lastModificationTimestamp = note.lastModificationTimestamp, 14 | creationTimestamp = note.creationTimestamp, 15 | hasSyncFailed = false 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/network/safeApiCall.kt: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | expect suspend fun safeApiCall(block: suspend () -> T): NetworkResponse 4 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/tests/NoteSocketFake.kt: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import feature.socket.NoteSocket 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.MutableStateFlow 6 | import network.NoteSchema 7 | 8 | class NoteSocketFake : NoteSocket { 9 | val notesMutableState: MutableStateFlow> = MutableStateFlow(listOf()) 10 | var notes: List 11 | get() = notesMutableState.value 12 | set(value) { 13 | notesMutableState.value = value 14 | } 15 | 16 | override fun getNotesFlow(): Flow> = notesMutableState 17 | 18 | override fun close() { /* Empty */} 19 | } 20 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/tests/README.md: -------------------------------------------------------------------------------- 1 | This package is needed for providing test utility classes in android/react modules. 2 | If these packages reside in test instead of main then they cannot be used in other modules. 3 | 4 | [Reason](https://stackoverflow.com/questions/58956010/how-to-add-dependencies-to-tests-of-another-project-in-a-multi-platform-multi-pr) -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/ExtenstionFunctionHelpers.kt: -------------------------------------------------------------------------------- 1 | import io.kotest.core.spec.style.scopes.FunSpecRootScope 2 | 3 | fun FunSpecRootScope.suspendingTest (name: String, block: suspend () -> T) { 4 | test(name) { 5 | runTest { 6 | block() 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/feature/local/search/SearchNotesTest.kt: -------------------------------------------------------------------------------- 1 | package feature.local.search 2 | 3 | import io.kotest.core.spec.style.FunSpec 4 | import io.kotest.data.headers 5 | import io.kotest.data.row 6 | import io.kotest.data.table 7 | import io.kotest.matchers.shouldBe 8 | import model.Note 9 | 10 | class SearchNotesTest : FunSpec({ 11 | 12 | lateinit var SUT: SearchNotes 13 | 14 | val notes = listOf( 15 | Note(title = "A title containing"), 16 | Note(title = "Some other title"), 17 | Note(title = "Other is good") 18 | ) 19 | 20 | beforeTest { 21 | SUT = SearchNotes() 22 | } 23 | 24 | test("Searches correctly") { 25 | io.kotest.data.forAll( 26 | table( 27 | headers("search keyword", "expected notes"), 28 | row("other", listOf(notes[1], notes[2])), 29 | row("OTHER", listOf(notes[1], notes[2])), 30 | row("title", listOf(notes[0], notes[1])), 31 | row(" ", listOf(notes[0], notes[1], notes[2])), 32 | row("e", listOf(notes[0], notes[1], notes[2])) 33 | 34 | ) 35 | ) { sortType, expectedOrder -> 36 | SUT.execute(notes, sortType) shouldBe expectedOrder 37 | } 38 | } 39 | }) 40 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/feature/local/sort/SortNotesTest.kt: -------------------------------------------------------------------------------- 1 | package feature.local.sort 2 | 3 | import io.kotest.core.spec.style.FunSpec 4 | import io.kotest.data.headers 5 | import io.kotest.data.row 6 | import io.kotest.data.table 7 | import io.kotest.matchers.shouldBe 8 | import model.CreationTimestamp 9 | import model.Note 10 | 11 | class SortNotesTest : FunSpec({ 12 | 13 | lateinit var SUT: SortNotes 14 | 15 | val notes = listOf( 16 | Note(title = "1 Note", creationTimestamp = CreationTimestamp(20)), 17 | Note(title = "2 Note", creationTimestamp = CreationTimestamp(10)), 18 | Note(title = "3 Note", creationTimestamp = CreationTimestamp(15)) 19 | ) 20 | 21 | beforeTest { 22 | SUT = SortNotes() 23 | } 24 | 25 | test("Correctly sorts") { 26 | io.kotest.data.forAll( 27 | table( 28 | headers("sortProperty", "expected order"), 29 | row(SortProperty(SortType.NAME, SortDirection.ASCENDING), listOf(notes[0], notes[1], notes[2])), 30 | row(SortProperty(SortType.NAME, SortDirection.DESCENDING), listOf(notes[2], notes[1], notes[0])), 31 | row(SortProperty(SortType.CREATION_DATE, SortDirection.ASCENDING), listOf(notes[1], notes[2], notes[0])), 32 | row(SortProperty(SortType.CREATION_DATE, SortDirection.DESCENDING), listOf(notes[0], notes[2], notes[1])) 33 | 34 | ) 35 | ) { sortType, expectedOrder -> 36 | SUT.execute(notes, sortType) shouldBe expectedOrder 37 | } 38 | } 39 | }) 40 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/feature/synchronization/SynchronizeNotesMock.kt: -------------------------------------------------------------------------------- 1 | package feature.synchronization 2 | 3 | import database.NoteEntity 4 | import feature.synchronization.SynchronizeNotes.Result 5 | import network.NoteSchema 6 | 7 | class SynchronizeNotesMock : SynchronizeNotes { 8 | 9 | var shouldFail = false 10 | 11 | var executeAsyncNoParamsCalled = false 12 | private set 13 | var executeAsyncWithParamsCalled = false 14 | private set 15 | 16 | var passedInLocalNotes: List? = null 17 | private set 18 | var passedInApiNotes: List? = null 19 | private set 20 | 21 | override suspend fun executeAsync(): Result { 22 | executeAsyncNoParamsCalled = true 23 | return getResult() 24 | } 25 | 26 | override suspend fun executeAsync( 27 | localNotes: List, 28 | apiNotes: List 29 | ): Result { 30 | executeAsyncWithParamsCalled = true 31 | passedInLocalNotes = localNotes 32 | passedInApiNotes = apiNotes 33 | return getResult() 34 | } 35 | 36 | private fun getResult(): Result = if (shouldFail) Result.SynchronizationFailed else Result.Success 37 | } 38 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/helpers/date/UnixTimestampProviderFake.kt: -------------------------------------------------------------------------------- 1 | package helpers.date 2 | 3 | class UnixTimestampProviderFake : UnixTimestampProvider { 4 | var timestamp: Long = -1 5 | override fun now(): Long = timestamp 6 | } 7 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/helpers/validation/NoteEditorInputValidatorTest.kt: -------------------------------------------------------------------------------- 1 | package helpers.validation 2 | 3 | import io.kotest.core.spec.style.FunSpec 4 | import io.kotest.matchers.should 5 | 6 | class NoteEditorInputValidatorTest : FunSpec ({ 7 | 8 | lateinit var SUT: NoteEditorInputValidator 9 | 10 | beforeTest { 11 | SUT = NoteEditorInputValidator() 12 | } 13 | 14 | test("A title longer than 30 is invalid") { 15 | val title = "K".repeat(31) 16 | 17 | val result = SUT.isTitleValid(title) 18 | 19 | result.should { it is NoteInputValidator.ValidationResult.Invalid } 20 | } 21 | 22 | test("A blank title is invalid") { 23 | val title = "" 24 | 25 | val result = SUT.isTitleValid(title) 26 | 27 | result.should { it is NoteInputValidator.ValidationResult.Invalid } 28 | } 29 | 30 | test("A title with length between 1 and 30 is valid") { 31 | val title = "An interesting title" 32 | 33 | val result = SUT.isTitleValid(title) 34 | 35 | result.should { it is NoteInputValidator.ValidationResult.Valid } 36 | } 37 | }) 38 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/runTest.kt: -------------------------------------------------------------------------------- 1 | expect fun runTest(block: suspend () -> T) 2 | -------------------------------------------------------------------------------- /shared/src/jsMain/kotlin/base/CommonDispatchers.kt: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.Dispatchers 5 | 6 | actual object CommonDispatchers { 7 | 8 | actual val BackgroundDispatcher: CoroutineDispatcher 9 | get() = Dispatchers.Default 10 | 11 | actual val MainDispatcher: CoroutineDispatcher 12 | get() = Dispatchers.Main 13 | } 14 | -------------------------------------------------------------------------------- /shared/src/jsMain/kotlin/database/NoteEntity.kt: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import model.CreationTimestamp 4 | import model.LastModificationTimestamp 5 | 6 | actual data class NoteEntity( 7 | actual val localId: Int = -1, 8 | actual val title: String, 9 | actual val content: String, 10 | actual val lastModificationTimestamp: LastModificationTimestamp, 11 | actual val creationTimestamp: CreationTimestamp, 12 | actual val hasSyncFailed: Boolean = false, 13 | actual val wasDeleted: Boolean = false 14 | ) 15 | -------------------------------------------------------------------------------- /shared/src/jsMain/kotlin/network/safeApiCall.kt: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import io.ktor.client.features.ServerResponseException 4 | 5 | actual suspend fun safeApiCall(block: suspend () -> T): NetworkResponse { 6 | return try { 7 | val result = block() 8 | NetworkResponse.Success(result) 9 | } catch (throwable: Throwable) { 10 | when (throwable) { 11 | is ServerResponseException -> NetworkResponse.ApiError 12 | else -> NetworkResponse.UnknownError 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /shared/src/jsTest/kotlin/runTest.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.GlobalScope 2 | import kotlinx.coroutines.promise 3 | 4 | actual fun runTest(block: suspend () -> T): dynamic = GlobalScope.promise { block() } 5 | -------------------------------------------------------------------------------- /system.properties: -------------------------------------------------------------------------------- 1 | java.runtime.version=1.8 --------------------------------------------------------------------------------