├── .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 | 
3 | 
4 | 
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 | 
17 |
18 | 
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 | 
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 |
40 |
41 |
42 | The gradle command for installing the android app:
43 | ```
44 | $ ./gradlew :android:app:installDebug
45 | ```
46 |
47 | ## React app
48 |
49 | 
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 |
--------------------------------------------------------------------------------
/android/app/src/main/res/menu/note_editor_add.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/android/app/src/main/res/menu/note_editor_update.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/android/app/src/main/res/menu/note_list_selection.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------