From 3679e240d549e39f8f064cb34bac5195eba26424 Mon Sep 17 00:00:00 2001 From: Ezekiel Wachira Date: Sat, 1 Jun 2024 15:15:38 +0300 Subject: [PATCH] Initial commit --- .editorconfig | 6 + .github/CODEOWNERS | 1 + .github/FUNDING.yml | 2 + .github/ci-gradle.properties | 31 ++ .github/dependabot.yml | 27 ++ .github/renovate.json | 9 + .github/workflows/Build.yaml | 43 +++ .github/workflows/Docs.yaml | 54 +++ .github/workflows/Release.yaml | 68 ++++ .gitignore | 42 +++ CHANGELOG.md | 1 + CODE_OF_CONDUCT.md | 128 ++++++++ CONTRIBUTING.md | 19 ++ LICENSE | 201 ++++++++++++ README.md | 131 ++++++++ app/.gitignore | 1 + app/build.gradle.kts | 113 +++++++ app/google-services.json | 51 +++ app/src/main/AndroidManifest.xml | 43 +++ app/src/main/kotlin/dev/atick/compose/App.kt | 37 +++ .../kotlin/dev/atick/compose/MainActivity.kt | 232 +++++++++++++ .../atick/compose/MainActivityViewModel.kt | 49 +++ .../atick/compose/data/home/HomeScreenData.kt | 120 +++++++ .../compose/data/profile/ProfileScreenData.kt | 28 ++ .../data/settings/UserEditableSettings.kt | 34 ++ .../di/notification/NotificationModule.kt | 51 +++ .../compose/di/repository/RepositoryModule.kt | 75 +++++ .../dev/atick/compose/navigation/NavHost.kt | 63 ++++ .../compose/navigation/TopLevelDestination.kt | 53 +++ .../navigation/details/DetailsNavigation.kt | 47 +++ .../compose/navigation/home/HomeNavigation.kt | 47 +++ .../navigation/profile/ProfileNavigation.kt | 37 +++ .../repository/home/PostsRepository.kt | 40 +++ .../repository/home/PostsRepositoryImpl.kt | 73 +++++ .../profile/ProfileDataRepository.kt | 40 +++ .../profile/ProfileDataRepositoryImpl.kt | 66 ++++ .../repository/user/UserDataRepository.kt | 66 ++++ .../repository/user/UserDataRepositoryImpl.kt | 89 +++++ .../kotlin/dev/atick/compose/ui/JetpackApp.kt | 286 ++++++++++++++++ .../dev/atick/compose/ui/JetpackAppState.kt | 122 +++++++ .../atick/compose/ui/details/DetailsScreen.kt | 118 +++++++ .../compose/ui/details/DetailsViewModel.kt | 54 +++ .../dev/atick/compose/ui/home/HomeScreen.kt | 105 ++++++ .../atick/compose/ui/home/HomeViewModel.kt | 69 ++++ .../atick/compose/ui/profile/ProfileScreen.kt | 88 +++++ .../compose/ui/profile/ProfileViewModel.kt | 47 +++ .../compose/ui/settings/SettingsDialog.kt | 229 +++++++++++++ .../compose/ui/settings/SettingsViewModel.kt | 79 +++++ .../drawable-v24/ic_launcher_foreground.xml | 47 +++ app/src/main/res/drawable/ic_avatar.xml | 29 ++ .../res/drawable/ic_launcher_background.xml | 186 +++++++++++ .../main/res/drawable/splash_background.xml | 25 ++ .../res/drawable/splash_background_night.xml | 25 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 21 ++ .../mipmap-anydpi-v26/ic_launcher_round.xml | 21 ++ app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes app/src/main/res/values-night/themes.xml | 27 ++ app/src/main/res/values/colors.xml | 21 ++ app/src/main/res/values/strings.xml | 50 +++ app/src/main/res/values/themes.xml | 37 +++ assets/modularization.svg | 1 + auth/.gitignore | 1 + auth/build.gradle.kts | 40 +++ auth/consumer-rules.pro | 4 + auth/src/main/AndroidManifest.xml | 20 ++ .../kotlin/dev/atick/auth/config/Config.kt | 30 ++ .../dev/atick/auth/data/AuthDataSource.kt | 77 +++++ .../dev/atick/auth/data/AuthDataSourceImpl.kt | 186 +++++++++++ .../atick/auth/di/CredentialManagerModule.kt | 37 +++ .../dev/atick/auth/di/DataSourceModule.kt | 45 +++ .../dev/atick/auth/di/FirebaseAuthModule.kt | 45 +++ .../dev/atick/auth/di/RepositoryModule.kt | 45 +++ .../dev/atick/auth/models/AuthScreenData.kt | 32 ++ .../kotlin/dev/atick/auth/models/AuthUser.kt | 57 ++++ .../atick/auth/navigation/AuthNavigation.kt | 65 ++++ .../atick/auth/repository/AuthRepository.kt | 63 ++++ .../auth/repository/AuthRepositoryImpl.kt | 101 ++++++ .../kotlin/dev/atick/auth/ui/AuthViewModel.kt | 158 +++++++++ .../dev/atick/auth/ui/signin/SignInScreen.kt | 199 +++++++++++ .../dev/atick/auth/ui/signup/SignUpScreen.kt | 215 ++++++++++++ auth/src/main/res/drawable/ic_google.xml | 35 ++ auth/src/main/res/values/strings.xml | 30 ++ bluetooth/classic/.gitignore | 1 + bluetooth/classic/build.gradle.kts | 28 ++ .../classic/src/main/AndroidManifest.xml | 36 ++ .../bluetooth/classic/BluetoothClassic.kt | 309 ++++++++++++++++++ .../classic/di/BluetoothClassicModule.kt | 71 ++++ bluetooth/common/.gitignore | 1 + bluetooth/common/build.gradle.kts | 27 ++ bluetooth/common/src/main/AndroidManifest.xml | 36 ++ .../common/data/BluetoothDataSource.kt | 40 +++ .../common/di/BluetoothAdapterModule.kt | 46 +++ .../common/manager/BluetoothManager.kt | 47 +++ .../atick/bluetooth/common/models/BtDevice.kt | 89 +++++ .../bluetooth/common/models/BtDeviceType.kt | 34 ++ .../bluetooth/common/models/BtMessage.kt | 30 ++ .../atick/bluetooth/common/models/BtState.kt | 32 ++ .../receivers/BluetoothStateReceiver.kt | 46 +++ .../common/receivers/DeviceStateReceiver.kt | 64 ++++ .../common/receivers/ScannedDeviceReceiver.kt | 58 ++++ .../bluetooth/common/utils/BluetoothUtils.kt | 52 +++ build-logic/convention/build.gradle.kts | 66 ++++ .../kotlin/ApplicationConventionPlugin.kt | 66 ++++ .../main/kotlin/DaggerHiltConventionPlugin.kt | 23 ++ .../main/kotlin/FirebaseConventionPlugin.kt | 25 ++ .../main/kotlin/LibraryConventionPlugin.kt | 47 +++ .../main/kotlin/UiLibraryConventionPlugin.kt | 60 ++++ build-logic/gradle.properties | 5 + build-logic/settings.gradle.kts | 16 + build.gradle.kts | 29 ++ core/android/.gitignore | 1 + core/android/build.gradle.kts | 44 +++ core/android/src/main/AndroidManifest.xml | 22 ++ .../dev/atick/core/di/DispatcherModule.kt | 80 +++++ .../dev/atick/core/di/StringDecoderModule.kt | 39 +++ .../core/extensions/ContextExtensions.kt | 183 +++++++++++ .../atick/core/extensions/FlowExtensions.kt | 41 +++ .../atick/core/extensions/StringExtensions.kt | 68 ++++ .../dev/atick/core/utils/CoroutineUtils.kt | 41 +++ .../kotlin/dev/atick/core/utils/Resource.kt | 93 ++++++ .../dev/atick/core/utils/SingleLiveEvent.kt | 58 ++++ .../dev/atick/core/utils/StringDecoder.kt | 30 ++ .../kotlin/dev/atick/core/utils/UriDecoder.kt | 35 ++ core/ui/.gitignore | 1 + core/ui/build.gradle.kts | 68 ++++ core/ui/src/main/AndroidManifest.xml | 22 ++ .../atick/core/ui/components/Background.kt | 136 ++++++++ .../dev/atick/core/ui/components/Button.kt | 274 ++++++++++++++++ .../dev/atick/core/ui/components/Chip.kt | 116 +++++++ .../core/ui/components/DynamicAsyncImage.kt | 44 +++ .../atick/core/ui/components/IconButton.kt | 78 +++++ .../atick/core/ui/components/LoadingWheel.kt | 151 +++++++++ .../atick/core/ui/components/Navigation.kt | 176 ++++++++++ .../dev/atick/core/ui/components/TextField.kt | 192 +++++++++++ .../dev/atick/core/ui/components/TopAppBar.kt | 112 +++++++ .../core/ui/extensions/ActivityExtensions.kt | 110 +++++++ .../core/ui/extensions/LifecycleExtensions.kt | 87 +++++ .../dev/atick/core/ui/theme/Background.kt | 36 ++ .../kotlin/dev/atick/core/ui/theme/Color.kt | 82 +++++ .../dev/atick/core/ui/theme/Gradient.kt | 40 +++ .../kotlin/dev/atick/core/ui/theme/Theme.kt | 250 ++++++++++++++ .../kotlin/dev/atick/core/ui/theme/Tint.kt | 34 ++ .../kotlin/dev/atick/core/ui/theme/Type.kt | 118 +++++++ .../dev/atick/core/ui/utils/DevicePreviews.kt | 29 ++ .../atick/core/ui/utils/StatefulComposable.kt | 63 ++++ .../ui/utils/TakePictureActivityContract.kt | 70 ++++ .../dev/atick/core/ui/utils/TextFiledData.kt | 22 ++ .../kotlin/dev/atick/core/ui/utils/UiText.kt | 69 ++++ core/ui/src/main/res/values/strings.xml | 21 ++ gradle.properties | 43 +++ gradle/init.gradle.kts | 64 ++++ gradle/libs.versions.toml | 152 +++++++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43453 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 249 ++++++++++++++ gradlew.bat | 92 ++++++ mkdocs.yml | 77 +++++ network/.gitignore | 1 + network/build.gradle.kts | 54 +++ network/consumer-rules.pro | 65 ++++ network/src/main/AndroidManifest.xml | 23 ++ .../dev/atick/network/api/JetpackRestApi.kt | 48 +++ .../atick/network/data/NetworkDataSource.kt | 44 +++ .../network/data/NetworkDataSourceImpl.kt | 63 ++++ .../network/di/ConnectivityManagerModule.kt | 50 +++ .../dev/atick/network/di/DataSourceModule.kt | 45 +++ .../atick/network/di/NetworkUtilsModule.kt | 45 +++ .../dev/atick/network/di/RestApiModule.kt | 50 +++ .../dev/atick/network/di/coil/CoilModule.kt | 62 ++++ .../network/di/okhttp/InterceptorModule.kt | 51 +++ .../network/di/okhttp/OkHttpClientModule.kt | 59 ++++ .../di/retrofit/GsonConverterModule.kt | 43 +++ .../network/di/retrofit/RetrofitModule.kt | 60 ++++ .../dev/atick/network/models/NetworkPost.kt | 32 ++ .../dev/atick/network/utils/NetworkState.kt | 47 +++ .../dev/atick/network/utils/NetworkUtils.kt | 29 ++ .../atick/network/utils/NetworkUtilsImpl.kt | 93 ++++++ network/src/main/res/values/strings.xml | 23 ++ secrets.defaults.properties | 1 + settings.gradle.kts | 57 ++++ spotless/copyright.gradle | 15 + spotless/copyright.kt | 16 + spotless/copyright.kts | 15 + spotless/copyright.xml | 17 + storage/preferences/.gitignore | 1 + storage/preferences/build.gradle.kts | 37 +++ storage/preferences/consumer-rules.pro | 2 + .../preferences/src/main/AndroidManifest.xml | 20 ++ .../data/UserPreferencesDataSource.kt | 62 ++++ .../data/UserPreferencesDataSourceImpl.kt | 103 ++++++ .../storage/preferences/di/DatastoreModule.kt | 63 ++++ .../di/PreferencesDataSourceModule.kt | 45 +++ .../preferences/models/DarkThemeConfig.kt | 36 ++ .../storage/preferences/models/Profile.kt | 35 ++ .../storage/preferences/models/ThemeBrand.kt | 33 ++ .../storage/preferences/models/UserData.kt | 42 +++ .../preferences/utils/UserDataSerializer.kt | 141 ++++++++ storage/room/.gitignore | 1 + storage/room/build.gradle.kts | 34 ++ storage/room/src/main/AndroidManifest.xml | 20 ++ .../dev/atick/storage/room/data/JetpackDao.kt | 73 +++++ .../storage/room/data/JetpackDatabase.kt | 40 +++ .../storage/room/data/LocalDataSource.kt | 67 ++++ .../storage/room/data/LocalDataSourceImpl.kt | 100 ++++++ .../dev/atick/storage/room/di/DaoModule.kt | 46 +++ .../atick/storage/room/di/DataSourceModule.kt | 45 +++ .../atick/storage/room/di/DatabaseModule.kt | 55 ++++ .../atick/storage/room/models/PostEntity.kt | 36 ++ 217 files changed, 12839 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/CODEOWNERS create mode 100644 .github/FUNDING.yml create mode 100644 .github/ci-gradle.properties create mode 100644 .github/dependabot.yml create mode 100644 .github/renovate.json create mode 100644 .github/workflows/Build.yaml create mode 100644 .github/workflows/Docs.yaml create mode 100644 .github/workflows/Release.yaml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/google-services.json create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/kotlin/dev/atick/compose/App.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/MainActivity.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/MainActivityViewModel.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/data/home/HomeScreenData.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/data/profile/ProfileScreenData.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/data/settings/UserEditableSettings.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/di/notification/NotificationModule.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/di/repository/RepositoryModule.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/navigation/NavHost.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/navigation/TopLevelDestination.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/navigation/details/DetailsNavigation.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/navigation/home/HomeNavigation.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/navigation/profile/ProfileNavigation.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/repository/home/PostsRepository.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/repository/home/PostsRepositoryImpl.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/repository/profile/ProfileDataRepository.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/repository/profile/ProfileDataRepositoryImpl.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/repository/user/UserDataRepository.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/repository/user/UserDataRepositoryImpl.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/ui/JetpackApp.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/ui/JetpackAppState.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/ui/details/DetailsScreen.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/ui/details/DetailsViewModel.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/ui/home/HomeScreen.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/ui/home/HomeViewModel.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/ui/profile/ProfileScreen.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/ui/profile/ProfileViewModel.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/ui/settings/SettingsDialog.kt create mode 100644 app/src/main/kotlin/dev/atick/compose/ui/settings/SettingsViewModel.kt create mode 100644 app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_avatar.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/splash_background.xml create mode 100644 app/src/main/res/drawable/splash_background_night.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 assets/modularization.svg create mode 100644 auth/.gitignore create mode 100644 auth/build.gradle.kts create mode 100644 auth/consumer-rules.pro create mode 100644 auth/src/main/AndroidManifest.xml create mode 100644 auth/src/main/kotlin/dev/atick/auth/config/Config.kt create mode 100644 auth/src/main/kotlin/dev/atick/auth/data/AuthDataSource.kt create mode 100644 auth/src/main/kotlin/dev/atick/auth/data/AuthDataSourceImpl.kt create mode 100644 auth/src/main/kotlin/dev/atick/auth/di/CredentialManagerModule.kt create mode 100644 auth/src/main/kotlin/dev/atick/auth/di/DataSourceModule.kt create mode 100644 auth/src/main/kotlin/dev/atick/auth/di/FirebaseAuthModule.kt create mode 100644 auth/src/main/kotlin/dev/atick/auth/di/RepositoryModule.kt create mode 100644 auth/src/main/kotlin/dev/atick/auth/models/AuthScreenData.kt create mode 100644 auth/src/main/kotlin/dev/atick/auth/models/AuthUser.kt create mode 100644 auth/src/main/kotlin/dev/atick/auth/navigation/AuthNavigation.kt create mode 100644 auth/src/main/kotlin/dev/atick/auth/repository/AuthRepository.kt create mode 100644 auth/src/main/kotlin/dev/atick/auth/repository/AuthRepositoryImpl.kt create mode 100644 auth/src/main/kotlin/dev/atick/auth/ui/AuthViewModel.kt create mode 100644 auth/src/main/kotlin/dev/atick/auth/ui/signin/SignInScreen.kt create mode 100644 auth/src/main/kotlin/dev/atick/auth/ui/signup/SignUpScreen.kt create mode 100644 auth/src/main/res/drawable/ic_google.xml create mode 100644 auth/src/main/res/values/strings.xml create mode 100644 bluetooth/classic/.gitignore create mode 100644 bluetooth/classic/build.gradle.kts create mode 100644 bluetooth/classic/src/main/AndroidManifest.xml create mode 100644 bluetooth/classic/src/main/kotlin/dev/atick/bluetooth/classic/BluetoothClassic.kt create mode 100644 bluetooth/classic/src/main/kotlin/dev/atick/bluetooth/classic/di/BluetoothClassicModule.kt create mode 100644 bluetooth/common/.gitignore create mode 100644 bluetooth/common/build.gradle.kts create mode 100644 bluetooth/common/src/main/AndroidManifest.xml create mode 100644 bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/data/BluetoothDataSource.kt create mode 100644 bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/di/BluetoothAdapterModule.kt create mode 100644 bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/manager/BluetoothManager.kt create mode 100644 bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtDevice.kt create mode 100644 bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtDeviceType.kt create mode 100644 bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtMessage.kt create mode 100644 bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtState.kt create mode 100644 bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/receivers/BluetoothStateReceiver.kt create mode 100644 bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/receivers/DeviceStateReceiver.kt create mode 100644 bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/receivers/ScannedDeviceReceiver.kt create mode 100644 bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/utils/BluetoothUtils.kt create mode 100644 build-logic/convention/build.gradle.kts create mode 100644 build-logic/convention/src/main/kotlin/ApplicationConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/DaggerHiltConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/FirebaseConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/LibraryConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/UiLibraryConventionPlugin.kt create mode 100644 build-logic/gradle.properties create mode 100644 build-logic/settings.gradle.kts create mode 100644 build.gradle.kts create mode 100644 core/android/.gitignore create mode 100644 core/android/build.gradle.kts create mode 100644 core/android/src/main/AndroidManifest.xml create mode 100644 core/android/src/main/kotlin/dev/atick/core/di/DispatcherModule.kt create mode 100644 core/android/src/main/kotlin/dev/atick/core/di/StringDecoderModule.kt create mode 100644 core/android/src/main/kotlin/dev/atick/core/extensions/ContextExtensions.kt create mode 100644 core/android/src/main/kotlin/dev/atick/core/extensions/FlowExtensions.kt create mode 100644 core/android/src/main/kotlin/dev/atick/core/extensions/StringExtensions.kt create mode 100644 core/android/src/main/kotlin/dev/atick/core/utils/CoroutineUtils.kt create mode 100644 core/android/src/main/kotlin/dev/atick/core/utils/Resource.kt create mode 100644 core/android/src/main/kotlin/dev/atick/core/utils/SingleLiveEvent.kt create mode 100644 core/android/src/main/kotlin/dev/atick/core/utils/StringDecoder.kt create mode 100644 core/android/src/main/kotlin/dev/atick/core/utils/UriDecoder.kt create mode 100644 core/ui/.gitignore create mode 100644 core/ui/build.gradle.kts create mode 100644 core/ui/src/main/AndroidManifest.xml create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/components/Background.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/components/Button.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/components/Chip.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/components/DynamicAsyncImage.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/components/IconButton.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/components/LoadingWheel.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/components/Navigation.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/components/TextField.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/components/TopAppBar.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/extensions/ActivityExtensions.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/extensions/LifecycleExtensions.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/theme/Background.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/theme/Color.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/theme/Gradient.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/theme/Theme.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/theme/Tint.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/theme/Type.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/utils/DevicePreviews.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/utils/StatefulComposable.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/utils/TakePictureActivityContract.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/utils/TextFiledData.kt create mode 100644 core/ui/src/main/kotlin/dev/atick/core/ui/utils/UiText.kt create mode 100644 core/ui/src/main/res/values/strings.xml create mode 100644 gradle.properties create mode 100644 gradle/init.gradle.kts create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 mkdocs.yml create mode 100644 network/.gitignore create mode 100644 network/build.gradle.kts create mode 100644 network/consumer-rules.pro create mode 100644 network/src/main/AndroidManifest.xml create mode 100644 network/src/main/kotlin/dev/atick/network/api/JetpackRestApi.kt create mode 100644 network/src/main/kotlin/dev/atick/network/data/NetworkDataSource.kt create mode 100644 network/src/main/kotlin/dev/atick/network/data/NetworkDataSourceImpl.kt create mode 100644 network/src/main/kotlin/dev/atick/network/di/ConnectivityManagerModule.kt create mode 100644 network/src/main/kotlin/dev/atick/network/di/DataSourceModule.kt create mode 100644 network/src/main/kotlin/dev/atick/network/di/NetworkUtilsModule.kt create mode 100644 network/src/main/kotlin/dev/atick/network/di/RestApiModule.kt create mode 100644 network/src/main/kotlin/dev/atick/network/di/coil/CoilModule.kt create mode 100644 network/src/main/kotlin/dev/atick/network/di/okhttp/InterceptorModule.kt create mode 100644 network/src/main/kotlin/dev/atick/network/di/okhttp/OkHttpClientModule.kt create mode 100644 network/src/main/kotlin/dev/atick/network/di/retrofit/GsonConverterModule.kt create mode 100644 network/src/main/kotlin/dev/atick/network/di/retrofit/RetrofitModule.kt create mode 100644 network/src/main/kotlin/dev/atick/network/models/NetworkPost.kt create mode 100644 network/src/main/kotlin/dev/atick/network/utils/NetworkState.kt create mode 100644 network/src/main/kotlin/dev/atick/network/utils/NetworkUtils.kt create mode 100644 network/src/main/kotlin/dev/atick/network/utils/NetworkUtilsImpl.kt create mode 100644 network/src/main/res/values/strings.xml create mode 100644 secrets.defaults.properties create mode 100644 settings.gradle.kts create mode 100644 spotless/copyright.gradle create mode 100644 spotless/copyright.kt create mode 100644 spotless/copyright.kts create mode 100644 spotless/copyright.xml create mode 100644 storage/preferences/.gitignore create mode 100644 storage/preferences/build.gradle.kts create mode 100644 storage/preferences/consumer-rules.pro create mode 100644 storage/preferences/src/main/AndroidManifest.xml create mode 100644 storage/preferences/src/main/kotlin/dev/atick/storage/preferences/data/UserPreferencesDataSource.kt create mode 100644 storage/preferences/src/main/kotlin/dev/atick/storage/preferences/data/UserPreferencesDataSourceImpl.kt create mode 100644 storage/preferences/src/main/kotlin/dev/atick/storage/preferences/di/DatastoreModule.kt create mode 100644 storage/preferences/src/main/kotlin/dev/atick/storage/preferences/di/PreferencesDataSourceModule.kt create mode 100644 storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/DarkThemeConfig.kt create mode 100644 storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/Profile.kt create mode 100644 storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/ThemeBrand.kt create mode 100644 storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/UserData.kt create mode 100644 storage/preferences/src/main/kotlin/dev/atick/storage/preferences/utils/UserDataSerializer.kt create mode 100644 storage/room/.gitignore create mode 100644 storage/room/build.gradle.kts create mode 100644 storage/room/src/main/AndroidManifest.xml create mode 100644 storage/room/src/main/kotlin/dev/atick/storage/room/data/JetpackDao.kt create mode 100644 storage/room/src/main/kotlin/dev/atick/storage/room/data/JetpackDatabase.kt create mode 100644 storage/room/src/main/kotlin/dev/atick/storage/room/data/LocalDataSource.kt create mode 100644 storage/room/src/main/kotlin/dev/atick/storage/room/data/LocalDataSourceImpl.kt create mode 100644 storage/room/src/main/kotlin/dev/atick/storage/room/di/DaoModule.kt create mode 100644 storage/room/src/main/kotlin/dev/atick/storage/room/di/DataSourceModule.kt create mode 100644 storage/room/src/main/kotlin/dev/atick/storage/room/di/DatabaseModule.kt create mode 100644 storage/room/src/main/kotlin/dev/atick/storage/room/models/PostEntity.kt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..a0b4d234b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +# https://editorconfig.org/ +# This configuration is used by ktlint when spotless invokes it + +[*.{kt,kts}] +ij_kotlin_allow_trailing_comma=true +ij_kotlin_allow_trailing_comma_on_call_site=true \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..e557dfd96 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @atick-faisal \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..bf0110b97 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +ko_fi: atickfaisal +patreon: atick diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties new file mode 100644 index 000000000..d5b40166f --- /dev/null +++ b/.github/ci-gradle.properties @@ -0,0 +1,31 @@ +# +# Copyright 2020 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +org.gradle.daemon=false +org.gradle.parallel=true +org.gradle.workers.max=2 + +kotlin.incremental=false +kotlin.compiler.execution.strategy=in-process + +# Controls KotlinOptions.allWarningsAsErrors. +# This value used in CI and is currently set to false. +# If you want to treat warnings as errors locally, set this property to true +# in your ~/.gradle/gradle.properties file. +warningsAsErrors=false + +# Fixes Not enough memory to run compilation. +org.gradle.jvmargs=-Xmx4096m diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..ec653cb6a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "weekly" + registries: "*" + labels: [ "version update" ] + groups: + kotlin-ksp-compose: + patterns: + - "org.jetbrains.kotlin:*" + - "org.jetbrains.kotlin.jvm" + - "com.google.devtools.ksp" + - "androidx.compose.compiler:compiler" + open-pull-requests-limit: 10 + reviewers: + - atick-faisal +registries: + maven-google: + type: "maven-repository" + url: "https://maven.google.com" + replaces-base: true diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 000000000..1558d227e --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base", + "group:all", + ":dependencyDashboard", + "schedule:daily" + ] +} diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml new file mode 100644 index 000000000..df25aca65 --- /dev/null +++ b/.github/workflows/Build.yaml @@ -0,0 +1,43 @@ +name: Build + +on: + repository_dispatch: + types: [ libs-versions-updated ] + pull_request: + paths: + - '**/*' + - '!**/.github/workflows/**' + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@v3 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'zulu' + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Check spotless + run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache + + - name: Clean + run: ./gradlew clean + + - name: Build + run: ./gradlew build \ No newline at end of file diff --git a/.github/workflows/Docs.yaml b/.github/workflows/Docs.yaml new file mode 100644 index 000000000..9e4f7569e --- /dev/null +++ b/.github/workflows/Docs.yaml @@ -0,0 +1,54 @@ +name: Docs + +on: + push: + branches: + - main + paths: + - '**/*' + - '!**/.github/workflows/**' + +jobs: + mkdocs: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v4 + # https://github.com/mkdocs/mkdocs/issues/2370 + with: + fetch-depth: 0 + + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@v3 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'zulu' + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Generate Docs + run: | + pip install mkdocs mkdocs-material Pygments + ./gradlew dokkaGfmMultiModule --no-configuration-cache + mv build/dokka/gfmMultiModule docs + cat README.md > docs/index.md + mkdocs gh-deploy + +# https://github.com/softprops/action-gh-release/issues/236 +permissions: + contents: write diff --git a/.github/workflows/Release.yaml b/.github/workflows/Release.yaml new file mode 100644 index 000000000..7d46a7df1 --- /dev/null +++ b/.github/workflows/Release.yaml @@ -0,0 +1,68 @@ +name: Release APK + +on: + push: + tags: + - "v*.*.*" + +jobs: + build: + name: Generate Signed APK + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@v3 + + - name: Set up Google Services + run: | + touch app/google-services.json + echo "${CONTENT// /}" | base64 --decode > app/google-services.json + env: + CONTENT: ${{ secrets.GOOGLE_SERVICES }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'zulu' + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Set up Signing Key + run: | + touch app/key.jks + echo "${KEYSTORE// /}" | base64 --decode > app/key.jks + touch keystore.properties + echo "${KEYSTORE_PROPERTIES// /}" | base64 --decode > keystore.properties + env: + KEYSTORE: ${{ secrets.KEYSTORE }} + KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }} + + - name: Clean + run: ./gradlew clean + + - name: Build Release APK + run: ./gradlew assembleRelease + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + path: ${{ github.workspace }}/app/build/outputs/apk/release/*.apk + + - name: Create Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + body_path: CHANGELOG.md + files: app/build/outputs/apk/release/*.apk + +# https://github.com/softprops/action-gh-release/issues/236 +permissions: + contents: write \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..fcf9176b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Keystore properties file +keystore.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof + +# Build Metadata +output-metadata.json + +# k2 compiler build dir +.kotlin \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..b363d7348 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +### FEATURE UPDATES & BUG FIXES \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..f95d69073 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..2a3f36c26 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,19 @@ +## Feeling Awesome! Thanks for thinking about this. + +You can contribute us by filing issues, bugs and PRs. You can also take a look at active issues and fix them. + +If you want to discuss on something then feel free to present your opinions, views or any other relevant comment on [discussions](https://github.com/atick-faisal/Jetpack-Compose-Starter/discussions). + +### Code contribution + +- Open issue regarding proposed change. +- If your proposed change is approved, Fork this repo and do changes. +- Open PR against latest *development* branch. Add nice description in PR. +- You're done! + +### Code contribution checklist + +- New code addition/deletion should not break existing flow of a system. +- All tests should be passed. +- Verify `./gradlew build` is passing before raising a PR. +- Reformat code with Spotless `./gradlew spotlessApply` before raising a PR. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 000000000..4704d53df --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +![Jetpack Logo](https://github.com/atick-faisal/Jetpack-Compose-Starter/assets/38709932/6d8f68ad-3045-4736-99ed-86c1593f1241) + +

+ + + + +

+ +## Documentation +
+

+ +
+ Read The Documentation Here +

+ +## Features +This template offers Modern Android Development principles and Architecture guidelines. It provides an out-of-the-box template for: +* Connecting to a remote API using Retrofit and OKHttp +* Persistent database solution using Room and Datastore +* Bluetooth communication using classic and low-energy (upcoming) protocols + +It contains easy-to-use Interfaces for common tasks. For example, the following provides utilities for Bluetooth communication: +``` kotlin +/** + * BluetoothManager interface provides methods to manage Bluetooth connections. + */ +interface BluetoothManager { + /** + * Attempts to establish a Bluetooth connection with the specified device address. + * + * @param address The address of the Bluetooth device to connect to. + * @return A [Result] indicating the success or failure of the connection attempt. + */ + suspend fun connect(address: String): Result + + /** + * Returns the state of the connected Bluetooth device. + * + * @return A [StateFlow] emitting the current state of the connected Bluetooth device. + */ + fun getConnectedDeviceState(): StateFlow + + /** + * Closes the existing Bluetooth connection. + * + * @return A [Result] indicating the success or failure of closing the connection. + */ + suspend fun closeConnection(): Result +} +``` + +It also contains several utilities and extension functions to make repetitive tasks easier. For example: +``` kotlin +/** + * Displays a short toast message. + * + * @param message The message to be displayed in the toast. + */ +fun Context.showToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() +} + +/** + * Checks if the app has a given permission. + * + * @param permission The permission to check. + * @return `true` if the permission is granted, `false` otherwise. + */ +fun Context.hasPermission(permission: String): Boolean { + return ContextCompat.checkSelfPermission(this, permission) == + PackageManager.PERMISSION_GRANTED +} + +/** + * Checks if all the given permissions are granted. + * + * @param permissions List of permissions to check. + * @return `true` if all permissions are granted, `false` otherwise. + */ +fun Context.isAllPermissionsGranted(permissions: List): Boolean { + return permissions.all { hasPermission(it) } +} +``` + +## Technologies +* Kotlin +* Jetpack Compose +* Kotlin Coroutines +* Kotlin Flow for Reactive Data +* Retrofit and OkHttp +* Room Database +* Preferences Datastore +* Dependency Injection with Hilt +* Gradle Kotlin DSL +* Gradle Version Catalog +* Convention Plugin + +## Architecture +This template follows the [official architecture guidance](https://developer.android.com/topic/architecture) suggested by Google. + +## Modularization +![Modularization](https://github.com/atick-faisal/Jetpack-Compose-Starter/blob/main/assets/modularization.svg) + +## Building +### Debug +This project requires Firebase for analytics. Building the app requires `google-services.json` to be present inside the `app` dir. This file can be generated from the [Google Cloud Console](https://firebase.google.com/docs/android/setup). After that, run the following from the terminal. + +``` sh +$ ./gradlew assembleDebug +``` + +Or, use `Build > Rebuild Project`. + +### Release +Building the `release` version requires a `Keystore` file in the `app` dir. Also, a `keystore.properties` file needs to be created in the `rootDir`. +``` +storePassword=**** +keyPassword=***** +keyAlias=**** +storeFile=keystore file name (e.g., key.jks) +``` +After that, run the following from the terminal. +``` sh +$ ./gradlew assembleRelease +``` + +

+

Qatar University Machine Learning Group +

diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 000000000..6453085e6 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,113 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("UnstableApiUsage") + +import com.android.build.gradle.internal.api.BaseVariantOutputImpl +import java.io.FileInputStream +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Properties + +val keystorePropertiesFile: File = rootProject.file("keystore.properties") + +plugins { + id("dev.atick.application") + id("dev.atick.dagger.hilt") + id("dev.atick.firebase") +} + +android { + // ... Application Version ... + val majorUpdateVersion = 1 + val minorUpdateVersion = 0 + val patchVersion = 0 + + val mVersionCode = majorUpdateVersion.times(10_000) + .plus(minorUpdateVersion.times(100)) + .plus(patchVersion) + + val mVersionName = "$majorUpdateVersion.$minorUpdateVersion.$patchVersion" + val formatter = DateTimeFormatter.ofPattern("yyyy_MM_dd_hh_mm_a") + val currentTime = LocalDateTime.now().format(formatter) + + defaultConfig { + versionCode = mVersionCode + versionName = mVersionName + applicationId = "dev.atick.compose" + } + + signingConfigs { + create("release") { + if (keystorePropertiesFile.exists()) { + val keystoreProperties = Properties() + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + + buildTypes { + debug { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("debug") + } + release { + isMinifyEnabled = true + applicationVariants.all { + outputs.all { + (this as BaseVariantOutputImpl).outputFileName = + rootProject.name.replace(" ", "_") + "_" + + (buildType.name + "_v") + + (versionName + "_") + + "${currentTime}.apk" + println(outputFileName) + } + } + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + println( + "keystore.properties file not found. Using debug key. Read more here: " + + "https://github.com/atick-faisal/Jetpack-Compose-Starter#release", + ) + signingConfigs.getByName("debug") + + } + } + } + + buildFeatures { + buildConfig = true + } + + namespace = "dev.atick.compose" +} + +dependencies { + implementation(project(":core:ui")) + implementation(project(":network")) + implementation(project(":storage:room")) + implementation(project(":storage:preferences")) + implementation(project(":bluetooth:classic")) + implementation(project(":auth")) + + // ... Splash Screen + implementation(libs.androidx.core.splashscreen) +} \ No newline at end of file diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 000000000..628144adc --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,51 @@ +{ + "project_info": { + "project_number": "", + "project_id": "" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:123456789012:android:1234567890123456", + "android_client_info": { + "package_name": "dev.atick.compose" + } + }, + "oauth_client": [ + { + "client_id": "", + "client_type": 3 + }, + { + "client_id": "", + "client_type": 1, + "android_info": { + "package_name": "dev.atick.compose", + "certificate_hash": "" + } + } + ], + "api_key": [ + { + "current_key": "" + } + ], + "services": { + "analytics_service": { + "status": 2, + "analytics_property": { + "tracking_id": "" + } + }, + "appinvite_service": { + "status": 1, + "other_platform_oauth_client": [] + }, + "ads_service": { + "status": 1 + } + } + } + ], + "configuration_version": "1" +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..b7ec797a8 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/kotlin/dev/atick/compose/App.kt b/app/src/main/kotlin/dev/atick/compose/App.kt new file mode 100644 index 000000000..36311a298 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/App.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber + +/** + * The main application class that extends [Application] and is annotated with [HiltAndroidApp]. + */ +@HiltAndroidApp +class App : Application() { + + /** + * Called when the application is first created. + * Performs initialization tasks, such as setting up Timber logging in debug mode. + */ + override fun onCreate() { + super.onCreate() + if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/MainActivity.kt b/app/src/main/kotlin/dev/atick/compose/MainActivity.kt new file mode 100644 index 000000000..eb568cac2 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/MainActivity.kt @@ -0,0 +1,232 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose + +import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.viewModels +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import dagger.hilt.android.AndroidEntryPoint +import dev.atick.bluetooth.common.models.BtState +import dev.atick.bluetooth.common.utils.BluetoothUtils +import dev.atick.compose.ui.JetpackApp +import dev.atick.core.extensions.isAllPermissionsGranted +import dev.atick.core.ui.extensions.checkForPermissions +import dev.atick.core.ui.extensions.collectWithLifecycle +import dev.atick.core.ui.extensions.resultLauncher +import dev.atick.core.ui.theme.JetpackTheme +import dev.atick.core.ui.utils.UiState +import dev.atick.network.utils.NetworkUtils +import dev.atick.storage.preferences.models.DarkThemeConfig +import dev.atick.storage.preferences.models.ThemeBrand +import dev.atick.storage.preferences.models.UserData +import javax.inject.Inject + +/** + * Main activity for the application. + */ +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + private val permissions = mutableListOf() + private lateinit var btLauncher: ActivityResultLauncher + + @Inject + lateinit var bluetoothUtils: BluetoothUtils + + @Inject + lateinit var networkUtils: NetworkUtils + + private val viewModel: MainActivityViewModel by viewModels() + + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() + super.onCreate(savedInstanceState) + + var uiState: UiState by mutableStateOf(UiState.Loading(UserData())) + + collectWithLifecycle(viewModel.uiState) { uiState = it } + + // Keep the splash screen on-screen until the UI state is loaded. This condition is + // evaluated each time the app needs to be redrawn so it should be fast to avoid blocking + // the UI. + splashScreen.setKeepOnScreenCondition { + when (uiState) { + is UiState.Loading -> true + else -> false + } + } + + // Turn off the decor fitting system windows, which allows us to handle insets, + // including IME animations, and go edge-to-edge + // This also sets up the initial system bar style based on the platform theme + enableEdgeToEdge() + + setContent { + val darkTheme = shouldUseDarkTheme(uiState) + + // Update the edge to edge configuration to match the theme + // This is the same parameters as the default enableEdgeToEdge call, but we manually + // resolve whether or not to show dark theme using uiState, since it can be different + // than the configuration's dark theme value based on the user preference. + DisposableEffect(darkTheme) { + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto( + android.graphics.Color.TRANSPARENT, + android.graphics.Color.TRANSPARENT, + ) { darkTheme }, + navigationBarStyle = SystemBarStyle.auto( + lightScrim, + darkScrim, + ) { darkTheme }, + ) + onDispose {} + } + + JetpackTheme( + darkTheme = darkTheme, + androidTheme = shouldUseAndroidTheme(uiState), + disableDynamicTheming = shouldDisableDynamicTheming(uiState), + ) { + JetpackApp( + isUserLoggedIn = isUserLoggedIn(uiState), + networkUtils = networkUtils, + windowSizeClass = calculateWindowSizeClass(this), + ) + } + } + + // Configure required permissions + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(Manifest.permission.POST_NOTIFICATIONS) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + permissions.add(Manifest.permission.BLUETOOTH_SCAN) + permissions.add(Manifest.permission.BLUETOOTH_CONNECT) + } + + // Check for permissions and launch Bluetooth enable request + checkForPermissions(permissions) { + btLauncher.launch( + Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), + ) + } + + // Configure Bluetooth state monitoring and enable request + btLauncher = resultLauncher(onFailure = { finishAffinity() }) + collectWithLifecycle(bluetoothUtils.getBluetoothState()) { state -> + if (state == BtState.DISABLED && isAllPermissionsGranted(permissions)) { + btLauncher.launch( + Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), + ) + } + } + } +} + +/** + * Returns `true` if the Android theme should be used, as a function of the [uiState]. + */ +@Composable +private fun shouldUseAndroidTheme( + uiState: UiState, +): Boolean = when (uiState) { + is UiState.Loading, + is UiState.Error, + -> false + + is UiState.Success -> when (uiState.data.themeBrand) { + ThemeBrand.DEFAULT -> false + ThemeBrand.ANDROID -> true + } +} + +/** + * Returns `true` if the dynamic color is disabled, as a function of the [uiState]. + */ +@Composable +private fun shouldDisableDynamicTheming( + uiState: UiState, +): Boolean = when (uiState) { + is UiState.Loading, + is UiState.Error, + -> false + + is UiState.Success -> !uiState.data.useDynamicColor +} + +/** + * Returns `true` if dark theme should be used, as a function of the [uiState] and the + * current system context. + */ +@Composable +private fun shouldUseDarkTheme( + uiState: UiState, +): Boolean = when (uiState) { + is UiState.Loading, + is UiState.Error, + -> isSystemInDarkTheme() + + is UiState.Success -> when (uiState.data.darkThemeConfig) { + DarkThemeConfig.FOLLOW_SYSTEM -> isSystemInDarkTheme() + DarkThemeConfig.LIGHT -> false + DarkThemeConfig.DARK -> true + } +} + +/** + * Determines whether a user is logged in based on the provided [UiState]. + * + * @param uiState The UI state representing the user data. + * @return `true` if the user is considered logged in; `false` otherwise. + */ +private fun isUserLoggedIn(uiState: UiState): Boolean { + return when (uiState) { + is UiState.Loading -> true // User is considered logged in during loading (assuming ongoing session). + is UiState.Success -> uiState.data.id.isNotEmpty() // User is logged in if the data ID is not "-1". + is UiState.Error -> false // User is not logged in in case of an error. + } +} + +/** + * The default light scrim, as defined by androidx and the platform: + * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=35-38;drc=27e7d52e8604a080133e8b842db10c89b4482598 + */ +private val lightScrim = android.graphics.Color.argb(0xe6, 0xFF, 0xFF, 0xFF) + +/** + * The default dark scrim, as defined by androidx and the platform: + * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=40-44;drc=27e7d52e8604a080133e8b842db10c89b4482598 + */ +private val darkScrim = android.graphics.Color.argb(0x80, 0x1b, 0x1b, 0x1b) diff --git a/app/src/main/kotlin/dev/atick/compose/MainActivityViewModel.kt b/app/src/main/kotlin/dev/atick/compose/MainActivityViewModel.kt new file mode 100644 index 000000000..2a81f0e73 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/MainActivityViewModel.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.atick.compose.repository.user.UserDataRepository +import dev.atick.core.extensions.stateInDelayed +import dev.atick.core.ui.utils.UiState +import dev.atick.storage.preferences.models.UserData +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * Annotates a ViewModel class that is managed by Hilt's dependency injection system. + * + * @constructor Creates a [MainActivityViewModel] instance. + * @param userDataRepository The repository providing access to user data. + */ +@HiltViewModel +class MainActivityViewModel @Inject constructor( + userDataRepository: UserDataRepository, +) : ViewModel() { + + /** + * Represents the state of the UI for user data. + */ + val uiState: StateFlow> = userDataRepository.userData + .catch { throwable -> UiState.Error(UserData(), throwable) } + .map { userData -> UiState.Success(userData) } + .stateInDelayed(UiState.Loading(UserData()), viewModelScope) +} diff --git a/app/src/main/kotlin/dev/atick/compose/data/home/HomeScreenData.kt b/app/src/main/kotlin/dev/atick/compose/data/home/HomeScreenData.kt new file mode 100644 index 000000000..7d5818e5d --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/data/home/HomeScreenData.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.data.home + +import dev.atick.network.models.NetworkPost +import dev.atick.storage.room.models.PostEntity + +data class HomeScreenData( + val posts: List = listOf(), +) + +/** + * Data class representing a post to be shown on the ui. + * + * @property id The unique identifier of the network post. + * @property title The title of the network post. + * @property url The URL associated with the network post. + * @property thumbnailUrl The URL of the thumbnail image associated with the network post. + */ +data class UiPost( + val id: Int, + val title: String, + val url: String, + val thumbnailUrl: String, +) + +/** + * Converts a [PostEntity] object to a [UiPost] object for displaying in the UI. + * + * @return The converted [UiPost] object. + */ +fun PostEntity.toUiPost(): UiPost { + return UiPost( + id = id, + title = title, + url = url, + thumbnailUrl = thumbnailUrl, + ) +} + +/** + * Converts a list of [PostEntity] objects to a list of [UiPost] objects for displaying in the UI, + * using the [toUiPost] conversion function. + * + * @receiver The list of [PostEntity] objects to be converted. + * @return A list of converted [UiPost] objects. + */ +fun List.mapToUiPost(): List { + return map(PostEntity::toUiPost) +} + +/** + * Converts a [NetworkPost] object to a corresponding [UiPost] object. + * + * This extension function facilitates the conversion of a [NetworkPost] object into a [UiPost] object by mapping + * the properties from the source to the destination. + * + * @receiver The [NetworkPost] object to convert to [UiPost]. + * @return A new [UiPost] object with properties mapped from the source [NetworkPost]. + */ +fun NetworkPost.toUiPost(): UiPost { + return UiPost( + id = id, + title = title, + url = url, + thumbnailUrl = thumbnailUrl, + ) +} + +/** + * Converts a [NetworkPost] object to a [PostEntity] object. + * + * @return The converted [PostEntity] object. + */ +fun NetworkPost.toPostEntity(): PostEntity { + return PostEntity( + id = id, + title = title, + url = url, + thumbnailUrl = thumbnailUrl, + ) +} + +/** + * Converts a list of [NetworkPost] objects to a list of corresponding [UiPost] objects. + * + * This extension function applies the [NetworkPost.toUiPost] conversion function to each element of the source list, + * effectively mapping the properties from [NetworkPost] objects to [UiPost] objects. + * + * @receiver The list of [NetworkPost] objects to convert to a list of [UiPost] objects. + * @return A new list containing [UiPost] objects with properties mapped from the source [NetworkPost] objects. + */ +fun List.mapToUiPosts(): List { + return map(NetworkPost::toUiPost) +} + +/** + * Converts a list of [NetworkPost] objects to a list of [PostEntity] objects using the + * [toPostEntity] conversion function. + * + * @receiver The list of [NetworkPost] objects to be converted. + * @return A list of converted [PostEntity] objects. + */ +fun List.mapToPostEntities(): List { + return map(NetworkPost::toPostEntity) +} diff --git a/app/src/main/kotlin/dev/atick/compose/data/profile/ProfileScreenData.kt b/app/src/main/kotlin/dev/atick/compose/data/profile/ProfileScreenData.kt new file mode 100644 index 000000000..95e146b56 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/data/profile/ProfileScreenData.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.data.profile + +/** + * Represents data for displaying a user's profile screen. + * + * @property name The user's name to be displayed on the profile screen. + * @property profilePictureUri The URI for the user's profile picture, or null if not available. + */ +data class ProfileScreenData( + val name: String = String(), + val profilePictureUri: String? = null, +) diff --git a/app/src/main/kotlin/dev/atick/compose/data/settings/UserEditableSettings.kt b/app/src/main/kotlin/dev/atick/compose/data/settings/UserEditableSettings.kt new file mode 100644 index 000000000..550c08d57 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/data/settings/UserEditableSettings.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.data.settings + +import dev.atick.storage.preferences.models.DarkThemeConfig +import dev.atick.storage.preferences.models.ThemeBrand + +/** + * Data class representing editable user settings related to themes and appearance. + * + * @property brand The selected brand for the theme. + * @property useDynamicColor Indicates whether dynamic colors are enabled. + * @property darkThemeConfig Configuration for the dark theme. + * @constructor Creates a [UserEditableSettings] instance with optional parameters. + */ +data class UserEditableSettings( + val brand: ThemeBrand = ThemeBrand.DEFAULT, + val useDynamicColor: Boolean = true, + val darkThemeConfig: DarkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, +) diff --git a/app/src/main/kotlin/dev/atick/compose/di/notification/NotificationModule.kt b/app/src/main/kotlin/dev/atick/compose/di/notification/NotificationModule.kt new file mode 100644 index 000000000..adf95c26b --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/di/notification/NotificationModule.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.di.notification + +import android.app.NotificationManager +import android.content.Context +import android.content.Context.NOTIFICATION_SERVICE +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Dagger module that provides the [NotificationManager] instance as a dependency for the application. + */ +@Module +@InstallIn(SingletonComponent::class) +object NotificationModule { + + /** + * Provides a singleton instance of [NotificationManager] using the application [Context]. + * + * @param context The application [Context] provided by Dagger through dependency injection. + * @return The singleton [NotificationManager] instance. + * @throws ClassCastException If the [NOTIFICATION_SERVICE] retrieved from the [Context] is not an instance of [NotificationManager]. + * @see Context.getSystemService + */ + @Provides + @Singleton + fun provideNotificationManager( + @ApplicationContext context: Context, + ): NotificationManager { + return context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/di/repository/RepositoryModule.kt b/app/src/main/kotlin/dev/atick/compose/di/repository/RepositoryModule.kt new file mode 100644 index 000000000..1f651b2e2 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/di/repository/RepositoryModule.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.di.repository + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.atick.compose.repository.home.PostsRepository +import dev.atick.compose.repository.home.PostsRepositoryImpl +import dev.atick.compose.repository.profile.ProfileDataRepository +import dev.atick.compose.repository.profile.ProfileDataRepositoryImpl +import dev.atick.compose.repository.user.UserDataRepository +import dev.atick.compose.repository.user.UserDataRepositoryImpl +import javax.inject.Singleton + +/** + * Dagger module that provides the binding for the [PostsRepository] interface. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + /** + * Binds the [PostsRepositoryImpl] implementation to the [PostsRepository] interface. + * + * @param postsRepositoryImpl The implementation of [PostsRepository] to be bound. + * @return The [PostsRepository] interface. + */ + @Binds + @Singleton + abstract fun bindPostsRepository( + postsRepositoryImpl: PostsRepositoryImpl, + ): PostsRepository + + /** + * Binds the [UserDataRepositoryImpl] implementation to the [UserDataRepository] interface. + * + * @param userDataRepositoryImpl The implementation of [UserDataRepository] to be bound. + * @return The [UserDataRepository] interface. + */ + @Binds + @Singleton + abstract fun bindUserDataRepository( + userDataRepositoryImpl: UserDataRepositoryImpl, + ): UserDataRepository + + /** + * This method is used to bind a [ProfileDataRepositoryImpl] instance to the [ProfileDataRepository] interface. + * It is annotated with [@Binds](https://developer.android.com/reference/dagger/Binds) and [@Singleton](https://developer.android.com/reference/javax/inject/Singleton), indicating that a single instance + * of [ProfileDataRepositoryImpl] should be used as the implementation of [ProfileDataRepository] throughout the application. + * + * @param profileDataRepositoryImpl The [ProfileDataRepositoryImpl] instance to be bound to [ProfileDataRepository]. + * @return An instance of [ProfileDataRepository] representing the bound implementation. + */ + @Binds + @Singleton + abstract fun bindProfileDataRepository( + profileDataRepositoryImpl: ProfileDataRepositoryImpl, + ): ProfileDataRepository +} diff --git a/app/src/main/kotlin/dev/atick/compose/navigation/NavHost.kt b/app/src/main/kotlin/dev/atick/compose/navigation/NavHost.kt new file mode 100644 index 000000000..62320d02c --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/navigation/NavHost.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import dev.atick.auth.navigation.authNavGraph +import dev.atick.auth.navigation.authNavGraphRoute +import dev.atick.auth.navigation.navigateToSignInRoute +import dev.atick.auth.navigation.navigateToSignUpRoute +import dev.atick.compose.navigation.details.detailsScreen +import dev.atick.compose.navigation.details.navigateToDetailsScreen +import dev.atick.compose.navigation.home.homeNavGraph +import dev.atick.compose.navigation.home.homeNavGraphRoute +import dev.atick.compose.navigation.profile.profileScreen +import dev.atick.compose.ui.JetpackAppState + +@Composable +fun JetpackNavHost( + appState: JetpackAppState, + onShowSnackbar: suspend (String, String?) -> Boolean, + modifier: Modifier = Modifier, +) { + val navController = appState.navController + val startDestination = if (appState.isUserLoggedIn) homeNavGraphRoute else authNavGraphRoute + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier, + ) { + authNavGraph( + onSignUpClick = navController::navigateToSignUpRoute, + onSignInCLick = navController::navigateToSignInRoute, + onShowSnackbar = onShowSnackbar, + ) + homeNavGraph( + onPostClick = navController::navigateToDetailsScreen, + onShowSnackbar = onShowSnackbar, + nestedNavGraphs = { + detailsScreen( + onBackClick = navController::popBackStack, + onShowSnackbar = onShowSnackbar, + ) + }, + ) + profileScreen(onShowSnackbar = onShowSnackbar) + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/navigation/TopLevelDestination.kt b/app/src/main/kotlin/dev/atick/compose/navigation/TopLevelDestination.kt new file mode 100644 index 000000000..5a9424d09 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/navigation/TopLevelDestination.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Person +import androidx.compose.ui.graphics.vector.ImageVector +import dev.atick.compose.R + +/** + * Enum class representing top-level destinations in a navigation system. + * + * @property selectedIcon The selected icon associated with the destination. + * @property unselectedIcon The unselected icon associated with the destination. + * @property iconTextId The resource ID for the icon's content description text. + * @property titleTextId The resource ID for the title text. + */ +enum class TopLevelDestination( + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, + val iconTextId: Int, + val titleTextId: Int, +) { + HOME( + selectedIcon = Icons.Filled.Home, + unselectedIcon = Icons.Outlined.Home, + iconTextId = R.string.home, + titleTextId = R.string.home, + ), + PROFILE( + selectedIcon = Icons.Filled.Person, + unselectedIcon = Icons.Outlined.Person, + iconTextId = R.string.profile, + titleTextId = R.string.profile, + ), +} diff --git a/app/src/main/kotlin/dev/atick/compose/navigation/details/DetailsNavigation.kt b/app/src/main/kotlin/dev/atick/compose/navigation/details/DetailsNavigation.kt new file mode 100644 index 000000000..257603d7b --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/navigation/details/DetailsNavigation.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.navigation.details + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import dev.atick.compose.ui.details.DetailsRoute + +internal const val postIdArg = "postId" + +fun NavController.navigateToDetailsScreen(postId: Int) { + navigate("details/$postId") { launchSingleTop = true } +} + +fun NavGraphBuilder.detailsScreen( + onBackClick: () -> Unit, + onShowSnackbar: suspend (String, String?) -> Boolean, +) { + composable( + route = "details/{$postIdArg}", + arguments = listOf( + navArgument(postIdArg) { type = NavType.IntType }, + ), + ) { + DetailsRoute( + onBackClick = onBackClick, + onShowSnackbar = onShowSnackbar, + ) + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/navigation/home/HomeNavigation.kt b/app/src/main/kotlin/dev/atick/compose/navigation/home/HomeNavigation.kt new file mode 100644 index 000000000..f0f1f2012 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/navigation/home/HomeNavigation.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.navigation.home + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import dev.atick.compose.ui.home.HomeRoute + +const val homeNavigationRoute = "home" +const val homeNavGraphRoute = "home_graph" + +fun NavController.navigateToHomeNavGraph(navOptions: NavOptions? = null) { + navigate(homeNavGraphRoute, navOptions) +} + +fun NavGraphBuilder.homeNavGraph( + onPostClick: (Int) -> Unit, + onShowSnackbar: suspend (String, String?) -> Boolean, + nestedNavGraphs: NavGraphBuilder.() -> Unit, +) { + navigation( + route = homeNavGraphRoute, + startDestination = homeNavigationRoute, + ) { + composable(route = homeNavigationRoute) { + HomeRoute(onPostCLick = onPostClick, onShowSnackbar = onShowSnackbar) + } + nestedNavGraphs() + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/navigation/profile/ProfileNavigation.kt b/app/src/main/kotlin/dev/atick/compose/navigation/profile/ProfileNavigation.kt new file mode 100644 index 000000000..f21f9b592 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/navigation/profile/ProfileNavigation.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.navigation.profile + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import dev.atick.compose.ui.profile.ProfileRoute + +const val profileNavigationRoute = "profile" + +fun NavController.navigateProfile(navOptions: NavOptions?) { + navigate(profileNavigationRoute, navOptions) +} + +fun NavGraphBuilder.profileScreen( + onShowSnackbar: suspend (String, String?) -> Boolean, +) { + composable(route = profileNavigationRoute) { + ProfileRoute(onShowSnackbar = onShowSnackbar) + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/repository/home/PostsRepository.kt b/app/src/main/kotlin/dev/atick/compose/repository/home/PostsRepository.kt new file mode 100644 index 000000000..8e0d4f908 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/repository/home/PostsRepository.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.repository.home + +import dev.atick.compose.data.home.UiPost +import kotlinx.coroutines.flow.Flow + +/** + * Interface defining operations for interacting with the home repository. + */ +interface PostsRepository { + + suspend fun synchronizePosts(): Result + + /** + * Retrieves a UI post with the specified ID wrapped in a [Result] from a data source. + * + * This function asynchronously fetches a UI post with the given ID and encapsulates the result in a [Result] wrapper. + * + * @param id The ID of the UI post to retrieve. + * @return A [Result] instance containing either the fetched [UiPost] object on success or an error on failure. + */ + suspend fun getPost(id: Int): Result + + fun getCachedPosts(): Flow> +} diff --git a/app/src/main/kotlin/dev/atick/compose/repository/home/PostsRepositoryImpl.kt b/app/src/main/kotlin/dev/atick/compose/repository/home/PostsRepositoryImpl.kt new file mode 100644 index 000000000..23c2284ca --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/repository/home/PostsRepositoryImpl.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.repository.home + +import dev.atick.compose.data.home.UiPost +import dev.atick.compose.data.home.mapToPostEntities +import dev.atick.compose.data.home.mapToUiPost +import dev.atick.compose.data.home.toUiPost +import dev.atick.network.data.NetworkDataSource +import dev.atick.storage.room.data.LocalDataSource +import dev.atick.storage.room.models.PostEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * Implementation of [PostsRepository] that coordinates data synchronization between network and local sources. + * + * @param networkDataSource The data source for network operations. + * @param localDataSource The data source for local storage operations. + */ +class PostsRepositoryImpl @Inject constructor( + private val networkDataSource: NetworkDataSource, + private val localDataSource: LocalDataSource, +) : PostsRepository { + + /** + * Synchronizes posts by fetching from the network and updating the local storage. + * + * @return A [Result] indicating the outcome of the synchronization operation. + */ + override suspend fun synchronizePosts(): Result { + return runCatching { + val networkPosts = networkDataSource.getPosts() + localDataSource.upsertPostEntities(networkPosts.mapToPostEntities()) + } + } + + /** + * Retrieves a post by its unique identifier from the network data source and converts it to a [UiPost]. + * + * @param id The unique identifier of the post. + * @return A [Result] containing the retrieved [UiPost] object. + */ + override suspend fun getPost(id: Int): Result { + return runCatching { + networkDataSource.getPost(id).toUiPost() + } + } + + /** + * Retrieves cached posts from the local data source and converts them to a [Flow] of [UiPost] objects. + * + * @return A [Flow] emitting a list of [UiPost] objects representing cached posts. + */ + override fun getCachedPosts(): Flow> { + return localDataSource.getPostEntities().map(List::mapToUiPost) + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/repository/profile/ProfileDataRepository.kt b/app/src/main/kotlin/dev/atick/compose/repository/profile/ProfileDataRepository.kt new file mode 100644 index 000000000..bcfd684fe --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/repository/profile/ProfileDataRepository.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.repository.profile + +import dev.atick.compose.data.profile.ProfileScreenData +import kotlinx.coroutines.flow.Flow + +/** + * Interface representing a repository for profile screen data and sign-out functionality. + */ +interface ProfileDataRepository { + /** + * A flow that provides [ProfileScreenData] updates to be displayed on the profile screen. + * + * The profile screen data can change over time, and clients can collect updates using a Flow. + */ + val profileScreenData: Flow + + /** + * Suspend function to sign the user out. + * + * @return A [Result] representing the sign-out operation result. It contains [Unit] if + * the sign-out was successful, or an error if there was a problem. + */ + suspend fun signOut(): Result +} diff --git a/app/src/main/kotlin/dev/atick/compose/repository/profile/ProfileDataRepositoryImpl.kt b/app/src/main/kotlin/dev/atick/compose/repository/profile/ProfileDataRepositoryImpl.kt new file mode 100644 index 000000000..b6dcdd7e2 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/repository/profile/ProfileDataRepositoryImpl.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.repository.profile + +import dev.atick.auth.data.AuthDataSource +import dev.atick.compose.data.profile.ProfileScreenData +import dev.atick.storage.preferences.data.UserPreferencesDataSource +import dev.atick.storage.preferences.models.Profile +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * Implementation of [ProfileDataRepository] that provides profile screen data and sign-out functionality. + * + * @param userPreferencesDataSource The data source for user preferences. + * @param authDataSource The data source for authentication operations. + */ +class ProfileDataRepositoryImpl @Inject constructor( + private val userPreferencesDataSource: UserPreferencesDataSource, + private val authDataSource: AuthDataSource, +) : ProfileDataRepository { + + /** + * A flow that provides [ProfileScreenData] updates to be displayed on the profile screen. + * + * The profile screen data is derived from the user's preferences. + */ + override val profileScreenData: Flow = + userPreferencesDataSource.userData.map { userData -> + ProfileScreenData( + name = userData.name, + profilePictureUri = userData.profilePictureUriString, + ) + } + + /** + * Suspend function to sign the user out. + * + * This function signs the user out by delegating to the [AuthDataSource] and then updates the user's + * profile data in [UserPreferencesDataSource]. + * + * @return A [Result] representing the sign-out operation result. It contains [Unit] if + * the sign-out was successful, or an error if there was a problem. + */ + override suspend fun signOut(): Result { + return runCatching { + authDataSource.signOut() + userPreferencesDataSource.setProfile(Profile()) + } + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/repository/user/UserDataRepository.kt b/app/src/main/kotlin/dev/atick/compose/repository/user/UserDataRepository.kt new file mode 100644 index 000000000..2a8443e27 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/repository/user/UserDataRepository.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.repository.user + +import dev.atick.storage.preferences.models.DarkThemeConfig +import dev.atick.storage.preferences.models.Profile +import dev.atick.storage.preferences.models.ThemeBrand +import dev.atick.storage.preferences.models.UserData +import kotlinx.coroutines.flow.Flow + +/** + * Interface defining methods to interact with user data and preferences. + */ +interface UserDataRepository { + + /** + * A [Flow] that emits [UserData] representing user-specific data. + */ + val userData: Flow + + /** + * Sets the user ID in the user preferences. + * + * @param profile The user ID to be set. + * @return [Result] indicating the success or failure of the operation. + */ + suspend fun setUserProfile(profile: Profile): Result + + /** + * Sets the theme brand in the user preferences. + * + * @param themeBrand The theme brand to be set. + * @return [Result] indicating the success or failure of the operation. + */ + suspend fun setThemeBrand(themeBrand: ThemeBrand): Result + + /** + * Sets the dark theme configuration in the user preferences. + * + * @param darkThemeConfig The dark theme configuration to be set. + * @return [Result] indicating the success or failure of the operation. + */ + suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig): Result + + /** + * Sets the dynamic color preferences in the user preferences. + * + * @param useDynamicColor A boolean indicating whether dynamic colors should be used. + * @return [Result] indicating the success or failure of the operation. + */ + suspend fun setDynamicColorPreference(useDynamicColor: Boolean): Result +} diff --git a/app/src/main/kotlin/dev/atick/compose/repository/user/UserDataRepositoryImpl.kt b/app/src/main/kotlin/dev/atick/compose/repository/user/UserDataRepositoryImpl.kt new file mode 100644 index 000000000..901873ddb --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/repository/user/UserDataRepositoryImpl.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.repository.user + +import dev.atick.storage.preferences.data.UserPreferencesDataSource +import dev.atick.storage.preferences.models.DarkThemeConfig +import dev.atick.storage.preferences.models.Profile +import dev.atick.storage.preferences.models.ThemeBrand +import dev.atick.storage.preferences.models.UserData +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * Implementation of [UserDataRepository] that utilizes [UserPreferencesDataSource] to manage user data and preferences. + * + * @property userPreferencesDataSource The data source for user preferences. + */ +class UserDataRepositoryImpl @Inject constructor( + private val userPreferencesDataSource: UserPreferencesDataSource, +) : UserDataRepository { + + /** + * A [Flow] that emits [UserData] representing user-specific data. + */ + override val userData: Flow + get() = userPreferencesDataSource.userData + + /** + * Sets the user [Profile] in the user preferences. + * + * @param profile The user [Profile] to be set. + * @return [Result] indicating the success or failure of the operation. + */ + override suspend fun setUserProfile(profile: Profile): Result { + return runCatching { + userPreferencesDataSource.setProfile(profile) + } + } + + /** + * Sets the theme brand in the user preferences. + * + * @param themeBrand The theme brand to be set. + * @return [Result] indicating the success or failure of the operation. + */ + override suspend fun setThemeBrand(themeBrand: ThemeBrand): Result { + return runCatching { + userPreferencesDataSource.setThemeBrand(themeBrand) + } + } + + /** + * Sets the dark theme configuration in the user preferences. + * + * @param darkThemeConfig The dark theme configuration to be set. + * @return [Result] indicating the success or failure of the operation. + */ + override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig): Result { + return runCatching { + userPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) + } + } + + /** + * Sets the dynamic color preferences in the user preferences. + * + * @param useDynamicColor A boolean indicating whether dynamic colors should be used. + * @return [Result] indicating the success or failure of the operation. + */ + override suspend fun setDynamicColorPreference(useDynamicColor: Boolean): Result { + return runCatching { + userPreferencesDataSource.setDynamicColorPreference(useDynamicColor) + } + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/ui/JetpackApp.kt b/app/src/main/kotlin/dev/atick/compose/ui/JetpackApp.kt new file mode 100644 index 000000000..119561052 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/ui/JetpackApp.kt @@ -0,0 +1,286 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hierarchy +import dev.atick.compose.R +import dev.atick.compose.navigation.JetpackNavHost +import dev.atick.compose.navigation.TopLevelDestination +import dev.atick.compose.ui.settings.SettingsDialog +import dev.atick.core.ui.components.AppBackground +import dev.atick.core.ui.components.AppGradientBackground +import dev.atick.core.ui.components.JetpackNavigationBar +import dev.atick.core.ui.components.JetpackNavigationBarItem +import dev.atick.core.ui.components.JetpackNavigationRail +import dev.atick.core.ui.components.JetpackNavigationRailItem +import dev.atick.core.ui.components.JetpackTopAppBar +import dev.atick.core.ui.theme.GradientColors +import dev.atick.core.ui.theme.LocalGradientColors +import dev.atick.network.utils.NetworkUtils + +@Composable +fun JetpackApp( + isUserLoggedIn: Boolean, + windowSizeClass: WindowSizeClass, + networkUtils: NetworkUtils, + appState: JetpackAppState = rememberJetpackAppState( + isUserLoggedIn = isUserLoggedIn, + windowSizeClass = windowSizeClass, + networkUtils = networkUtils, + ), +) { + val shouldShowGradientBackground = + appState.currentTopLevelDestination == TopLevelDestination.HOME + var showSettingsDialog by rememberSaveable { mutableStateOf(false) } + + AppBackground { + AppGradientBackground( + gradientColors = if (shouldShowGradientBackground) { + LocalGradientColors.current + } else { + GradientColors() + }, + ) { + val snackbarHostState = remember { SnackbarHostState() } + val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsState() + val isOffline by appState.isOffline.collectAsState() + + // If user is not connected to the internet show a snack bar to inform them. + val notConnectedMessage = stringResource(R.string.not_connected) + LaunchedEffect(isOffline) { + if (isOffline) { + snackbarHostState.showSnackbar( + message = notConnectedMessage, + duration = SnackbarDuration.Indefinite, + ) + } + } + + if (showSettingsDialog) { + SettingsDialog( + onDismiss = { showSettingsDialog = false }, + ) + } + + Scaffold( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + // Snackbar displays incorrectly if used + // contentWindowInsets = WindowInsets(0, 0, 0, 0), + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { + if (appState.shouldShowBottomBar) { + JetpackBottomBar( + destinations = appState.topLevelDestinations, + destinationsWithUnreadResources = setOf(TopLevelDestination.PROFILE), + onNavigateToDestination = appState::navigateToTopLevelDestination, + currentDestination = appState.currentDestination, + ) + } + }, + ) { padding -> + Row( + Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal, + ), + ), + ) { + if (appState.shouldShowNavRail) { + JetpackNavRail( + destinations = appState.topLevelDestinations, + destinationsWithUnreadResources = unreadDestinations, + onNavigateToDestination = appState::navigateToTopLevelDestination, + currentDestination = appState.currentDestination, + modifier = Modifier.safeDrawingPadding(), + ) + } + + Column(Modifier.fillMaxSize()) { + // Show the top app bar on top level destinations. + val destination = appState.currentTopLevelDestination + if (destination != null) { + JetpackTopAppBar( + titleRes = destination.titleTextId, + navigationIcon = Icons.Default.Menu, + navigationIconContentDescription = stringResource(id = R.string.search), + actionIcon = Icons.Default.Settings, + actionIconContentDescription = stringResource(id = R.string.more), + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent, + ), + onActionClick = { showSettingsDialog = true }, + onNavigationClick = { }, + ) + } + JetpackNavHost( + appState = appState, + onShowSnackbar = { message, action -> + snackbarHostState.showSnackbar( + message = message, + actionLabel = action, + duration = SnackbarDuration.Short, + ) == SnackbarResult.ActionPerformed + }, + ) + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } + } + } + } + } +} + +@Composable +fun JetpackNavRail( + destinations: List, + destinationsWithUnreadResources: Set, + onNavigateToDestination: (TopLevelDestination) -> Unit, + currentDestination: NavDestination?, + modifier: Modifier = Modifier, +) { + JetpackNavigationRail(modifier = modifier) { + destinations.forEach { destination -> + val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) + val hasUnread = destinationsWithUnreadResources.contains(destination) + JetpackNavigationRailItem( + selected = selected, + onClick = { onNavigateToDestination(destination) }, + icon = { + Icon( + imageVector = destination.unselectedIcon, + contentDescription = null, + ) + }, + selectedIcon = { + Icon( + imageVector = destination.selectedIcon, + contentDescription = null, + ) + }, + label = { Text(stringResource(destination.iconTextId)) }, + modifier = if (hasUnread) Modifier.notificationDot() else Modifier, + ) + } + } +} + +@Composable +fun JetpackBottomBar( + destinations: List, + destinationsWithUnreadResources: Set, + onNavigateToDestination: (TopLevelDestination) -> Unit, + currentDestination: NavDestination?, + modifier: Modifier = Modifier, +) { + JetpackNavigationBar( + modifier = modifier, + ) { + destinations.forEach { destination -> + val hasUnread = destinationsWithUnreadResources.contains(destination) + val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) + JetpackNavigationBarItem( + selected = selected, + onClick = { onNavigateToDestination(destination) }, + icon = { + Icon( + imageVector = destination.unselectedIcon, + contentDescription = null, + ) + }, + selectedIcon = { + Icon( + imageVector = destination.selectedIcon, + contentDescription = null, + ) + }, + label = { Text(stringResource(destination.iconTextId)) }, + modifier = if (hasUnread) Modifier.notificationDot() else Modifier, + ) + } + } +} + +private fun Modifier.notificationDot(): Modifier = + composed { + val tertiaryColor = MaterialTheme.colorScheme.tertiary + drawWithContent { + drawContent() + drawCircle( + tertiaryColor, + radius = 5.dp.toPx(), + // This is based on the dimensions of the NavigationBar's "indicator pill"; + // however, its parameters are private, so we must depend on them implicitly + // (NavigationBarTokens.ActiveIndicatorWidth = 64.dp) + center = center + Offset( + 64.dp.toPx() * .45f, + 32.dp.toPx() * -.45f - 6.dp.toPx(), + ), + ) + } + } + +private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) = + this?.hierarchy?.any { + it.route?.contains(destination.name, true) ?: false + } ?: false diff --git a/app/src/main/kotlin/dev/atick/compose/ui/JetpackAppState.kt b/app/src/main/kotlin/dev/atick/compose/ui/JetpackAppState.kt new file mode 100644 index 000000000..a4be22dd4 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/ui/JetpackAppState.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.ui + +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.navigation.NavDestination +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions +import dev.atick.compose.navigation.TopLevelDestination +import dev.atick.compose.navigation.home.homeNavigationRoute +import dev.atick.compose.navigation.home.navigateToHomeNavGraph +import dev.atick.compose.navigation.profile.navigateProfile +import dev.atick.compose.navigation.profile.profileNavigationRoute +import dev.atick.core.extensions.stateInDelayed +import dev.atick.network.utils.NetworkState +import dev.atick.network.utils.NetworkUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map + +@Composable +fun rememberJetpackAppState( + isUserLoggedIn: Boolean, + windowSizeClass: WindowSizeClass, + networkUtils: NetworkUtils, + coroutineScope: CoroutineScope = rememberCoroutineScope(), + navController: NavHostController = rememberNavController(), +): JetpackAppState { + return remember( + isUserLoggedIn, + navController, + windowSizeClass, + coroutineScope, + networkUtils, + ) { + JetpackAppState( + isUserLoggedIn, + navController, + windowSizeClass, + coroutineScope, + networkUtils, + ) + } +} + +@Suppress("MemberVisibilityCanBePrivate", "UNUSED") +@Stable +class JetpackAppState( + val isUserLoggedIn: Boolean, + val navController: NavHostController, + val windowSizeClass: WindowSizeClass, + coroutineScope: CoroutineScope, + networkUtils: NetworkUtils, +) { + val currentDestination: NavDestination? + @Composable get() = navController + .currentBackStackEntryAsState().value?.destination + + val currentTopLevelDestination: TopLevelDestination? + @Composable get() = when (currentDestination?.route) { + homeNavigationRoute -> TopLevelDestination.HOME + profileNavigationRoute -> TopLevelDestination.PROFILE + else -> null + } + + val shouldShowBottomBar: Boolean + @Composable get() = (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact) && + (currentTopLevelDestination != null) + + val shouldShowNavRail: Boolean + @Composable get() = (windowSizeClass.widthSizeClass != WindowWidthSizeClass.Compact) && + (currentTopLevelDestination != null) + + val isOffline = networkUtils.currentState + .map { it != NetworkState.CONNECTED } + .stateInDelayed(false, coroutineScope) + + val topLevelDestinations: List = TopLevelDestination.values().asList() + + val topLevelDestinationsWithUnreadResources: StateFlow> = + // TODO: Requires Implementation + MutableStateFlow(setOf()).asStateFlow() + + fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) { + val topLevelNavOptions = navOptions { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + + when (topLevelDestination) { + TopLevelDestination.HOME -> navController.navigateToHomeNavGraph(topLevelNavOptions) + TopLevelDestination.PROFILE -> navController.navigateProfile(topLevelNavOptions) + } + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/ui/details/DetailsScreen.kt b/app/src/main/kotlin/dev/atick/compose/ui/details/DetailsScreen.kt new file mode 100644 index 000000000..5cfc9ee49 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/ui/details/DetailsScreen.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.ui.details + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.SubcomposeAsyncImage +import dev.atick.compose.R +import dev.atick.compose.data.home.UiPost +import dev.atick.core.ui.components.JetpackButton +import dev.atick.core.ui.components.JetpackLoadingWheel +import dev.atick.core.ui.utils.StatefulComposable + +@Composable +internal fun DetailsRoute( + onBackClick: () -> Unit, + onShowSnackbar: suspend (String, String?) -> Boolean, + detailsViewModel: DetailsViewModel = hiltViewModel(), +) { + val detailsUiState by detailsViewModel.detailsUiState.collectAsState() + StatefulComposable(state = detailsUiState, onShowSnackbar = onShowSnackbar) { + DetailsScreen(post = detailsUiState.data, onBackClick = onBackClick) + } +} + +@Composable +private fun DetailsScreen( + post: UiPost?, + onBackClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + DetailsToolbar(onBackClick = onBackClick) + post?.let { + SubcomposeAsyncImage( + model = post.url, + contentDescription = post.title, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)), + loading = { + JetpackLoadingWheel(contentDesc = post.title) + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = post.title, + style = MaterialTheme.typography.bodyLarge, + ) + } + } +} + +@Composable +private fun DetailsToolbar( + modifier: Modifier = Modifier, + onBackClick: () -> Unit, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(bottom = 32.dp), + ) { + IconButton(onClick = { onBackClick() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.back), + ) + } + JetpackButton(onClick = onBackClick) { + Text(text = stringResource(R.string.done)) + } + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/ui/details/DetailsViewModel.kt b/app/src/main/kotlin/dev/atick/compose/ui/details/DetailsViewModel.kt new file mode 100644 index 000000000..e04dfa082 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/ui/details/DetailsViewModel.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.ui.details + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.atick.compose.data.home.UiPost +import dev.atick.compose.navigation.details.postIdArg +import dev.atick.compose.repository.home.PostsRepository +import dev.atick.core.ui.utils.UiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DetailsViewModel @Inject constructor( + postsRepository: PostsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val postId = checkNotNull(savedStateHandle.get(postIdArg)) + + private val _detailsUiState: MutableStateFlow> = + MutableStateFlow(UiState.Loading(null)) + val detailsUiState = _detailsUiState.asStateFlow() + + init { + viewModelScope.launch { + val result = postsRepository.getPost(postId) + if (result.isSuccess) { + _detailsUiState.update { UiState.Success(result.getOrNull()) } + } else { + _detailsUiState.update { UiState.Error(null, result.exceptionOrNull()) } + } + } + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/ui/home/HomeScreen.kt b/app/src/main/kotlin/dev/atick/compose/ui/home/HomeScreen.kt new file mode 100644 index 000000000..e0db328dc --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/ui/home/HomeScreen.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.ui.home + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage +import dev.atick.compose.data.home.HomeScreenData +import dev.atick.core.ui.utils.StatefulComposable + +/** + * Composable function that represents the home screen. + * + * @param homeViewModel The view model for the home screen. + */ +@Composable +internal fun HomeRoute( + onPostCLick: (Int) -> Unit, + onShowSnackbar: suspend (String, String?) -> Boolean, + homeViewModel: HomeViewModel = hiltViewModel(), +) { + val homeState by homeViewModel.homeUiState.collectAsState() + + StatefulComposable( + state = homeState, + onShowSnackbar = onShowSnackbar, + ) { homeScreenData -> + HomeScreen(homeScreenData, onPostCLick) + } +} + +@Composable +private fun HomeScreen( + homeScreenData: HomeScreenData, + onPostCLick: (Int) -> Unit, +) { + val scrollableState = rememberLazyListState() + + LazyColumn( + modifier = Modifier.padding(horizontal = 24.dp), + contentPadding = PaddingValues(vertical = 16.dp), + state = scrollableState, + ) { + items(homeScreenData.posts) { post -> + ListItem( + leadingContent = { + AsyncImage( + model = post.thumbnailUrl, + contentDescription = null, + modifier = Modifier.clip(CircleShape), + ) + }, + headlineContent = { Text(text = post.title) }, + trailingContent = { + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = null, + modifier = Modifier.size(64.dp), + ) + } + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + modifier = Modifier.clickable { onPostCLick(post.id) }, + ) + } + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/ui/home/HomeViewModel.kt b/app/src/main/kotlin/dev/atick/compose/ui/home/HomeViewModel.kt new file mode 100644 index 000000000..1e4390c06 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/ui/home/HomeViewModel.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.ui.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.atick.compose.data.home.HomeScreenData +import dev.atick.compose.repository.home.PostsRepository +import dev.atick.core.ui.utils.UiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * View model for the home screen. + * + * @param postsRepository The repository for accessing home screen data. + */ +@HiltViewModel +class HomeViewModel @Inject constructor( + private val postsRepository: PostsRepository, +) : ViewModel() { + private val _homeUiState: MutableStateFlow> = + MutableStateFlow(UiState.Loading(HomeScreenData())) + val homeUiState = _homeUiState.asStateFlow() + + init { + postsRepository.getCachedPosts() + .map(::HomeScreenData) + .onEach { homeScreenData -> + _homeUiState.update { + UiState.Success(homeScreenData) + } + }.launchIn(viewModelScope) + + viewModelScope.launch { + _homeUiState.update { UiState.Loading(HomeScreenData()) } + val result = postsRepository.synchronizePosts() + if (result.isFailure) { + _homeUiState.update { + UiState.Error( + homeUiState.value.data, + result.exceptionOrNull(), + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/ui/profile/ProfileScreen.kt b/app/src/main/kotlin/dev/atick/compose/ui/profile/ProfileScreen.kt new file mode 100644 index 000000000..39b79c206 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/ui/profile/ProfileScreen.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.ui.profile + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage +import dev.atick.compose.R +import dev.atick.compose.data.profile.ProfileScreenData +import dev.atick.core.ui.components.JetpackButton +import dev.atick.core.ui.utils.StatefulComposable + +@Composable +internal fun ProfileRoute( + onShowSnackbar: suspend (String, String?) -> Boolean, + profileViewModel: ProfileViewModel = hiltViewModel(), +) { + val profileState by profileViewModel.profileUiState.collectAsState() + + StatefulComposable( + state = profileState, + onShowSnackbar = onShowSnackbar, + ) { profileScreenData -> + ProfileScreen( + profileScreenData = profileScreenData, + onSignOutClick = profileViewModel::signOut, + ) + } +} + +@Composable +private fun ProfileScreen( + profileScreenData: ProfileScreenData, + onSignOutClick: () -> Unit, + +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(64.dp)) + AsyncImage( + model = profileScreenData.profilePictureUri, + contentDescription = stringResource(R.string.profile_picture), + placeholder = painterResource(id = R.drawable.ic_avatar), + fallback = painterResource(id = R.drawable.ic_avatar), + modifier = Modifier + .size(96.dp) + .clip(CircleShape), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text(text = profileScreenData.name, style = MaterialTheme.typography.headlineLarge) + Spacer(modifier = Modifier.height(16.dp)) + JetpackButton(onClick = onSignOutClick) { + Text(text = stringResource(id = R.string.sign_out)) + } + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/ui/profile/ProfileViewModel.kt b/app/src/main/kotlin/dev/atick/compose/ui/profile/ProfileViewModel.kt new file mode 100644 index 000000000..b82832896 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/ui/profile/ProfileViewModel.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.ui.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.atick.compose.data.profile.ProfileScreenData +import dev.atick.compose.repository.profile.ProfileDataRepository +import dev.atick.core.extensions.stateInDelayed +import dev.atick.core.ui.utils.UiState +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val profileDataRepository: ProfileDataRepository, +) : ViewModel() { + val profileUiState: StateFlow> = + profileDataRepository.profileScreenData + .map { UiState.Success(it) } + .catch { UiState.Error(ProfileScreenData(), it) } + .stateInDelayed(UiState.Loading(ProfileScreenData()), viewModelScope) + + fun signOut() { + viewModelScope.launch { + profileDataRepository.signOut() + } + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/ui/settings/SettingsDialog.kt b/app/src/main/kotlin/dev/atick/compose/ui/settings/SettingsDialog.kt new file mode 100644 index 000000000..09a37dbbc --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/ui/settings/SettingsDialog.kt @@ -0,0 +1,229 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.ui.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.navigation.compose.hiltViewModel +import dev.atick.compose.R +import dev.atick.compose.data.settings.UserEditableSettings +import dev.atick.core.ui.theme.supportsDynamicTheming +import dev.atick.core.ui.utils.UiState +import dev.atick.storage.preferences.models.DarkThemeConfig +import dev.atick.storage.preferences.models.ThemeBrand + +@Composable +fun SettingsDialog( + onDismiss: () -> Unit, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val settingsUiState by viewModel.settingsUiState.collectAsState() + SettingsDialog( + onDismiss = onDismiss, + settingsUiState = settingsUiState, + onChangeThemeBrand = viewModel::updateThemeBrand, + onChangeDynamicColorPreference = viewModel::updateDynamicColorPreference, + onChangeDarkThemeConfig = viewModel::updateDarkThemeConfig, + ) +} + +@Composable +fun SettingsDialog( + settingsUiState: UiState, + supportDynamicColor: Boolean = supportsDynamicTheming(), + onDismiss: () -> Unit, + onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit, + onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit, + onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit, +) { + val configuration = LocalConfiguration.current + + /** + * usePlatformDefaultWidth = false is use as a temporary fix to allow + * height recalculation during recomposition. This, however, causes + * Dialog's to occupy full width in Compact mode. Therefore max width + * is configured below. This should be removed when there's fix to + * https://issuetracker.google.com/issues/221643630 + */ + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + modifier = Modifier.widthIn(max = configuration.screenWidthDp.dp - 80.dp), + onDismissRequest = { onDismiss() }, + title = { + Text( + text = stringResource(R.string.settings_title), + style = MaterialTheme.typography.titleLarge, + ) + }, + text = { + HorizontalDivider() + Column(Modifier.verticalScroll(rememberScrollState())) { + when (settingsUiState) { + is UiState.Loading -> { + Text( + text = stringResource(R.string.loading), + modifier = Modifier.padding(vertical = 16.dp), + ) + } + + is UiState.Success -> { + SettingsPanel( + settings = settingsUiState.data, + supportDynamicColor = supportDynamicColor, + onChangeThemeBrand = onChangeThemeBrand, + onChangeDynamicColorPreference = onChangeDynamicColorPreference, + onChangeDarkThemeConfig = onChangeDarkThemeConfig, + ) + } + + else -> {} + } + } + }, + confirmButton = { + Text( + text = stringResource(R.string.dismiss_dialog_button_text), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(horizontal = 8.dp) + .clickable { onDismiss() }, + ) + }, + ) +} + +// [ColumnScope] is used for using the [ColumnScope.AnimatedVisibility] extension overload composable. +@Composable +private fun ColumnScope.SettingsPanel( + settings: UserEditableSettings, + supportDynamicColor: Boolean, + onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit, + onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit, + onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit, +) { + SettingsDialogSectionTitle(text = stringResource(R.string.theme)) + Column(Modifier.selectableGroup()) { + SettingsDialogThemeChooserRow( + text = stringResource(R.string.brand_default), + selected = settings.brand == ThemeBrand.DEFAULT, + onClick = { onChangeThemeBrand(ThemeBrand.DEFAULT) }, + ) + SettingsDialogThemeChooserRow( + text = stringResource(R.string.brand_android), + selected = settings.brand == ThemeBrand.ANDROID, + onClick = { onChangeThemeBrand(ThemeBrand.ANDROID) }, + ) + } + AnimatedVisibility(visible = settings.brand == ThemeBrand.DEFAULT && supportDynamicColor) { + Column { + SettingsDialogSectionTitle(text = stringResource(R.string.dynamic_color_preference)) + Column(Modifier.selectableGroup()) { + SettingsDialogThemeChooserRow( + text = stringResource(R.string.dynamic_color_yes), + selected = settings.useDynamicColor, + onClick = { onChangeDynamicColorPreference(true) }, + ) + SettingsDialogThemeChooserRow( + text = stringResource(R.string.dynamic_color_no), + selected = !settings.useDynamicColor, + onClick = { onChangeDynamicColorPreference(false) }, + ) + } + } + } + SettingsDialogSectionTitle(text = stringResource(R.string.dark_mode_preference)) + Column(Modifier.selectableGroup()) { + SettingsDialogThemeChooserRow( + text = stringResource(R.string.dark_mode_config_system_default), + selected = settings.darkThemeConfig == DarkThemeConfig.FOLLOW_SYSTEM, + onClick = { onChangeDarkThemeConfig(DarkThemeConfig.FOLLOW_SYSTEM) }, + ) + SettingsDialogThemeChooserRow( + text = stringResource(R.string.dark_mode_config_light), + selected = settings.darkThemeConfig == DarkThemeConfig.LIGHT, + onClick = { onChangeDarkThemeConfig(DarkThemeConfig.LIGHT) }, + ) + SettingsDialogThemeChooserRow( + text = stringResource(R.string.dark_mode_config_dark), + selected = settings.darkThemeConfig == DarkThemeConfig.DARK, + onClick = { onChangeDarkThemeConfig(DarkThemeConfig.DARK) }, + ) + } +} + +@Composable +private fun SettingsDialogSectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), + ) +} + +@Composable +fun SettingsDialogThemeChooserRow( + text: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .selectable( + selected = selected, + role = Role.RadioButton, + onClick = onClick, + ) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected, + onClick = null, + ) + Spacer(Modifier.width(8.dp)) + Text(text) + } +} diff --git a/app/src/main/kotlin/dev/atick/compose/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/dev/atick/compose/ui/settings/SettingsViewModel.kt new file mode 100644 index 000000000..d38b1eee1 --- /dev/null +++ b/app/src/main/kotlin/dev/atick/compose/ui/settings/SettingsViewModel.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.compose.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.atick.compose.data.settings.UserEditableSettings +import dev.atick.compose.repository.user.UserDataRepository +import dev.atick.core.ui.utils.UiState +import dev.atick.storage.preferences.models.DarkThemeConfig +import dev.atick.storage.preferences.models.ThemeBrand +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val userDataRepository: UserDataRepository, +) : ViewModel() { + + val settingsUiState: StateFlow> = + userDataRepository.userData + .map { userData -> + UiState.Success( + UserEditableSettings( + brand = userData.themeBrand, + useDynamicColor = userData.useDynamicColor, + darkThemeConfig = userData.darkThemeConfig, + ), + ) + } + .stateIn( + scope = viewModelScope, + // Starting eagerly means the user data is ready when the SettingsDialog is laid out + // for the first time. Without this, due to b/221643630 the layout is done using the + // "Loading" text, then replaced with the user editable fields once loaded, however, + // the layout height doesn't change meaning all the fields are squashed into a small + // scrollable column. + // TODO: Change to SharingStarted.WhileSubscribed(5_000) when b/221643630 is fixed + started = SharingStarted.Eagerly, + initialValue = UiState.Loading(UserEditableSettings()), + ) + + fun updateThemeBrand(themeBrand: ThemeBrand) { + viewModelScope.launch { + userDataRepository.setThemeBrand(themeBrand) + } + } + + fun updateDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { + viewModelScope.launch { + userDataRepository.setDarkThemeConfig(darkThemeConfig) + } + } + + fun updateDynamicColorPreference(useDynamicColor: Boolean) { + viewModelScope.launch { + userDataRepository.setDynamicColorPreference(useDynamicColor) + } + } +} diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..5ca6dc61d --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_avatar.xml b/app/src/main/res/drawable/ic_avatar.xml new file mode 100644 index 000000000..84e56b688 --- /dev/null +++ b/app/src/main/res/drawable/ic_avatar.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..fb732fcd5 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/splash_background.xml b/app/src/main/res/drawable/splash_background.xml new file mode 100644 index 000000000..3d2d22088 --- /dev/null +++ b/app/src/main/res/drawable/splash_background.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/splash_background_night.xml b/app/src/main/res/drawable/splash_background_night.xml new file mode 100644 index 000000000..7f414b168 --- /dev/null +++ b/app/src/main/res/drawable/splash_background_night.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..2670a3afc --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..2670a3afc --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 000000000..9186ca2b7 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..fe9fed030 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,21 @@ + + + + + #FFFCFDF6 + #FF1A1C19 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..a94033712 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,50 @@ + + + + + Jetpack + Get Item + Home + Error Loading Item + Profile + Search + More + Back + Settings + Search + Settings + Loading… + Privacy policy + Licenses + Brand Guidelines + Feedback + Theme + Default + Android + Dark mode preference + System default + Light + Dark + Use Dynamic Color + Yes + No + OK + ⚠️ You aren’t connected to the internet + Sign Out + Profile Picture + Done + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..0bebc304d --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/assets/modularization.svg b/assets/modularization.svg new file mode 100644 index 000000000..5babb26b7 --- /dev/null +++ b/assets/modularization.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/auth/.gitignore b/auth/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/auth/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts new file mode 100644 index 000000000..5b58fc6a2 --- /dev/null +++ b/auth/build.gradle.kts @@ -0,0 +1,40 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("dev.atick.ui.library") + id("dev.atick.dagger.hilt") +} + +android { + namespace = "dev.atick.auth" +} + +dependencies { + // ... Modules + implementation(project(":core:ui")) + implementation(project(":storage:preferences")) + + // ... Firebase Auth + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.auth) + implementation(libs.play.services.auth) + + // ... Credential Manager + implementation(libs.androidx.credentials) + implementation(libs.credentials.play.services.auth) + implementation(libs.identity.google.id) +} \ No newline at end of file diff --git a/auth/consumer-rules.pro b/auth/consumer-rules.pro new file mode 100644 index 000000000..78e0e03f2 --- /dev/null +++ b/auth/consumer-rules.pro @@ -0,0 +1,4 @@ +-if class androidx.credentials.CredentialManager +-keep class androidx.credentials.playservices.** { + *; +} \ No newline at end of file diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml new file mode 100644 index 000000000..4e5744354 --- /dev/null +++ b/auth/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/auth/src/main/kotlin/dev/atick/auth/config/Config.kt b/auth/src/main/kotlin/dev/atick/auth/config/Config.kt new file mode 100644 index 000000000..870867bb1 --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/config/Config.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.config + +/** + * Configuration object containing constants and values used in the application. + */ +object Config { + /** + * The Web Client ID used for Google Sign-In authentication. + * This identifier is associated with your application and is used to authenticate with Google services. + * Ensure that it is properly configured and secured in the Google Developer Console. + */ + const val WEB_CLIENT_ID = + "1052755869243-tb3s2g60ct3fji814vq59ht295946ai2.apps.googleusercontent.com" +} diff --git a/auth/src/main/kotlin/dev/atick/auth/data/AuthDataSource.kt b/auth/src/main/kotlin/dev/atick/auth/data/AuthDataSource.kt new file mode 100644 index 000000000..7f8820bb7 --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/data/AuthDataSource.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.data + +import android.app.Activity +import dev.atick.auth.models.AuthUser + +/** + * Interface defining data source operations for authentication. + */ +interface AuthDataSource { + + /** + * Gets the currently authenticated user, if any. + * + * @return The currently authenticated [AuthUser], or null if not signed in. + */ + val currentUser: AuthUser? + + /** + * Sign in with an email and password. + * + * @param email The user's email address. + * @param password The user's password. + * @return The authenticated [AuthUser] upon successful sign-in. + */ + suspend fun signInWithEmailAndPassword(email: String, password: String): AuthUser + + /** + * Register a new user with an email and password. + * + * @param name The user's name. + * @param email The user's email address. + * @param password The user's password. + * @return The authenticated [AuthUser] upon successful registration. + */ + suspend fun registerWithEmailAndPassword( + name: String, + email: String, + password: String, + ): AuthUser + + /** + * Sign in with a Google account. + * + * @param activity The activity instance. + * @return The authenticated [AuthUser] upon successful sign-in. + */ + suspend fun signInWithGoogle(activity: Activity): AuthUser + + /** + * Register a new user with Google. + * + * @param activity The activity instance. + * @return The authenticated [AuthUser] upon successful registration. + */ + suspend fun registerWithGoogle(activity: Activity): AuthUser + + /** + * Sign out the currently authenticated user. + */ + suspend fun signOut() +} diff --git a/auth/src/main/kotlin/dev/atick/auth/data/AuthDataSourceImpl.kt b/auth/src/main/kotlin/dev/atick/auth/data/AuthDataSourceImpl.kt new file mode 100644 index 000000000..de70906e5 --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/data/AuthDataSourceImpl.kt @@ -0,0 +1,186 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.data + +import android.app.Activity +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.GoogleAuthProvider +import com.google.firebase.auth.UserProfileChangeRequest +import dev.atick.auth.config.Config +import dev.atick.auth.models.AuthUser +import dev.atick.auth.models.asAuthUser +import dev.atick.core.di.IoDispatcher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * Implementation of the [AuthDataSource] interface responsible for handling authentication data operations. + * + * @param firebaseAuth The Firebase Authentication instance for performing authentication operations. + * @param credentialManager The [CredentialManager] for handling credential operations. + * @param ioDispatcher The [CoroutineDispatcher] for executing suspend functions in an IO context. + */ +class AuthDataSourceImpl @Inject constructor( + private val firebaseAuth: FirebaseAuth, + private val credentialManager: CredentialManager, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : AuthDataSource { + + /** + * Gets the currently authenticated user, if any. + * + * @return The currently authenticated [AuthUser], or null if not signed in. + */ + override val currentUser: AuthUser? + get() = firebaseAuth.currentUser?.run { asAuthUser() } + + /** + * Sign in with an email and password. + * + * @param email The user's email address. + * @param password The user's password. + * @return The authenticated [AuthUser] upon successful sign-in. + */ + override suspend fun signInWithEmailAndPassword( + email: String, + password: String, + ): AuthUser { + return withContext(ioDispatcher) { + val user = firebaseAuth.signInWithEmailAndPassword(email, password).await().user!! + user.asAuthUser() + } + } + + /** + * Register a new user with an email and password. + * + * @param name The user's name. + * @param email The user's email address. + * @param password The user's password. + * @return The authenticated [AuthUser] upon successful registration. + */ + override suspend fun registerWithEmailAndPassword( + name: String, + email: String, + password: String, + ): AuthUser { + return withContext(ioDispatcher) { + val user = firebaseAuth.createUserWithEmailAndPassword(email, password).await().user!! + user.updateProfile(UserProfileChangeRequest.Builder().setDisplayName(name).build()) + .await() + user.asAuthUser() + } + } + + /** + * Sign in with Google. + * + * @param activity The activity context. + * @return The authenticated [AuthUser] upon successful sign-in. + */ + override suspend fun signInWithGoogle(activity: Activity): AuthUser { + val request = getSignInWithGoogleRequest() + return withContext(ioDispatcher) { + val result = credentialManager.getCredential( + request = request, + context = activity, + ) + handleGoogleAuthResult(result) + } + } + + /** + * Register with Google. + * + * @param activity The activity context. + * @return The authenticated [AuthUser] upon successful registration. + */ + override suspend fun registerWithGoogle(activity: Activity): AuthUser { + val request = registerWithGoogleRequest() + return withContext(ioDispatcher) { + val result = credentialManager.getCredential( + request = request, + context = activity, + ) + handleGoogleAuthResult(result) + } + } + + /** + * Sign out the currently authenticated user. + */ + override suspend fun signOut() { + withContext(ioDispatcher) { + firebaseAuth.signOut() + } + } + + /** + * Get the sign-in request for Google. + * + * @return The [GetCredentialRequest] for Google sign-in. + */ + private fun getSignInWithGoogleRequest(): GetCredentialRequest { + val signInRequestOptions = GetGoogleIdOption.Builder().setFilterByAuthorizedAccounts(true) + .setServerClientId(Config.WEB_CLIENT_ID).setAutoSelectEnabled(true).build() + return GetCredentialRequest.Builder().addCredentialOption(signInRequestOptions).build() + } + + /** + * Get the registration request for Google. + * + * @return The [GetCredentialRequest] for Google registration. + */ + private fun registerWithGoogleRequest(): GetCredentialRequest { + val signInRequestOptions = GetGoogleIdOption.Builder().setFilterByAuthorizedAccounts(false) + .setServerClientId(Config.WEB_CLIENT_ID).setAutoSelectEnabled(false).build() + return GetCredentialRequest.Builder().addCredentialOption(signInRequestOptions).build() + } + + /** + * Handle the result of Google authentication. + * + * @param result The result of Google authentication. + * @return The authenticated [AuthUser] upon successful authentication. + */ + private suspend fun handleGoogleAuthResult(result: GetCredentialResponse): AuthUser { + if (result.credential !is CustomCredential) throw Exception("Something went wrong when signing in with Google") + + val credential = result.credential as CustomCredential + + if (credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { + throw Exception( + "Something went wrong when signing in with Google", + ) + } + + val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data) + val googleCredentials = + GoogleAuthProvider.getCredential(googleIdTokenCredential.idToken, null) + + val user = firebaseAuth.signInWithCredential(googleCredentials).await().user!! + return user.asAuthUser() + } +} diff --git a/auth/src/main/kotlin/dev/atick/auth/di/CredentialManagerModule.kt b/auth/src/main/kotlin/dev/atick/auth/di/CredentialManagerModule.kt new file mode 100644 index 000000000..17f03d434 --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/di/CredentialManagerModule.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.di + +import android.content.Context +import androidx.credentials.CredentialManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object CredentialManagerModule { + + @Provides + @Singleton + fun provideCredentialManager(@ApplicationContext context: Context): CredentialManager { + return CredentialManager.create(context) + } +} diff --git a/auth/src/main/kotlin/dev/atick/auth/di/DataSourceModule.kt b/auth/src/main/kotlin/dev/atick/auth/di/DataSourceModule.kt new file mode 100644 index 000000000..a7b431bed --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/di/DataSourceModule.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.atick.auth.data.AuthDataSource +import dev.atick.auth.data.AuthDataSourceImpl +import javax.inject.Singleton + +/** + * Dagger Hilt module for providing data source dependencies. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class DataSourceModule { + + /** + * Binds the [AuthDataSourceImpl] implementation to the [AuthDataSource] interface. + * + * @param authDataSourceImpl The implementation of [AuthDataSource] to be bound. + * @return An instance of [AuthDataSource] for dependency injection. + */ + @Binds + @Singleton + abstract fun bindAuthDataSource( + authDataSourceImpl: AuthDataSourceImpl, + ): AuthDataSource +} diff --git a/auth/src/main/kotlin/dev/atick/auth/di/FirebaseAuthModule.kt b/auth/src/main/kotlin/dev/atick/auth/di/FirebaseAuthModule.kt new file mode 100644 index 000000000..b6d790aac --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/di/FirebaseAuthModule.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.di + +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.ktx.auth +import com.google.firebase.ktx.Firebase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Dagger Hilt module for providing Firebase Authentication-related dependencies. + */ +@Module +@InstallIn(SingletonComponent::class) +object FirebaseAuthModule { + + /** + * Provides a singleton instance of [FirebaseAuth]. + * + * @return An instance of [FirebaseAuth] for authentication operations. + */ + @Provides + @Singleton + fun provideFirebaseAuth(): FirebaseAuth { + return Firebase.auth + } +} diff --git a/auth/src/main/kotlin/dev/atick/auth/di/RepositoryModule.kt b/auth/src/main/kotlin/dev/atick/auth/di/RepositoryModule.kt new file mode 100644 index 000000000..3c3a5d1a5 --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/di/RepositoryModule.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.atick.auth.repository.AuthRepository +import dev.atick.auth.repository.AuthRepositoryImpl +import javax.inject.Singleton + +/** + * Dagger Hilt module for providing repository dependencies. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + /** + * Binds the [AuthRepositoryImpl] implementation to the [AuthRepository] interface. + * + * @param authRepositoryImpl The implementation of [AuthRepository] to be bound. + * @return An instance of [AuthRepository] for dependency injection. + */ + @Binds + @Singleton + abstract fun bindAuthRepository( + authRepositoryImpl: AuthRepositoryImpl, + ): AuthRepository +} diff --git a/auth/src/main/kotlin/dev/atick/auth/models/AuthScreenData.kt b/auth/src/main/kotlin/dev/atick/auth/models/AuthScreenData.kt new file mode 100644 index 000000000..8ed5f58aa --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/models/AuthScreenData.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.models + +import dev.atick.core.ui.utils.TextFiledData + +/** + * Data class representing the input data for an authentication screen. + * + * @param name The data for the user's name input field. + * @param email The data for the user's email input field. + * @param password The data for the user's password input field. + */ +data class AuthScreenData( + val name: TextFiledData = TextFiledData(String()), + val email: TextFiledData = TextFiledData(String()), + val password: TextFiledData = TextFiledData(String()), +) diff --git a/auth/src/main/kotlin/dev/atick/auth/models/AuthUser.kt b/auth/src/main/kotlin/dev/atick/auth/models/AuthUser.kt new file mode 100644 index 000000000..e5f6f0913 --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/models/AuthUser.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.models + +import android.net.Uri +import com.google.firebase.auth.FirebaseUser +import dev.atick.storage.preferences.models.Profile + +/** + * Represents an authenticated user with basic information. + * + * @property id The unique identifier for the user. + * @property name The user's name. + * @property profilePictureUri The URI for the user's profile picture, or null if not available. + */ +data class AuthUser( + val id: String, + val name: String, + val profilePictureUri: Uri?, +) { + /** + * Converts this [AuthUser] object to a [Profile] object. + * + * @return The corresponding [Profile] object. + */ + fun asProfile(): Profile { + return Profile( + id = id, + name = name, + profilePictureUriString = profilePictureUri?.toString(), + ) + } +} + +/** + * Converts a Firebase user object to an [AuthUser] object. + * @return The corresponding [AuthUser] object. + */ +fun FirebaseUser.asAuthUser() = AuthUser( + id = uid, + name = displayName.orEmpty(), + profilePictureUri = photoUrl, +) diff --git a/auth/src/main/kotlin/dev/atick/auth/navigation/AuthNavigation.kt b/auth/src/main/kotlin/dev/atick/auth/navigation/AuthNavigation.kt new file mode 100644 index 000000000..f37d0f9aa --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/navigation/AuthNavigation.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import dev.atick.auth.ui.signin.SignInRoute +import dev.atick.auth.ui.signup.SignUpRoute + +const val signInNavigationRoute = "sign_in" +const val signUpNavigationRoute = "sign_up" +const val authNavGraphRoute = "auth_graph" + +fun NavController.navigateToAuthNavGraph(navOptions: NavOptions? = null) { + navigate(authNavGraphRoute, navOptions) +} + +fun NavController.navigateToSignInRoute(navOptions: NavOptions? = null) { + navigate(signInNavigationRoute, navOptions) +} + +fun NavController.navigateToSignUpRoute(navOptions: NavOptions? = null) { + navigate(signUpNavigationRoute, navOptions) +} + +fun NavGraphBuilder.authNavGraph( + onSignUpClick: () -> Unit, + onSignInCLick: () -> Unit, + onShowSnackbar: suspend (String, String?) -> Boolean, +) { + navigation( + route = authNavGraphRoute, + startDestination = signInNavigationRoute, + ) { + composable(signInNavigationRoute) { + SignInRoute( + onSignUpClick = onSignUpClick, + onShowSnackbar = onShowSnackbar, + ) + } + composable(signUpNavigationRoute) { + SignUpRoute( + onSignInClick = onSignInCLick, + onShowSnackbar = onShowSnackbar, + ) + } + } +} diff --git a/auth/src/main/kotlin/dev/atick/auth/repository/AuthRepository.kt b/auth/src/main/kotlin/dev/atick/auth/repository/AuthRepository.kt new file mode 100644 index 000000000..1a1e34dc0 --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/repository/AuthRepository.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.repository + +import android.app.Activity +import dev.atick.auth.models.AuthUser + +/** + * Interface defining authentication-related operations. + */ +interface AuthRepository { + /** + * Sign in with an email and password. + * + * @param email The user's email address. + * @param password The user's password. + * @return A [Result] containing the authenticated [AuthUser] upon successful sign-in. + */ + suspend fun signInWithEmailAndPassword(email: String, password: String): Result + + /** + * Register a new user with an email and password. + * + * @param name The user's name. + * @param email The user's email address. + * @param password The user's password. + * @return A [Result] containing the authenticated [AuthUser] upon successful registration. + */ + suspend fun registerWithEmailAndPassword( + name: String, + email: String, + password: String, + ): Result + + /** + * Sign in with Google. + * + * @return A [Result] containing the authenticated [AuthUser] upon successful sign-in. + */ + suspend fun signInWithGoogle(activity: Activity): Result + + /** + * Register a new user with Google. + * + * @param activity The activity used to launch the Google sign-in intent. + * @return A [Result] containing the authenticated [AuthUser] upon successful registration. + */ + suspend fun registerWithGoogle(activity: Activity): Result +} diff --git a/auth/src/main/kotlin/dev/atick/auth/repository/AuthRepositoryImpl.kt b/auth/src/main/kotlin/dev/atick/auth/repository/AuthRepositoryImpl.kt new file mode 100644 index 000000000..74f2bcbc2 --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/repository/AuthRepositoryImpl.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.repository + +import android.app.Activity +import dev.atick.auth.data.AuthDataSource +import dev.atick.auth.models.AuthUser +import dev.atick.storage.preferences.data.UserPreferencesDataSource +import javax.inject.Inject + +/** + * Implementation of the [AuthRepository] interface responsible for handling authentication operations. + * + * @param authDataSource The data source for authentication operations. + * @param userPreferencesDataSource The data source for user preferences. + */ +class AuthRepositoryImpl @Inject constructor( + private val authDataSource: AuthDataSource, + private val userPreferencesDataSource: UserPreferencesDataSource, +) : AuthRepository { + + /** + * Sign in with an email and password. + * + * @param email The user's email address. + * @param password The user's password. + * @return A [Result] containing the authenticated [AuthUser] upon successful sign-in. + */ + override suspend fun signInWithEmailAndPassword( + email: String, + password: String, + ): Result { + return runCatching { + val user = authDataSource.signInWithEmailAndPassword(email, password) + userPreferencesDataSource.setProfile(user.asProfile()) + user + } + } + + /** + * Register a new user with an email and password. + * + * @param name The user's name. + * @param email The user's email address. + * @param password The user's password. + * @return A [Result] containing the authenticated [AuthUser] upon successful registration. + */ + override suspend fun registerWithEmailAndPassword( + name: String, + email: String, + password: String, + ): Result { + return runCatching { + val user = authDataSource.registerWithEmailAndPassword(name, email, password) + userPreferencesDataSource.setProfile(user.asProfile()) + user + } + } + + /** + * Sign in with Google. + * + * @param activity The current activity. + * @return A [Result] containing the authenticated [AuthUser] upon successful sign-in. + */ + override suspend fun signInWithGoogle(activity: Activity): Result { + return runCatching { + val user = authDataSource.signInWithGoogle(activity) + userPreferencesDataSource.setProfile(user.asProfile()) + user + } + } + + /** + * Register a new user with Google. + * + * @param activity The current activity. + * @return A [Result] containing the authenticated [AuthUser] upon successful registration. + */ + override suspend fun registerWithGoogle(activity: Activity): Result { + return kotlin.runCatching { + val user = authDataSource.registerWithGoogle(activity) + userPreferencesDataSource.setProfile(user.asProfile()) + user + } + } +} diff --git a/auth/src/main/kotlin/dev/atick/auth/ui/AuthViewModel.kt b/auth/src/main/kotlin/dev/atick/auth/ui/AuthViewModel.kt new file mode 100644 index 000000000..2f9f6dd8a --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/ui/AuthViewModel.kt @@ -0,0 +1,158 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.ui + +import android.app.Activity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.atick.auth.models.AuthScreenData +import dev.atick.auth.repository.AuthRepository +import dev.atick.core.extensions.isEmailValid +import dev.atick.core.extensions.isPasswordValid +import dev.atick.core.extensions.isValidFullName +import dev.atick.core.ui.utils.TextFiledData +import dev.atick.core.ui.utils.UiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + + private val _authUiState: MutableStateFlow> = + MutableStateFlow(UiState.Success(AuthScreenData())) + val authUiState = _authUiState.asStateFlow() + + fun updateName(name: String) { + _authUiState.update { + UiState.Success( + it.data.copy( + name = TextFiledData( + value = name, + errorMessage = if (name.isValidFullName()) null else "Name Not Valid", + ), + ), + ) + } + } + + fun updateEmail(email: String) { + _authUiState.update { + UiState.Success( + it.data.copy( + email = TextFiledData( + value = email, + errorMessage = if (email.isEmailValid()) null else "Email Not Valid", + ), + ), + ) + } + } + + fun updatePassword(password: String) { + _authUiState.update { + UiState.Success( + it.data.copy( + password = TextFiledData( + value = password, + errorMessage = if (password.isPasswordValid()) null else "Password Not Valid", + ), + ), + ) + } + } + + fun loginWithEmailAndPassword() { + _authUiState.update { UiState.Loading(authUiState.value.data) } + viewModelScope.launch { + val result = authRepository.signInWithEmailAndPassword( + email = authUiState.value.data.email.value, + password = authUiState.value.data.email.value, + ) + if (result.isSuccess) { + _authUiState.update { UiState.Success(AuthScreenData()) } + } else { + _authUiState.update { + UiState.Error( + authUiState.value.data, + result.exceptionOrNull(), + ) + } + } + } + } + + fun registerWithEmailAndPassword() { + _authUiState.update { UiState.Loading(authUiState.value.data) } + viewModelScope.launch { + val result = authRepository.registerWithEmailAndPassword( + name = authUiState.value.data.name.value, + email = authUiState.value.data.email.value, + password = authUiState.value.data.email.value, + ) + if (result.isSuccess) { + _authUiState.update { UiState.Success(AuthScreenData()) } + } else { + _authUiState.update { + UiState.Error( + authUiState.value.data, + result.exceptionOrNull(), + ) + } + } + } + } + + fun signInWithGoogle(activity: Activity) { + _authUiState.update { UiState.Loading(authUiState.value.data) } + viewModelScope.launch { + val result = authRepository.signInWithGoogle(activity) + if (result.isSuccess) { + _authUiState.update { UiState.Success(AuthScreenData()) } + } else { + _authUiState.update { + UiState.Error( + authUiState.value.data, + result.exceptionOrNull(), + ) + } + } + } + } + + fun registerWithGoogle(activity: Activity) { + _authUiState.update { UiState.Loading(authUiState.value.data) } + viewModelScope.launch { + val result = authRepository.registerWithGoogle(activity) + if (result.isSuccess) { + _authUiState.update { UiState.Success(AuthScreenData()) } + } else { + _authUiState.update { + UiState.Error( + authUiState.value.data, + result.exceptionOrNull(), + ) + } + } + } + } +} diff --git a/auth/src/main/kotlin/dev/atick/auth/ui/signin/SignInScreen.kt b/auth/src/main/kotlin/dev/atick/auth/ui/signin/SignInScreen.kt new file mode 100644 index 000000000..dc418a24b --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/ui/signin/SignInScreen.kt @@ -0,0 +1,199 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.ui.signin + +import android.app.Activity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Password +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import dev.atick.auth.R +import dev.atick.auth.models.AuthScreenData +import dev.atick.auth.ui.AuthViewModel +import dev.atick.core.extensions.getActivity +import dev.atick.core.ui.components.JetpackButton +import dev.atick.core.ui.components.JetpackOutlinedButton +import dev.atick.core.ui.components.JetpackPasswordFiled +import dev.atick.core.ui.components.JetpackTextButton +import dev.atick.core.ui.components.JetpackTextFiled +import dev.atick.core.ui.utils.DevicePreviews +import dev.atick.core.ui.utils.StatefulComposable + +@Composable +fun SignInRoute( + onSignUpClick: () -> Unit, + onShowSnackbar: suspend (String, String?) -> Boolean, + authViewModel: AuthViewModel = hiltViewModel(), +) { + val loginState by authViewModel.authUiState.collectAsState() +// val googleSignInIntent = loginState.data.googleSignInIntent +// +// val launcher = rememberLauncherForActivityResult( +// contract = ActivityResultContracts.StartIntentSenderForResult(), +// onResult = { result -> +// if (result.resultCode == RESULT_OK) { +// result.data?.run { +// authViewModel.signInWithIntent(this) +// } +// } +// }, +// ) +// +// googleSignInIntent?.let { intentSender -> +// LaunchedEffect(key1 = googleSignInIntent) { +// launcher.launch(IntentSenderRequest.Builder(intentSender).build()) +// } +// } + + StatefulComposable( + state = loginState, + onShowSnackbar = onShowSnackbar, + ) { homeScreenData -> + SignInScreen( + homeScreenData, + authViewModel::updateEmail, + authViewModel::updatePassword, + authViewModel::signInWithGoogle, + authViewModel::loginWithEmailAndPassword, + onSignUpClick, + ) + } +} + +@Composable +private fun SignInScreen( + authScreenData: AuthScreenData, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onSignInWithGoogleClick: (Activity) -> Unit, + onSignInClick: () -> Unit, + onSignUpClick: () -> Unit, +) { + val focusManager = LocalFocusManager.current + val activity = LocalContext.current.getActivity() + + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.Center, + ) { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + Text(stringResource(R.string.sign_in), style = MaterialTheme.typography.headlineLarge) + Spacer(modifier = Modifier.height(24.dp)) + JetpackOutlinedButton( + onClick = { activity?.run { onSignInWithGoogleClick.invoke(this) } }, + text = { Text(text = "Sign In with Google") }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_google), + contentDescription = "Google", + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .height(56.dp), + ) + Text(text = "or", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + JetpackTextFiled( + value = authScreenData.email.value, + errorMessage = authScreenData.email.errorMessage, + onValueChange = onEmailChange, + label = { Text(stringResource(R.string.email)) }, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Email), + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = stringResource(R.string.email), + ) + }, + ) + JetpackPasswordFiled( + value = authScreenData.password.value, + errorMessage = authScreenData.password.errorMessage, + onValueChange = onPasswordChange, + label = { Text(stringResource(R.string.password)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Password, + contentDescription = stringResource(R.string.password), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + JetpackButton( + onClick = { + focusManager.clearFocus() + onSignInClick.invoke() + }, + modifier = Modifier.fillMaxWidth(), + text = { Text(stringResource(R.string.sign_in)) }, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = stringResource(R.string.do_not_have_an_account)) + JetpackTextButton(onClick = onSignUpClick) { + Text( + text = stringResource(R.string.sign_up), + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + +@DevicePreviews +@Composable +fun SignInScreenPreview() { + SignInScreen( + authScreenData = AuthScreenData(), + onEmailChange = {}, + onPasswordChange = {}, + onSignInWithGoogleClick = {}, + onSignInClick = {}, + onSignUpClick = {}, + ) +} diff --git a/auth/src/main/kotlin/dev/atick/auth/ui/signup/SignUpScreen.kt b/auth/src/main/kotlin/dev/atick/auth/ui/signup/SignUpScreen.kt new file mode 100644 index 000000000..9c7f03bd4 --- /dev/null +++ b/auth/src/main/kotlin/dev/atick/auth/ui/signup/SignUpScreen.kt @@ -0,0 +1,215 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.auth.ui.signup + +import android.app.Activity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Password +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import dev.atick.auth.R +import dev.atick.auth.models.AuthScreenData +import dev.atick.auth.ui.AuthViewModel +import dev.atick.core.extensions.getActivity +import dev.atick.core.ui.components.JetpackButton +import dev.atick.core.ui.components.JetpackOutlinedButton +import dev.atick.core.ui.components.JetpackPasswordFiled +import dev.atick.core.ui.components.JetpackTextButton +import dev.atick.core.ui.components.JetpackTextFiled +import dev.atick.core.ui.utils.DevicePreviews +import dev.atick.core.ui.utils.StatefulComposable + +@Composable +fun SignUpRoute( + onSignInClick: () -> Unit, + onShowSnackbar: suspend (String, String?) -> Boolean, + authViewModel: AuthViewModel = hiltViewModel(), +) { + val authState by authViewModel.authUiState.collectAsState() +// val googleSignInIntent = authState.data.googleSignInIntent + +// val launcher = rememberLauncherForActivityResult( +// contract = ActivityResultContracts.StartIntentSenderForResult(), +// onResult = { result -> +// if (result.resultCode == Activity.RESULT_OK) { +// result.data?.run { +// authViewModel.signInWithIntent(this) +// } +// } +// }, +// ) +// +// googleSignInIntent?.let { intentSender -> +// LaunchedEffect(key1 = googleSignInIntent) { +// launcher.launch(IntentSenderRequest.Builder(intentSender).build()) +// } +// } + + StatefulComposable( + state = authState, + onShowSnackbar = onShowSnackbar, + ) { authScreenData -> + SignUpScreen( + authScreenData, + authViewModel::updateName, + authViewModel::updateEmail, + authViewModel::updatePassword, + authViewModel::registerWithGoogle, + authViewModel::registerWithEmailAndPassword, + onSignInClick, + ) + } +} + +@Composable +private fun SignUpScreen( + authScreenData: AuthScreenData, + onNameChange: (String) -> Unit, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onRegisterWithGoogleClick: (Activity) -> Unit, + onSignUpClick: () -> Unit, + onSignInClick: () -> Unit, +) { + val focusManager = LocalFocusManager.current + val activity = LocalContext.current.getActivity() + + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.Center, + ) { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + Text(stringResource(id = R.string.sign_up), style = MaterialTheme.typography.headlineLarge) + Spacer(modifier = Modifier.height(24.dp)) + JetpackOutlinedButton( + onClick = { activity?.run { onRegisterWithGoogleClick.invoke(this) } }, + text = { Text(text = stringResource(R.string.sign_up_with_google)) }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_google), + contentDescription = "Google", + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .height(56.dp), + ) + Text(text = "or", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + JetpackTextFiled( + value = authScreenData.name.value, + errorMessage = authScreenData.name.errorMessage, + onValueChange = onNameChange, + label = { Text(stringResource(R.string.name)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = stringResource(R.string.name), + ) + }, + ) + JetpackTextFiled( + value = authScreenData.email.value, + errorMessage = authScreenData.email.errorMessage, + onValueChange = onEmailChange, + label = { Text(stringResource(R.string.email)) }, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Email), + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = stringResource(R.string.email), + ) + }, + ) + JetpackPasswordFiled( + value = authScreenData.password.value, + errorMessage = authScreenData.password.errorMessage, + onValueChange = onPasswordChange, + label = { Text(stringResource(R.string.password)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Password, + contentDescription = stringResource(R.string.password), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + JetpackButton( + onClick = { + focusManager.clearFocus() + onSignUpClick.invoke() + }, + modifier = Modifier.fillMaxWidth(), + text = { Text(stringResource(R.string.sign_up)) }, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = stringResource(R.string.already_have_an_account)) + JetpackTextButton(onClick = onSignInClick) { + Text( + text = stringResource(R.string.sign_in), + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + +@DevicePreviews +@Composable +fun SignUpScreenPreview() { + SignUpScreen( + authScreenData = AuthScreenData(), + onNameChange = {}, + onEmailChange = {}, + onPasswordChange = {}, + onRegisterWithGoogleClick = {}, + onSignUpClick = {}, + onSignInClick = {}, + ) +} diff --git a/auth/src/main/res/drawable/ic_google.xml b/auth/src/main/res/drawable/ic_google.xml new file mode 100644 index 000000000..9f5f3c92e --- /dev/null +++ b/auth/src/main/res/drawable/ic_google.xml @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml new file mode 100644 index 000000000..229631ec3 --- /dev/null +++ b/auth/src/main/res/values/strings.xml @@ -0,0 +1,30 @@ + + + + + Password + Show password + Hide password + Sign In + "Do not have an account? " + Sign Up + Email + Full Name + Sign Up with Google + Already have an account? + Login + \ No newline at end of file diff --git a/bluetooth/classic/.gitignore b/bluetooth/classic/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/bluetooth/classic/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/bluetooth/classic/build.gradle.kts b/bluetooth/classic/build.gradle.kts new file mode 100644 index 000000000..6b8b16c21 --- /dev/null +++ b/bluetooth/classic/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id("dev.atick.library") + id("dev.atick.dagger.hilt") +} + +android { + namespace = "dev.atick.bluetooth.classic" +} + +dependencies { + implementation(project(":core:android")) + api(project(":bluetooth:common")) +} \ No newline at end of file diff --git a/bluetooth/classic/src/main/AndroidManifest.xml b/bluetooth/classic/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e3aab3428 --- /dev/null +++ b/bluetooth/classic/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bluetooth/classic/src/main/kotlin/dev/atick/bluetooth/classic/BluetoothClassic.kt b/bluetooth/classic/src/main/kotlin/dev/atick/bluetooth/classic/BluetoothClassic.kt new file mode 100644 index 000000000..05312ea5e --- /dev/null +++ b/bluetooth/classic/src/main/kotlin/dev/atick/bluetooth/classic/BluetoothClassic.kt @@ -0,0 +1,309 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.bluetooth.classic + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothSocket +import android.content.Context +import android.content.IntentFilter +import dagger.hilt.android.qualifiers.ApplicationContext +import dev.atick.bluetooth.common.data.BluetoothDataSource +import dev.atick.bluetooth.common.manager.BluetoothManager +import dev.atick.bluetooth.common.models.BtDevice +import dev.atick.bluetooth.common.models.BtMessage +import dev.atick.bluetooth.common.models.BtState +import dev.atick.bluetooth.common.models.simplify +import dev.atick.bluetooth.common.receivers.BluetoothStateReceiver +import dev.atick.bluetooth.common.receivers.DeviceStateReceiver +import dev.atick.bluetooth.common.receivers.ScannedDeviceReceiver +import dev.atick.bluetooth.common.utils.BluetoothUtils +import dev.atick.core.di.IoDispatcher +import dev.atick.core.extensions.hasPermission +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implementation of [BluetoothUtils], [BluetoothManager], and [BluetoothDataSource] interfaces + * for managing Bluetooth operations. + * + * @property bluetoothAdapter The Bluetooth adapter instance. + * @property context The application context. + * @property ioDispatcher The [CoroutineDispatcher] for performing IO operations. + */ +@Singleton +class BluetoothClassic @Inject constructor( + private val bluetoothAdapter: BluetoothAdapter?, + @ApplicationContext private val context: Context, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : BluetoothUtils, BluetoothManager, BluetoothDataSource { + + companion object { + const val BT_UUID = "00001101-0000-1000-8000-00805F9B34FB" + } + + private var bluetoothSocket: BluetoothSocket? = null + + private val _bluetoothState = MutableStateFlow( + if (bluetoothAdapter?.isEnabled == true) { + BtState.ENABLED + } else { + BtState.DISABLED + }, + ) + + private val _scannedDevices = MutableStateFlow(emptyList()) + private val _pairedDevices = MutableStateFlow(emptyList()) + private val _deviceState = MutableStateFlow(null) + private val _bluetoothMessage = MutableStateFlow(null) + private var connectedDeviceAddress: String? = null + + private val bluetoothStateReceiver = BluetoothStateReceiver { state -> + Timber.i("BT STATE UPDATE: $state") + _bluetoothState.update { state } + } + + private val scannedDeviceReceiver = ScannedDeviceReceiver { device -> + if (device in _scannedDevices.value) return@ScannedDeviceReceiver + Timber.i("FOUND NEW DEVICE: $device") + _scannedDevices.update { it + device } + } + + private val deviceStateReceiver = DeviceStateReceiver { device -> + if (connectedDeviceAddress != device.address) return@DeviceStateReceiver + _deviceState.update { device } + } + + /** + * Retrieves the state of the Bluetooth adapter. + * + * @return [StateFlow] representing the Bluetooth state. + */ + override fun getBluetoothState(): StateFlow { + Timber.d("REGISTERING BT STATE RECEIVER ... ") + context.registerReceiver( + bluetoothStateReceiver, + IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED), + ) + return _bluetoothState.asStateFlow() + } + + /** + * Retrieves the list of scanned Bluetooth devices. + * + * @return [StateFlow] representing the list of scanned devices. + */ + @SuppressLint("MissingPermission") + override fun getScannedDevices(): StateFlow> { + Timber.d("STARTING BT CLASSIC SCAN ... ") + clearScannedDevices() + context.registerReceiver( + scannedDeviceReceiver, + IntentFilter(BluetoothDevice.ACTION_FOUND), + ) + if (context.hasPermission(Manifest.permission.BLUETOOTH_SCAN)) { + bluetoothAdapter?.startDiscovery() + } + return _scannedDevices.asStateFlow() + } + + /** + * Retrieves the list of paired Bluetooth devices. + * + * @return [StateFlow] representing the list of paired devices. + */ + @SuppressLint("MissingPermission") + override fun getPairedDevices(): StateFlow> { + Timber.d("FETCHING PAIRED DEVICES ... ") + if (context.hasPermission(Manifest.permission.BLUETOOTH_CONNECT)) { + _pairedDevices.update { + bluetoothAdapter?.bondedDevices?.map { it.simplify() } ?: emptyList() + } + } + return _pairedDevices.asStateFlow() + } + + /** + * Stops the device discovery process. + */ + @SuppressLint("MissingPermission") + override fun stopDiscovery() { + Timber.d("STOPPING DISCOVERY ... ") + if (context.hasPermission(Manifest.permission.BLUETOOTH_SCAN)) { + try { + bluetoothAdapter?.cancelDiscovery() + context.unregisterReceiver(scannedDeviceReceiver) + } catch (e: Exception) { + Timber.e(e) + } + } + } + + /** + * Connects to a Bluetooth device with the specified address. + * + * @param address The MAC address of the Bluetooth device. + * @return [Result] indicating the success or failure of the connection. + */ + @SuppressLint("MissingPermission") + override suspend fun connect(address: String): Result { + if (!context.hasPermission(Manifest.permission.BLUETOOTH_CONNECT)) { + return Result.failure(SecurityException("Missing Permission")) + } + if (bluetoothSocket?.isConnected == true) { + return Result.failure(IllegalStateException("Please Close Existing Connection")) + } + Timber.d("INITIATING CONNECTION ... ") + bluetoothSocket = bluetoothAdapter?.getRemoteDevice(address) + ?.createInsecureRfcommSocketToServiceRecord(UUID.fromString(BT_UUID)) + return try { + withContext(ioDispatcher) { + bluetoothSocket?.run { connect() } + connectedDeviceAddress = address + listenForIncomingBluetoothMessages() + Result.success(Unit) + } + } catch (e: IOException) { + Timber.e(e) + Result.failure(e) + } + } + + /** + * Retrieves the state of the connected Bluetooth device. + * + * @return [StateFlow] representing the device state. + */ + override fun getConnectedDeviceState(): StateFlow { + Timber.d("FETCHING DEVICE STATE ... ") + context.registerReceiver( + deviceStateReceiver, + IntentFilter().apply { + addAction(BluetoothDevice.ACTION_ACL_CONNECTED) + addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED) + }, + ) + return _deviceState.asStateFlow() + } + + /** + * Closes the Bluetooth connection. + * + * @return [Result] indicating the success or failure of the operation. + */ + override suspend fun closeConnection(): Result { + Timber.d("CLOSING CONNECTION ... ") + return try { + withContext(ioDispatcher) { + bluetoothSocket?.close() + while (_deviceState.value?.connected == true) { + delay(1000L) + } + cleanup() + Result.success(Unit) + } + } catch (e: IOException) { + Result.failure(e) + } + } + + /** + * Retrieves the data stream of incoming Bluetooth messages. + * + * @return [StateFlow] representing the incoming Bluetooth messages. + */ + override fun getBluetoothDataStream(): StateFlow { + return _bluetoothMessage.asStateFlow() + } + + /** + * Sends data to the connected Bluetooth device. + * + * @param data The data to send. + * @return [Result] indicating the success or failure of the operation. + */ + @Suppress("BlockingMethodInNonBlockingContext") + override suspend fun sendDataToBluetoothDevice(data: String): Result { + Timber.d("SENDING : $data") + return try { + withContext(ioDispatcher) { + bluetoothSocket?.outputStream?.write(data.toByteArray()) + Result.success(Unit) + } + } catch (e: IOException) { + Result.failure(e) + } + } + + /** + * Suspends the current coroutine and listens for incoming Bluetooth messages from the connected device. + * This function runs on the IO dispatcher. + */ + private suspend fun listenForIncomingBluetoothMessages() { + Timber.d("LISTENING FOR BLUETOOTH MESSAGES ... ") + Timber.d("SOCKET: $bluetoothSocket") + withContext(ioDispatcher) { + bluetoothSocket?.run { + val bufferedReader = BufferedReader(InputStreamReader(inputStream)) + while (isConnected) { + try { + if (bufferedReader.ready()) { + val message = bufferedReader.readLine() + _bluetoothMessage.update { BtMessage(message = message) } + Timber.i("RECEIVED MESSAGE: $message") + } + } catch (e: IOException) { + Timber.e(e) + } + } + } + } + } + + /** + * Clears the list of scanned Bluetooth devices. + */ + private fun clearScannedDevices() { + _scannedDevices.update { emptyList() } + } + + /** + * Cleans up the Bluetooth Classic module by unregistering receivers and clearing scanned devices. + */ + private fun cleanup() { + Timber.d("CLEANING UP ... ") + // bluetoothSocket = null + connectedDeviceAddress = null + context.unregisterReceiver(bluetoothStateReceiver) + context.unregisterReceiver(deviceStateReceiver) + clearScannedDevices() + } +} diff --git a/bluetooth/classic/src/main/kotlin/dev/atick/bluetooth/classic/di/BluetoothClassicModule.kt b/bluetooth/classic/src/main/kotlin/dev/atick/bluetooth/classic/di/BluetoothClassicModule.kt new file mode 100644 index 000000000..8013bfe5c --- /dev/null +++ b/bluetooth/classic/src/main/kotlin/dev/atick/bluetooth/classic/di/BluetoothClassicModule.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.bluetooth.classic.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.atick.bluetooth.classic.BluetoothClassic +import dev.atick.bluetooth.common.data.BluetoothDataSource +import dev.atick.bluetooth.common.manager.BluetoothManager +import dev.atick.bluetooth.common.utils.BluetoothUtils +import javax.inject.Singleton + +/** + * Dagger module for providing Bluetooth Classic related dependencies. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class BluetoothClassicModule { + + /** + * Binds [BluetoothClassic] implementation to [BluetoothUtils] interface. + * + * @param bluetoothClassic The Bluetooth Classic implementation. + * @return The BluetoothUtils interface. + */ + @Binds + @Singleton + abstract fun provideBluetoothUtils( + bluetoothClassic: BluetoothClassic, + ): BluetoothUtils + + /** + * Binds [BluetoothClassic] implementation to [BluetoothManager] interface. + * + * @param bluetoothClassic The Bluetooth Classic implementation. + * @return The BluetoothManager interface. + */ + @Binds + @Singleton + abstract fun bindBluetoothManager( + bluetoothClassic: BluetoothClassic, + ): BluetoothManager + + /** + * Binds [BluetoothClassic] implementation to [BluetoothDataSource] interface. + * + * @param bluetoothClassic The Bluetooth Classic implementation. + * @return The BluetoothDataSource interface. + */ + @Binds + @Singleton + abstract fun bindBluetoothDataSource( + bluetoothClassic: BluetoothClassic, + ): BluetoothDataSource +} diff --git a/bluetooth/common/.gitignore b/bluetooth/common/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/bluetooth/common/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/bluetooth/common/build.gradle.kts b/bluetooth/common/build.gradle.kts new file mode 100644 index 000000000..9a7071522 --- /dev/null +++ b/bluetooth/common/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id("dev.atick.library") + id("dev.atick.dagger.hilt") +} + +android { + namespace = "dev.atick.bluetooth.common" +} + +dependencies { + implementation(project(":core:android")) +} \ No newline at end of file diff --git a/bluetooth/common/src/main/AndroidManifest.xml b/bluetooth/common/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e3aab3428 --- /dev/null +++ b/bluetooth/common/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/data/BluetoothDataSource.kt b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/data/BluetoothDataSource.kt new file mode 100644 index 000000000..200413593 --- /dev/null +++ b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/data/BluetoothDataSource.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.bluetooth.common.data + +import dev.atick.bluetooth.common.models.BtMessage +import kotlinx.coroutines.flow.StateFlow + +/** + * Interface representing a Bluetooth data source. + */ +interface BluetoothDataSource { + /** + * Returns the state flow of Bluetooth messages received from the connected device. + * + * @return The state flow of Bluetooth messages. + */ + fun getBluetoothDataStream(): StateFlow + + /** + * Sends data to the connected Bluetooth device. + * + * @param data The data to send. + * @return A result indicating the success or failure of sending the data. + */ + suspend fun sendDataToBluetoothDevice(data: String): Result +} diff --git a/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/di/BluetoothAdapterModule.kt b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/di/BluetoothAdapterModule.kt new file mode 100644 index 000000000..0411c9405 --- /dev/null +++ b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/di/BluetoothAdapterModule.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.bluetooth.common.di + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object BluetoothAdapterModule { + /** + * Provides the BluetoothAdapter instance. + * + * @param context The application context. + * @return The BluetoothAdapter instance, or null if Bluetooth is not supported. + */ + @Provides + @Singleton + fun provideBluetoothAdapter( + @ApplicationContext context: Context, + ): BluetoothAdapter? { + val bluetoothManager = context.getSystemService(BluetoothManager::class.java) + return bluetoothManager?.adapter + } +} diff --git a/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/manager/BluetoothManager.kt b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/manager/BluetoothManager.kt new file mode 100644 index 000000000..b86edf359 --- /dev/null +++ b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/manager/BluetoothManager.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.bluetooth.common.manager + +import dev.atick.bluetooth.common.models.BtDevice +import kotlinx.coroutines.flow.StateFlow + +/** + * BluetoothManager interface provides methods to manage Bluetooth connections. + */ +interface BluetoothManager { + /** + * Attempts to establish a Bluetooth connection with the specified device address. + * + * @param address The address of the Bluetooth device to connect to. + * @return A [Result] indicating the success or failure of the connection attempt. + */ + suspend fun connect(address: String): Result + + /** + * Returns the state of the connected Bluetooth device. + * + * @return A [StateFlow] emitting the current state of the connected Bluetooth device. + */ + fun getConnectedDeviceState(): StateFlow + + /** + * Closes the existing Bluetooth connection. + * + * @return A [Result] indicating the success or failure of closing the connection. + */ + suspend fun closeConnection(): Result +} diff --git a/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtDevice.kt b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtDevice.kt new file mode 100644 index 000000000..33494b077 --- /dev/null +++ b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtDevice.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.bluetooth.common.models + +import android.bluetooth.BluetoothClass.Device.Major.AUDIO_VIDEO +import android.bluetooth.BluetoothClass.Device.Major.COMPUTER +import android.bluetooth.BluetoothClass.Device.Major.HEALTH +import android.bluetooth.BluetoothClass.Device.Major.IMAGING +import android.bluetooth.BluetoothClass.Device.Major.MISC +import android.bluetooth.BluetoothClass.Device.Major.NETWORKING +import android.bluetooth.BluetoothClass.Device.Major.PERIPHERAL +import android.bluetooth.BluetoothClass.Device.Major.PHONE +import android.bluetooth.BluetoothClass.Device.Major.TOY +import android.bluetooth.BluetoothClass.Device.Major.UNCATEGORIZED +import android.bluetooth.BluetoothClass.Device.Major.WEARABLE +import android.bluetooth.BluetoothDevice + +/** + * Represents a Bluetooth device. + * + * @property name The name of the Bluetooth device. + * @property address The address of the Bluetooth device. + * @property type The type of the Bluetooth device. + * @property connected Indicates whether the Bluetooth device is currently connected. + */ +data class BtDevice( + val name: String, + val address: String, + val type: BtDeviceType, + val connected: Boolean, +) + +/** + * Converts a [BluetoothDevice] object to a simplified [BtDevice] object. + * + * @param connected Indicates whether the Bluetooth device is connected. + * @return A simplified [BtDevice] object. + */ +fun BluetoothDevice.simplify( + connected: Boolean = false, +): BtDevice { + val simpleName = try { + name ?: "Unknown" + } catch (e: SecurityException) { + "Permission Required" + } + + val simpleAddress = address ?: "Unknown" + + val simpleType = try { + when (bluetoothClass.majorDeviceClass) { + AUDIO_VIDEO -> BtDeviceType.AUDIO_VIDEO + COMPUTER -> BtDeviceType.COMPUTER + HEALTH -> BtDeviceType.HEALTH + IMAGING -> BtDeviceType.IMAGING + MISC -> BtDeviceType.MISC + NETWORKING -> BtDeviceType.NETWORKING + PERIPHERAL -> BtDeviceType.PERIPHERAL + PHONE -> BtDeviceType.PHONE + TOY -> BtDeviceType.TOY + UNCATEGORIZED -> BtDeviceType.UNCATEGORIZED + WEARABLE -> BtDeviceType.WEARABLE + else -> BtDeviceType.UNCATEGORIZED + } + } catch (e: SecurityException) { + BtDeviceType.UNCATEGORIZED + } + + return BtDevice( + name = simpleName, + address = simpleAddress, + type = simpleType, + connected = connected, + ) +} diff --git a/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtDeviceType.kt b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtDeviceType.kt new file mode 100644 index 000000000..26642f892 --- /dev/null +++ b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtDeviceType.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.bluetooth.common.models + +/** + * Enum class representing the type of a Bluetooth device. + */ +enum class BtDeviceType { + AUDIO_VIDEO, + COMPUTER, + HEALTH, + IMAGING, + MISC, + NETWORKING, + PERIPHERAL, + PHONE, + TOY, + UNCATEGORIZED, + WEARABLE, +} diff --git a/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtMessage.kt b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtMessage.kt new file mode 100644 index 000000000..510ec9694 --- /dev/null +++ b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtMessage.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.bluetooth.common.models + +import java.util.Date + +/** + * Data class representing a Bluetooth message. + * + * @property timestamp The timestamp of the message. + * @property message The content of the message. + */ +data class BtMessage( + val timestamp: Long = Date().time, + val message: CharSequence, +) diff --git a/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtState.kt b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtState.kt new file mode 100644 index 000000000..23ee494a5 --- /dev/null +++ b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/models/BtState.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.bluetooth.common.models + +/** + * Enum class representing the Bluetooth state. + */ +enum class BtState { + /** + * Bluetooth is enabled. + */ + ENABLED, + + /** + * Bluetooth is disabled. + */ + DISABLED, +} diff --git a/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/receivers/BluetoothStateReceiver.kt b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/receivers/BluetoothStateReceiver.kt new file mode 100644 index 000000000..55e39ef44 --- /dev/null +++ b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/receivers/BluetoothStateReceiver.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.bluetooth.common.receivers + +import android.bluetooth.BluetoothAdapter +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dev.atick.bluetooth.common.models.BtState + +/** + * BroadcastReceiver for receiving Bluetooth state changes. + * + * @param onBtStateChange Callback function to handle Bluetooth state changes. + */ +class BluetoothStateReceiver( + private val onBtStateChange: (BtState) -> Unit, +) : BroadcastReceiver() { + + /** + * Called when a broadcast is received. + * + * @param context The context of the receiver. + * @param intent The intent containing the broadcast information. + */ + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)) { + BluetoothAdapter.STATE_ON -> onBtStateChange(BtState.ENABLED) + BluetoothAdapter.STATE_OFF -> onBtStateChange(BtState.DISABLED) + } + } +} diff --git a/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/receivers/DeviceStateReceiver.kt b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/receivers/DeviceStateReceiver.kt new file mode 100644 index 000000000..1acfe2220 --- /dev/null +++ b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/receivers/DeviceStateReceiver.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("DEPRECATION") + +package dev.atick.bluetooth.common.receivers + +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import dev.atick.bluetooth.common.models.BtDevice +import dev.atick.bluetooth.common.models.simplify + +/** + * BroadcastReceiver for receiving Bluetooth device connection state changes. + * + * @param onConnectionStateChange Callback function to handle Bluetooth device connection state changes. + */ +class DeviceStateReceiver( + private val onConnectionStateChange: (BtDevice) -> Unit, +) : BroadcastReceiver() { + + /** + * Called when a broadcast is received. + * + * @param context The context of the receiver. + * @param intent The intent containing the broadcast information. + */ + override fun onReceive(context: Context?, intent: Intent?) { + val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent?.getParcelableExtra( + BluetoothDevice.EXTRA_DEVICE, + BluetoothDevice::class.java, + ) + } else { + intent?.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + } + if (device == null) return + when (intent?.action) { + BluetoothDevice.ACTION_ACL_CONNECTED -> { + onConnectionStateChange(device.simplify(true)) + } + + BluetoothDevice.ACTION_ACL_DISCONNECTED -> { + onConnectionStateChange(device.simplify(false)) + } + } + } +} diff --git a/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/receivers/ScannedDeviceReceiver.kt b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/receivers/ScannedDeviceReceiver.kt new file mode 100644 index 000000000..5ef55afd1 --- /dev/null +++ b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/receivers/ScannedDeviceReceiver.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.bluetooth.common.receivers + +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import dev.atick.bluetooth.common.models.BtDevice +import dev.atick.bluetooth.common.models.simplify + +/** + * BroadcastReceiver for receiving Bluetooth device discovery events. + * + * @param onDeviceFound Callback function to handle discovered Bluetooth devices. + */ +class ScannedDeviceReceiver( + private val onDeviceFound: (BtDevice) -> Unit, +) : BroadcastReceiver() { + + /** + * Called when a broadcast is received. + * + * @param context The context of the receiver. + * @param intent The intent containing the broadcast information. + */ + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + BluetoothDevice.ACTION_FOUND -> { + val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra( + BluetoothDevice.EXTRA_DEVICE, + BluetoothDevice::class.java, + ) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + } + device?.let { onDeviceFound(it.simplify()) } + } + } + } +} diff --git a/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/utils/BluetoothUtils.kt b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/utils/BluetoothUtils.kt new file mode 100644 index 000000000..d8ee98175 --- /dev/null +++ b/bluetooth/common/src/main/kotlin/dev/atick/bluetooth/common/utils/BluetoothUtils.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.bluetooth.common.utils + +import dev.atick.bluetooth.common.models.BtDevice +import dev.atick.bluetooth.common.models.BtState +import kotlinx.coroutines.flow.StateFlow + +/** + * Interface defining Bluetooth utility methods. + */ +interface BluetoothUtils { + /** + * Retrieves the current state of Bluetooth. + * + * @return A [StateFlow] emitting the current Bluetooth state. + */ + fun getBluetoothState(): StateFlow + + /** + * Retrieves the list of scanned Bluetooth devices. + * + * @return A [StateFlow] emitting the list of scanned Bluetooth devices. + */ + fun getScannedDevices(): StateFlow> + + /** + * Retrieves the list of paired Bluetooth devices. + * + * @return A [StateFlow] emitting the list of paired Bluetooth devices. + */ + fun getPairedDevices(): StateFlow> + + /** + * Stops the Bluetooth device discovery process. + */ + fun stopDiscovery() +} diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts new file mode 100644 index 000000000..6aa6c0b2d --- /dev/null +++ b/build-logic/convention/build.gradle.kts @@ -0,0 +1,66 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `kotlin-dsl` +} + +group = "dev.atick.build.logic" + +val javaVersion = libs.versions.java.get().toInt() + +java { + sourceCompatibility = JavaVersion.values()[javaVersion - 1] + targetCompatibility = JavaVersion.values()[javaVersion - 1] +} + +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = "$javaVersion" + } +} + +dependencies { + compileOnly(libs.kotlin.gradlePlugin) + compileOnly(libs.android.gradlePlugin) +} + +gradlePlugin { + plugins { + register("library") { + id = "dev.atick.library" + implementationClass = "LibraryConventionPlugin" + } + register("uiLibrary") { + id = "dev.atick.ui.library" + implementationClass = "UiLibraryConventionPlugin" + } + register("application") { + id = "dev.atick.application" + implementationClass = "ApplicationConventionPlugin" + } + register("daggerHilt") { + id = "dev.atick.dagger.hilt" + implementationClass = "DaggerHiltConventionPlugin" + } + register("firebase") { + id = "dev.atick.firebase" + implementationClass = "FirebaseConventionPlugin" + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/ApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/ApplicationConventionPlugin.kt new file mode 100644 index 000000000..a21557182 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ApplicationConventionPlugin.kt @@ -0,0 +1,66 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.getByType +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension + +class ApplicationConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + val libs = extensions.getByType().named("libs") + val javaVersion = libs.findVersion("java").get().toString() + val minSdkVersion = libs.findVersion("minSdk").get().toString().toInt() + val targetSdkVersion = libs.findVersion("targetSdk").get().toString().toInt() + val compileSdkVersion = libs.findVersion("compileSdk").get().toString().toInt() + + with(pluginManager) { + apply("com.android.application") + apply("org.jetbrains.kotlin.android") + apply("org.jetbrains.kotlin.plugin.compose") + apply("org.jetbrains.dokka") + } + + extensions.configure { + compileSdk = compileSdkVersion + + defaultConfig { + minSdk = minSdkVersion + targetSdk = targetSdkVersion + vectorDrawables { + useSupportLibrary = true + } + } + + compileOptions { + sourceCompatibility = JavaVersion.valueOf("VERSION_$javaVersion") + targetCompatibility = JavaVersion.valueOf("VERSION_$javaVersion") + } + + with(extensions.getByType()) { + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(javaVersion)) + freeCompilerArgs.addAll( + "-opt-in=kotlin.RequiresOptIn", + "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", + ) + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/DaggerHiltConventionPlugin.kt b/build-logic/convention/src/main/kotlin/DaggerHiltConventionPlugin.kt new file mode 100644 index 000000000..c8d992760 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/DaggerHiltConventionPlugin.kt @@ -0,0 +1,23 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.getByType + +class DaggerHiltConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + val libs = extensions.getByType().named("libs") + + with(pluginManager) { + apply("com.google.dagger.hilt.android") + apply("com.google.devtools.ksp") + } + + dependencies { + "implementation"(libs.findLibrary("dagger.hilt.android").get()) + "ksp"(libs.findLibrary("dagger.hilt.compiler").get()) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/FirebaseConventionPlugin.kt b/build-logic/convention/src/main/kotlin/FirebaseConventionPlugin.kt new file mode 100644 index 000000000..0004ab224 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/FirebaseConventionPlugin.kt @@ -0,0 +1,25 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.getByType + +class FirebaseConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + val libs = extensions.getByType().named("libs") + val firebaseBom = libs.findLibrary("firebase-bom").get() + + with(pluginManager) { + apply("com.google.gms.google-services") + apply("com.google.firebase.crashlytics") + } + + dependencies { + add("implementation", platform(firebaseBom)) + "implementation"(libs.findLibrary("firebase.analytics").get()) + "implementation"(libs.findLibrary("firebase.crashlytics").get()) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/LibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/LibraryConventionPlugin.kt new file mode 100644 index 000000000..99ebc23a4 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/LibraryConventionPlugin.kt @@ -0,0 +1,47 @@ +import com.android.build.gradle.LibraryExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.getByType +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension + +class LibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + val libs = extensions.getByType().named("libs") + val javaVersion = libs.findVersion("java").get().toString() + val minSdkVersion = libs.findVersion("minSdk").get().toString().toInt() + val compileSdkVersion = libs.findVersion("compileSdk").get().toString().toInt() + + with(pluginManager) { + apply("com.android.library") + apply("org.jetbrains.kotlin.android") + apply("org.jetbrains.dokka") + } + + extensions.configure { + + compileSdk = compileSdkVersion + + defaultConfig { + minSdk = minSdkVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.valueOf("VERSION_$javaVersion") + targetCompatibility = JavaVersion.valueOf("VERSION_$javaVersion") + } + + } + + extensions.configure { + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(javaVersion)) + } + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/UiLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/UiLibraryConventionPlugin.kt new file mode 100644 index 000000000..ec61aacd9 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/UiLibraryConventionPlugin.kt @@ -0,0 +1,60 @@ +import com.android.build.gradle.LibraryExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.getByType +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension + +class UiLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + val libs = extensions.getByType().named("libs") + val javaVersion = libs.findVersion("java").get().toString() + val minSdkVersion = libs.findVersion("minSdk").get().toString().toInt() + val compileSdkVersion = libs.findVersion("compileSdk").get().toString().toInt() + + with(pluginManager) { + apply("com.android.library") + apply("org.jetbrains.kotlin.android") + apply("org.jetbrains.kotlin.plugin.compose") + apply("org.jetbrains.dokka") + } + + extensions.configure { + compileSdk = compileSdkVersion + + defaultConfig { + minSdk = minSdkVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.valueOf("VERSION_$javaVersion") + targetCompatibility = JavaVersion.valueOf("VERSION_$javaVersion") + } + + with(extensions.getByType()) { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.addAll( + "-opt-in=kotlin.RequiresOptIn", + "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", + ) + } + } + + buildFeatures { + compose = true + } + } + + extensions.configure { + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(javaVersion)) + } + } + } + } +} \ No newline at end of file diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties new file mode 100644 index 000000000..4745d543c --- /dev/null +++ b/build-logic/gradle.properties @@ -0,0 +1,5 @@ +# Gradle properties are not passed to included builds +# https://github.com/gradle/gradle/issues/2534 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configureondemand=true \ No newline at end of file diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 000000000..92512cf06 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,16 @@ +@file:Suppress("UnstableApiUsage") + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" +include(":convention") \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 000000000..2f49d3a3a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.kotlin) apply(false) + alias(libs.plugins.android.library) apply(false) + alias(libs.plugins.android.application) apply(false) + alias(libs.plugins.kotlin.serialization) apply(false) + alias(libs.plugins.dagger.hilt.android) apply(false) + alias(libs.plugins.firebase.crashlytics) apply(false) + alias(libs.plugins.kotlin.compose.compiler) apply(false) + alias(libs.plugins.secrets) apply(false) + alias(libs.plugins.gms) apply(false) + alias(libs.plugins.ksp) apply(false) + alias(libs.plugins.dokka) +} diff --git a/core/android/.gitignore b/core/android/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/core/android/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/android/build.gradle.kts b/core/android/build.gradle.kts new file mode 100644 index 000000000..a1041db16 --- /dev/null +++ b/core/android/build.gradle.kts @@ -0,0 +1,44 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("dev.atick.library") + id("dev.atick.dagger.hilt") +} + +android { + namespace = "dev.atick.core.android" +} + +dependencies { + // ... Core Android + api(libs.androidx.core.ktx) + + // ... Coroutines + api(libs.kotlinx.coroutines.android) + + // ... Serialization + api(libs.kotlinx.serialization.json) + + // ... Date-Time + api(libs.kotlinx.datetime) + + // ... Dagger-Hilt + api(libs.dagger.hilt.android) + + // ... Logger + api(libs.timber.logging) +} \ No newline at end of file diff --git a/core/android/src/main/AndroidManifest.xml b/core/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..64b585011 --- /dev/null +++ b/core/android/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/core/android/src/main/kotlin/dev/atick/core/di/DispatcherModule.kt b/core/android/src/main/kotlin/dev/atick/core/di/DispatcherModule.kt new file mode 100644 index 000000000..526cc6486 --- /dev/null +++ b/core/android/src/main/kotlin/dev/atick/core/di/DispatcherModule.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Qualifier + +/** + * Dagger module that provides coroutine dispatchers for different contexts. + */ +@Module +@InstallIn(SingletonComponent::class) +object DispatcherModule { + /** + * Provides the default coroutine dispatcher, which is used for general-purpose background tasks. + * + * @return The default coroutine dispatcher. + */ + @DefaultDispatcher + @Provides + fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + /** + * Provides the I/O coroutine dispatcher, which is used for I/O-bound tasks such as disk or network operations. + * + * @return The I/O coroutine dispatcher. + */ + @IoDispatcher + @Provides + fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO + + /** + * Provides the main coroutine dispatcher, which is used for executing tasks on the main/UI thread. + * + * @return The main coroutine dispatcher. + */ + @MainDispatcher + @Provides + fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main +} + +/** + * Annotation used to mark the default coroutine dispatcher. + */ +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class DefaultDispatcher + +/** + * Annotation used to mark the I/O coroutine dispatcher. + */ +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class IoDispatcher + +/** + * Annotation used to mark the main coroutine dispatcher. + */ +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class MainDispatcher diff --git a/core/android/src/main/kotlin/dev/atick/core/di/StringDecoderModule.kt b/core/android/src/main/kotlin/dev/atick/core/di/StringDecoderModule.kt new file mode 100644 index 000000000..c2a37d304 --- /dev/null +++ b/core/android/src/main/kotlin/dev/atick/core/di/StringDecoderModule.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.atick.core.utils.StringDecoder +import dev.atick.core.utils.UriDecoder + +/** + * Dagger module providing bindings for StringDecoder implementations. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class StringDecoderModule { + /** + * Binds the provided [UriDecoder] instance as the implementation for [StringDecoder]. + * + * @param uriDecoder The instance of [UriDecoder] to be bound as [StringDecoder]. + */ + @Binds + abstract fun bindStringDecoder(uriDecoder: UriDecoder): StringDecoder +} diff --git a/core/android/src/main/kotlin/dev/atick/core/extensions/ContextExtensions.kt b/core/android/src/main/kotlin/dev/atick/core/extensions/ContextExtensions.kt new file mode 100644 index 000000000..6a73c61af --- /dev/null +++ b/core/android/src/main/kotlin/dev/atick/core/extensions/ContextExtensions.kt @@ -0,0 +1,183 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.extensions + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Notification +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.PackageManager +import android.net.Uri +import android.webkit.MimeTypeMap +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * Provides the activity from Context (https://stackoverflow.com/a/68423182/12737399) + * + * @return The activity associated with the context, or `null` if the context is not an activity. + */ +fun Context.getActivity(): ComponentActivity? { + return when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null + } +} + +/** + * Displays a short toast message. + * + * @param message The message to be displayed in the toast. + */ +fun Context.showToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() +} + +/** + * Checks if the app has a given permission. + * + * @param permission The permission to check. + * @return `true` if the permission is granted, `false` otherwise. + */ +fun Context.hasPermission(permission: String): Boolean { + return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED +} + +/** + * Checks if all the given permissions are granted. + * + * @param permissions List of permissions to check. + * @return `true` if all permissions are granted, `false` otherwise. + */ +fun Context.isAllPermissionsGranted(permissions: List): Boolean { + return permissions.all { hasPermission(it) } +} + +/** + * Shows a notification using the specified notification ID and notification object. + * + * @param notificationId The ID of the notification. + * @param notification The notification object to be shown. + */ +@SuppressLint("MissingPermission") +fun Context.showNotification( + notificationId: Int, + notification: Notification, +) { + if (hasPermission(Manifest.permission.POST_NOTIFICATIONS)) { + with(NotificationManagerCompat.from(this)) { + notify(notificationId, notification) + } + } +} + +/** + * Cancels a previously shown notification. + * + * @param notificationId The ID of the notification to be canceled. + */ +fun Context.cancelNotification(notificationId: Int) { + with(NotificationManagerCompat.from(this)) { + cancel(notificationId) + } +} + +/** + * Retrieves a temporary file URI for the specified app ID. + * + * @param appId The ID of the app. + * @return The URI of the temporary file. + * @throws IllegalAccessException if unable to create or retrieve the temporary file. + */ +@Throws(IllegalAccessException::class) +fun Context.getTmpFileUri(appId: String): Uri { + val tmpFile = File.createTempFile( + "tmp_image_file", + ".png", + cacheDir, + ).apply { + createNewFile() + deleteOnExit() + } + + return FileProvider.getUriForFile( + applicationContext, + "$appId.provider", + tmpFile, + ) +} + +/** + * Retrieves a File object from the given content URI. + * + * @param contentUri The content URI of the file. + * @return The File object representing the content URI, or `null` if an error occurred. + */ +fun Context.getFileFromContentUri(contentUri: Uri): File? { + return try { + val fileExtension = getFileExtension(this, contentUri) + val fileName = "temp_file" + if (fileExtension != null) ".$fileExtension" else "" + val tempFile = File(cacheDir, fileName) + tempFile.createNewFile() + val oStream = FileOutputStream(tempFile) + val inputStream = contentResolver.openInputStream(contentUri) + inputStream?.let { copy(inputStream, oStream) } + oStream.flush() + tempFile + } catch (e: Exception) { + e.printStackTrace() + null + } +} + +/** + * Retrieves the file extension from the given content URI. + * + * @param context The context. + * @param uri The content URI of the file. + * @return The file extension, or `null` if the extension could not be determined. + */ +private fun getFileExtension(context: Context, uri: Uri): String? { + val fileType: String? = context.contentResolver.getType(uri) + return MimeTypeMap.getSingleton().getExtensionFromMimeType(fileType) +} + +/** + * Copies the data from the input stream to the output stream. + * + * @param source The input stream to read from. + * @param target The output stream to write to. + * @throws IOException if an I/O error occurs during the copy operation. + */ +@Throws(IOException::class) +private fun copy(source: InputStream, target: OutputStream) { + val buf = ByteArray(8192) + var length: Int + while (source.read(buf).also { length = it } > 0) { + target.write(buf, 0, length) + } +} diff --git a/core/android/src/main/kotlin/dev/atick/core/extensions/FlowExtensions.kt b/core/android/src/main/kotlin/dev/atick/core/extensions/FlowExtensions.kt new file mode 100644 index 000000000..0a6adb895 --- /dev/null +++ b/core/android/src/main/kotlin/dev/atick/core/extensions/FlowExtensions.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.extensions + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +/** + * Returns a [StateFlow] that represents the last value emitted by the [Flow] + * + * @param initialValue The initial value of the [StateFlow] + * @param scope The [CoroutineScope] to be used for the [StateFlow] + * @return A [StateFlow] that represents the last value emitted by the [Flow] + * */ +fun Flow.stateInDelayed( + initialValue: T, + scope: CoroutineScope, +): StateFlow { + return this.stateIn( + scope = scope, + initialValue = initialValue, + started = SharingStarted.WhileSubscribed(5000L), + ) +} diff --git a/core/android/src/main/kotlin/dev/atick/core/extensions/StringExtensions.kt b/core/android/src/main/kotlin/dev/atick/core/extensions/StringExtensions.kt new file mode 100644 index 000000000..06b961988 --- /dev/null +++ b/core/android/src/main/kotlin/dev/atick/core/extensions/StringExtensions.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.extensions + +import android.util.Patterns +import java.util.regex.Matcher +import java.util.regex.Pattern + +/** + * Checks if the string is a valid email address. + * + * @return `true` if the string is a valid email address, `false` otherwise. + */ +fun String?.isEmailValid(): Boolean { + return !isNullOrEmpty() && Patterns.EMAIL_ADDRESS.matcher(this).matches() +} + +/** + * Checks if the string is a valid password based on the specified criteria. + * + * @return `true` if the string is a valid password, `false` otherwise. + */ +fun String?.isPasswordValid(): Boolean { + val passwordRegex = "^(?=.*\\d)(?=.*[a-z]).{8,20}$" + val pattern: Pattern = Pattern.compile(passwordRegex) + val matcher: Matcher = pattern.matcher(this ?: "") + return matcher.matches() +} + +/** + * Checks if a given full name is valid. + * + * A valid full name consists of at least two parts: a first name and a last name. + * Each part should contain only letters (assuming names don't contain special characters). + * + * @return `true` if the full name is valid, `false` otherwise. + */ +fun String?.isValidFullName(): Boolean { + if (this == null) return false + // Split the full name into parts using spaces as separators + val parts = split(" ") + + // Check if there are at least two parts (first name and last name) + if (parts.size < 2) return false + + // Check if each part contains only letters (assuming names don't contain special characters) + for (part in parts) { + if (!part.all { it.isLetter() }) { + return false + } + } + + return true +} diff --git a/core/android/src/main/kotlin/dev/atick/core/utils/CoroutineUtils.kt b/core/android/src/main/kotlin/dev/atick/core/utils/CoroutineUtils.kt new file mode 100644 index 000000000..144a489d3 --- /dev/null +++ b/core/android/src/main/kotlin/dev/atick/core/utils/CoroutineUtils.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.utils + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout +import kotlin.coroutines.Continuation +import kotlin.time.Duration + +suspend inline fun suspendCoroutineWithTimeout( + timeout: Duration, + crossinline block: (Continuation) -> Unit, +): T { + return withTimeout(timeout) { + suspendCancellableCoroutine(block) + } +} + +suspend inline fun suspendCoroutineWithTimeout( + timeMillis: Long, + crossinline block: (CancellableContinuation) -> Unit, +): T { + return withTimeout(timeMillis) { + suspendCancellableCoroutine(block) + } +} diff --git a/core/android/src/main/kotlin/dev/atick/core/utils/Resource.kt b/core/android/src/main/kotlin/dev/atick/core/utils/Resource.kt new file mode 100644 index 000000000..d05934a6d --- /dev/null +++ b/core/android/src/main/kotlin/dev/atick/core/utils/Resource.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.utils + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +/** + * A sealed class that represents the result of a resource operation. + * + * @param T The type of data. + * @param data The data result of the operation. + * @param error The error that occurred during the operation, if any. + */ +sealed class Resource( + val data: T? = null, + val error: Throwable? = null, +) { + /** + * Represents a successful result with data. + * + * @param T The type of data. + * @param data The data result of the operation. + */ + class Success(data: T) : Resource(data) + + /** + * Represents a loading state with optional data. + * + * @param T The type of data. + * @param data The optional data result of the operation. + */ + class Loading(data: T? = null) : Resource(data) + + /** + * Represents an error state with optional data and an error. + * + * @param T The type of data. + * @param data The optional data result of the operation. + * @param error The error that occurred during the operation. + */ + class Error(data: T? = null, error: Throwable) : Resource(data, error) +} + +/** + * Creates a network-bound resource flow that performs a query and fetches new data if necessary. + * + * @param ResultType The type of the query result. + * @param RequestType The type of the fetched data. + * @param query The query function that returns a flow of the current data. + * @param fetch The suspend function that fetches new data. + * @param saveFetchedResult The suspend function that saves the fetched result. + * @param shouldFetch The predicate function that determines if fetching new data is necessary. + * @return A flow emitting the resource state based on the query and fetch operations. + */ +inline fun networkBoundResource( + crossinline query: () -> Flow, + crossinline fetch: suspend () -> RequestType, + crossinline saveFetchedResult: suspend (RequestType) -> Unit, + crossinline shouldFetch: (ResultType) -> Boolean = { true }, +): Flow> = flow { + val data = query().first() + + val flow = if (shouldFetch(data)) { + emit(Resource.Loading(data)) + try { + saveFetchedResult(fetch()) + query().map { Resource.Success(it) } + } catch (throwable: Throwable) { + query().map { Resource.Error(it, throwable) } + } + } else { + query().map { Resource.Success(it) } + } + + emitAll(flow) +} diff --git a/core/android/src/main/kotlin/dev/atick/core/utils/SingleLiveEvent.kt b/core/android/src/main/kotlin/dev/atick/core/utils/SingleLiveEvent.kt new file mode 100644 index 000000000..5f2277512 --- /dev/null +++ b/core/android/src/main/kotlin/dev/atick/core/utils/SingleLiveEvent.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("MemberVisibilityCanBePrivate") + +package dev.atick.core.utils + +// Event wrapper for Single Events +// Details here: https://medium.com/androiddevelopers/livedata-with-snackbar-navigation-and-other-events-the-singleliveevent-case-ac2622673150 + +/** + * A class that represents a single-use event. + * + * @param T The type of the event content. + * @param content The content of the event. + */ +open class SingleLiveEvent(private val content: T) { + + /** + * Flag indicating whether the event has been handled. + */ + var hasBeenHandled = false + private set + + /** + * Returns the content if it has not been handled yet. + * + * @return The content of the event if it has not been handled, or null if it has been handled. + */ + fun getContentIfNotHandled(): T? { + return if (hasBeenHandled) { + null + } else { + hasBeenHandled = true + content + } + } + + /** + * Returns the content regardless of whether it has been handled. + * + * @return The content of the event. + */ + fun peekContent(): T = content +} diff --git a/core/android/src/main/kotlin/dev/atick/core/utils/StringDecoder.kt b/core/android/src/main/kotlin/dev/atick/core/utils/StringDecoder.kt new file mode 100644 index 000000000..0454677b4 --- /dev/null +++ b/core/android/src/main/kotlin/dev/atick/core/utils/StringDecoder.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.utils + +/** + * Interface representing a string decoder. + */ +interface StringDecoder { + /** + * Decodes an encoded string. + * + * @param encodedString The string to be decoded. + * @return The decoded string. + */ + fun decodeString(encodedString: String): String +} diff --git a/core/android/src/main/kotlin/dev/atick/core/utils/UriDecoder.kt b/core/android/src/main/kotlin/dev/atick/core/utils/UriDecoder.kt new file mode 100644 index 000000000..e4d451cbb --- /dev/null +++ b/core/android/src/main/kotlin/dev/atick/core/utils/UriDecoder.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.utils + +import android.net.Uri +import javax.inject.Inject + +/** + * Implementation of [StringDecoder] that uses Android's Uri.decode method for decoding strings. + * + * @constructor Creates a [UriDecoder] instance. + */ +class UriDecoder @Inject constructor() : StringDecoder { + /** + * Decodes an encoded string using Android's Uri.decode method. + * + * @param encodedString The string to be decoded. + * @return The decoded string. + */ + override fun decodeString(encodedString: String): String = Uri.decode(encodedString) +} diff --git a/core/ui/.gitignore b/core/ui/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/core/ui/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts new file mode 100644 index 000000000..f5ee17a64 --- /dev/null +++ b/core/ui/build.gradle.kts @@ -0,0 +1,68 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("dev.atick.ui.library") +} + +android { + namespace = "dev.atick.core.ui" +} + +dependencies { + api(project(":core:android")) + + // ... AppCompat + api(libs.androidx.appcompat) + + // ... Fragment + api(libs.androidx.fragment.ktx) + + // ... Activity + api(libs.androidx.activity.ktx) + api(libs.androidx.activity.compose) + + // ... Lifecycle + api(libs.androidx.lifecycle.runtime.ktx) + api(libs.androidx.lifecycle.livedata.ktx) + api(libs.androidx.lifecycle.viewmodel.ktx) + api(libs.androidx.lifecycle.runtimeCompose) + api(libs.androidx.lifecycle.viewModelCompose) + + // ... Jetpack Compose + api(platform(libs.androidx.compose.bom)) + api(libs.androidx.compose.runtime) + api(libs.androidx.compose.foundation) + api(libs.androidx.compose.ui.util) + api(libs.androidx.compose.material3) + api(libs.androidx.compose.material3.windowSizeClass) + api(libs.androidx.compose.material.iconsExtended) + api(libs.androidx.compose.ui.tooling.preview) + debugApi(libs.androidx.compose.ui.tooling) + + // ... Navigation + api(libs.androidx.navigation.fragment) + api(libs.androidx.navigation.compose) + api(libs.androidx.hilt.navigation.compose) + + + // ... Coil + api(libs.coil.kt) + api(libs.coil.kt.compose) + + // ... Lottie + api(libs.lottie.compose) +} \ No newline at end of file diff --git a/core/ui/src/main/AndroidManifest.xml b/core/ui/src/main/AndroidManifest.xml new file mode 100644 index 000000000..64b585011 --- /dev/null +++ b/core/ui/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/components/Background.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/components/Background.kt new file mode 100644 index 000000000..b3c220d27 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/components/Background.kt @@ -0,0 +1,136 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.LocalAbsoluteTonalElevation +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.atick.core.ui.theme.GradientColors +import dev.atick.core.ui.theme.LocalBackgroundTheme +import dev.atick.core.ui.theme.LocalGradientColors +import kotlin.math.tan + +/** + * The main background for the app. + * Uses [LocalBackgroundTheme] to set the color and tonal elevation of a [Surface]. + * + * @param modifier Modifier to be applied to the background. + * @param content The background content. + */ +@Composable +fun AppBackground( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val color = LocalBackgroundTheme.current.color + val tonalElevation = LocalBackgroundTheme.current.tonalElevation + Surface( + color = if (color == Color.Unspecified) Color.Transparent else color, + tonalElevation = if (tonalElevation == Dp.Unspecified) 0.dp else tonalElevation, + modifier = modifier.fillMaxSize(), + ) { + CompositionLocalProvider(LocalAbsoluteTonalElevation provides 0.dp) { + content() + } + } +} + +/** + * A gradient background for select screens. Uses [LocalBackgroundTheme] to set the gradient colors + * of a [Box] within a [Surface]. + * + * @param modifier Modifier to be applied to the background. + * @param gradientColors The gradient colors to be rendered. + * @param content The background content. + */ +@Composable +fun AppGradientBackground( + modifier: Modifier = Modifier, + gradientColors: GradientColors = LocalGradientColors.current, + content: @Composable () -> Unit, +) { + val currentTopColor by rememberUpdatedState(gradientColors.top) + val currentBottomColor by rememberUpdatedState(gradientColors.bottom) + Surface( + color = if (gradientColors.container == Color.Unspecified) { + Color.Transparent + } else { + gradientColors.container + }, + modifier = modifier.fillMaxSize(), + ) { + Box( + Modifier + .fillMaxSize() + .drawWithCache { + // Compute the start and end coordinates such that the gradients are angled 11.06 + // degrees off the vertical axis + val offset = size.height * tan( + Math + .toRadians(11.06) + .toFloat(), + ) + + val start = Offset(size.width / 2 + offset / 2, 0f) + val end = Offset(size.width / 2 - offset / 2, size.height) + + // Create the top gradient that fades out after the halfway point vertically + val topGradient = Brush.linearGradient( + 0f to if (currentTopColor == Color.Unspecified) { + Color.Transparent + } else { + currentTopColor + }, + 0.724f to Color.Transparent, + start = start, + end = end, + ) + // Create the bottom gradient that fades in before the halfway point vertically + val bottomGradient = Brush.linearGradient( + 0.2552f to Color.Transparent, + 1f to if (currentBottomColor == Color.Unspecified) { + Color.Transparent + } else { + currentBottomColor + }, + start = start, + end = end, + ) + + onDrawBehind { + // There is overlap here, so order is important + drawRect(topGradient) + drawRect(bottomGradient) + } + }, + ) { + content() + } + } +} diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/components/Button.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/components/Button.kt new file mode 100644 index 000000000..e75f15308 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/components/Button.kt @@ -0,0 +1,274 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Jetpack filled button with generic content slot. Wraps Material 3 [Button]. + * + * @param onClick Will be called when the user clicks the button. + * @param modifier Modifier to be applied to the button. + * @param enabled Controls the enabled state of the button. When `false`, this button will not be + * clickable and will appear disabled to accessibility services. + * @param contentPadding The spacing values to apply internally between the container and the + * content. + * @param content The button content. + */ +@Composable +fun JetpackButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + content: @Composable RowScope.() -> Unit, +) { + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.onBackground, + ), + contentPadding = contentPadding, + content = content, + ) +} + +/** + * Jetpack filled button with text and icon content slots. + * + * @param onClick Will be called when the user clicks the button. + * @param modifier Modifier to be applied to the button. + * @param enabled Controls the enabled state of the button. When `false`, this button will not be + * clickable and will appear disabled to accessibility services. + * @param text The button text label content. + * @param leadingIcon The button leading icon content. Pass `null` here for no leading icon. + */ +@Composable +fun JetpackButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: @Composable () -> Unit, + leadingIcon: @Composable (() -> Unit)? = null, +) { + JetpackButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + contentPadding = if (leadingIcon != null) { + ButtonDefaults.ButtonWithIconContentPadding + } else { + ButtonDefaults.ContentPadding + }, + ) { + JetpackButtonContent( + text = text, + leadingIcon = leadingIcon, + ) + } +} + +/** + * Jetpack outlined button with generic content slot. Wraps Material 3 [OutlinedButton]. + * + * @param onClick Will be called when the user clicks the button. + * @param modifier Modifier to be applied to the button. + * @param enabled Controls the enabled state of the button. When `false`, this button will not be + * clickable and will appear disabled to accessibility services. + * @param contentPadding The spacing values to apply internally between the container and the + * content. + * @param content The button content. + */ +@Composable +fun JetpackOutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + content: @Composable RowScope.() -> Unit, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground, + ), + border = BorderStroke( + width = JetpackButtonDefaults.OutlinedButtonBorderWidth, + color = if (enabled) { + MaterialTheme.colorScheme.outline + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = JetpackButtonDefaults.DisabledOutlinedButtonBorderAlpha, + ) + }, + ), + contentPadding = contentPadding, + content = content, + ) +} + +/** + * Jetpack outlined button with text and icon content slots. + * + * @param onClick Will be called when the user clicks the button. + * @param modifier Modifier to be applied to the button. + * @param enabled Controls the enabled state of the button. When `false`, this button will not be + * clickable and will appear disabled to accessibility services. + * @param text The button text label content. + * @param leadingIcon The button leading icon content. Pass `null` here for no leading icon. + */ +@Composable +fun JetpackOutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: @Composable () -> Unit, + leadingIcon: @Composable (() -> Unit)? = null, +) { + JetpackOutlinedButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + contentPadding = if (leadingIcon != null) { + ButtonDefaults.ButtonWithIconContentPadding + } else { + ButtonDefaults.ContentPadding + }, + ) { + JetpackButtonContent( + text = text, + leadingIcon = leadingIcon, + ) + } +} + +/** + * Jetpack text button with generic content slot. Wraps Material 3 [TextButton]. + * + * @param onClick Will be called when the user clicks the button. + * @param modifier Modifier to be applied to the button. + * @param enabled Controls the enabled state of the button. When `false`, this button will not be + * clickable and will appear disabled to accessibility services. + * @param content The button content. + */ +@Composable +fun JetpackTextButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { + TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground, + ), + content = content, + ) +} + +/** + * Jetpack text button with text and icon content slots. + * + * @param onClick Will be called when the user clicks the button. + * @param modifier Modifier to be applied to the button. + * @param enabled Controls the enabled state of the button. When `false`, this button will not be + * clickable and will appear disabled to accessibility services. + * @param text The button text label content. + * @param leadingIcon The button leading icon content. Pass `null` here for no leading icon. + */ +@Composable +fun JetpackTextButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: @Composable () -> Unit, + leadingIcon: @Composable (() -> Unit)? = null, +) { + JetpackTextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + ) { + JetpackButtonContent( + text = text, + leadingIcon = leadingIcon, + ) + } +} + +/** + * Internal Jetpack button content layout for arranging the text label and leading icon. + * + * @param text The button text label content. + * @param leadingIcon The button leading icon content. Default is `null` for no leading icon.Ï + */ +@Composable +private fun JetpackButtonContent( + text: @Composable () -> Unit, + leadingIcon: @Composable (() -> Unit)? = null, +) { + if (leadingIcon != null) { + Box(Modifier.sizeIn(maxHeight = ButtonDefaults.IconSize)) { + leadingIcon() + } + } + Box( + Modifier + .padding( + start = if (leadingIcon != null) { + ButtonDefaults.IconSpacing + } else { + 0.dp + }, + ), + ) { + text() + } +} + +/** + * Jetpack button default values. + */ +object JetpackButtonDefaults { + // TODO: File bug + // OutlinedButton border color doesn't respect disabled state by default + const val DisabledOutlinedButtonBorderAlpha = 0.12f + + // TODO: File bug + // OutlinedButton default border width isn't exposed via ButtonDefaults + val OutlinedButtonBorderWidth = 1.dp +} diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/components/Chip.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/components/Chip.kt new file mode 100644 index 000000000..a70ef4263 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/components/Chip.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.components + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Jetpack filter chip with included leading checked icon as well as text content slot. + * + * @param selected Whether the chip is currently checked. + * @param onSelectedChange Called when the user clicks the chip and toggles checked. + * @param modifier Modifier to be applied to the chip. + * @param enabled Controls the enabled state of the chip. When `false`, this chip will not be + * clickable and will appear disabled to accessibility services. + * @param label The text label content. + */ +@Composable +fun JetpackFilterChip( + selected: Boolean, + onSelectedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + label: @Composable () -> Unit, +) { + FilterChip( + selected = selected, + onClick = { onSelectedChange(!selected) }, + label = { + ProvideTextStyle(value = MaterialTheme.typography.labelSmall) { + label() + } + }, + modifier = modifier, + enabled = enabled, + leadingIcon = if (selected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + ) + } + } else { + null + }, + shape = CircleShape, + border = FilterChipDefaults.filterChipBorder( + enabled = enabled, + selected = selected, + borderColor = MaterialTheme.colorScheme.onBackground, + selectedBorderColor = MaterialTheme.colorScheme.onBackground, + disabledBorderColor = MaterialTheme.colorScheme.onBackground.copy( + alpha = JetpackChipDefaults.DisabledChipContentAlpha, + ), + disabledSelectedBorderColor = MaterialTheme.colorScheme.onBackground.copy( + alpha = JetpackChipDefaults.DisabledChipContentAlpha, + ), + selectedBorderWidth = JetpackChipDefaults.ChipBorderWidth, + ), + colors = FilterChipDefaults.filterChipColors( + labelColor = MaterialTheme.colorScheme.onBackground, + iconColor = MaterialTheme.colorScheme.onBackground, + disabledContainerColor = if (selected) { + MaterialTheme.colorScheme.onBackground.copy( + alpha = JetpackChipDefaults.DisabledChipContainerAlpha, + ) + } else { + Color.Transparent + }, + disabledLabelColor = MaterialTheme.colorScheme.onBackground.copy( + alpha = JetpackChipDefaults.DisabledChipContentAlpha, + ), + disabledLeadingIconColor = MaterialTheme.colorScheme.onBackground.copy( + alpha = JetpackChipDefaults.DisabledChipContentAlpha, + ), + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onBackground, + selectedLeadingIconColor = MaterialTheme.colorScheme.onBackground, + ), + ) +} + +/** + * Jetpack chip default values. + */ +object JetpackChipDefaults { + // TODO: File bug + // FilterChip default values aren't exposed via FilterChipDefaults + const val DisabledChipContainerAlpha = 0.12f + const val DisabledChipContentAlpha = 0.38f + val ChipBorderWidth = 1.dp +} diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/components/DynamicAsyncImage.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/components/DynamicAsyncImage.kt new file mode 100644 index 000000000..7da8341ad --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/components/DynamicAsyncImage.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import coil.compose.AsyncImage +import dev.atick.core.ui.theme.LocalTintTheme + +/** + * A wrapper around [AsyncImage] which determines the colorFilter based on the theme + */ +@Composable +fun DynamicAsyncImage( + imageUrl: String, + contentDescription: String?, + modifier: Modifier = Modifier, + placeholder: Painter? = null, +) { + val iconTint = LocalTintTheme.current.iconTint + AsyncImage( + placeholder = placeholder, + model = imageUrl, + contentDescription = contentDescription, + colorFilter = if (iconTint != null) ColorFilter.tint(iconTint) else null, + modifier = modifier, + ) +} diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/components/IconButton.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/components/IconButton.kt new file mode 100644 index 000000000..f022cc6dc --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/components/IconButton.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.components + +import androidx.compose.material3.FilledIconToggleButton +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +/** + * Jetpack toggle button with icon and checked icon content slots. Wraps Material 3 + * [IconButton]. + * + * @param checked Whether the toggle button is currently checked. + * @param onCheckedChange Called when the user clicks the toggle button and toggles checked. + * @param modifier Modifier to be applied to the toggle button. + * @param enabled Controls the enabled state of the toggle button. When `false`, this toggle button + * will not be clickable and will appear disabled to accessibility services. + * @param icon The icon content to show when unchecked. + * @param checkedIcon The icon content to show when checked. + */ +@Composable +fun JetpackIconToggleButton( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + icon: @Composable () -> Unit, + checkedIcon: @Composable () -> Unit = icon, +) { + // TODO: File bug + // Can't use regular IconToggleButton as it doesn't include a shape (appears square) + FilledIconToggleButton( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + enabled = enabled, + colors = IconButtonDefaults.iconToggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.primaryContainer, + checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + disabledContainerColor = if (checked) { + MaterialTheme.colorScheme.onBackground.copy( + alpha = JetpackIconButtonDefaults.DisabledIconButtonContainerAlpha, + ) + } else { + Color.Transparent + }, + ), + ) { + if (checked) checkedIcon() else icon() + } +} + +/** + * Jetpack icon button default values. + */ +object JetpackIconButtonDefaults { + // TODO: File bug + // IconToggleButton disabled container alpha not exposed by IconButtonDefaults + const val DisabledIconButtonContainerAlpha = 0.12f +} diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/components/LoadingWheel.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/components/LoadingWheel.kt new file mode 100644 index 000000000..a2117838f --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/components/LoadingWheel.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.components + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +@Composable +fun JetpackLoadingWheel( + contentDesc: String, + modifier: Modifier = Modifier, +) { + val infiniteTransition = rememberInfiniteTransition(label = "wheel transition") + + // Specifies the float animation for slowly drawing out the lines on entering + val startValue = if (LocalInspectionMode.current) 0F else 1F + val floatAnimValues = (0 until NUM_OF_LINES).map { remember { Animatable(startValue) } } + LaunchedEffect(floatAnimValues) { + (0 until NUM_OF_LINES).map { index -> + launch { + floatAnimValues[index].animateTo( + targetValue = 0F, + animationSpec = tween( + durationMillis = 100, + easing = FastOutSlowInEasing, + delayMillis = 40 * index, + ), + ) + } + } + } + + // Specifies the rotation animation of the entire Canvas composable + val rotationAnim by infiniteTransition.animateFloat( + initialValue = 0F, + targetValue = 360F, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = ROTATION_TIME, easing = LinearEasing), + ), + label = "wheel rotation animation", + ) + + // Specifies the color animation for the base-to-progress line color change + val baseLineColor = MaterialTheme.colorScheme.onBackground + val progressLineColor = MaterialTheme.colorScheme.inversePrimary + val colorAnimValues = (0 until NUM_OF_LINES).map { index -> + infiniteTransition.animateColor( + initialValue = baseLineColor, + targetValue = baseLineColor, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = ROTATION_TIME / 2 + progressLineColor at ROTATION_TIME / NUM_OF_LINES / 2 using LinearEasing + baseLineColor at ROTATION_TIME / NUM_OF_LINES using LinearEasing + }, + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset(ROTATION_TIME / NUM_OF_LINES / 2 * index), + ), + label = "wheel color animation", + ) + } + + // Draws out the LoadingWheel Canvas composable and sets the animations + Canvas( + modifier = modifier + .size(48.dp) + .padding(8.dp) + .graphicsLayer { rotationZ = rotationAnim } + .semantics { contentDescription = contentDesc } + .testTag("loadingWheel"), + ) { + repeat(NUM_OF_LINES) { index -> + rotate(degrees = index * 30f) { + drawLine( + color = colorAnimValues[index].value, + // Animates the initially drawn 1 pixel alpha from 0 to 1 + alpha = if (floatAnimValues[index].value < 1f) 1f else 0f, + strokeWidth = 4F, + cap = StrokeCap.Round, + start = Offset(size.width / 2, size.height / 4), + end = Offset(size.width / 2, floatAnimValues[index].value * size.height / 4), + ) + } + } + } +} + +@Composable +fun JetpackOverlayLoadingWheel( + contentDesc: String, + modifier: Modifier = Modifier, +) { + Surface( + shape = RoundedCornerShape(60.dp), + shadowElevation = 8.dp, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.83f), + modifier = modifier + .size(60.dp), + ) { + JetpackLoadingWheel( + contentDesc = contentDesc, + ) + } +} + +private const val ROTATION_TIME = 12000 +private const val NUM_OF_LINES = 12 diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/components/Navigation.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/components/Navigation.kt new file mode 100644 index 000000000..3391771ad --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/components/Navigation.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.components + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Jetpack navigation bar item with icon and label content slots. Wraps Material 3 + * [NavigationBarItem]. + * + * @param selected Whether this item is selected. + * @param onClick The callback to be invoked when this item is selected. + * @param icon The item icon content. + * @param modifier Modifier to be applied to this item. + * @param selectedIcon The item icon content when selected. + * @param enabled controls the enabled state of this item. When `false`, this item will not be + * clickable and will appear disabled to accessibility services. + * @param label The item text label content. + * @param alwaysShowLabel Whether to always show the label for this item. If false, the label will + * only be shown when this item is selected. + */ +@Composable +fun RowScope.JetpackNavigationBarItem( + selected: Boolean, + onClick: () -> Unit, + icon: @Composable () -> Unit, + modifier: Modifier = Modifier, + selectedIcon: @Composable () -> Unit = icon, + enabled: Boolean = true, + label: @Composable (() -> Unit)? = null, + alwaysShowLabel: Boolean = true, +) { + NavigationBarItem( + selected = selected, + onClick = onClick, + icon = if (selected) selectedIcon else icon, + modifier = modifier, + enabled = enabled, + label = label, + alwaysShowLabel = alwaysShowLabel, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = JetpackNavigationDefaults.navigationSelectedItemColor(), + unselectedIconColor = JetpackNavigationDefaults.navigationContentColor(), + selectedTextColor = JetpackNavigationDefaults.navigationSelectedItemColor(), + unselectedTextColor = JetpackNavigationDefaults.navigationContentColor(), + indicatorColor = JetpackNavigationDefaults.navigationIndicatorColor(), + ), + ) +} + +/** + * Jetpack navigation bar with content slot. Wraps Material 3 [NavigationBar]. + * + * @param modifier Modifier to be applied to the navigation bar. + * @param content Destinations inside the navigation bar. This should contain multiple + * [NavigationBarItem]s. + */ +@Composable +fun JetpackNavigationBar( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + NavigationBar( + modifier = modifier, + contentColor = JetpackNavigationDefaults.navigationContentColor(), + tonalElevation = 0.dp, + content = content, + ) +} + +/** + * Jetpack navigation rail item with icon and label content slots. Wraps Material 3 + * [NavigationRailItem]. + * + * @param selected Whether this item is selected. + * @param onClick The callback to be invoked when this item is selected. + * @param icon The item icon content. + * @param modifier Modifier to be applied to this item. + * @param selectedIcon The item icon content when selected. + * @param enabled controls the enabled state of this item. When `false`, this item will not be + * clickable and will appear disabled to accessibility services. + * @param label The item text label content. + * @param alwaysShowLabel Whether to always show the label for this item. If false, the label will + * only be shown when this item is selected. + */ +@Composable +fun JetpackNavigationRailItem( + selected: Boolean, + onClick: () -> Unit, + icon: @Composable () -> Unit, + modifier: Modifier = Modifier, + selectedIcon: @Composable () -> Unit = icon, + enabled: Boolean = true, + label: @Composable (() -> Unit)? = null, + alwaysShowLabel: Boolean = true, +) { + NavigationRailItem( + selected = selected, + onClick = onClick, + icon = if (selected) selectedIcon else icon, + modifier = modifier, + enabled = enabled, + label = label, + alwaysShowLabel = alwaysShowLabel, + colors = NavigationRailItemDefaults.colors( + selectedIconColor = JetpackNavigationDefaults.navigationSelectedItemColor(), + unselectedIconColor = JetpackNavigationDefaults.navigationContentColor(), + selectedTextColor = JetpackNavigationDefaults.navigationSelectedItemColor(), + unselectedTextColor = JetpackNavigationDefaults.navigationContentColor(), + indicatorColor = JetpackNavigationDefaults.navigationIndicatorColor(), + ), + ) +} + +/** + * Jetpack navigation rail with header and content slots. Wraps Material 3 [NavigationRail]. + * + * @param modifier Modifier to be applied to the navigation rail. + * @param header Optional header that may hold a floating action button or a logo. + * @param content Destinations inside the navigation rail. This should contain multiple + * [NavigationRailItem]s. + */ +@Composable +fun JetpackNavigationRail( + modifier: Modifier = Modifier, + header: @Composable (ColumnScope.() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + NavigationRail( + modifier = modifier, + containerColor = Color.Transparent, + contentColor = JetpackNavigationDefaults.navigationContentColor(), + header = header, + content = content, + ) +} + +/** + * Jetpack navigation default values. + */ +object JetpackNavigationDefaults { + @Composable + fun navigationContentColor() = MaterialTheme.colorScheme.onSurfaceVariant + + @Composable + fun navigationSelectedItemColor() = MaterialTheme.colorScheme.onPrimaryContainer + + @Composable + fun navigationIndicatorColor() = MaterialTheme.colorScheme.primaryContainer +} diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/components/TextField.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/components/TextField.kt new file mode 100644 index 000000000..05a523ef4 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/components/TextField.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import dev.atick.core.ui.R + +/** + * A Jetpack Compose text field with customizable appearance and optional error message display. + * + * @param value The current text value of the text field. + * @param onValueChange The callback invoked when the text value changes. + * @param label A composable function that represents the label of the text field. + * @param leadingIcon A composable function that represents the leading icon of the text field. + * @param modifier The modifier for this text field. + * @param keyboardOptions The keyboard options for the text field. + * @param trailingIcon A composable function that represents the trailing icon of the text field. + * @param errorMessage The error message to display below the text field, if any. + */ +@Composable +fun JetpackTextFiled( + value: String, + onValueChange: (String) -> Unit, + label: @Composable () -> Unit, + leadingIcon: @Composable () -> Unit, + modifier: Modifier = Modifier, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + trailingIcon: @Composable () -> Unit = {}, + errorMessage: String? = null, +) { + JetpackTextFieldWithError( + value = value, + onValueChange = onValueChange, + label = label, + leadingIcon = leadingIcon, + errorMessage = errorMessage, + trailingIcon = trailingIcon, + keyboardOptions = keyboardOptions, + modifier = modifier, + ) +} + +/** + * A Jetpack Compose password field with customizable appearance and optional error message display. + * + * @param value The current text value of the password field. + * @param onValueChange The callback invoked when the text value changes. + * @param label A composable function that represents the label of the password field. + * @param leadingIcon A composable function that represents the leading icon of the password field. + * @param modifier The modifier for this password field. + * @param errorMessage The error message to display below the password field, if any. + */ +@Composable +fun JetpackPasswordFiled( + value: String, + onValueChange: (String) -> Unit, + label: @Composable () -> Unit, + leadingIcon: @Composable () -> Unit, + modifier: Modifier = Modifier, + errorMessage: String? = null, +) { + var passwordVisible by rememberSaveable { mutableStateOf(false) } + + JetpackTextFieldWithError( + value = value, + onValueChange = onValueChange, + label = label, + leadingIcon = leadingIcon, + errorMessage = errorMessage, + modifier = modifier, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Password), + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + val image = if (passwordVisible) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + } + val description = if (passwordVisible) { + stringResource(R.string.hide_password) + } else { + stringResource(R.string.show_password) + } + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon(imageVector = image, description) + } + }, + ) +} + +/** + * A Jetpack Compose internal component for rendering a text field with optional error message display. + * + * @param value The current text value of the text field. + * @param onValueChange The callback invoked when the text value changes. + * @param label A composable function that represents the label of the text field. + * @param leadingIcon A composable function that represents the leading icon of the text field. + * @param modifier The modifier for this text field. + * @param trailingIcon A composable function that represents the trailing icon of the text field. + * @param errorMessage The error message to display below the text field, if any. + * @param keyboardOptions The keyboard options for the text field. + * @param visualTransformation The visual transformation to apply to the text. + * @param shape The shape of the text field. + */ +@Composable +private fun JetpackTextFieldWithError( + value: String, + onValueChange: (String) -> Unit, + label: @Composable () -> Unit, + leadingIcon: @Composable () -> Unit, + modifier: Modifier = Modifier, + trailingIcon: @Composable () -> Unit = {}, + errorMessage: String? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + shape: Shape = RoundedCornerShape(percent = 50), +) { + Column( + modifier = modifier, + ) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = label, + keyboardOptions = keyboardOptions, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + visualTransformation = visualTransformation, + shape = shape, + colors = if (errorMessage == null) { + OutlinedTextFieldDefaults.colors() + } else { + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.error, + unfocusedBorderColor = MaterialTheme.colorScheme.error, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + AnimatedVisibility(visible = errorMessage != null) { + errorMessage?.let { + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/components/TopAppBar.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/components/TopAppBar.kt new file mode 100644 index 000000000..080de06fb --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/components/TopAppBar.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.components + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JetpackTopAppBar( + @StringRes titleRes: Int, + navigationIcon: ImageVector, + navigationIconContentDescription: String?, + actionIcon: ImageVector, + actionIconContentDescription: String?, + modifier: Modifier = Modifier, + colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(), + onNavigationClick: () -> Unit = {}, + onActionClick: () -> Unit = {}, +) { + CenterAlignedTopAppBar( + title = { Text(text = stringResource(id = titleRes)) }, + navigationIcon = { + IconButton(onClick = onNavigationClick) { + Icon( + imageVector = navigationIcon, + contentDescription = navigationIconContentDescription, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + actions = { + IconButton(onClick = onActionClick) { + Icon( + imageVector = actionIcon, + contentDescription = actionIconContentDescription, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + colors = colors, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JetpackTopAppBar( + @StringRes titleRes: Int, + actionIcon: ImageVector, + actionIconContentDescription: String?, + modifier: Modifier = Modifier, + colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(), + onActionClick: () -> Unit = {}, +) { + CenterAlignedTopAppBar( + title = { Text(text = stringResource(id = titleRes)) }, + actions = { + IconButton(onClick = onActionClick) { + Icon( + imageVector = actionIcon, + contentDescription = actionIconContentDescription, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + colors = colors, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun JetpackTopAppBarPreview() { + JetpackTopAppBar( + titleRes = android.R.string.untitled, + navigationIcon = Icons.Default.Search, + navigationIconContentDescription = "Navigation icon", + actionIcon = Icons.Default.MoreVert, + actionIconContentDescription = "Action icon", + ) +} diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/extensions/ActivityExtensions.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/extensions/ActivityExtensions.kt new file mode 100644 index 000000000..6e69bdfb6 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/extensions/ActivityExtensions.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.extensions + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import dev.atick.core.extensions.isAllPermissionsGranted +import dev.atick.core.extensions.showToast + +/** + * Launch an activity for result. + * + * @param onSuccess Callback when the result is successful. + * @param onFailure Callback when the result is failed. + */ +inline fun ComponentActivity.resultLauncher( + crossinline onSuccess: () -> Unit = {}, + crossinline onFailure: () -> Unit = {}, +): ActivityResultLauncher { + val resultCallback = registerForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + val success = (result.resultCode == Activity.RESULT_OK) + if (success) { + onSuccess.invoke() + } else { + onFailure.invoke() + } + } + return resultCallback +} + +/** + * Launch an activity for permission. + * + * @param onSuccess Callback when the result is successful. + * @param onFailure Callback when the result is failed. + */ +inline fun ComponentActivity.permissionLauncher( + crossinline onSuccess: () -> Unit = {}, + crossinline onFailure: () -> Unit = {}, +): ActivityResultLauncher> { + val resultCallback = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { permissions -> + val granted = permissions.entries.all { it.value } + if (granted) { + onSuccess.invoke() + } else { + onFailure.invoke() + } + } + return resultCallback +} + +/** + * Check for permissions. + * + * @param permissions List of permissions to be checked. + * @param onSuccess Callback when the result is successful. + */ +inline fun ComponentActivity.checkForPermissions( + permissions: List, + crossinline onSuccess: () -> Unit, +) { + if (isAllPermissionsGranted(permissions)) return + val launcher = permissionLauncher( + onSuccess = onSuccess, + onFailure = { + showToast("PLEASE ALLOW ALL PERMISSIONS") + openPermissionSettings() + }, + ) + launcher.launch(permissions.toTypedArray()) +} + +// ... Open App Settings +// ... https://stackoverflow.com/a/37093460/12737399 + +/** + * Open app settings. + */ +fun ComponentActivity.openPermissionSettings() { + val intent = Intent( + ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:$packageName"), + ) + intent.addCategory(Intent.CATEGORY_DEFAULT) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) +} diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/extensions/LifecycleExtensions.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/extensions/LifecycleExtensions.kt new file mode 100644 index 000000000..940444f3e --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/extensions/LifecycleExtensions.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.extensions + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dev.atick.core.utils.SingleLiveEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +/** + * Observe a [LiveData] and execute an action when the value is changed. + * + * @param action The action to be executed when the value is changed. + */ +inline fun LifecycleOwner.observe( + liveData: LiveData, + crossinline action: (T) -> Unit, +) { + liveData.observe(this) { value -> + value?.let { action(value) } + } +} + +/** + * Observe a [LiveData] and execute an action when the value is changed. + * + * @param action The action to be executed when the value is changed. + */ +inline fun LifecycleOwner.observeEvent( + liveData: LiveData>, + crossinline action: (T) -> Unit, +) { + liveData.observe(this) { + it?.getContentIfNotHandled()?.let(action) + } +} + +/** + * Observe a [LiveData] and execute an action when the value is changed. + * + * @param action The action to be executed when the value is changed. + */ +inline fun LifecycleOwner.observeEvent( + liveData: MutableLiveData>, + crossinline action: (T) -> Unit, +) { + liveData.observe(this) { + it?.getContentIfNotHandled()?.let(action) + } +} + +/** + * Observe a [Flow] and execute an action when the value is changed. + * + * @param action The action to be executed when the value is changed. + */ +inline fun LifecycleOwner.collectWithLifecycle( + flow: Flow, + crossinline action: (T) -> Unit, +) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + flow.collect { + it?.let { action(it) } + } + } + } +} diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Background.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Background.kt new file mode 100644 index 000000000..c7baca1a2 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Background.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp + +/** + * A class to model background color and tonal elevation values for Now in Android. + */ +@Immutable +data class BackgroundTheme( + val color: Color = Color.Unspecified, + val tonalElevation: Dp = Dp.Unspecified, +) + +/** + * A composition local for [BackgroundTheme]. + */ +val LocalBackgroundTheme = staticCompositionLocalOf { BackgroundTheme() } diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Color.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Color.kt new file mode 100644 index 000000000..8e559de75 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Color.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.theme + +import androidx.compose.ui.graphics.Color + +internal val Blue10 = Color(0xFF001F28) +internal val Blue20 = Color(0xFF003544) +internal val Blue30 = Color(0xFF004D61) +internal val Blue40 = Color(0xFF006780) +internal val Blue80 = Color(0xFF5DD5FC) +internal val Blue90 = Color(0xFFB8EAFF) +internal val DarkGreen10 = Color(0xFF0D1F12) +internal val DarkGreen20 = Color(0xFF223526) +internal val DarkGreen30 = Color(0xFF394B3C) +internal val DarkGreen40 = Color(0xFF4F6352) +internal val DarkGreen80 = Color(0xFFB7CCB8) +internal val DarkGreen90 = Color(0xFFD3E8D3) +internal val DarkGreenGray10 = Color(0xFF1A1C1A) +internal val DarkGreenGray20 = Color(0xFF2F312E) +internal val DarkGreenGray90 = Color(0xFFE2E3DE) +internal val DarkGreenGray95 = Color(0xFFF0F1EC) +internal val DarkGreenGray99 = Color(0xFFFBFDF7) +internal val DarkPurpleGray10 = Color(0xFF201A1B) +internal val DarkPurpleGray20 = Color(0xFF362F30) +internal val DarkPurpleGray90 = Color(0xFFECDFE0) +internal val DarkPurpleGray95 = Color(0xFFFAEEEF) +internal val DarkPurpleGray99 = Color(0xFFFCFCFC) +internal val Green10 = Color(0xFF00210B) +internal val Green20 = Color(0xFF003919) +internal val Green30 = Color(0xFF005227) +internal val Green40 = Color(0xFF006D36) +internal val Green80 = Color(0xFF0EE37C) +internal val Green90 = Color(0xFF5AFF9D) +internal val GreenGray30 = Color(0xFF414941) +internal val GreenGray50 = Color(0xFF727971) +internal val GreenGray60 = Color(0xFF8B938A) +internal val GreenGray80 = Color(0xFFC1C9BF) +internal val GreenGray90 = Color(0xFFDDE5DB) +internal val Orange10 = Color(0xFF380D00) +internal val Orange20 = Color(0xFF5B1A00) +internal val Orange30 = Color(0xFF812800) +internal val Orange40 = Color(0xFFA23F16) +internal val Orange80 = Color(0xFFFFB59B) +internal val Orange90 = Color(0xFFFFDBCF) +internal val Purple10 = Color(0xFF36003C) +internal val Purple20 = Color(0xFF560A5D) +internal val Purple30 = Color(0xFF702776) +internal val Purple40 = Color(0xFF8B418F) +internal val Purple80 = Color(0xFFFFA9FE) +internal val Purple90 = Color(0xFFFFD6FA) +internal val PurpleGray30 = Color(0xFF4D444C) +internal val PurpleGray50 = Color(0xFF7F747C) +internal val PurpleGray60 = Color(0xFF998D96) +internal val PurpleGray80 = Color(0xFFD0C3CC) +internal val PurpleGray90 = Color(0xFFEDDEE8) +internal val Red10 = Color(0xFF410002) +internal val Red20 = Color(0xFF690005) +internal val Red30 = Color(0xFF93000A) +internal val Red40 = Color(0xFFBA1A1A) +internal val Red80 = Color(0xFFFFB4AB) +internal val Red90 = Color(0xFFFFDAD6) +internal val Teal10 = Color(0xFF001F26) +internal val Teal20 = Color(0xFF02363F) +internal val Teal30 = Color(0xFF214D56) +internal val Teal40 = Color(0xFF3A656F) +internal val Teal80 = Color(0xFFA2CED9) +internal val Teal90 = Color(0xFFBEEAF6) diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Gradient.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Gradient.kt new file mode 100644 index 000000000..1167e7953 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Gradient.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * A class to model gradient color values. + * + * @param top The top gradient color to be rendered. + * @param bottom The bottom gradient color to be rendered. + * @param container The container gradient color over which the gradient will be rendered. + */ +@Immutable +data class GradientColors( + val top: Color = Color.Unspecified, + val bottom: Color = Color.Unspecified, + val container: Color = Color.Unspecified, +) + +/** + * A composition local for [GradientColors]. + */ +val LocalGradientColors = staticCompositionLocalOf { GradientColors() } diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Theme.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Theme.kt new file mode 100644 index 000000000..03eecb6e2 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Theme.kt @@ -0,0 +1,250 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.theme + +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp + +/** + * Light default theme color scheme + */ +@VisibleForTesting +val LightDefaultColorScheme = lightColorScheme( + primary = Purple40, + onPrimary = Color.White, + primaryContainer = Purple90, + onPrimaryContainer = Purple10, + secondary = Orange40, + onSecondary = Color.White, + secondaryContainer = Orange90, + onSecondaryContainer = Orange10, + tertiary = Blue40, + onTertiary = Color.White, + tertiaryContainer = Blue90, + onTertiaryContainer = Blue10, + error = Red40, + onError = Color.White, + errorContainer = Red90, + onErrorContainer = Red10, + background = DarkPurpleGray99, + onBackground = DarkPurpleGray10, + surface = DarkPurpleGray99, + onSurface = DarkPurpleGray10, + surfaceVariant = PurpleGray90, + onSurfaceVariant = PurpleGray30, + inverseSurface = DarkPurpleGray20, + inverseOnSurface = DarkPurpleGray95, + outline = PurpleGray50, +) + +/** + * Dark default theme color scheme + */ +@VisibleForTesting +val DarkDefaultColorScheme = darkColorScheme( + primary = Purple80, + onPrimary = Purple20, + primaryContainer = Purple30, + onPrimaryContainer = Purple90, + secondary = Orange80, + onSecondary = Orange20, + secondaryContainer = Orange30, + onSecondaryContainer = Orange90, + tertiary = Blue80, + onTertiary = Blue20, + tertiaryContainer = Blue30, + onTertiaryContainer = Blue90, + error = Red80, + onError = Red20, + errorContainer = Red30, + onErrorContainer = Red90, + background = DarkPurpleGray10, + onBackground = DarkPurpleGray90, + surface = DarkPurpleGray10, + onSurface = DarkPurpleGray90, + surfaceVariant = PurpleGray30, + onSurfaceVariant = PurpleGray80, + inverseSurface = DarkPurpleGray90, + inverseOnSurface = DarkPurpleGray10, + outline = PurpleGray60, +) + +/** + * Light Android theme color scheme + */ +@VisibleForTesting +val LightAndroidColorScheme = lightColorScheme( + primary = Green40, + onPrimary = Color.White, + primaryContainer = Green90, + onPrimaryContainer = Green10, + secondary = DarkGreen40, + onSecondary = Color.White, + secondaryContainer = DarkGreen90, + onSecondaryContainer = DarkGreen10, + tertiary = Teal40, + onTertiary = Color.White, + tertiaryContainer = Teal90, + onTertiaryContainer = Teal10, + error = Red40, + onError = Color.White, + errorContainer = Red90, + onErrorContainer = Red10, + background = DarkGreenGray99, + onBackground = DarkGreenGray10, + surface = DarkGreenGray99, + onSurface = DarkGreenGray10, + surfaceVariant = GreenGray90, + onSurfaceVariant = GreenGray30, + inverseSurface = DarkGreenGray20, + inverseOnSurface = DarkGreenGray95, + outline = GreenGray50, +) + +/** + * Dark Android theme color scheme + */ +@VisibleForTesting +val DarkAndroidColorScheme = darkColorScheme( + primary = Green80, + onPrimary = Green20, + primaryContainer = Green30, + onPrimaryContainer = Green90, + secondary = DarkGreen80, + onSecondary = DarkGreen20, + secondaryContainer = DarkGreen30, + onSecondaryContainer = DarkGreen90, + tertiary = Teal80, + onTertiary = Teal20, + tertiaryContainer = Teal30, + onTertiaryContainer = Teal90, + error = Red80, + onError = Red20, + errorContainer = Red30, + onErrorContainer = Red90, + background = DarkGreenGray10, + onBackground = DarkGreenGray90, + surface = DarkGreenGray10, + onSurface = DarkGreenGray90, + surfaceVariant = GreenGray30, + onSurfaceVariant = GreenGray80, + inverseSurface = DarkGreenGray90, + inverseOnSurface = DarkGreenGray10, + outline = GreenGray60, +) + +/** + * Light Android gradient colors + */ +val LightAndroidGradientColors = GradientColors(container = DarkGreenGray95) + +/** + * Dark Android gradient colors + */ +val DarkAndroidGradientColors = GradientColors(container = Color.Black) + +/** + * Light Android background theme + */ +val LightAndroidBackgroundTheme = BackgroundTheme(color = DarkGreenGray95) + +/** + * Dark Android background theme + */ +val DarkAndroidBackgroundTheme = BackgroundTheme(color = Color.Black) + +/** + * Now in Android theme. + * + * @param darkTheme Whether the theme should use a dark color scheme (follows system by default). + * @param androidTheme Whether the theme should use the Android theme color scheme instead of the + * default theme. + * @param disableDynamicTheming If `true`, disables the use of dynamic theming, even when it is + * supported. This parameter has no effect if [androidTheme] is `true`. + */ +@Composable +fun JetpackTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + androidTheme: Boolean = false, + disableDynamicTheming: Boolean = false, + content: @Composable () -> Unit, +) { + // Color scheme + val colorScheme = when { + androidTheme -> if (darkTheme) DarkAndroidColorScheme else LightAndroidColorScheme + !disableDynamicTheming && supportsDynamicTheming() -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + else -> if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme + } + // Gradient colors + val emptyGradientColors = GradientColors(container = colorScheme.surfaceColorAtElevation(2.dp)) + val defaultGradientColors = GradientColors( + top = colorScheme.inverseOnSurface, + bottom = colorScheme.primaryContainer, + container = colorScheme.surface, + ) + val gradientColors = when { + androidTheme -> if (darkTheme) DarkAndroidGradientColors else LightAndroidGradientColors + !disableDynamicTheming && supportsDynamicTheming() -> emptyGradientColors + else -> defaultGradientColors + } + // Background theme + val defaultBackgroundTheme = BackgroundTheme( + color = colorScheme.surface, + tonalElevation = 2.dp, + ) + val backgroundTheme = when { + androidTheme -> if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme + else -> defaultBackgroundTheme + } + val tintTheme = when { + androidTheme -> TintTheme() + !disableDynamicTheming && supportsDynamicTheming() -> TintTheme(colorScheme.primary) + else -> TintTheme() + } + // Composition locals + CompositionLocalProvider( + LocalGradientColors provides gradientColors, + LocalBackgroundTheme provides backgroundTheme, + LocalTintTheme provides tintTheme, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = JetpackTypography, + content = content, + ) + } +} + +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) +fun supportsDynamicTheming() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Tint.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Tint.kt new file mode 100644 index 000000000..16e165b23 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Tint.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * A class to model background color and tonal elevation values. + */ +@Immutable +data class TintTheme( + val iconTint: Color? = null, +) + +/** + * A composition local for [TintTheme]. + */ +val LocalTintTheme = staticCompositionLocalOf { TintTheme() } diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Type.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Type.kt new file mode 100644 index 000000000..11af412f2 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/theme/Type.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * JetpackTypography. + */ +internal val JetpackTypography = Typography( + displayLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + headlineLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + titleLarge = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + lineHeight = 24.sp, + letterSpacing = 0.1.sp, + ), + titleSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 10.sp, + lineHeight = 16.sp, + letterSpacing = 0.sp, + ), +) diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/utils/DevicePreviews.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/utils/DevicePreviews.kt new file mode 100644 index 000000000..d03595c6e --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/utils/DevicePreviews.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.utils + +import androidx.compose.ui.tooling.preview.Preview + +/** + * Multipreview annotation that represents various device sizes. Add this annotation to a composable + * to render various devices. + */ +@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") +@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480") +@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") +@Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480") +annotation class DevicePreviews diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/utils/StatefulComposable.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/utils/StatefulComposable.kt new file mode 100644 index 000000000..b0a327162 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/utils/StatefulComposable.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.utils + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import dev.atick.core.ui.components.JetpackOverlayLoadingWheel + +@Stable +@Composable +fun StatefulComposable( + state: UiState, + onShowSnackbar: suspend (String, String?) -> Boolean, + content: @Composable (T) -> Unit, +) { + content(state.data) + when (state) { + is UiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + ) { + JetpackOverlayLoadingWheel( + modifier = Modifier + .align(Alignment.Center), + contentDesc = "", + ) + } + } + + is UiState.Error -> { + LaunchedEffect(onShowSnackbar) { + onShowSnackbar(state.t?.message.toString(), null) + } + } + + else -> {} + } +} + +sealed class UiState(val data: T) { + data class Loading(val d: T) : UiState(d) + data class Success(val d: T) : UiState(d) + data class Error(val d: T, val t: Throwable?) : UiState(d) +} diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/utils/TakePictureActivityContract.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/utils/TakePictureActivityContract.kt new file mode 100644 index 000000000..dd84d1afb --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/utils/TakePictureActivityContract.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.utils + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import androidx.activity.result.contract.ActivityResultContract +import androidx.annotation.CallSuper + +/** + * Contract for taking picture. + */ +class TakePictureActivityContract : ActivityResultContract>() { + + private lateinit var imageUri: Uri + + /** + * Create an intent for taking picture. + * + * @param context The context. + * @param input The input. + * @return The intent. + */ + @CallSuper + override fun createIntent(context: Context, input: Uri): Intent { + imageUri = input + return Intent(MediaStore.ACTION_IMAGE_CAPTURE).putExtra(MediaStore.EXTRA_OUTPUT, input) + } + + /** + * Get the synchronous result. + * + * @param context The context. + * @param input The input. + * @return The synchronous result. + */ + override fun getSynchronousResult( + context: Context, + input: Uri, + ): SynchronousResult>? = null + + /** + * Parse the result. + * + * @param resultCode The result code. + * @param intent The intent. + * @return The result. + */ + @Suppress("AutoBoxing") + override fun parseResult(resultCode: Int, intent: Intent?): Pair { + return (resultCode == Activity.RESULT_OK) to imageUri + } +} diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/utils/TextFiledData.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/utils/TextFiledData.kt new file mode 100644 index 000000000..e143726aa --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/utils/TextFiledData.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.utils + +data class TextFiledData( + val value: String, + val errorMessage: String? = null, +) diff --git a/core/ui/src/main/kotlin/dev/atick/core/ui/utils/UiText.kt b/core/ui/src/main/kotlin/dev/atick/core/ui/utils/UiText.kt new file mode 100644 index 000000000..bdf872f08 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/atick/core/ui/utils/UiText.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.core.ui.utils + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +// ... UiText by Phillip Lackner +// ... https://youtu.be/mB1Lej0aDus + +/** + * A sealed class that represents a string that can be either a string resource or a dynamic string. + */ +sealed class UiText { + /** + * A dynamic string that can be used to represent a string that is not known at compile time. + * + * @param value The string value. + */ + data class DynamicString(val value: String) : UiText() + + /** + * A string resource that can be used to represent a string that is known at compile time. + * + * @param resId The string resource id. + * @param args The string resource arguments. + */ + class StringResource( + @StringRes val resId: Int, + vararg val args: Any, + ) : UiText() + + /** + * Returns the string value of this [UiText]. + */ + @Composable + fun asString(): String { + return when (this) { + is DynamicString -> value + is StringResource -> stringResource(resId, *args) + } + } + + /** + * Returns the string value of this [UiText]. + */ + fun asString(context: Context): String { + return when (this) { + is DynamicString -> value + is StringResource -> context.getString(resId, *args) + } + } +} diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml new file mode 100644 index 000000000..355f28720 --- /dev/null +++ b/core/ui/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + + + + Hide Password + Show Password + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..2893265a1 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,43 @@ +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Ensure important default jvmargs aren't overwritten. See https://github.com/gradle/gradle/issues/19750 +org.gradle.jvmargs=-Xmx8g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +org.gradle.parallel=true + +# Not encouraged by Gradle and can produce weird results. Wait for isolated projects instead. +org.gradle.configureondemand=false + +# Enable caching between builds. +org.gradle.caching=true + +# Enable configuration caching between builds. +org.gradle.configuration-cache=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official + +# Non-transitive R classes is recommended and is faster/smaller +android.nonTransitiveRClass=true + +# Disable build features that are enabled by default, +# https://developer.android.com/build/releases/gradle-plugin#default-changes +android.defaults.buildfeatures.resvalues=false +android.defaults.buildfeatures.shaders=false + +# Compile target 34 warning suppression +# TODO: Remove this when support for 34 arrives +android.suppressUnsupportedCompileSdk=34 \ No newline at end of file diff --git a/gradle/init.gradle.kts b/gradle/init.gradle.kts new file mode 100644 index 000000000..dcf71e137 --- /dev/null +++ b/gradle/init.gradle.kts @@ -0,0 +1,64 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +val ktlintVersion = "0.48.1" + +initscript { + val spotlessVersion = "6.25.0" + + repositories { + mavenCentral() + } + + dependencies { + classpath("com.diffplug.spotless:spotless-plugin-gradle:$spotlessVersion") + } +} + +rootProject { + subprojects { + apply() + extensions.configure { + kotlin { + target("**/*.kt") + targetExclude("**/build/**/*.kt") + // FIXME: This no longer working after spotless updata + // ktlint(ktlintVersion).userData(mapOf("android" to "true")) + // Temp Fix + ktlint(ktlintVersion) + licenseHeaderFile(rootProject.file("spotless/copyright.kt")) + } + groovy { + target("**/*.gradle") + targetExclude("**/build/**/*.gradle") + // Look for the first line that doesn't have a block comment (assumed to be the license) + licenseHeaderFile(rootProject.file("spotless/copyright.gradle"), "(^(?![\\/ ]\\*).*$)") + } + format("kts") { + target("**/*.kts") + targetExclude("**/build/**/*.kts") + // Look for the first line that doesn't have a block comment (assumed to be the license) + licenseHeaderFile(rootProject.file("spotless/copyright.kts"), "(^(?![\\/ ]\\*).*$)") + } + format("xml") { + target("**/*.xml") + targetExclude("**/build/**/*.xml") + // Look for the first XML tag that isn't a comment ( + + + + + + + \ No newline at end of file diff --git a/network/src/main/kotlin/dev/atick/network/api/JetpackRestApi.kt b/network/src/main/kotlin/dev/atick/network/api/JetpackRestApi.kt new file mode 100644 index 000000000..afe860649 --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/api/JetpackRestApi.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.api + +import dev.atick.network.models.NetworkPost +import retrofit2.http.GET +import retrofit2.http.Path + +/** + * Retrofit API interface for Jetpack. + */ +interface JetpackRestApi { + + /** + * Retrieves a list of network posts from the specified endpoint. + * + * This function uses the HTTP GET method to retrieve a list of network posts from the "/posts" endpoint. + * + * @return A [List] of [NetworkPost] objects representing the retrieved network posts. + */ + @GET("/photos") + suspend fun getPosts(): List + + /** + * Retrieves a network post with the specified ID from the designated endpoint. + * + * This function uses the HTTP GET method to retrieve a single network post with the given ID from the "/posts/{id}" endpoint. + * + * @param id The ID of the network post to retrieve. + * @return A [NetworkPost] object representing the retrieved network post. + */ + @GET("/photos/{id}") + suspend fun getPost(@Path("id") id: Int): NetworkPost +} diff --git a/network/src/main/kotlin/dev/atick/network/data/NetworkDataSource.kt b/network/src/main/kotlin/dev/atick/network/data/NetworkDataSource.kt new file mode 100644 index 000000000..c1788f161 --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/data/NetworkDataSource.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.data + +import dev.atick.network.models.NetworkPost + +/** + * Data source interface for Jetpack. + */ +interface NetworkDataSource { + + /** + * Retrieves a list of network posts from the specified endpoint. + * + * This function uses the HTTP GET method to retrieve a list of network posts from the "/posts" endpoint. + * + * @return A [List] of [NetworkPost] objects representing the retrieved network posts. + */ + suspend fun getPosts(): List + + /** + * Retrieves a network post with the specified ID from the designated endpoint. + * + * This function uses the HTTP GET method to retrieve a single network post with the given ID from the "/posts/{id}" endpoint. + * + * @param id The ID of the network post to retrieve. + * @return A [NetworkPost] object representing the retrieved network post. + */ + suspend fun getPost(id: Int): NetworkPost +} diff --git a/network/src/main/kotlin/dev/atick/network/data/NetworkDataSourceImpl.kt b/network/src/main/kotlin/dev/atick/network/data/NetworkDataSourceImpl.kt new file mode 100644 index 000000000..98567739a --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/data/NetworkDataSourceImpl.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.data + +import dev.atick.core.di.IoDispatcher +import dev.atick.network.api.JetpackRestApi +import dev.atick.network.models.NetworkPost +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * Data source implementation for Jetpack. + * + * @param jetpackRestApi The [JetpackRestApi] instance. + */ +class NetworkDataSourceImpl @Inject constructor( + private val jetpackRestApi: JetpackRestApi, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : NetworkDataSource { + + /** + * Retrieves a list of network posts from a remote source using the specified IO dispatcher. + * + * This function overrides the suspend function [getPosts] and fetches a list of network posts by invoking [jetpackRestApi.getPosts()] + * within the provided IO dispatcher context. + * + * @return A [List] of [NetworkPost] objects representing the retrieved network posts. + */ + override suspend fun getPosts(): List { + return withContext(ioDispatcher) { + jetpackRestApi.getPosts() + } + } + + /** + * Retrieves a network post with the specified ID from the designated endpoint. + * + * This function uses the HTTP GET method to retrieve a single network post with the given ID from the "/posts/{id}" endpoint. + * + * @param id The ID of the network post to retrieve. + * @return A [NetworkPost] object representing the retrieved network post. + */ + override suspend fun getPost(id: Int): NetworkPost { + return withContext(ioDispatcher) { + jetpackRestApi.getPost(id) + } + } +} diff --git a/network/src/main/kotlin/dev/atick/network/di/ConnectivityManagerModule.kt b/network/src/main/kotlin/dev/atick/network/di/ConnectivityManagerModule.kt new file mode 100644 index 000000000..fe67bf3af --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/di/ConnectivityManagerModule.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.di + +import android.content.Context +import android.net.ConnectivityManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Module for providing [ConnectivityManager]. + */ +@Module +@InstallIn(SingletonComponent::class) +object ConnectivityManagerModule { + + /** + * Provides [ConnectivityManager]. + * + * @param context [Context]. + * @return [ConnectivityManager]. + */ + @Provides + @Singleton + fun provideConnectivityManager( + @ApplicationContext context: Context, + ): ConnectivityManager { + return context.getSystemService( + Context.CONNECTIVITY_SERVICE, + ) as ConnectivityManager + } +} diff --git a/network/src/main/kotlin/dev/atick/network/di/DataSourceModule.kt b/network/src/main/kotlin/dev/atick/network/di/DataSourceModule.kt new file mode 100644 index 000000000..02d4dd884 --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/di/DataSourceModule.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.atick.network.data.NetworkDataSource +import dev.atick.network.data.NetworkDataSourceImpl +import javax.inject.Singleton + +/** + * Module for providing [NetworkDataSource]. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class DataSourceModule { + + /** + * Binds [NetworkDataSourceImpl] to [NetworkDataSource]. + * + * @param jetpackDataSourceImpl [NetworkDataSourceImpl]. + * @return [NetworkDataSource]. + */ + @Binds + @Singleton + abstract fun bindJetpackDataSource( + jetpackDataSourceImpl: NetworkDataSourceImpl, + ): NetworkDataSource +} diff --git a/network/src/main/kotlin/dev/atick/network/di/NetworkUtilsModule.kt b/network/src/main/kotlin/dev/atick/network/di/NetworkUtilsModule.kt new file mode 100644 index 000000000..1bebc0e94 --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/di/NetworkUtilsModule.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.atick.network.utils.NetworkUtils +import dev.atick.network.utils.NetworkUtilsImpl +import javax.inject.Singleton + +/** + * Module for providing [NetworkUtils]. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class NetworkUtilsModule { + + /** + * Binds [NetworkUtilsImpl] to [NetworkUtils]. + * + * @param networkUtilsImpl [NetworkUtilsImpl]. + * @return [NetworkUtils]. + */ + @Binds + @Singleton + abstract fun bindNetworkUtils( + networkUtilsImpl: NetworkUtilsImpl, + ): NetworkUtils +} diff --git a/network/src/main/kotlin/dev/atick/network/di/RestApiModule.kt b/network/src/main/kotlin/dev/atick/network/di/RestApiModule.kt new file mode 100644 index 000000000..86137d7ae --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/di/RestApiModule.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.atick.network.api.JetpackRestApi +import dev.atick.network.di.retrofit.RetrofitModule +import retrofit2.Retrofit +import javax.inject.Singleton + +/** + * Module for providing [JetpackRestApi]. + */ +@Module( + includes = [ + RetrofitModule::class, + ], +) +@InstallIn(SingletonComponent::class) +object RestApiModule { + + /** + * Provides [JetpackRestApi]. + * + * @param retrofit [Retrofit]. + * @return [JetpackRestApi]. + */ + @Singleton + @Provides + fun provideApiService(retrofit: Retrofit): JetpackRestApi { + return retrofit.create(JetpackRestApi::class.java) + } +} diff --git a/network/src/main/kotlin/dev/atick/network/di/coil/CoilModule.kt b/network/src/main/kotlin/dev/atick/network/di/coil/CoilModule.kt new file mode 100644 index 000000000..f24f1189d --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/di/coil/CoilModule.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.di.coil + +import android.content.Context +import coil.ImageLoader +import coil.decode.SvgDecoder +import coil.util.DebugLogger +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dev.atick.network.BuildConfig +import okhttp3.Call +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object CoilModule { + + /** + * Since we're displaying SVGs in the app, Coil needs an ImageLoader which supports this + * format. During Coil's initialization it will call `applicationContext.newImageLoader()` to + * obtain an ImageLoader. + * + * @see Coil + */ + @Provides + @Singleton + fun provideImageLoader( + okHttpCallFactory: Call.Factory, + @ApplicationContext application: Context, + ): ImageLoader = ImageLoader.Builder(application) + .callFactory(okHttpCallFactory) + .components { + add(SvgDecoder.Factory()) + } + // Assume most content images are versioned urls + // but some problematic images are fetching each time + .respectCacheHeaders(false) + .apply { + if (BuildConfig.DEBUG) { + logger(DebugLogger()) + } + } + .build() +} diff --git a/network/src/main/kotlin/dev/atick/network/di/okhttp/InterceptorModule.kt b/network/src/main/kotlin/dev/atick/network/di/okhttp/InterceptorModule.kt new file mode 100644 index 000000000..fa6407a0b --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/di/okhttp/InterceptorModule.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.di.okhttp + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.atick.network.BuildConfig +import okhttp3.logging.HttpLoggingInterceptor +import timber.log.Timber +import javax.inject.Singleton + +/** + * Module for providing interceptors. + */ +@Module +@InstallIn(SingletonComponent::class) +object InterceptorModule { + + /** + * Provides [HttpLoggingInterceptor]. + * + * @return [HttpLoggingInterceptor]. + */ + @Provides + @Singleton + fun provideLoggingInterceptor(): HttpLoggingInterceptor { + return HttpLoggingInterceptor { message -> + Timber.i(message) + }.apply { + if (BuildConfig.DEBUG) { + setLevel(HttpLoggingInterceptor.Level.BODY) + } + } + } +} diff --git a/network/src/main/kotlin/dev/atick/network/di/okhttp/OkHttpClientModule.kt b/network/src/main/kotlin/dev/atick/network/di/okhttp/OkHttpClientModule.kt new file mode 100644 index 000000000..0d4c42cd0 --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/di/okhttp/OkHttpClientModule.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.di.okhttp + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import java.util.concurrent.TimeUnit.SECONDS +import javax.inject.Singleton + +/** + * Module for providing [OkHttpClient]. + */ +@Module( + includes = [ + InterceptorModule::class, + ], +) +@InstallIn(SingletonComponent::class) +object OkHttpClientModule { + + private const val TIME_OUT = 60L + + /** + * Provides [OkHttpClient]. + * + * @param loggingInterceptor [HttpLoggingInterceptor]. + * @return [OkHttpClient]. + */ + @Singleton + @Provides + fun provideOkHttpClient( + loggingInterceptor: HttpLoggingInterceptor, + ): OkHttpClient { + return OkHttpClient.Builder() + .connectTimeout(TIME_OUT, SECONDS) + .readTimeout(TIME_OUT, SECONDS) + .writeTimeout(TIME_OUT, SECONDS) + .addInterceptor(loggingInterceptor) + .build() + } +} diff --git a/network/src/main/kotlin/dev/atick/network/di/retrofit/GsonConverterModule.kt b/network/src/main/kotlin/dev/atick/network/di/retrofit/GsonConverterModule.kt new file mode 100644 index 000000000..13b665ec9 --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/di/retrofit/GsonConverterModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.di.retrofit + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Singleton + +/** + * Module for providing [GsonConverterFactory]. + */ +@Module +@InstallIn(SingletonComponent::class) +object GsonConverterModule { + + /** + * Provides [GsonConverterFactory]. + * + * @return [GsonConverterFactory]. + */ + @Singleton + @Provides + fun provideGsonConverterFactory(): GsonConverterFactory { + return GsonConverterFactory.create() + } +} diff --git a/network/src/main/kotlin/dev/atick/network/di/retrofit/RetrofitModule.kt b/network/src/main/kotlin/dev/atick/network/di/retrofit/RetrofitModule.kt new file mode 100644 index 000000000..e3c6aeed0 --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/di/retrofit/RetrofitModule.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.di.retrofit + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.atick.network.BuildConfig +import dev.atick.network.di.okhttp.OkHttpClientModule +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Singleton + +/** + * Module for providing [Retrofit]. + */ +@Module( + includes = [ + OkHttpClientModule::class, + ], +) +@InstallIn(SingletonComponent::class) +object RetrofitModule { + + /** + * Provides [Retrofit]. + * + * @param converterFactory [GsonConverterFactory]. + * @param okHttpClient [OkHttpClient]. + * @return [Retrofit]. + */ + @Singleton + @Provides + fun provideRetrofitClient( + converterFactory: GsonConverterFactory, + okHttpClient: OkHttpClient, + ): Retrofit { + return Retrofit.Builder() + .baseUrl(BuildConfig.BACKEND_URL) + .addConverterFactory(converterFactory) + .client(okHttpClient) + .build() + } +} diff --git a/network/src/main/kotlin/dev/atick/network/models/NetworkPost.kt b/network/src/main/kotlin/dev/atick/network/models/NetworkPost.kt new file mode 100644 index 000000000..b80514f65 --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/models/NetworkPost.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.models + +/** + * Data class representing a network post retrieved from a remote source. + * + * @property id The unique identifier of the network post. + * @property title The title of the network post. + * @property url The URL associated with the network post. + * @property thumbnailUrl The URL of the thumbnail image associated with the network post. + */ +data class NetworkPost( + val id: Int, + val title: String, + val url: String, + val thumbnailUrl: String, +) diff --git a/network/src/main/kotlin/dev/atick/network/utils/NetworkState.kt b/network/src/main/kotlin/dev/atick/network/utils/NetworkState.kt new file mode 100644 index 000000000..9ff6ae94f --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/utils/NetworkState.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.utils + +import androidx.annotation.StringRes +import dev.atick.network.R + +/** + * Network state. + * + * @param description [StringRes] description. + */ +enum class NetworkState(@StringRes val description: Int) { + /** + * Network is connected. + */ + CONNECTED(R.string.network_connected), + + /** + * Network is connecting. + */ + LOSING(R.string.network_losing), + + /** + * Network is disconnected. + */ + LOST(R.string.network_lost), + + /** + * Network is unavailable. + */ + UNAVAILABLE(R.string.network_not_available), +} diff --git a/network/src/main/kotlin/dev/atick/network/utils/NetworkUtils.kt b/network/src/main/kotlin/dev/atick/network/utils/NetworkUtils.kt new file mode 100644 index 000000000..b6a17845e --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/utils/NetworkUtils.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.utils + +import kotlinx.coroutines.flow.Flow + +/** + * Network utils. + */ +interface NetworkUtils { + /** + * Current network state as [Flow]. + */ + val currentState: Flow +} diff --git a/network/src/main/kotlin/dev/atick/network/utils/NetworkUtilsImpl.kt b/network/src/main/kotlin/dev/atick/network/utils/NetworkUtilsImpl.kt new file mode 100644 index 000000000..37279e61d --- /dev/null +++ b/network/src/main/kotlin/dev/atick/network/utils/NetworkUtilsImpl.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.network.utils + +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import timber.log.Timber +import javax.inject.Inject + +/** + * Implementation of [NetworkUtils]. + * + * @param connectivityManager [ConnectivityManager]. + */ +class NetworkUtilsImpl @Inject constructor( + private val connectivityManager: ConnectivityManager, +) : NetworkUtils { + + /** + * Current network state as [Flow]. + */ + override val currentState: Flow + get() = callbackFlow { + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + trySend(NetworkState.CONNECTED) + Timber.i("NETWORK CONNECTED") + } + + override fun onLosing(network: Network, maxMsToLive: Int) { + super.onLosing(network, maxMsToLive) + Timber.i("LOSING NETWORK CONNECTION ... ") + } + + override fun onLost(network: Network) { + super.onLost(network) + trySend(NetworkState.LOST) + Timber.i("NETWORK CONNECTION LOST") + } + + override fun onUnavailable() { + super.onUnavailable() + trySend(NetworkState.UNAVAILABLE) + Timber.i("NETWORK UNAVAILABLE") + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + super.onCapabilitiesChanged(network, networkCapabilities) + Timber.i("NETWORK TYPE CHANGED") + } + + override fun onLinkPropertiesChanged( + network: Network, + linkProperties: LinkProperties, + ) { + super.onLinkPropertiesChanged(network, linkProperties) + Timber.i("LINK PROPERTIES CHANGED") + } + + override fun onBlockedStatusChanged(network: Network, blocked: Boolean) { + Timber.i("BLOCKED STATUS CHANGED") + } + } + connectivityManager.registerDefaultNetworkCallback(callback) + + awaitClose { + connectivityManager.unregisterNetworkCallback(callback) + } + } +} diff --git a/network/src/main/res/values/strings.xml b/network/src/main/res/values/strings.xml new file mode 100644 index 000000000..61d88351c --- /dev/null +++ b/network/src/main/res/values/strings.xml @@ -0,0 +1,23 @@ + + + + + Network Connected + Losing Network Connection + Network Connection Lost + Network Not Available + \ No newline at end of file diff --git a/secrets.defaults.properties b/secrets.defaults.properties new file mode 100644 index 000000000..0eb9ecec0 --- /dev/null +++ b/secrets.defaults.properties @@ -0,0 +1 @@ +BACKEND_URL="https://jsonplaceholder.typicode.com" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 000000000..1327ce779 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,57 @@ +/* +* Copyright 2023 Atick Faisal +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +pluginManagement { + includeBuild("build-logic") + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.gradle.enterprise") version ("3.17.4") +} + +gradleEnterprise { + if (System.getenv("CI") != null) { + buildScan { + publishAlways() + termsOfServiceUrl = "https://gradle.com/terms-of-service" + termsOfServiceAgree = "yes" + } + } +} + +rootProject.name = "Jetpack" +include(":app") +include(":core:ui") +include(":core:android") +include(":network") +include(":storage:room") +include(":storage:preferences") +include(":bluetooth:common") +include(":bluetooth:classic") +include(":auth") \ No newline at end of file diff --git a/spotless/copyright.gradle b/spotless/copyright.gradle new file mode 100644 index 000000000..69915ad0b --- /dev/null +++ b/spotless/copyright.gradle @@ -0,0 +1,15 @@ +/* + * Copyright $YEAR Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/spotless/copyright.kt b/spotless/copyright.kt new file mode 100644 index 000000000..9b56bcf48 --- /dev/null +++ b/spotless/copyright.kt @@ -0,0 +1,16 @@ +/* + * Copyright $YEAR Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + diff --git a/spotless/copyright.kts b/spotless/copyright.kts new file mode 100644 index 000000000..69915ad0b --- /dev/null +++ b/spotless/copyright.kts @@ -0,0 +1,15 @@ +/* + * Copyright $YEAR Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/spotless/copyright.xml b/spotless/copyright.xml new file mode 100644 index 000000000..7237fbf04 --- /dev/null +++ b/spotless/copyright.xml @@ -0,0 +1,17 @@ + + + diff --git a/storage/preferences/.gitignore b/storage/preferences/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/storage/preferences/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/storage/preferences/build.gradle.kts b/storage/preferences/build.gradle.kts new file mode 100644 index 000000000..489186c0f --- /dev/null +++ b/storage/preferences/build.gradle.kts @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("dev.atick.library") + id("kotlinx-serialization") + id("dev.atick.dagger.hilt") +} + +android { + namespace = "dev.atick.storage.preferences" + defaultConfig { + consumerProguardFiles("consumer-rules.pro") + } +} + +dependencies { + // ... Modules + implementation(project(":core:android")) + + // ... DataStore + implementation(libs.androidx.dataStore.core) + implementation(libs.androidx.dataStore.preferences) +} \ No newline at end of file diff --git a/storage/preferences/consumer-rules.pro b/storage/preferences/consumer-rules.pro new file mode 100644 index 000000000..de8f9d636 --- /dev/null +++ b/storage/preferences/consumer-rules.pro @@ -0,0 +1,2 @@ +# Keep model classes used for deserialization. +-keep class dev.atick.storage.preferences.models.** { *; } \ No newline at end of file diff --git a/storage/preferences/src/main/AndroidManifest.xml b/storage/preferences/src/main/AndroidManifest.xml new file mode 100644 index 000000000..4e5744354 --- /dev/null +++ b/storage/preferences/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/data/UserPreferencesDataSource.kt b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/data/UserPreferencesDataSource.kt new file mode 100644 index 000000000..8dedd5dc2 --- /dev/null +++ b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/data/UserPreferencesDataSource.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.preferences.data + +import dev.atick.storage.preferences.models.DarkThemeConfig +import dev.atick.storage.preferences.models.Profile +import dev.atick.storage.preferences.models.ThemeBrand +import dev.atick.storage.preferences.models.UserData +import kotlinx.coroutines.flow.Flow + +/** + * Interface defining methods to interact with user preferences data source. + */ +interface UserPreferencesDataSource { + + /** + * A [Flow] that emits [UserData] representing user-specific data. + */ + val userData: Flow + + /** + * Sets the user profile in the user preferences. + * + * @param profile The user ID to be set. + */ + suspend fun setProfile(profile: Profile) + + /** + * Sets the theme brand in the user preferences. + * + * @param themeBrand The theme brand to be set. + */ + suspend fun setThemeBrand(themeBrand: ThemeBrand) + + /** + * Sets the dark theme configuration in the user preferences. + * + * @param darkThemeConfig The dark theme configuration to be set. + */ + suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) + + /** + * Sets the dynamic color preferences in the user preferences. + * + * @param useDynamicColor A boolean indicating whether dynamic colors should be used. + */ + suspend fun setDynamicColorPreference(useDynamicColor: Boolean) +} diff --git a/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/data/UserPreferencesDataSourceImpl.kt b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/data/UserPreferencesDataSourceImpl.kt new file mode 100644 index 000000000..7824b3c98 --- /dev/null +++ b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/data/UserPreferencesDataSourceImpl.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.preferences.data + +import androidx.datastore.core.DataStore +import dev.atick.core.di.IoDispatcher +import dev.atick.storage.preferences.models.DarkThemeConfig +import dev.atick.storage.preferences.models.Profile +import dev.atick.storage.preferences.models.ThemeBrand +import dev.atick.storage.preferences.models.UserData +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * Implementation of the [UserPreferencesDataSource] interface using DataStore to manage user preferences. + * + * @property datastore The DataStore instance to manage user preferences data. + * @property ioDispatcher The CoroutineDispatcher for performing I/O operations. + */ +class UserPreferencesDataSourceImpl @Inject constructor( + private val datastore: DataStore, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : UserPreferencesDataSource { + + /** + * A [Flow] that emits [UserData] representing user-specific data. + */ + override val userData: Flow + get() = datastore.data.flowOn(ioDispatcher) + + /** + * Sets the user profile in the user preferences. + * + * @param profile The user [Profile] to be set. + */ + override suspend fun setProfile(profile: Profile) { + withContext(ioDispatcher) { + datastore.updateData { userData -> + userData.copy( + id = profile.id, + name = profile.name, + profilePictureUriString = profile.profilePictureUriString, + ) + } + } + } + + /** + * Sets the theme brand in the user preferences. + * + * @param themeBrand The theme brand to be set. + */ + override suspend fun setThemeBrand(themeBrand: ThemeBrand) { + withContext(ioDispatcher) { + datastore.updateData { userData -> + userData.copy(themeBrand = themeBrand) + } + } + } + + /** + * Sets the dark theme configuration in the user preferences. + * + * @param darkThemeConfig The dark theme configuration to be set. + */ + override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { + withContext(ioDispatcher) { + datastore.updateData { userData -> + userData.copy(darkThemeConfig = darkThemeConfig) + } + } + } + + /** + * Sets the dynamic color preferences in the user preferences. + * + * @param useDynamicColor A boolean indicating whether dynamic colors should be used. + */ + override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) { + withContext(ioDispatcher) { + datastore.updateData { userData -> + userData.copy(useDynamicColor = useDynamicColor) + } + } + } +} diff --git a/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/di/DatastoreModule.kt b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/di/DatastoreModule.kt new file mode 100644 index 000000000..ada02222b --- /dev/null +++ b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/di/DatastoreModule.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.preferences.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dev.atick.core.di.IoDispatcher +import dev.atick.storage.preferences.models.UserData +import dev.atick.storage.preferences.utils.UserDataSerializer +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + +/** + * Datastore module + */ +@Module +@InstallIn(SingletonComponent::class) +object DatastoreModule { + + private const val DATA_STORE_FILE_NAME = "user_preferences.json" + + /** + * Provide preferences datastore + * + * @param appContext application context + * @return DataStore + */ + @Singleton + @Provides + fun providePreferencesDataStore( + @ApplicationContext appContext: Context, + @IoDispatcher ioDispatcher: CoroutineDispatcher, + ): DataStore { + return DataStoreFactory.create( + serializer = UserDataSerializer, + produceFile = { appContext.dataStoreFile(DATA_STORE_FILE_NAME) }, + scope = CoroutineScope(ioDispatcher + SupervisorJob()), + ) + } +} diff --git a/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/di/PreferencesDataSourceModule.kt b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/di/PreferencesDataSourceModule.kt new file mode 100644 index 000000000..7bf8270fd --- /dev/null +++ b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/di/PreferencesDataSourceModule.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.preferences.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.atick.storage.preferences.data.UserPreferencesDataSource +import dev.atick.storage.preferences.data.UserPreferencesDataSourceImpl +import javax.inject.Singleton + +/** + * Preferences DataSource module + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class PreferencesDataSourceModule { + + /** + * Bind preferences datasource + * + * @param userPreferencesDataSourceImpl PreferencesDatastoreImpl + * @return [UserPreferencesDataSource] + */ + @Binds + @Singleton + abstract fun bindUserPreferencesDataSource( + userPreferencesDataSourceImpl: UserPreferencesDataSourceImpl, + ): UserPreferencesDataSource +} diff --git a/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/DarkThemeConfig.kt b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/DarkThemeConfig.kt new file mode 100644 index 000000000..ca10ec79e --- /dev/null +++ b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/DarkThemeConfig.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.preferences.models + +import dev.atick.storage.preferences.models.DarkThemeConfig.DARK +import dev.atick.storage.preferences.models.DarkThemeConfig.FOLLOW_SYSTEM +import dev.atick.storage.preferences.models.DarkThemeConfig.LIGHT +import kotlinx.serialization.Serializable + +/** + * Enum class representing configuration options for the dark theme. + * + * @property FOLLOW_SYSTEM The dark theme configuration follows the system-wide setting. + * @property LIGHT The app's dark theme is disabled, using the light theme. + * @property DARK The app's dark theme is enabled, using the dark theme. + */ +@Serializable +enum class DarkThemeConfig { + FOLLOW_SYSTEM, + LIGHT, + DARK, +} diff --git a/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/Profile.kt b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/Profile.kt new file mode 100644 index 000000000..c29d27889 --- /dev/null +++ b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/Profile.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.preferences.models + +import kotlinx.serialization.Serializable + +/** + * Represents a user profile. + * + * This data class is used for storing information about a user's profile. + * + * @property id The unique identifier for the profile. Defaults to empty if not provided. + * @property name The name of the user. Defaults to empty if not provided. + * @property profilePictureUriString The URI string for the user's profile picture, if available. Defaults to `null` if not provided. + */ +@Serializable +data class Profile( + val id: String = String(), + val name: String = String(), + val profilePictureUriString: String? = null, +) diff --git a/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/ThemeBrand.kt b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/ThemeBrand.kt new file mode 100644 index 000000000..7b72ea422 --- /dev/null +++ b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/ThemeBrand.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.preferences.models + +import dev.atick.storage.preferences.models.ThemeBrand.ANDROID +import dev.atick.storage.preferences.models.ThemeBrand.DEFAULT +import kotlinx.serialization.Serializable + +/** + * Enum class representing different brand options for the app's theme. + * + * @property DEFAULT The default brand option for the app's theme. + * @property ANDROID The brand option representing the Android platform theme. + */ +@Serializable +enum class ThemeBrand { + DEFAULT, + ANDROID, +} diff --git a/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/UserData.kt b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/UserData.kt new file mode 100644 index 000000000..76b7a46c6 --- /dev/null +++ b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/models/UserData.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.preferences.models + +import kotlinx.serialization.Serializable + +/** + * Represents user data. + * + * This data class is used to store information about a user, including their ID, name, profile picture URI string, + * preferred theme brand, dark theme configuration, and dynamic color preference. + * + * @property id The unique identifier for the user. Defaults to empty if not provided. + * @property name The name of the user. Defaults to "No Name" if not provided. + * @property profilePictureUriString The URI string for the user's profile picture, if available. Defaults to `null` if not provided. + * @property themeBrand The preferred theme brand for the user. Defaults to [ThemeBrand.DEFAULT]. + * @property darkThemeConfig The user's preferred dark theme configuration. Defaults to [DarkThemeConfig.FOLLOW_SYSTEM]. + * @property useDynamicColor A boolean indicating whether the user prefers dynamic colors. Defaults to `true`. + */ +@Serializable +data class UserData( + val id: String = String(), + val name: String = "No Name", + val profilePictureUriString: String? = null, + val themeBrand: ThemeBrand = ThemeBrand.DEFAULT, + val darkThemeConfig: DarkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, + val useDynamicColor: Boolean = true, +) diff --git a/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/utils/UserDataSerializer.kt b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/utils/UserDataSerializer.kt new file mode 100644 index 000000000..1f2da5e40 --- /dev/null +++ b/storage/preferences/src/main/kotlin/dev/atick/storage/preferences/utils/UserDataSerializer.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.preferences.utils + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import dev.atick.storage.preferences.models.DarkThemeConfig +import dev.atick.storage.preferences.models.ThemeBrand +import dev.atick.storage.preferences.models.UserData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import java.io.InputStream +import java.io.OutputStream + +/** + * Serializer implementation for serializing and deserializing [UserData] objects. + */ +object UserDataSerializer : Serializer { + + /** + * The default value of [UserData] to be used when deserialization fails. + */ + override val defaultValue: UserData = UserData() + + /** + * Reads a [UserData] object from the provided [InputStream]. + * + * @param input The input stream to read data from. + * @return The deserialized [UserData] object. + * @throws CorruptionException if there's an issue with deserialization. + */ + override suspend fun readFrom(input: InputStream): UserData { + try { + return Json.decodeFromString( + UserData.serializer(), + input.readBytes().decodeToString(), + ) + } catch (serialization: SerializationException) { + throw CorruptionException("Unable to read UserPrefs", serialization) + } + } + + /** + * Writes a [UserData] object to the provided [OutputStream]. + * + * @param t The [UserData] object to be serialized. + * @param output The output stream to write data to. + */ + override suspend fun writeTo(t: UserData, output: OutputStream) { + withContext(Dispatchers.IO) { + output.write( + Json.encodeToString(UserData.serializer(), t) + .encodeToByteArray(), + ) + } + } +} + +/** + * Custom serializer for serializing and deserializing [DarkThemeConfig] enums. + */ +object DarkThemeConfigSerializer : KSerializer { + /** + * The descriptor for the serialized form of [DarkThemeConfig]. + */ + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("DarkThemeConfig", PrimitiveKind.STRING) + + /** + * Serializes the provided [value] of [DarkThemeConfig] enum to a string representation. + * + * @param encoder The encoder to write the serialized data to. + * @param value The [DarkThemeConfig] value to be serialized. + */ + override fun serialize(encoder: Encoder, value: DarkThemeConfig) { + encoder.encodeString(value.name) + } + + /** + * Deserializes the string representation from the provided [decoder] and converts it to a [DarkThemeConfig] enum. + * + * @param decoder The decoder to read the serialized data from. + * @return The deserialized [DarkThemeConfig] enum value. + */ + override fun deserialize(decoder: Decoder): DarkThemeConfig { + return DarkThemeConfig.valueOf(decoder.decodeString()) + } +} + +/** + * Custom serializer for serializing and deserializing [ThemeBrand] enums. + */ +object ThemeBrandSerializer : KSerializer { + /** + * The descriptor for the serialized form of [ThemeBrand]. + */ + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("ThemeBrand", PrimitiveKind.STRING) + + /** + * Serializes the provided [value] of [ThemeBrand] enum to a string representation. + * + * @param encoder The encoder to write the serialized data to. + * @param value The [ThemeBrand] value to be serialized. + */ + override fun serialize(encoder: Encoder, value: ThemeBrand) { + encoder.encodeString(value.name) + } + + /** + * Deserializes the string representation from the provided [decoder] and converts it to a [ThemeBrand] enum. + * + * @param decoder The decoder to read the serialized data from. + * @return The deserialized [ThemeBrand] enum value. + */ + override fun deserialize(decoder: Decoder): ThemeBrand { + return ThemeBrand.valueOf(decoder.decodeString()) + } +} diff --git a/storage/room/.gitignore b/storage/room/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/storage/room/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/storage/room/build.gradle.kts b/storage/room/build.gradle.kts new file mode 100644 index 000000000..be7bf2ae4 --- /dev/null +++ b/storage/room/build.gradle.kts @@ -0,0 +1,34 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("dev.atick.library") + id("dev.atick.dagger.hilt") +} + +android { + namespace = "dev.atick.storage.room" +} + +dependencies { + // ... Modules + implementation(project(":core:android")) + + // ... Room + implementation(libs.room.ktx) + implementation(libs.room.runtime) + ksp(libs.room.compiler) +} \ No newline at end of file diff --git a/storage/room/src/main/AndroidManifest.xml b/storage/room/src/main/AndroidManifest.xml new file mode 100644 index 000000000..4e5744354 --- /dev/null +++ b/storage/room/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/storage/room/src/main/kotlin/dev/atick/storage/room/data/JetpackDao.kt b/storage/room/src/main/kotlin/dev/atick/storage/room/data/JetpackDao.kt new file mode 100644 index 000000000..e7f83e291 --- /dev/null +++ b/storage/room/src/main/kotlin/dev/atick/storage/room/data/JetpackDao.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.room.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Query +import androidx.room.Upsert +import dev.atick.storage.room.models.PostEntity +import kotlinx.coroutines.flow.Flow + +/** + * DAO for handling [PostEntity] operations. + */ +@Dao +interface JetpackDao { + + /** + * Upsert operation (Insert an entity into the database. If the entity already exists, replace it.) + * @param postEntity The entity to be inserted or updated. + */ + @Upsert + suspend fun insertOrUpdatePostEntity(postEntity: PostEntity) + + /** + * Delete a [PostEntity] from the database. + * @param postEntity The entity to be deleted. + */ + @Delete + suspend fun deletePostEntity(postEntity: PostEntity) + + /** + * Retrieve a [PostEntity] by ID. + * @param id The id of the entity. + * @return The entity with the given id, or null if no such entity exists. + */ + @Query("SELECT * FROM posts WHERE id = :id") + suspend fun getPostEntity(id: Int): PostEntity? + + /** + * Retrieve all [PostEntity] from the database. + * @return A [Flow] that emits the list of entities. + */ + @Query("SELECT * FROM posts") + fun getPostEntities(): Flow> + + /** + * Upsert operation (Insert a list of entities into the database. If an entity already exists, replace it.) + * @param postEntities The list of entities to be inserted or updated. + */ + @Upsert + suspend fun upsertPostEntities(postEntities: List) + + /** + * Delete all [PostEntity] items from the database. + */ + @Query("DELETE FROM posts") + suspend fun deleteAllPostEntities() +} diff --git a/storage/room/src/main/kotlin/dev/atick/storage/room/data/JetpackDatabase.kt b/storage/room/src/main/kotlin/dev/atick/storage/room/data/JetpackDatabase.kt new file mode 100644 index 000000000..995d57c2a --- /dev/null +++ b/storage/room/src/main/kotlin/dev/atick/storage/room/data/JetpackDatabase.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.room.data + +import androidx.room.Database +import androidx.room.RoomDatabase +import dev.atick.storage.room.models.PostEntity + +/** + * Room database for Jetpack. + */ +@Database( + version = 1, + exportSchema = false, + entities = [ + PostEntity::class, + ], +) +abstract class JetpackDatabase : RoomDatabase() { + /** + * Get the data access object for [PostEntity] entity. + * + * @return The data access object for [PostEntity] entity. + */ + abstract fun getJetpackDao(): JetpackDao +} diff --git a/storage/room/src/main/kotlin/dev/atick/storage/room/data/LocalDataSource.kt b/storage/room/src/main/kotlin/dev/atick/storage/room/data/LocalDataSource.kt new file mode 100644 index 000000000..f5730c398 --- /dev/null +++ b/storage/room/src/main/kotlin/dev/atick/storage/room/data/LocalDataSource.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.room.data + +import dev.atick.storage.room.models.PostEntity +import kotlinx.coroutines.flow.Flow + +/** + * Data source interface for managing local storage operations related to [PostEntity] objects. + */ +interface LocalDataSource { + + /** + * Inserts or updates a [PostEntity] object in the local storage. + * + * @param postEntity The [PostEntity] object to be inserted or updated. + */ + suspend fun insertOrUpdatePostEntity(postEntity: PostEntity) + + /** + * Deletes a [PostEntity] object from the local storage. + * + * @param postEntity The [PostEntity] object to be deleted. + */ + suspend fun deletePostEntity(postEntity: PostEntity) + + /** + * Retrieves a [PostEntity] object from the local storage based on its unique identifier. + * + * @param id The unique identifier of the [PostEntity] to retrieve. + * @return The retrieved [PostEntity] object, or null if not found. + */ + suspend fun getPostEntity(id: Int): PostEntity? + + /** + * Retrieves a [Flow] of [List] of [PostEntity] objects from the local storage. + * + * @return A [Flow] emitting a list of [PostEntity] objects. + */ + fun getPostEntities(): Flow> + + /** + * Inserts or updates a list of [PostEntity] objects in the local storage. + * + * @param postEntities The list of [PostEntity] objects to be inserted or updated. + */ + suspend fun upsertPostEntities(postEntities: List) + + /** + * Deletes all [PostEntity] objects from the local storage. + */ + suspend fun deleteAllPostEntities() +} diff --git a/storage/room/src/main/kotlin/dev/atick/storage/room/data/LocalDataSourceImpl.kt b/storage/room/src/main/kotlin/dev/atick/storage/room/data/LocalDataSourceImpl.kt new file mode 100644 index 000000000..09179dba7 --- /dev/null +++ b/storage/room/src/main/kotlin/dev/atick/storage/room/data/LocalDataSourceImpl.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.room.data + +import dev.atick.core.di.IoDispatcher +import dev.atick.storage.room.models.PostEntity +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * Implementation of [LocalDataSource] that interacts with the local storage using [JetpackDao]. + * + * @param jetpackDao The data access object for performing database operations. + * @param ioDispatcher The coroutine dispatcher for performing IO-bound tasks. + */ +class LocalDataSourceImpl @Inject constructor( + private val jetpackDao: JetpackDao, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : LocalDataSource { + + /** + * Inserts or updates a [PostEntity] object in the local storage. + * + * @param postEntity The [PostEntity] object to be inserted or updated. + */ + override suspend fun insertOrUpdatePostEntity(postEntity: PostEntity) { + withContext(ioDispatcher) { + jetpackDao.insertOrUpdatePostEntity(postEntity) + } + } + + /** + * Deletes a [PostEntity] object from the local storage. + * + * @param postEntity The [PostEntity] object to be deleted. + */ + override suspend fun deletePostEntity(postEntity: PostEntity) { + withContext(ioDispatcher) { + jetpackDao.deletePostEntity(postEntity) + } + } + + /** + * Retrieves a [PostEntity] object from the local storage based on its unique identifier. + * + * @param id The unique identifier of the [PostEntity] to retrieve. + * @return The retrieved [PostEntity] object, or null if not found. + */ + override suspend fun getPostEntity(id: Int): PostEntity? { + return withContext(ioDispatcher) { + jetpackDao.getPostEntity(id) + } + } + + /** + * Retrieves a [Flow] of [List] of [PostEntity] objects from the local storage. + * + * @return A [Flow] emitting a list of [PostEntity] objects. + */ + override fun getPostEntities(): Flow> { + return jetpackDao.getPostEntities().flowOn(ioDispatcher) + } + + /** + * Inserts or updates a list of [PostEntity] objects in the local storage. + * + * @param postEntities The list of [PostEntity] objects to be inserted or updated. + */ + override suspend fun upsertPostEntities(postEntities: List) { + withContext(ioDispatcher) { + jetpackDao.upsertPostEntities(postEntities) + } + } + + /** + * Deletes all [PostEntity] objects from the local storage. + */ + override suspend fun deleteAllPostEntities() { + withContext(ioDispatcher) { + jetpackDao.deleteAllPostEntities() + } + } +} diff --git a/storage/room/src/main/kotlin/dev/atick/storage/room/di/DaoModule.kt b/storage/room/src/main/kotlin/dev/atick/storage/room/di/DaoModule.kt new file mode 100644 index 000000000..9d066f876 --- /dev/null +++ b/storage/room/src/main/kotlin/dev/atick/storage/room/di/DaoModule.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.room.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.atick.storage.room.data.JetpackDatabase +import javax.inject.Singleton + +/** + * Dagger module for data access object. + */ +@Module( + includes = [ + DatabaseModule::class, + ], +) +@InstallIn(SingletonComponent::class) +object DaoModule { + + /** + * Get the data access. + * + * @param jetpackDatabase The database for Jetpack. + * @return The data access object. + */ + @Singleton + @Provides + fun provideJetpackDao(jetpackDatabase: JetpackDatabase) = jetpackDatabase.getJetpackDao() +} diff --git a/storage/room/src/main/kotlin/dev/atick/storage/room/di/DataSourceModule.kt b/storage/room/src/main/kotlin/dev/atick/storage/room/di/DataSourceModule.kt new file mode 100644 index 000000000..2f9be06ad --- /dev/null +++ b/storage/room/src/main/kotlin/dev/atick/storage/room/di/DataSourceModule.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.room.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.atick.storage.room.data.LocalDataSource +import dev.atick.storage.room.data.LocalDataSourceImpl +import javax.inject.Singleton + +/** + * Dagger Hilt module responsible for providing implementations of data source interfaces. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class DataSourceModule { + + /** + * Binds the [LocalDataSourceImpl] implementation to the [LocalDataSource] interface. + * + * @param localDataSourceImpl The concrete implementation of [LocalDataSourceImpl]. + * @return An instance of [LocalDataSource] representing the local data source. + */ + @Binds + @Singleton + abstract fun bindLocalDataSource( + localDataSourceImpl: LocalDataSourceImpl, + ): LocalDataSource +} diff --git a/storage/room/src/main/kotlin/dev/atick/storage/room/di/DatabaseModule.kt b/storage/room/src/main/kotlin/dev/atick/storage/room/di/DatabaseModule.kt new file mode 100644 index 000000000..9afc74738 --- /dev/null +++ b/storage/room/src/main/kotlin/dev/atick/storage/room/di/DatabaseModule.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.room.di + +import android.content.Context +import androidx.room.Room +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dev.atick.storage.room.data.JetpackDatabase +import javax.inject.Singleton + +/** + * Dagger module for database. + */ +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + private const val ROOM_DATABASE_NAME = "dev.atick.jetpack.room" + + /** + * Get the database for Jetpack. + * + * @param appContext The application context. + * @return The database for Jetpack. + */ + @Singleton + @Provides + fun provideRoomDatabase( + @ApplicationContext appContext: Context, + ): JetpackDatabase { + return Room.databaseBuilder( + appContext, + JetpackDatabase::class.java, + ROOM_DATABASE_NAME, + ).fallbackToDestructiveMigration().build() + } +} diff --git a/storage/room/src/main/kotlin/dev/atick/storage/room/models/PostEntity.kt b/storage/room/src/main/kotlin/dev/atick/storage/room/models/PostEntity.kt new file mode 100644 index 000000000..118cb0db3 --- /dev/null +++ b/storage/room/src/main/kotlin/dev/atick/storage/room/models/PostEntity.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2023 Atick Faisal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.atick.storage.room.models + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Represents a PostEntity, which is a data structure for storing information about a post. + * + * @property id The unique identifier for the post entity. + * @property title The title of the post. + * @property url The URL associated with the post. + * @property thumbnailUrl The URL of the thumbnail image associated with the post. + */ +@Entity(tableName = "posts") +data class PostEntity( + @PrimaryKey val id: Int, + val title: String, + val url: String, + val thumbnailUrl: String, +)