diff --git a/.gitignore b/.gitignore index e510fa9..9b82eac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.iml .gradle +.kotlin .idea .DS_Store build diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index ed065aa..037b4f6 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.parcelize) } @@ -19,9 +20,6 @@ android { buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() - } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" diff --git a/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/classmates/ClassmatesViewModel.kt b/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/classmates/ClassmatesViewModel.kt index 3911571..81b2099 100644 --- a/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/classmates/ClassmatesViewModel.kt +++ b/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/classmates/ClassmatesViewModel.kt @@ -18,7 +18,7 @@ class ClassmatesViewModel( coursesRepository.getCourseClassmates(savedStateHandle[KEY_COURSE_ID]!!) val state = classmates - .asFlow() + .flow .map { when (it) { is DomainResponse.Loading -> ClassmatesState.Loading diff --git a/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/groups/GroupsViewModel.kt b/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/groups/GroupsViewModel.kt index e674cbc..f1c7d6e 100644 --- a/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/groups/GroupsViewModel.kt +++ b/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/groups/GroupsViewModel.kt @@ -23,7 +23,7 @@ class GroupsViewModel( private val courses = coursesRepository.getCourseGroups(courseId) val state = courses - .asFlow() + .flow .map { when (it) { is DomainResponse.Loading -> GroupsState.Loading diff --git a/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/scores/ScoresViewModel.kt b/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/scores/ScoresViewModel.kt index e7d264f..aed8a2c 100644 --- a/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/scores/ScoresViewModel.kt +++ b/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/scores/ScoresViewModel.kt @@ -17,7 +17,7 @@ class ScoresViewModel( private val course = coursesRepository.getCourseScores(savedStateHandle[KEY_COURSE_ID]!!) val state = course - .asFlow() + .flow .map { when (it) { is DomainResponse.Loading -> ScoresState.Loading diff --git a/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/syllabus/SyllabusViewModel.kt b/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/syllabus/SyllabusViewModel.kt index c66a9c7..6bf888c 100644 --- a/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/syllabus/SyllabusViewModel.kt +++ b/androidApp/src/main/java/dev/xinto/argos/ui/screen/course/page/syllabus/SyllabusViewModel.kt @@ -17,7 +17,7 @@ class SyllabusViewModel( private val syllabus = coursesRepository.getCourseSyllabus(savedStateHandle[KEY_COURSE_ID]!!) val state = syllabus - .asFlow() + .flow .map { when (it) { is DomainResponse.Loading -> SyllabusState.Loading diff --git a/androidApp/src/main/java/dev/xinto/argos/ui/screen/main/MainViewModel.kt b/androidApp/src/main/java/dev/xinto/argos/ui/screen/main/MainViewModel.kt index c89d506..45c87f5 100644 --- a/androidApp/src/main/java/dev/xinto/argos/ui/screen/main/MainViewModel.kt +++ b/androidApp/src/main/java/dev/xinto/argos/ui/screen/main/MainViewModel.kt @@ -14,10 +14,11 @@ class MainViewModel( private val userRepository: UserRepository ) : ViewModel() { - private val meUserInfo = userRepository.getMeUserInfo() - private val meUserState = userRepository.getMeUserState() - val state = combine(meUserInfo.asFlow(), meUserState.asFlow()) { info, state -> + val state = combine( + userRepository.meUserInfo.flow, + userRepository.meUserState.flow + ) { info, state -> info to state }.map { when (it) { diff --git a/androidApp/src/main/java/dev/xinto/argos/ui/screen/main/dialog/user/UserInfoViewModel.kt b/androidApp/src/main/java/dev/xinto/argos/ui/screen/main/dialog/user/UserInfoViewModel.kt index c1876a7..97e9f40 100644 --- a/androidApp/src/main/java/dev/xinto/argos/ui/screen/main/dialog/user/UserInfoViewModel.kt +++ b/androidApp/src/main/java/dev/xinto/argos/ui/screen/main/dialog/user/UserInfoViewModel.kt @@ -14,9 +14,10 @@ class UserInfoViewModel( ) : ViewModel() { val state = combine( - userRepository.getMeUserInfo().asFlow(), - userRepository.getMeUserState().asFlow() - ) { info, state -> info to state }.map { + userRepository.meUserInfo.flow, + userRepository.meUserState.flow, + ::Pair + ).map { when (it) { is DomainResponse.Loading -> UserInfoState.Loading is DomainResponse.Success -> UserInfoState.Success( diff --git a/androidApp/src/main/java/dev/xinto/argos/ui/screen/message/MessageViewModel.kt b/androidApp/src/main/java/dev/xinto/argos/ui/screen/message/MessageViewModel.kt index 9670d2b..f58a61e 100644 --- a/androidApp/src/main/java/dev/xinto/argos/ui/screen/message/MessageViewModel.kt +++ b/androidApp/src/main/java/dev/xinto/argos/ui/screen/message/MessageViewModel.kt @@ -20,7 +20,7 @@ class MessageViewModel( ) val state = message - .asFlow() + .flow .map { when (it) { is DomainResponse.Loading -> MessageState.Loading diff --git a/androidApp/src/main/java/dev/xinto/argos/ui/screen/meuserprofile/MeUserProfileViewModel.kt b/androidApp/src/main/java/dev/xinto/argos/ui/screen/meuserprofile/MeUserProfileViewModel.kt index a2d2c62..db9c392 100644 --- a/androidApp/src/main/java/dev/xinto/argos/ui/screen/meuserprofile/MeUserProfileViewModel.kt +++ b/androidApp/src/main/java/dev/xinto/argos/ui/screen/meuserprofile/MeUserProfileViewModel.kt @@ -19,9 +19,7 @@ class MeUserProfileViewModel( private val tempInfo = MutableStateFlow(null) - private val userInfo = userRepository.getMeUserInfo() - - val state = combine(userInfo.asFlow(), tempInfo) { userInfo, tempUserInfo -> + val state = combine(userRepository.meUserInfo.flow, tempInfo) { userInfo, tempUserInfo -> when (userInfo) { is DomainResponse.Loading -> MeUserProfileState.Loading is DomainResponse.Success -> { @@ -39,7 +37,7 @@ class MeUserProfileViewModel( private val _saving = MutableStateFlow(false) val saving = _saving.asStateFlow() - val canSave = combine(userInfo.asFlow(), tempInfo) { userInfo, tempInfo -> + val canSave = combine(userRepository.meUserInfo.flow, tempInfo) { userInfo, tempInfo -> userInfo is DomainResponse.Success && tempInfo != null && userInfo.value != tempInfo }.stateIn( scope = viewModelScope, @@ -78,7 +76,7 @@ class MeUserProfileViewModel( viewModelScope.launch { _saving.value = true if (userRepository.updateUserInfo(tempInfo.value!!)) { - userInfo.refresh() + userRepository.meUserInfo.refresh() tempInfo.value = null } _saving.value = false diff --git a/androidApp/src/main/java/dev/xinto/argos/ui/screen/notifications/NotificationsScreen.kt b/androidApp/src/main/java/dev/xinto/argos/ui/screen/notifications/NotificationsScreen.kt index 55d427c..74be52c 100644 --- a/androidApp/src/main/java/dev/xinto/argos/ui/screen/notifications/NotificationsScreen.kt +++ b/androidApp/src/main/java/dev/xinto/argos/ui/screen/notifications/NotificationsScreen.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.paging.LoadState -import app.cash.paging.compose.LazyPagingItems -import app.cash.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems import dev.xinto.argos.R import dev.xinto.argos.domain.notifications.DomainNotification import dev.xinto.argos.ui.component.SegmentedListItem diff --git a/androidApp/src/main/java/dev/xinto/argos/ui/screen/userprofile/UserProfileViewModel.kt b/androidApp/src/main/java/dev/xinto/argos/ui/screen/userprofile/UserProfileViewModel.kt index 47ee135..1acd932 100644 --- a/androidApp/src/main/java/dev/xinto/argos/ui/screen/userprofile/UserProfileViewModel.kt +++ b/androidApp/src/main/java/dev/xinto/argos/ui/screen/userprofile/UserProfileViewModel.kt @@ -17,7 +17,7 @@ class UserProfileViewModel( private val profile = userRepository.getUserProfile(savedStateHandle[KEY_USER_ID]!!) val state = profile - .asFlow() + .flow .map { when (it) { is DomainResponse.Loading -> UserProfileState.Loading diff --git a/build.gradle.kts b/build.gradle.kts index 7db6a87..e6afdaa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,13 @@ plugins { //trick: for the same plugin versions in all sub-modules - alias(libs.plugins.kotlin.multiplatform).apply(false) - alias(libs.plugins.kotlin.android).apply(false) - alias(libs.plugins.kotlin.serialization).apply(false) - alias(libs.plugins.kotlin.parcelize).apply(false) - alias(libs.plugins.android.library).apply(false) - alias(libs.plugins.android.application).apply(false) + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.cocoapods) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.skie) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 774c877..29a5acd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,30 +1,29 @@ [versions] -agp = "8.2.0" -kotlin = "1.9.21" -compose = "1.5.4" -compose-compiler = "1.5.7" -ktor = "2.3.6" +agp = "8.2.2" +kotlin = "2.0.10" +compose = "1.6.8" +ktor = "2.3.11" coil = "2.5.0" -paging = "3.3.0-alpha02-0.4.0" +paging = "3.3.2" +skie = "0.8.4" [libraries] -androidx-core = { group = "androidx.core", name = "core-ktx", version = "1.12.0"} -androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.8.2" } -androidx-browser = { module = "androidx.browser:browser", version = "1.7.0" } +androidx-core = { group = "androidx.core", name = "core-ktx", version = "1.13.1"} +androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.9.1" } +androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } androidx-crypto = { group = "androidx.security", name = "security-crypto", version = "1.1.0-alpha06" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version = "2.6.2" } desugaring = { module = "com.android.tools:desugar_jdk_libs", version = "2.0.4" } -paging-common = { group = "app.cash.paging", name = "paging-common", version.ref = "paging" } -paging-compose = { group = "app.cash.paging", name = "paging-compose-common", version.ref = "paging" } -paging-uikit = { group = "app.cash.paging", name = "paging-uikit", version.ref = "paging" } +paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" } +paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } -compose-material3 = { module = "androidx.compose.material3:material3", version = "1.2.0-beta01" } +compose-material3 = { module = "androidx.compose.material3:material3", version = "1.2.1" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version = "0.4.1" } @@ -49,6 +48,8 @@ coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } jsoup = { group = "org.jsoup", name = "jsoup", version = "1.17.1"} +skie-annotations = { group = "co.touchlab.skie", name = "configuration-annotations", version.ref="skie" } + [bundles] ktor = ["ktor-core", "ktor-auth", "ktor-serialization", "ktor-serialization-json"] coil = ["coil-compose", "coil-svg"] @@ -61,3 +62,6 @@ kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref kotlin-cocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +skie = { id = "co.touchlab.skie", version.ref="skie" } +ksp = { id = "com.google.devtools.ksp", version = "2.0.0-1.0.23" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b6ddc97..33ff3dc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Nov 10 13:02:55 GET 2023 +#Thu Jul 25 21:46:33 GET 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 3979e96..d528723 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -10,9 +10,24 @@ 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; }; 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; + 4B0777362C62371300ECEEBC /* MessageScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0777352C62371300ECEEBC /* MessageScreen.swift */; }; + 4B0777382C624BEB00ECEEBC /* HtmlText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0777372C624BEB00ECEEBC /* HtmlText.swift */; }; + 4B07773A2C67504E00ECEEBC /* NewsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0777392C67504E00ECEEBC /* NewsScreen.swift */; }; + 4B07773C2C675BDE00ECEEBC /* NotificationsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B07773B2C675BDE00ECEEBC /* NotificationsScreen.swift */; }; + 4B07773E2C6805C500ECEEBC /* SearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B07773D2C6805C400ECEEBC /* SearchField.swift */; }; + 4B3601FF2C4D2D9400A585DD /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B3601FE2C4D2D9400A585DD /* LoginScreen.swift */; }; + 4B3602012C4D2EEE00A585DD /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B3602002C4D2EEE00A585DD /* HomeScreen.swift */; }; + 4B3602032C4D305500A585DD /* TabPager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B3602022C4D305500A585DD /* TabPager.swift */; }; + 4B3602062C4D59A200A585DD /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 4B3602052C4D59A200A585DD /* OrderedCollections */; }; + 4B3602082C4DA9A700A585DD /* MainScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B3602072C4DA9A700A585DD /* MainScreen.swift */; }; 4B8233492B0007E20012800B /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 4B8233482B0007E20012800B /* GoogleSignIn */; }; - 4B82334B2B0007E20012800B /* GoogleSignInSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 4B82334A2B0007E20012800B /* GoogleSignInSwift */; }; - 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; }; + 4B94D8E22C58E5B3006FB536 /* PagingItemsObservable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B94D8E12C58E5B3006FB536 /* PagingItemsObservable.swift */; }; + 4BCC45E02C4FF2CA006FDB5D /* MessagesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCC45DF2C4FF2CA006FDB5D /* MessagesScreen.swift */; }; + 4BCC45E22C4FF71E006FDB5D /* ArgosCoreBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCC45E12C4FF71E006FDB5D /* ArgosCoreBridge.swift */; }; + 4BCC45E42C5049FE006FDB5D /* TabPager2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCC45E32C5049FE006FDB5D /* TabPager2.swift */; }; + 4BCC45EB2C52BAD6006FDB5D /* RootScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCC45EA2C52BAD6006FDB5D /* RootScreen.swift */; }; + 4BCC45EF2C556780006FDB5D /* UserSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCC45EE2C556780006FDB5D /* UserSheet.swift */; }; + 4BCC45F12C5593FC006FDB5D /* StudentInfoScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCC45F02C5593FC006FDB5D /* StudentInfoScreen.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -32,8 +47,23 @@ 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; + 4B0777352C62371300ECEEBC /* MessageScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageScreen.swift; sourceTree = ""; }; + 4B0777372C624BEB00ECEEBC /* HtmlText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlText.swift; sourceTree = ""; }; + 4B0777392C67504E00ECEEBC /* NewsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsScreen.swift; sourceTree = ""; }; + 4B07773B2C675BDE00ECEEBC /* NotificationsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsScreen.swift; sourceTree = ""; }; + 4B07773D2C6805C400ECEEBC /* SearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchField.swift; sourceTree = ""; }; + 4B3601FE2C4D2D9400A585DD /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = ""; }; + 4B3602002C4D2EEE00A585DD /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; + 4B3602022C4D305500A585DD /* TabPager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPager.swift; sourceTree = ""; }; + 4B3602072C4DA9A700A585DD /* MainScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreen.swift; sourceTree = ""; }; + 4B94D8E12C58E5B3006FB536 /* PagingItemsObservable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingItemsObservable.swift; sourceTree = ""; }; + 4BCC45DF2C4FF2CA006FDB5D /* MessagesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesScreen.swift; sourceTree = ""; }; + 4BCC45E12C4FF71E006FDB5D /* ArgosCoreBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArgosCoreBridge.swift; sourceTree = ""; }; + 4BCC45E32C5049FE006FDB5D /* TabPager2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPager2.swift; sourceTree = ""; }; + 4BCC45EA2C52BAD6006FDB5D /* RootScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootScreen.swift; sourceTree = ""; }; + 4BCC45EE2C556780006FDB5D /* UserSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSheet.swift; sourceTree = ""; }; + 4BCC45F02C5593FC006FDB5D /* StudentInfoScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudentInfoScreen.swift; sourceTree = ""; }; 7555FF7B242A565900829871 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ @@ -43,7 +73,7 @@ buildActionMask = 2147483647; files = ( 4B8233492B0007E20012800B /* GoogleSignIn in Frameworks */, - 4B82334B2B0007E20012800B /* GoogleSignInSwift in Frameworks */, + 4B3602062C4D59A200A585DD /* OrderedCollections in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -79,10 +109,25 @@ isa = PBXGroup; children = ( 058557BA273AAA24004C7B11 /* Assets.xcassets */, - 7555FF82242A565900829871 /* ContentView.swift */, 7555FF8C242A565B00829871 /* Info.plist */, 2152FB032600AC8F00CF470E /* iOSApp.swift */, 058557D7273AAEEB004C7B11 /* Preview Content */, + 4BCC45EA2C52BAD6006FDB5D /* RootScreen.swift */, + 4B3601FE2C4D2D9400A585DD /* LoginScreen.swift */, + 4B3602072C4DA9A700A585DD /* MainScreen.swift */, + 4BCC45EE2C556780006FDB5D /* UserSheet.swift */, + 4B3602002C4D2EEE00A585DD /* HomeScreen.swift */, + 4BCC45DF2C4FF2CA006FDB5D /* MessagesScreen.swift */, + 4B0777352C62371300ECEEBC /* MessageScreen.swift */, + 4B3602022C4D305500A585DD /* TabPager.swift */, + 4BCC45F02C5593FC006FDB5D /* StudentInfoScreen.swift */, + 4BCC45E12C4FF71E006FDB5D /* ArgosCoreBridge.swift */, + 4BCC45E32C5049FE006FDB5D /* TabPager2.swift */, + 4B94D8E12C58E5B3006FB536 /* PagingItemsObservable.swift */, + 4B0777372C624BEB00ECEEBC /* HtmlText.swift */, + 4B0777392C67504E00ECEEBC /* NewsScreen.swift */, + 4B07773B2C675BDE00ECEEBC /* NotificationsScreen.swift */, + 4B07773D2C6805C400ECEEBC /* SearchField.swift */, ); path = iosApp; sourceTree = ""; @@ -114,7 +159,7 @@ name = iosApp; packageProductDependencies = ( 4B8233482B0007E20012800B /* GoogleSignIn */, - 4B82334A2B0007E20012800B /* GoogleSignInSwift */, + 4B3602052C4D59A200A585DD /* OrderedCollections */, ); productName = iosApp; productReference = 7555FF7B242A565900829871 /* iosApp.app */; @@ -126,8 +171,9 @@ 7555FF73242A565900829871 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1130; - LastUpgradeCheck = 1130; + LastUpgradeCheck = 1540; ORGANIZATIONNAME = orgName; TargetAttributes = { 7555FF7A242A565900829871 = { @@ -146,6 +192,7 @@ mainGroup = 7555FF72242A565900829871; packageReferences = ( 4B8233472B0007E20012800B /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, + 4B3602042C4D59A200A585DD /* XCRemoteSwiftPackageReference "swift-collections" */, ); productRefGroup = 7555FF7C242A565900829871 /* Products */; projectDirPath = ""; @@ -194,8 +241,23 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4B3602082C4DA9A700A585DD /* MainScreen.swift in Sources */, + 4B0777362C62371300ECEEBC /* MessageScreen.swift in Sources */, + 4B94D8E22C58E5B3006FB536 /* PagingItemsObservable.swift in Sources */, + 4B07773A2C67504E00ECEEBC /* NewsScreen.swift in Sources */, + 4B0777382C624BEB00ECEEBC /* HtmlText.swift in Sources */, 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, - 7555FF83242A565900829871 /* ContentView.swift in Sources */, + 4B07773C2C675BDE00ECEEBC /* NotificationsScreen.swift in Sources */, + 4B07773E2C6805C500ECEEBC /* SearchField.swift in Sources */, + 4BCC45F12C5593FC006FDB5D /* StudentInfoScreen.swift in Sources */, + 4BCC45E22C4FF71E006FDB5D /* ArgosCoreBridge.swift in Sources */, + 4BCC45EB2C52BAD6006FDB5D /* RootScreen.swift in Sources */, + 4B3602032C4D305500A585DD /* TabPager.swift in Sources */, + 4B3602012C4D2EEE00A585DD /* HomeScreen.swift in Sources */, + 4BCC45E42C5049FE006FDB5D /* TabPager2.swift in Sources */, + 4BCC45E02C4FF2CA006FDB5D /* MessagesScreen.swift in Sources */, + 4B3601FF2C4D2D9400A585DD /* LoginScreen.swift in Sources */, + 4BCC45EF2C556780006FDB5D /* UserSheet.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -206,6 +268,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -239,6 +302,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -253,7 +317,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -267,6 +331,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -300,6 +365,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -308,7 +374,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -324,13 +390,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = 85342F9WTR; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", ); INFOPLIST_FILE = iosApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -338,9 +405,9 @@ OTHER_LDFLAGS = ( "$(inherited)", "-framework", - shared, + ArgosCore, ); - PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosApp; + PRODUCT_BUNDLE_IDENTIFIER = dev.xinto.argos; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -353,13 +420,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = 85342F9WTR; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", ); INFOPLIST_FILE = iosApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -367,9 +435,9 @@ OTHER_LDFLAGS = ( "$(inherited)", "-framework", - shared, + ArgosCore, ); - PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosApp; + PRODUCT_BUNDLE_IDENTIFIER = dev.xinto.argos; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -400,6 +468,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 4B3602042C4D59A200A585DD /* XCRemoteSwiftPackageReference "swift-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-collections.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.2; + }; + }; 4B8233472B0007E20012800B /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/google/GoogleSignIn-iOS"; @@ -411,15 +487,15 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 4B8233482B0007E20012800B /* GoogleSignIn */ = { + 4B3602052C4D59A200A585DD /* OrderedCollections */ = { isa = XCSwiftPackageProductDependency; - package = 4B8233472B0007E20012800B /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; - productName = GoogleSignIn; + package = 4B3602042C4D59A200A585DD /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = OrderedCollections; }; - 4B82334A2B0007E20012800B /* GoogleSignInSwift */ = { + 4B8233482B0007E20012800B /* GoogleSignIn */ = { isa = XCSwiftPackageProductDependency; package = 4B8233472B0007E20012800B /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; - productName = GoogleSignInSwift; + productName = GoogleSignIn; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b5369f7..3eb11ca 100644 --- a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,13 @@ { + "originHash" : "8b99aaeb06327c9a4cb149271b07977e95a43abff22665308bc2402dca15f8b4", "pins" : [ { "identity" : "appauth-ios", "kind" : "remoteSourceControl", "location" : "https://github.com/openid/AppAuth-iOS.git", "state" : { - "revision" : "71cde449f13d453227e687458144bde372d30fc7", - "version" : "1.6.2" + "revision" : "c89ed571ae140f8eb1142735e6e23d7bb8c34cb2", + "version" : "1.7.5" } }, { @@ -14,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleSignIn-iOS", "state" : { - "revision" : "7932d33686c1dc4d7df7a919aae47361d1cdfda4", - "version" : "7.0.0" + "revision" : "a7965d134c5d3567026c523e0a8a583f73b62b0d", + "version" : "7.1.0" } }, { @@ -23,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/gtm-session-fetcher.git", "state" : { - "revision" : "d415594121c9e8a4f9d79cecee0965cf35e74dbd", - "version" : "3.1.1" + "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", + "version" : "3.5.0" } }, { @@ -32,10 +33,19 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GTMAppAuth.git", "state" : { - "revision" : "cee3c709307912d040bd1e06ca919875a92339c6", - "version" : "2.0.0" + "revision" : "5d7d66f647400952b1758b230e019b07c0b4b22a", + "version" : "4.1.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", + "version" : "1.1.2" } } ], - "version" : 2 + "version" : 3 } diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme index e9f28cb..a528ae6 100644 --- a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -1,6 +1,6 @@ - - - - - - = Paging_commonPagingData +let PagingDataCompanion = Paging_commonPagingDataCompanion.shared diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift deleted file mode 100644 index f0b1c3d..0000000 --- a/iosApp/iosApp/ContentView.swift +++ /dev/null @@ -1,79 +0,0 @@ -import SwiftUI -import GoogleSignIn -import GoogleSignInSwift - -struct ContentView: View { - - @State private var user: GIDGoogleUser? = nil - @State private var error: String = "" - - - var body: some View { - VStack { - if user != nil { - Text(user!.profile!.email) - Text(user!.idToken!.tokenString) - - Button(action: { - GIDSignIn.sharedInstance.signOut() - user = nil - }) { - Text("Sign Out") - } - } else { - Text(error) - - Button(action: { - guard let presentingViewController = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first?.rootViewController else {return} - - GIDSignIn - .sharedInstance - .signIn( - withPresenting: presentingViewController - ) { result, error in - if let result = result { - user = result.user - print(user!.idToken!.tokenString) - } else { - self.error = error!.localizedDescription - return - } - } - }) { - Text("Sign in") - } - } - } - .onOpenURL(perform: { url in - GIDSignIn.sharedInstance.handle(url) - }) - .onAppear { - GIDSignIn.sharedInstance.restorePreviousSignIn { user, error in - self.user = user - if user != nil { - print(user!.idToken!.tokenString) - } - } - } - } -} - -struct ContentViewController : UIViewControllerRepresentable { - - func makeUIViewController(context: Context) -> some UIViewController { - let presentingViewController = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first?.rootViewController - return presentingViewController! - } - - func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { - - } - -} - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - ContentViewController() - } -} diff --git a/iosApp/iosApp/HomeScreen.swift b/iosApp/iosApp/HomeScreen.swift new file mode 100644 index 0000000..1c1a339 --- /dev/null +++ b/iosApp/iosApp/HomeScreen.swift @@ -0,0 +1,36 @@ +// +// Home Screen.swift +// iosApp +// +// Created by Tornike Khintibidze on 21.07.24. +// Copyright © 2024 orgName. All rights reserved. +// + +import SwiftUI + +struct HomeScreen: View { + @State var selectedIndex: Int = 0 + + var body: some View { + TabPager( + selectedIndex: $selectedIndex, + items: [ + "page 1": "kaci shedis saxinkleshi", + "page 2": "99 xinkali mindao", + "page 3": "99 ra barem 100 aigeo", + "page 4": "100 ra gori kiar varo", + "page 5": "muteli", + "page 6": "yle", + ], + tabContent: { tab in + Text(tab) + } + ) { page in + Text(page) + } + } +} + +#Preview { + HomeScreen() +} diff --git a/iosApp/iosApp/HtmlText.swift b/iosApp/iosApp/HtmlText.swift new file mode 100644 index 0000000..0cfa2d6 --- /dev/null +++ b/iosApp/iosApp/HtmlText.swift @@ -0,0 +1,39 @@ +// +// HtmlText.swift +// iosApp +// +// Created by Tornike Khintibidze on 06.08.24. +// Copyright © 2024 orgName. All rights reserved. +// + +import SwiftUI + +struct HtmlText: View { + let text: String + + init(_ text: String) { + self.text = text + } + + private var attributedString: AttributedString? { + guard let nsAttributedString = try? NSAttributedString( + data: text.data(using: .utf16)!, + options: [.documentType: NSAttributedString.DocumentType.html], + documentAttributes: nil + ) else { return nil } + + return try? AttributedString(nsAttributedString, including: \.uiKit) + } + + var body: some View { + if let attributedString = attributedString { + Text(attributedString) + } else { + Text(text) + } + } +} + +#Preview { + HtmlText("Test") +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index e8c383c..08ebb76 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -16,8 +16,19 @@ $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString 1.0 + CFBundleURLTypes + + + CFBundleURLSchemes + + com.googleusercontent.apps.590553979193-1jilroobo7m2p55apfk1icuo0pktc9ru + + + CFBundleVersion 1 + GIDClientID + 590553979193-1jilroobo7m2p55apfk1icuo0pktc9ru.apps.googleusercontent.com LSRequiresIPhoneOS UIApplicationSceneManifest @@ -25,35 +36,24 @@ UIApplicationSupportsMultipleScenes + UILaunchScreen + UIRequiredDeviceCapabilities armv7 UISupportedInterfaceOrientations - UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait UISupportedInterfaceOrientations~ipad - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown - UILaunchScreen - - GIDClientID - 590553979193-1jilroobo7m2p55apfk1icuo0pktc9ru.apps.googleusercontent.com - CFBundleURLTypes - - - CFBundleURLSchemes - - com.googleusercontent.apps.590553979193-1jilroobo7m2p55apfk1icuo0pktc9ru - - - diff --git a/iosApp/iosApp/LoginScreen.swift b/iosApp/iosApp/LoginScreen.swift new file mode 100644 index 0000000..a8e93c9 --- /dev/null +++ b/iosApp/iosApp/LoginScreen.swift @@ -0,0 +1,89 @@ +// +// LoginScreen.swift +// iosApp +// +// Created by Tornike Khintibidze on 21.07.24. +// Copyright © 2024 orgName. All rights reserved. +// + +import SwiftUI +import GoogleSignIn +import ArgosCore + +@Observable +class LoginViewModel { + + var state: LoginState = .stale + + private var userRepository: UserRepository { + DiProvider.shared.userRepository + } + + func login() { + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + return + } + + guard let viewController = scene.windows.first?.rootViewController else { + return + } + + state = .working + + GIDSignIn.sharedInstance.signIn(withPresenting: viewController) { result, error in + if error != nil { + self.state = .error(error!.localizedDescription) + return + } + + guard let tokenString = result!.user.idToken?.tokenString else { + return + } + + Task { + let result = try! await self.userRepository.login(googleIdToken: tokenString) + if result.boolValue { + self.state = .stale + } else { + self.state = .error("Could not log in") + } + } + } + } + +} + +enum LoginState { + case stale + case working + case error(String) +} + +struct LoginScreen: View { + + @State private var viewModel = LoginViewModel() + + var body: some View { + ZStack(alignment: .center) { + Button(action: { + viewModel.login() + }) { + switch viewModel.state { + case .stale: + Text("Login") + case .working: + ProgressView() + case .error(let error): + Text(error) + } + } + } + .onOpenURL { url in + GIDSignIn.sharedInstance.handle(url) + } + } +} + +#Preview { + LoginScreen() +} diff --git a/iosApp/iosApp/MainScreen.swift b/iosApp/iosApp/MainScreen.swift new file mode 100644 index 0000000..3eddc2f --- /dev/null +++ b/iosApp/iosApp/MainScreen.swift @@ -0,0 +1,204 @@ +// +// MainScreen.swift +// iosApp +// +// Created by Tornike Khintibidze on 22.07.24. +// Copyright © 2024 orgName. All rights reserved. +// + +import SwiftUI +import ArgosCore + +@Observable +class MainViewModel { + private var userTask: Task? + + private var userRepository: UserRepository { + DiProvider.shared.userRepository + } + + var state: MainState = .loading + + init() { + userTask = Task { + for try await data in userRepository.meUserInfoAndStateFlow { + self.state = switch onEnum(of: data) { + case let .success(userInfoAndState): + .success( + userInfo: userInfoAndState.value!.first!, + userState: userInfoAndState.value!.second! + ) + case .loading: .loading + case .error(_): .error + } + } + } + } + + deinit { + userTask?.cancel() + } +} + +struct MainScreen: View { + + @State private var viewModel = MainViewModel() + + var body: some View { + _MainScreen( + homeScreen: HomeScreen(), + messaagesScreen: MessagesScreen(), + newsScreen: NewsScreen(), + notificationsScreen: NotificationsScreen(), + userSheet: UserSheet(), + state: viewModel.state + ) + } +} + +enum MainState { + case loading + case success(userInfo: DomainMeUserInfo, userState: DomainMeUserState) + case error + + static let mockSuccess: Self = .success( + userInfo: DomainMeUserInfo( + firstName: "Donald", + lastName: "Trump", + fullName: "Donald Trump", + birthDate: "19/09/1999", + idNumber: "01011234567", + email: "donald.trump.1@iliauni.edu.ge", + mobileNumber1: "+995555555555", + mobileNumber2: "", + homeNumber: "", + juridicalAddress: "WA, White House", + currentAddress: "WA, White House", + photoUrl: "https://upload.wikimedia.org/wikipedia/en/c/c5/Donald_Trump_mug_shot.jpg", + degree: 1 + ), + userState: DomainMeUserState( + billingBalance: "0.00", + libraryBalance: "0", + newsUnread: 100, + messagesUnread: 7, + notificationsUnread: 1000 + ) + ) + +} + +struct MainScreenPreview: View { + var body: some View { + _MainScreen( + homeScreen: HomeScreen(), + messaagesScreen: MessagesScreenPreview(), + newsScreen: HomeScreen(), + notificationsScreen: EmptyView(), + userSheet: UserSheetPreview(), + state: .mockSuccess + ) + } +} + +private struct _MainScreen : View { + + let homeScreen: HomeScreen + let messaagesScreen: MessagesScreen + let newsScreen: NewsScreen + let notificationsScreen: NotificationScreen + let userSheet: UserSheet + + let state: MainState + + @State private var userSheetVisible = false + @State private var selectedTab = "Home" + + private var messagesUnreadCount: Int { + if case let .success(_, state) = state { + return Int(state.messagesUnread) + } + return 0 + } + + private var newsUnreadCount: Int { + if case let .success(_, state) = state { + return Int(state.newsUnread) + } + return 0 + } + + private var notificationsUnreadCount: Int { + if case let .success(_, state) = state { + return Int(state.newsUnread) + } + return 0 + } + + var body: some View { + NavigationStack { + TabView(selection: $selectedTab) { + homeScreen + .tabItem { + Label("Home", systemImage: "house") + } + .tag("Home") + + messaagesScreen + .tabItem { + Label("Messages", systemImage: "mail") + } + .tag("Messages") + .badge(messagesUnreadCount) + + newsScreen + .tabItem { + Label("News", systemImage: "newspaper") + } + .tag("News") + .badge(newsUnreadCount) + } + .navigationTitle("Argos") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup { + NavigationLink(destination: notificationsScreen) { + Image(systemName: "bell") + .badge(notificationsUnreadCount) + } + + Button(action: { + userSheetVisible = true + }) { + switch state { + case .loading: + ProgressView() + case let .success(userInfo, _): + if let photoUrl = userInfo.photoUrl { + AsyncImage(url: URL(string: photoUrl)) { image in + image + .resizable() + .scaledToFill() + .clipShape(Circle()) + } placeholder: { + ProgressView() + }.frame(maxWidth: 28, maxHeight: 28) + } else { + Image(systemName: "person.crop.circle") + } + case .error: + Image(systemName: "person.crop.circle.badge.exclamationmark") + } + }.buttonStyle(.borderless) + } + } + } + .sheet(isPresented: $userSheetVisible) { + userSheet + } + } +} + +#Preview { + MainScreenPreview() +} diff --git a/iosApp/iosApp/MessageScreen.swift b/iosApp/iosApp/MessageScreen.swift new file mode 100644 index 0000000..11339f2 --- /dev/null +++ b/iosApp/iosApp/MessageScreen.swift @@ -0,0 +1,125 @@ +// +// MessageScreen.swift +// iosApp +// +// Created by Tornike Khintibidze on 06.08.24. +// Copyright © 2024 orgName. All rights reserved. +// + +import SwiftUI +import ArgosCore +import Observation + +@Observable +class MessageViewModel { + + private var messageTask: Task? + + private var messagesRepository: MessagesRepository { + DiProvider.shared.messagesRepository + } + + var state: MessageState = .loading + + init(messageId: String, semesterId: String) { + messageTask = Task { + for try await result in messagesRepository.getMessage(id: messageId, semId: semesterId).flow { + self.state = switch onEnum(of: result) { + case .loading: .loading + case .success(let success): .success(message: success.value!) + case .error: .error + } + } + } + } + + deinit { + messageTask?.cancel() + } +} + +struct MessageScreen: View { + let messageId: String + let semesterId: String + + @State private var viewModel: MessageViewModel + + init(messageId: String, semesterId: String) { + self.messageId = messageId + self.semesterId = semesterId + self.viewModel = MessageViewModel(messageId: messageId, semesterId: semesterId) + } + + var body: some View { + _MessageScreen(state: viewModel.state) + } +} + +struct MessageScreenPreview: View { + var body: some View { + _MessageScreen( + state: .success( + message: DomainMessage( + id: "", + subject: "Test", + body: "Lorem ipsum", + semId: "", + date: "", + sender: DomainMessageUser( + fullName: "George Bush", + uid: "" + ), + receiver: DomainMessageUser( + fullName: "Donald Trump", + uid: "" + ) + ) + ) + ) + } +} + +enum MessageState { + case loading + case success(message: DomainMessage) + case error +} + +struct _MessageScreen : View { + + let state: MessageState + + var body: some View { + Group { + switch state { + case .loading: + ProgressView() + case .success(let message): + ScrollView { + Text(message.body).padding(16) + } + case .error: + Text("Error") + } + } + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Spacer() + Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/) { + Label("Reply", systemImage: "arrowshape.turn.up.left") + } + Spacer() + Button(role: .destructive, action: {}) { + Label("Delete", systemImage: "trash") + } + .tint(Color.red) + Spacer() + } + } + } + +} + +#Preview { + MessageScreenPreview() +} diff --git a/iosApp/iosApp/MessagesScreen.swift b/iosApp/iosApp/MessagesScreen.swift new file mode 100644 index 0000000..a4d070b --- /dev/null +++ b/iosApp/iosApp/MessagesScreen.swift @@ -0,0 +1,247 @@ +// +// MessagesScreen.swift +// iosApp +// +// Created by Tornike Khintibidze on 23.07.24. +// Copyright © 2024 orgName. All rights reserved. +// + +import SwiftUI +import ArgosCore +import Observation + +@Observable +class MessagesViewModel { + @ObservationIgnored private var semesterTask: Task? + + private var messagesRepository: MessagesRepository { + DiProvider.shared.messagesRepository + } + private var semesterRepository: SemesterRepository { + DiProvider.shared.semesterRepository + } + + private(set) var receivedMessages: PagingItemsObservable? + private(set) var sentMessages: PagingItemsObservable? + private(set) var state: MessagesState = .loading + + var activeSemester: DomainSemester? = nil { + didSet { + guard let semester = activeSemester else { return } + receivedMessages = PagingItemsObservable(messagesRepository.getInboxMessages(semesterId: semester.id)) + sentMessages = PagingItemsObservable(messagesRepository.getOutboxMessages(semesterId: semester.id)) + } + } + + init() { + semesterTask = Task { + for try await semesterResponse in semesterRepository.semesters.flow { + switch onEnum(of: semesterResponse) { + case .loading: + state = .loading + case let .success(data): + let semesters = data.value! as! [DomainSemester] + activeSemester = semesters.first { $0.active } + state = .success(semesters: semesters) + case .error: + state = .error + } + } + } + } + + deinit { + semesterTask?.cancel() + } +} + +struct MessagesScreen: View { + + @State private var viewModel = MessagesViewModel() + + var body: some View { + _MessagesScreen( + activeSemester: $viewModel.activeSemester, + state: viewModel.state, + receivedMessages: viewModel.receivedMessages, + sentMessages: viewModel.sentMessages + ) + } +} + + +enum MessagesState { + case loading + case success(semesters: [DomainSemester]) + case error +} + +struct MessagesScreenPreview: View { + + private let receivedMessages = Paging_commonPagingDataCompanion.shared.from( + data: [ + DomainMessageReceived( + id: "", + subject: "", + semId: "", + date: "", + sender: DomainMessageUser( + fullName: "", + uid: "" + ), + seen: true + ) + ] + ) + private let sentMessages = Paging_commonPagingDataCompanion.shared.from( + data: [ + DomainMessageSent( + id: "", + subject: "", + semId: "", + date: "", + receiver: DomainMessageUser( + fullName: "", + uid: "" + ) + ) + ] + ) + + var body: some View { + _MessagesScreen( + activeSemester: .constant(nil), + state: .success(semesters: []), + receivedMessages: nil, + sentMessages: nil + ) + } +} + +private enum MessagesPage { + case received + case sent +} + +private struct _MessagesScreen: View { + + @Binding var activeSemester: DomainSemester? + @State var selectedPage = MessagesPage.received + let state: MessagesState + let receivedMessages: PagingItemsObservable? + let sentMessages: PagingItemsObservable? + + init( + activeSemester: Binding = .constant(nil), + state: MessagesState, + selectedPage: MessagesPage = MessagesPage.received, + receivedMessages: PagingItemsObservable? = nil, + sentMessages: PagingItemsObservable? = nil + ) { + self._activeSemester = activeSemester + self.state = state + self.selectedPage = selectedPage + self.receivedMessages = receivedMessages + self.sentMessages = sentMessages + } + + var body: some View { + VStack { + VStack { + if case let .success(semesters) = state { + HStack { + SearchField( + text: .constant(""), + placeholder: "Search messages" + ) + .padding(.horizontal, -8) + .padding(.vertical, -10) + + Menu { + Picker(selection: $activeSemester, label: EmptyView()) { + ForEach(semesters, id: \.self) { semester in + Text(semester.name) + .tag(DomainSemester?.some(semester)) + } + } + .labelStyle(.iconOnly) + } label: { + Image(systemName: "calendar") + } + } + } + + HStack { + Picker("Page", selection: $selectedPage) { + Text("Inbox").tag(MessagesPage.received) + Text("Outbox").tag(MessagesPage.sent) + } + .pickerStyle(.segmented) + } + } + .padding(.horizontal, 16) + + Spacer() + + switch selectedPage { + case .received: + if let messages = receivedMessages { + List { + ForEach(0..? + @ObservationIgnored private var newsRepository: NewsRepository { + DiProvider.shared.newsRepository + } + + private(set) var newsItems: PagingItemsObservable? + + init() { + newsTask = Task { + newsItems = PagingItemsObservable(newsRepository.getNews()) + } + } + + deinit { + newsTask?.cancel() + } +} + +struct NewsScreen: View { + @State private var viewModel = NewsViewModel() + + var body: some View { + _NewsScreen( + news: viewModel.newsItems + ) + } +} + +struct NewsScreenPreview: View { + var body: some View { + EmptyView() + } +} + +struct _NewsScreen: View { + + let news: PagingItemsObservable? + + var body: some View { + if let news = news { + switch onEnum(of: news.loadingStates.refresh) { + case .loading: + ProgressView() + case .notLoading: + List { + ForEach(0..? + @ObservationIgnored private var notificationsRepository: NotificationsRepository { + DiProvider.shared.notificationsRepository + } + + private(set) var notifications: PagingItemsObservable? + + init() { + notificationsTask = Task { + notifications = PagingItemsObservable(notificationsRepository.getNotifications()) + } + } + + deinit { + notificationsTask?.cancel() + } +} + +struct NotificationsScreen: View { + + @State private var viewModel = NotificationsViewModel() + + var body: some View { + _NotificationsScreen( + notifications: viewModel.notifications + ) + } +} + +struct _NotificationsScreen: View { + + let notifications: PagingItemsObservable? + + var body: some View { + if let notifications = notifications { + switch onEnum(of: notifications.loadingStates.refresh) { + case .loading: + ProgressView() + case .notLoading: + List { + ForEach(0.. { + + @ObservationIgnored private var pagingTask: Task? + @ObservationIgnored private var statesTask: Task? + + @ObservationIgnored private let pagingDataPresenter: IosPagingItems + + private(set) var items: [T] = [] + private(set) var loadingStates = Paging_commonCombinedLoadStates( + refresh: Paging_commonLoadState.Loading(), + prepend: Paging_commonLoadState.NotLoading(endOfPaginationReached: false), + append: Paging_commonLoadState.NotLoading(endOfPaginationReached: false), + source: Paging_commonLoadStates( + refresh: Paging_commonLoadState.Loading(), + prepend: Paging_commonLoadState.NotLoading(endOfPaginationReached: false), + append: Paging_commonLoadState.NotLoading(endOfPaginationReached: false) + ), + mediator: nil + ) + + var count: Int { + items.count + } + + init(_ pagingData: Kotlinx_coroutines_coreFlow) { + pagingDataPresenter = try! CreateIosPagingItems(flow: pagingData) as! IosPagingItems + pagingTask = Task { + for try await items in pagingDataPresenter.snapshotList { + self.items = items + } + } + statesTask = Task { + for try await state in pagingDataPresenter.loadState { + self.loadingStates = state + } + } + } + + convenience init(_ pagingSource: DomainPagedResponsePager) { + self.init(pagingSource.flow) + } + + subscript(index: Int) -> T { + return pagingDataPresenter.get(index: Int32(index)) + } + + deinit { + pagingTask?.cancel() + statesTask?.cancel() + pagingDataPresenter.dispose() + } + +} diff --git a/iosApp/iosApp/RootScreen.swift b/iosApp/iosApp/RootScreen.swift new file mode 100644 index 0000000..ddfcadd --- /dev/null +++ b/iosApp/iosApp/RootScreen.swift @@ -0,0 +1,46 @@ +// +// RootScreen.swift +// iosApp +// +// Created by Tornike Khintibidze on 25.07.24. +// Copyright © 2024 orgName. All rights reserved. +// + +import SwiftUI +import ArgosCore + +@Observable +class RootViewModel { + var isLoggedIn = false + + private var loginTask: Task? + + private var userRepository: UserRepository { + DiProvider.shared.userRepository + } + + init() { + loginTask = Task { + for try await loggedIn in self.userRepository.observeLoggedIn() { + self.isLoggedIn = loggedIn.boolValue + } + } + } + + deinit { + loginTask?.cancel() + } +} + +struct RootScreen: View { + + @State private var viewModel = RootViewModel() + + var body: some View { + if !viewModel.isLoggedIn { + LoginScreen() + } else { + MainScreen() + } + } +} diff --git a/iosApp/iosApp/SearchField.swift b/iosApp/iosApp/SearchField.swift new file mode 100644 index 0000000..031d2fd --- /dev/null +++ b/iosApp/iosApp/SearchField.swift @@ -0,0 +1,51 @@ +// +// SearchField.swift +// iosApp +// +// Created by Tornike Khintibidze on 11.08.24. +// Copyright © 2024 orgName. All rights reserved. +// + +import SwiftUI +import UIKit + +struct SearchField : UIViewRepresentable { + @Binding var text: String + let placeholder: String? + + func updateUIView(_ uiView: UISearchBar, context: Context) { + uiView.text = text + } + + + func makeUIView(context: Context) -> UISearchBar { + let searchBar = UISearchBar() + searchBar.delegate = context.coordinator + searchBar.placeholder = placeholder + searchBar.backgroundImage = UIImage() + return searchBar + } + + func makeCoordinator() -> Coordinator { + return Coordinator(text: $text) + } + + class Coordinator: NSObject, UISearchBarDelegate { + @Binding var text: String + + init(text: Binding) { + self._text = text + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + text = searchText + } + } +} + +#Preview { + SearchField( + text: .constant("Hello"), + placeholder: "Placeholder" + ) +} diff --git a/iosApp/iosApp/Searchable.swift b/iosApp/iosApp/Searchable.swift new file mode 100644 index 0000000..bb329db --- /dev/null +++ b/iosApp/iosApp/Searchable.swift @@ -0,0 +1,21 @@ +// +// Searchable.swift +// iosApp +// +// Created by Tornike Khintibidze on 27.07.24. +// Copyright © 2024 orgName. All rights reserved. +// + +import SwiftUI + +extension View { + func searchable(text: Binding, isVisible: Bool) -> some View { + Group { + if isVisible { + self.searchable(text: text) + } else { + self + } + } + } +} diff --git a/iosApp/iosApp/StudentInfoScreen.swift b/iosApp/iosApp/StudentInfoScreen.swift new file mode 100644 index 0000000..2deb102 --- /dev/null +++ b/iosApp/iosApp/StudentInfoScreen.swift @@ -0,0 +1,180 @@ +// +// PersonalInfoScreen.swift +// iosApp +// +// Created by Tornike Khintibidze on 28.07.24. +// Copyright © 2024 orgName. All rights reserved. +// + +import SwiftUI +import ArgosCore + +@Observable +class StudentInfoViewModel { + + private var userInfoTask: Task? + private var userRepository: UserRepository { + DiProvider.shared.userRepository + } + + var state: StudentInfoState = .loading + + init() { + userInfoTask = Task { + for try await meUserInfo in userRepository.meUserInfo.flow { + state = switch onEnum(of: meUserInfo) { + case .loading: .loading + case let .success(userInfo): .success(userInfo.value!) + case .error: .error + } + } + } + } + + deinit { + userInfoTask?.cancel() + } + +} + +struct StudentInfoScreen: View { + + @State private var viewModel = StudentInfoViewModel() + + var body: some View { + _PersonalInfoScreen(state: viewModel.state) + } +} + +struct StudentInfoScreenPreview: View { + var body: some View { + _PersonalInfoScreen(state: .success( + DomainMeUserInfo( + firstName: "Donald", + lastName: "Trump", + fullName: "Donald Trump", + birthDate: "19/09/1999", + idNumber: "01011234567", + email: "donald.trump.1@iliauni.edu.ge", + mobileNumber1: "+995555555555", + mobileNumber2: "", + homeNumber: "", + juridicalAddress: "WA, White House", + currentAddress: "WA, White House", + photoUrl: "https://upload.wikimedia.org/wikipedia/en/c/c5/Donald_Trump_mug_shot.jpg", + degree: 1 + ) + )) + } +} + +enum StudentInfoState { + case loading + case success(DomainMeUserInfo) + case error +} + +struct _PersonalInfoScreen: View { + + let state: StudentInfoState + + private let contactInfo: StudentContactInfo + @State private var changedContactInfo: StudentContactInfo + + init(state: StudentInfoState) { + self.state = state + + contactInfo = if case let .success(domainMeUserInfo) = state { + StudentContactInfo( + currentAddress: domainMeUserInfo.currentAddress, + mobile1: domainMeUserInfo.mobileNumber1, + mobile2: domainMeUserInfo.mobileNumber2, + phone1: domainMeUserInfo.homeNumber + ) + } else { + StudentContactInfo( + currentAddress: "", + mobile1: "", + mobile2: "", + phone1: "" + ) + } + changedContactInfo = contactInfo + } + + var body: some View { + Group { + switch state { + case .loading: + ProgressView() + case let .success(userInfo): + Form { + Section("Personal information") { + Group { + TextField(text: .constant(userInfo.firstName)) { + Text("First name") + } + TextField(text: .constant(userInfo.lastName)) { + Text("Last name") + } + TextField(text: .constant(userInfo.birthDate)) { + Text("Birth date") + } + TextField(text: .constant(userInfo.idNumber)) { + Text("Personal ID") + } + TextField(text: .constant(userInfo.juridicalAddress)) { + Text("Juridical address") + } + } + .foregroundStyle(Color.gray) + .fontWeight(.medium) + .disabled(true) + } + + Section("Contact information") { + TextField(text: .constant(userInfo.email)) { + Text("Email") + } + .foregroundStyle(Color.gray) + .fontWeight(.medium) + .disabled(true) + + TextField(text: $changedContactInfo.mobile1) { + Text("Mobile 1") + } + TextField(text: $changedContactInfo.mobile2) { + Text("Mobile 2") + } + TextField(text: $changedContactInfo.phone1) { + Text("Phone 1") + } + TextField(text: $changedContactInfo.currentAddress) { + Text("Current address") + } + } + } + case .error: + Text("Error") + } + } + .navigationTitle("Student Information") + .toolbar { + Button(action: {}) { + Text("Save") + } + .disabled(changedContactInfo == contactInfo) + } + } +} + +struct StudentContactInfo: Equatable { + var currentAddress: String + var mobile1: String + var mobile2: String + var phone1: String +} + +#Preview { + StudentInfoScreenPreview() +} diff --git a/iosApp/iosApp/TabPager.swift b/iosApp/iosApp/TabPager.swift new file mode 100644 index 0000000..64e7569 --- /dev/null +++ b/iosApp/iosApp/TabPager.swift @@ -0,0 +1,87 @@ +// +// TabPager.swift +// iosApp +// +// Created by Tornike Khintibidze on 21.07.24. +// Copyright © 2024 orgName. All rights reserved. +// + +import SwiftUI +import OrderedCollections + +struct TabPager: View { + + @Binding var selected: Int + var items: OrderedDictionary + var tabContent: (Tab) -> TabContent + var pageContent: (Page) -> PageContent + + init( + selectedIndex: Binding, + items: OrderedDictionary, + @ViewBuilder tabContent: @escaping (Tab) -> TabContent, + @ViewBuilder content: @escaping (Page) -> PageContent + ) { + self._selected = selectedIndex + self.items = items + self.tabContent = tabContent + self.pageContent = content + } + + var body: some View { + VStack { + ScrollViewReader { value in + ScrollView(.horizontal) { + HStack(spacing: 20) { + ForEach(Array(items.keys.enumerated()), id: \.offset) { index, tab in + Button(action: { + selected = index + }) { + tabContent(tab) + .foregroundStyle(selected == index ? Color.accentColor : .secondary) + .animation(.easeIn, value: selected) + } + } + .buttonStyle(.plain) + } + .padding(.all, CGFloat(16.0)) + .scrollTargetLayout() + } + .scrollIndicators(.hidden) + .scrollPosition(id: Binding($selected), anchor: .center) + } + GeometryReader { size in + ScrollView(.horizontal) { + LazyHStack(content: { + ForEach(Array(items.values.enumerated()), id: \.offset) { index, page in + pageContent(page) + .frame(width: size.size.width, height: size.size.height) + } + }) + .scrollTargetLayout() + } + .scrollTargetBehavior(.viewAligned) + .scrollPosition(id: Binding($selected)) + .scrollIndicators(.hidden) + } + } + } +} + + +#Preview { + @State var test: Int = 0 + + return TabPager( + selectedIndex: $test, + items: [ + "page 1": "Hello", + "page 2": "World!" + ], + tabContent: { tab in + Text(tab) + } + ) { page in + Text(page) + } +} diff --git a/iosApp/iosApp/TabPager2.swift b/iosApp/iosApp/TabPager2.swift new file mode 100644 index 0000000..24a8f6a --- /dev/null +++ b/iosApp/iosApp/TabPager2.swift @@ -0,0 +1,71 @@ +// +// TabPager2.swift +// iosApp +// +// Created by Tornike Khintibidze on 24.07.24. +// Copyright © 2024 orgName. All rights reserved. +// + +import SwiftUI + +struct TabPagePreferenceKey : PreferenceKey { + + typealias Value = [String] + + static var defaultValue: Value = [] + + static func reduce(value: inout Value, nextValue: () -> Value) { + value.append(contentsOf: nextValue()) + } +} + +extension View { + func tabPage(_ title: String) -> some View { + self.preference(key: TabPagePreferenceKey.self, value: [title]) + } +} + +struct TabPager2 : View { + + var body: some View { + VStack { +// ScrollView(.horizontal) { +// HStack(spacing: 20) { +// ForEach(Array(items.keys.enumerated()), id: \.offset) { index, tab in +// Button(action: { +// selected = index +// }) { +// tabContent(tab) +// .foregroundStyle(selected == index ? Color.accentColor : .secondary) +// .animation(.easeIn, value: selected) +// } +// } +// .buttonStyle(.plain) +// } +// .padding(.all, CGFloat(16.0)) +// } +// .scrollIndicators(.hidden) +// .scrollPosition(id: Binding($selected)) +// +// GeometryReader { size in +// ScrollView(.horizontal) { +// LazyHStack(content: { +// ForEach(Array(items.values.enumerated()), id: \.offset) { index, page in +// pageContent(page) +// .frame(width: size.size.width, height: size.size.height) +// } +// }) +// .scrollTargetLayout() +// } +// .scrollTargetBehavior(.paging) +// .scrollPosition(id: Binding($selected)) +// .scrollIndicators(.hidden) +// } + } + } + +} + +#Preview { + TabPager2() +} diff --git a/iosApp/iosApp/UserSheet.swift b/iosApp/iosApp/UserSheet.swift new file mode 100644 index 0000000..0b6e79a --- /dev/null +++ b/iosApp/iosApp/UserSheet.swift @@ -0,0 +1,251 @@ +// +// UserSheet.swift +// iosApp +// +// Created by Tornike Khintibidze on 27.07.24. +// Copyright © 2024 orgName. All rights reserved. +// + +import SwiftUI +import ArgosCore + +@Observable +class UserViewModel { + + private var userTask: Task? + + private var userRepository: UserRepository { + DiProvider.shared.userRepository + } + + var state: UserState = .loading + + init() { + userTask = Task { + for try await meUserInfoAndState in userRepository.meUserInfoAndStateFlow { + switch onEnum(of: meUserInfoAndState) { + case .loading: + state = .loading + case let .success(userData): + state = .success(info: userData.value!.first!, state: userData.value!.second!) + case let .error(_): + state = .error + } + } + } + } + + func signOut() { + userRepository.logout() + } + + deinit { + userTask?.cancel() + } +} + +struct UserSheet: View { + + @State private var viewModel = UserViewModel() + + var body: some View { + _UserSheet( + settings: HStack {}, + studentInfo: StudentInfoScreen(), + state: viewModel.state, + onSignOut: viewModel.signOut + ) + } +} + +enum UserState { + case loading + case success(info: DomainMeUserInfo, state: DomainMeUserState) + case error +} + +struct UserSheetPreview: View { + var body: some View { + _UserSheet( + settings: HStack {}, + studentInfo: StudentInfoScreenPreview(), + state: UserState.success( + info: DomainMeUserInfo( + firstName: "Donald", + lastName: "Trump", + fullName: "Donald Trump", + birthDate: "19/09/1999", + idNumber: "01011234567", + email: "donald.trump.1@iliauni.edu.ge", + mobileNumber1: "+995555555555", + mobileNumber2: "", + homeNumber: "", + juridicalAddress: "WA, White House", + currentAddress: "WA, White House", + photoUrl: "https://upload.wikimedia.org/wikipedia/en/c/c5/Donald_Trump_mug_shot.jpg", + degree: 1 + ), + state: DomainMeUserState( + billingBalance: "0", + libraryBalance: "0", + newsUnread: 0, + messagesUnread: 0, + notificationsUnread: 0 + ) + ), + onSignOut: {} + ) + } +} + +struct _UserSheet: View { + + let settings: Settings + let studentInfo: StudentInfo + + let state: UserState + let onSignOut: () -> Void + + var body: some View { + NavigationView { + ZStack { + List { + Section { + VStack(alignment: .center) { + switch state { + case .loading: + ProgressView() + case .success(let userInfo, _): + if let photoUrl = userInfo.photoUrl { + AsyncImage(url: URL(string: photoUrl)) { image in + image + .resizable() + .scaledToFill() + .clipShape(Circle()) + .frame(maxWidth: 92, maxHeight: 92) + } placeholder: { + ProgressView() + } + } else { + Image(systemName: "person.crop.circle") + .resizable() + .scaledToFit() + .frame(maxWidth: 60, maxHeight: 60) + } + + + VStack(spacing: 4) { + Text(userInfo.fullName) + .font(.system(size: 24)) + .fontWeight(.bold) + Text(userInfo.email) + .font(.system(size: 14)) + .foregroundStyle(.secondary) + } + case .error: + Image(systemName: "person.crop.circle.badge.exclamationmark") + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .foregroundStyle(Color.red) + } + } + .frame(maxWidth: .infinity) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + Section { + Button(action: {}) { + Label { + HStack { + Text("Balance") + Spacer() + + Group { + switch state { + case .loading: + ProgressView() + case .success(_, let userState): + Text(userState.billingBalance).foregroundStyle(.secondary) + case .error: + Image(systemName: "exclamationmark.circle").foregroundStyle(.red) + } + } + } + } icon: { + Image(systemName: "creditcard") + .foregroundStyle(Color.green) + } + } + Button(action: {}) { + Label { + HStack { + Text("Library") + Spacer() + + switch state { + case .loading: + ProgressView() + case .success(_, let userState): + Text(userState.libraryBalance).foregroundStyle(.secondary) + case .error: + Image(systemName: "exclamationmark.circle").foregroundStyle(.red) + } + + } + } icon: { + Image(systemName: "book.closed") + .foregroundStyle(Color.yellow) + } + } + }.buttonStyle(.plain) + + Section { + NavigationLink(destination: studentInfo) { + Label("Student Information", systemImage: "person.text.rectangle") + } + NavigationLink(destination: EmptyView()) { + Label("Settings", systemImage: "gear") + } + } + + Section { + Button(action: onSignOut) { + Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") + } + .foregroundStyle(Color.red) + } + } + } + } + } +} + +extension NavigationLink { + init(destinaton: Destination, @ViewBuilder label: () -> Label) { + self.init(destination: { destinaton }, label: label) + } +} + +#Preview { + UserSheetPreview() +} + +#Preview { + _UserSheet( + settings: EmptyView(), + studentInfo: StudentInfoScreenPreview(), + state: .loading, + onSignOut: {} + ) +} + +#Preview { + _UserSheet( + settings: EmptyView(), + studentInfo: StudentInfoScreenPreview(), + state: .error, + onSignOut: {} + ) +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 0648e86..87b635a 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -1,10 +1,46 @@ import SwiftUI +import ArgosCore + +@Observable +class AppViewModel { + var isLoggedIn = false + + private var loginTask: Task? + + private var userRepository: UserRepository { + DiProvider.shared.userRepository + } + + init() { + loginTask = Task { + for try await loggedIn in DiProvider.shared.userRepository.observeLoggedIn() { + isLoggedIn = loggedIn.boolValue + } + } + } + + deinit { + loginTask?.cancel() + } +} @main struct iOSApp: App { + + init() { + DiProvider.shared.doInitKoin() + } + var body: some Scene { WindowGroup { - ContentView() + RootScreen() + .onAppear { + if #available(iOS 15.0, *) { + let appearance = UITabBarAppearance() + appearance.configureWithDefaultBackground() + UITabBar.appearance().scrollEdgeAppearance = appearance + } + } } } -} \ No newline at end of file +} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 2cdf0e7..864cfcd 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,15 +1,17 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.skie) } kotlin { androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = "1.8" - } + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) } } @@ -21,23 +23,27 @@ kotlin { iosSimulatorArm64() ).forEach { it.binaries.framework { - baseName = "shared" + baseName = "ArgosCore" isStatic = true } } + sourceSets.all { + languageSettings.optIn("kotlin.experimental.ExperimentalObjCName") + } + sourceSets { commonMain.dependencies { implementation(libs.bundles.ktor) implementation(libs.koin.core) implementation(libs.paging.common) + implementation(libs.skie.annotations) api(libs.kotlinx.datetime) } androidMain.dependencies { implementation(libs.androidx.crypto) implementation(libs.androidx.core) implementation(libs.ktor.android) - implementation(libs.koin.android) } iosMain.dependencies { implementation(libs.ktor.darwin) diff --git a/shared/src/androidMain/kotlin/dev/xinto/argos/local/account/ArgosAccountManager.kt b/shared/src/androidMain/kotlin/dev/xinto/argos/local/account/ArgosAccountManager.kt index bfd5d21..b2d437c 100644 --- a/shared/src/androidMain/kotlin/dev/xinto/argos/local/account/ArgosAccountManager.kt +++ b/shared/src/androidMain/kotlin/dev/xinto/argos/local/account/ArgosAccountManager.kt @@ -1,12 +1,15 @@ package dev.xinto.argos.local.account import android.content.Context +import android.content.SharedPreferences import android.os.Build +import androidx.core.content.edit import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKeys -import dev.xinto.argos.local.CoroutineSharedPreferences +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow actual class ArgosAccountManager(context: Context) { @@ -27,42 +30,56 @@ actual class ArgosAccountManager(context: Context) { EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) - private val coroutineSecurePrefs = CoroutineSharedPreferences(securePrefs) actual fun isLoggedIn(): Flow { - return coroutineSecurePrefs.observeValue { - it.getString(KEY_TOKEN, null) != null && - it.getString(KEY_REFRESH_TOKEN, null) != null && - it.getString(KEY_ID, null) != null + return callbackFlow { + val isLoggedIn = { prefs: SharedPreferences -> + val token = prefs.getString(KEY_TOKEN, null) + val refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null) + val id = prefs.getString(KEY_ID, null) + + token != null && refreshToken != null && id != null + } + + trySend(isLoggedIn(securePrefs)) + + val callback = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> + trySend(isLoggedIn(sharedPreferences)) + } + + securePrefs.registerOnSharedPreferenceChangeListener(callback) + awaitClose { + securePrefs.unregisterOnSharedPreferenceChangeListener(callback) + } } } - actual suspend fun logout() { - coroutineSecurePrefs.edit { + actual fun logout() { + securePrefs.edit { putString(KEY_TOKEN, null) putString(KEY_REFRESH_TOKEN, null) putString(KEY_ID, null) } } - actual suspend fun getProfileId(): String? = coroutineSecurePrefs.getString(KEY_ID, null) - actual suspend fun getToken(): String? = coroutineSecurePrefs.getString(KEY_TOKEN, null) - actual suspend fun getRefreshToken(): String? = coroutineSecurePrefs.getString(KEY_REFRESH_TOKEN, null) + actual fun getProfileId(): String? = securePrefs.getString(KEY_ID, null) + actual fun getToken(): String? = securePrefs.getString(KEY_TOKEN, null) + actual fun getRefreshToken(): String? = securePrefs.getString(KEY_REFRESH_TOKEN, null) - actual suspend fun setProfileId(profileId: String) { - coroutineSecurePrefs.edit { + actual fun setProfileId(profileId: String) { + securePrefs.edit { putString(KEY_ID, profileId) } } - actual suspend fun setToken(token: String) { - coroutineSecurePrefs.edit { + actual fun setToken(token: String) { + securePrefs.edit { putString(KEY_TOKEN, token) } } - actual suspend fun setRefreshToken(refreshToken: String) { - coroutineSecurePrefs.edit { + actual fun setRefreshToken(refreshToken: String) { + securePrefs.edit { putString(KEY_REFRESH_TOKEN, refreshToken) } } diff --git a/shared/src/androidMain/kotlin/dev/xinto/argos/util/NumberFormat.kt b/shared/src/androidMain/kotlin/dev/xinto/argos/util/NumberFormat.kt index 76063b3..ab80737 100644 --- a/shared/src/androidMain/kotlin/dev/xinto/argos/util/NumberFormat.kt +++ b/shared/src/androidMain/kotlin/dev/xinto/argos/util/NumberFormat.kt @@ -7,11 +7,8 @@ import java.util.Locale actual fun Int.formatCurrency(currency: String): String { - return NumberFormat.getCurrencyInstance(Locale.forLanguageTag("ka")) - .apply { - setCurrency(Currency.getInstance(currency)) - }.format( - BigDecimal(this) - .movePointLeft(2) - ) + val georgianLocale = Locale.forLanguageTag("ka") + return NumberFormat.getCurrencyInstance(georgianLocale).apply { + setCurrency(Currency.getInstance(currency)) + }.format(BigDecimal(this).movePointLeft(2)) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/dev/xinto/argos/domain/DomainPagedResponse.kt b/shared/src/commonMain/kotlin/dev/xinto/argos/domain/DomainPagedResponse.kt index f5cb511..d66e7eb 100644 --- a/shared/src/commonMain/kotlin/dev/xinto/argos/domain/DomainPagedResponse.kt +++ b/shared/src/commonMain/kotlin/dev/xinto/argos/domain/DomainPagedResponse.kt @@ -5,6 +5,7 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingSource import androidx.paging.PagingState +import co.touchlab.skie.configuration.annotations.FlowInterop import dev.xinto.argos.local.settings.ArgosLanguage import dev.xinto.argos.local.settings.ArgosSettings import dev.xinto.argos.network.response.ApiResponsePaged @@ -37,6 +38,7 @@ class DomainPagedResponsePager( pagingSourceFactory = invalidatingFactory ) + @FlowInterop.Disabled @OptIn(ExperimentalCoroutinesApi::class) val flow = settings.observeLanguage() .onEach { diff --git a/shared/src/commonMain/kotlin/dev/xinto/argos/domain/DomainResponse.kt b/shared/src/commonMain/kotlin/dev/xinto/argos/domain/DomainResponse.kt index 6501171..68e7d92 100644 --- a/shared/src/commonMain/kotlin/dev/xinto/argos/domain/DomainResponse.kt +++ b/shared/src/commonMain/kotlin/dev/xinto/argos/domain/DomainResponse.kt @@ -17,14 +17,12 @@ import kotlin.contracts.InvocationKind import kotlin.contracts.contract import kotlin.jvm.JvmInline -sealed interface DomainResponse { - @JvmInline - value class Success(val value: T) : DomainResponse +sealed class DomainResponse { + data class Success(val value: T) : DomainResponse() - @JvmInline - value class Error(val error: String) : DomainResponse + data class Error(val error: String) : DomainResponse() - data object Loading : DomainResponse + data object Loading : DomainResponse() } inline fun combine( @@ -55,24 +53,22 @@ class DomainResponseSource( private val state = MutableStateFlow>(DomainResponse.Loading) - fun asFlow(): Flow> { - return combine(state, settings.observeLanguage()) { state, language -> - state.takeUnlessOr(predicate = { it is DomainResponse.Loading }) { - try { - val result = fetch(language) - if (result.message == "ok") { - DomainResponse.Success(transform(result)) - } else { - DomainResponse.Error(result.errors!!.general[0]) - } - } catch (e: Exception) { - DomainResponse.Error(e.message ?: e.stackTraceToString()) - }.also { - this.state.value = it + val flow = combine(state, settings.observeLanguage()) { state, language -> + state.takeUnlessOr(predicate = { it is DomainResponse.Loading }) { + try { + val result = fetch(language) + if (result.message == "ok") { + DomainResponse.Success(transform(result)) + } else { + DomainResponse.Error(result.errors!!.general[0]) } + } catch (e: Exception) { + DomainResponse.Error(e.message ?: e.stackTraceToString()) + }.also { + this.state.value = it } - }.flowOn(Dispatchers.IO) - } + } + }.flowOn(Dispatchers.IO) fun refresh() { state.value = DomainResponse.Loading @@ -90,5 +86,5 @@ class DomainResponseSource( return if (!predicate(this)) this else unless() } - suspend operator fun invoke() = asFlow().first() + suspend operator fun invoke() = flow.first() } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/dev/xinto/argos/domain/lectures/LecturesRepository.kt b/shared/src/commonMain/kotlin/dev/xinto/argos/domain/lectures/LecturesRepository.kt index 6c04228..dab32e7 100644 --- a/shared/src/commonMain/kotlin/dev/xinto/argos/domain/lectures/LecturesRepository.kt +++ b/shared/src/commonMain/kotlin/dev/xinto/argos/domain/lectures/LecturesRepository.kt @@ -29,6 +29,6 @@ class LecturesRepository( /** * @return A [Flow] of map of day to a list of [DomainLectureInfo] */ - fun observeLectures() = lectures.asFlow() + fun observeLectures() = lectures.flow } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/dev/xinto/argos/domain/semester/SemesterRepository.kt b/shared/src/commonMain/kotlin/dev/xinto/argos/domain/semester/SemesterRepository.kt index 43f19b9..d251a01 100644 --- a/shared/src/commonMain/kotlin/dev/xinto/argos/domain/semester/SemesterRepository.kt +++ b/shared/src/commonMain/kotlin/dev/xinto/argos/domain/semester/SemesterRepository.kt @@ -9,7 +9,7 @@ class SemesterRepository( private val argosApi: ArgosApi ) { - private val semesters = DomainResponseSource( + val semesters = DomainResponseSource( fetch = { argosApi.getSemesters() }, @@ -24,8 +24,7 @@ class SemesterRepository( } ) - fun getSemesters() = semesters.asFlow() - fun getActiveSemester() = getSemesters().mapNotNull { + fun getActiveSemester() = semesters.flow.mapNotNull { when (it) { is DomainResponse.Loading -> it is DomainResponse.Error -> it diff --git a/shared/src/commonMain/kotlin/dev/xinto/argos/domain/user/UserRepository.kt b/shared/src/commonMain/kotlin/dev/xinto/argos/domain/user/UserRepository.kt index 9a962ba..ae8ef44 100644 --- a/shared/src/commonMain/kotlin/dev/xinto/argos/domain/user/UserRepository.kt +++ b/shared/src/commonMain/kotlin/dev/xinto/argos/domain/user/UserRepository.kt @@ -12,7 +12,7 @@ class UserRepository( private val argosAccountManager: ArgosAccountManager ) { - private val meUserInfo = DomainResponseSource({ argosApi.getUserAuth() }) { state -> + val meUserInfo = DomainResponseSource({ argosApi.getUserAuth() }) { state -> state.data!!.let { (_, attributes, relationships) -> DomainMeUserInfo( firstName = attributes.firstName, @@ -32,7 +32,7 @@ class UserRepository( } } - private val meUserState = DomainResponseSource({ argosApi.getUserState() }) { state -> + val meUserState = DomainResponseSource({ argosApi.getUserState() }) { state -> state.data!!.attributes.let { attributes -> DomainMeUserState( billingBalance = attributes.billingBalance.formatCurrency("GEL"), @@ -44,12 +44,16 @@ class UserRepository( } } - suspend fun login(googleIdToken: String): Boolean { - return argosApi.loginGoogle(googleIdToken) + try { + return argosApi.loginGoogle(googleIdToken) + } catch (e: Exception) { + e.printStackTrace() + return false + } } - suspend fun logout() { + fun logout() { argosAccountManager.logout() } @@ -69,10 +73,6 @@ class UserRepository( return argosAccountManager.isLoggedIn() } - fun getMeUserInfo() = meUserInfo - - fun getMeUserState() = meUserState - fun getUserProfile(userId: String): DomainResponseSource<*, DomainUserProfile> { return DomainResponseSource( fetch = { diff --git a/shared/src/commonMain/kotlin/dev/xinto/argos/local/account/ArgosAccountManager.kt b/shared/src/commonMain/kotlin/dev/xinto/argos/local/account/ArgosAccountManager.kt index d844c2b..6974ac7 100644 --- a/shared/src/commonMain/kotlin/dev/xinto/argos/local/account/ArgosAccountManager.kt +++ b/shared/src/commonMain/kotlin/dev/xinto/argos/local/account/ArgosAccountManager.kt @@ -6,14 +6,14 @@ expect class ArgosAccountManager { fun isLoggedIn(): Flow - suspend fun logout() + fun logout() - suspend fun getProfileId(): String? - suspend fun getToken(): String? - suspend fun getRefreshToken(): String? + fun getProfileId(): String? + fun getToken(): String? + fun getRefreshToken(): String? - suspend fun setProfileId(profileId: String) - suspend fun setToken(token: String) - suspend fun setRefreshToken(refreshToken: String) + fun setProfileId(profileId: String) + fun setToken(token: String) + fun setRefreshToken(refreshToken: String) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/dev/xinto/argos/network/ArgosApi.kt b/shared/src/commonMain/kotlin/dev/xinto/argos/network/ArgosApi.kt index 65e3572..eecf61f 100644 --- a/shared/src/commonMain/kotlin/dev/xinto/argos/network/ArgosApi.kt +++ b/shared/src/commonMain/kotlin/dev/xinto/argos/network/ArgosApi.kt @@ -205,7 +205,7 @@ class ArgosApi(private val argosAccountManager: ArgosAccountManager) { } } - private suspend inline fun saveTokens(response: ApiResponseAuth): Boolean { + private inline fun saveTokens(response: ApiResponseAuth): Boolean { if (response.message == "ok") { val data = response.data!! argosAccountManager.setRefreshToken(data.attributes.refreshToken) diff --git a/shared/src/iosMain/kotlin/dev/xinto/argos/di/ArgosDiPlatform.kt b/shared/src/iosMain/kotlin/dev/xinto/argos/di/ArgosDiPlatform.kt index c30bd50..ceacf12 100644 --- a/shared/src/iosMain/kotlin/dev/xinto/argos/di/ArgosDiPlatform.kt +++ b/shared/src/iosMain/kotlin/dev/xinto/argos/di/ArgosDiPlatform.kt @@ -1,6 +1,7 @@ package dev.xinto.argos.di import dev.xinto.argos.local.account.ArgosAccountManager +import dev.xinto.argos.local.settings.ArgosSettings import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.dsl.module @@ -9,6 +10,7 @@ internal actual object ArgosDiPlatform { actual val LocalModule: Module = module { singleOf(::ArgosAccountManager) + singleOf(::ArgosSettings) } } diff --git a/shared/src/iosMain/kotlin/dev/xinto/argos/di/DiProvider.kt b/shared/src/iosMain/kotlin/dev/xinto/argos/di/DiProvider.kt new file mode 100644 index 0000000..a7594e4 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/xinto/argos/di/DiProvider.kt @@ -0,0 +1,27 @@ +package dev.xinto.argos.di + +import dev.xinto.argos.domain.courses.CoursesRepository +import dev.xinto.argos.domain.lectures.LecturesRepository +import dev.xinto.argos.domain.messages.MessagesRepository +import dev.xinto.argos.domain.news.NewsRepository +import dev.xinto.argos.domain.notifications.NotificationsRepository +import dev.xinto.argos.domain.semester.SemesterRepository +import dev.xinto.argos.domain.user.UserRepository +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +object DiProvider : KoinComponent { + + val userRepository by inject() + val newsRepository by inject() + val messagesRepository by inject() + val notificationsRepository by inject() + val coursesRepository by inject() + val lecturesRepository by inject() + val semesterRepository by inject() + + fun initKoin() { + initArgosDi { } + } + +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/dev/xinto/argos/domain/user/UserRepository.ios.kt b/shared/src/iosMain/kotlin/dev/xinto/argos/domain/user/UserRepository.ios.kt new file mode 100644 index 0000000..fd4a6c3 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/xinto/argos/domain/user/UserRepository.ios.kt @@ -0,0 +1,6 @@ +package dev.xinto.argos.domain.user + +import dev.xinto.argos.domain.combine + +val UserRepository.meUserInfoAndStateFlow + get() = combine(meUserInfo.flow, meUserState.flow, ::Pair) \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/dev/xinto/argos/local/account/ArgosAccountManager.kt b/shared/src/iosMain/kotlin/dev/xinto/argos/local/account/ArgosAccountManager.kt index e378206..68b1a86 100644 --- a/shared/src/iosMain/kotlin/dev/xinto/argos/local/account/ArgosAccountManager.kt +++ b/shared/src/iosMain/kotlin/dev/xinto/argos/local/account/ArgosAccountManager.kt @@ -1,56 +1,170 @@ package dev.xinto.argos.local.account +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.cstr +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update +import platform.CoreFoundation.CFDictionaryAddValue +import platform.CoreFoundation.CFDictionaryCreateMutable +import platform.CoreFoundation.CFDictionaryRef +import platform.CoreFoundation.CFRelease +import platform.CoreFoundation.CFTypeRefVar +import platform.CoreFoundation.kCFBooleanTrue +import platform.Foundation.CFBridgingRelease +import platform.Foundation.CFBridgingRetain +import platform.Foundation.NSData +import platform.Foundation.NSDictionary +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create +import platform.Foundation.dataWithBytes +import platform.Foundation.dataWithData +import platform.Security.SecItemAdd +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.SecItemUpdate +import platform.Security.errSecDuplicateItem +import platform.Security.errSecSuccess +import platform.Security.kSecAttrAccessible +import platform.Security.kSecAttrAccessibleWhenUnlocked +import platform.Security.kSecAttrAccount +import platform.Security.kSecAttrKeyType +import platform.Security.kSecAttrKeyTypeRSA +import platform.Security.kSecAttrType +import platform.Security.kSecClass +import platform.Security.kSecClassGenericPassword +import platform.Security.kSecReturnData +import platform.Security.kSecValueData +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) actual class ArgosAccountManager { - actual fun isLoggedIn(): Flow { - return callbackFlow { + private val isLoggedIn = MutableStateFlow( + getToken() != null && getRefreshToken() != null && getProfileId() != null + ) + + fun fetchLoginStatus() { + isLoggedIn.update { + getToken() != null && getRefreshToken() != null && getProfileId() != null } } - actual suspend fun logout() { + actual fun isLoggedIn(): Flow = isLoggedIn + actual fun logout() { + deleteData(KEY_TOKEN) + deleteData(KEY_REFRESH_TOKEN) + deleteData(KEY_PROFILE_ID) + fetchLoginStatus() } - actual suspend fun getToken(): String? { - return suspendCoroutine { - it.resume("") - } + actual fun getToken(): String? = getData(KEY_TOKEN) + actual fun getRefreshToken(): String? = getData(KEY_REFRESH_TOKEN) + actual fun getProfileId(): String? = getData(KEY_PROFILE_ID) + + actual fun setToken(token: String) { + saveData(KEY_TOKEN, token) + fetchLoginStatus() } - actual suspend fun getRefreshToken(): String? { - return suspendCoroutine { - it.resume("") - } + actual fun setRefreshToken(refreshToken: String) { + saveData(KEY_REFRESH_TOKEN, refreshToken) + fetchLoginStatus() } - actual suspend fun setToken(token: String) { - suspendCoroutine { - it.resume(Unit) - } + actual fun setProfileId(profileId: String) { + saveData(KEY_PROFILE_ID, profileId) + fetchLoginStatus() } - actual suspend fun setRefreshToken(refreshToken: String) { - suspendCoroutine { - it.resume(Unit) + private fun saveData(account: String, value: String) { + return memScoped { + val accountBytes = NSData.dataWithBytes( + bytes = account.cstr.ptr, + length = account.length.toULong() + ) + val valueBytes = NSData.create( + bytes = value.cstr.ptr, + length = value.length.toULong() + ) + val attributes = CFDictionaryCreateMutable(null, 4, null, null).apply { + CFDictionaryAddValue(this, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(this, kSecAttrAccount, CFBridgingRetain(accountBytes)) + CFDictionaryAddValue(this, kSecValueData, CFBridgingRetain(valueBytes)) + CFDictionaryAddValue(this, kSecAttrAccessible, kSecAttrAccessibleWhenUnlocked) + } + val status = SecItemAdd(attributes, null) + + if (status == errSecDuplicateItem) { + val query = CFDictionaryCreateMutable(null, 2, null, null).apply { + CFDictionaryAddValue(this, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(this, kSecAttrAccount, CFBridgingRetain(accountBytes)) + } + val updateAttributes = CFDictionaryCreateMutable(null, 1, null, null).apply { + CFDictionaryAddValue(this, kSecValueData, CFBridgingRetain(valueBytes)) + } + SecItemUpdate(query, updateAttributes) + CFRelease(query) + CFRelease(updateAttributes) + } + + CFRelease(attributes) } } - actual suspend fun getProfileId(): String? { - return suspendCoroutine { - it.resume("") + + private fun getData(account: String): String? { + return memScoped { + val query = CFDictionaryCreateMutable(null, 3, null, null).apply { + val accountBytes = NSData.dataWithBytes( + bytes = account.cstr.ptr, + length = account.length.toULong() + ) + + CFDictionaryAddValue(this, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(this, kSecAttrAccount, CFBridgingRetain(accountBytes)) + CFDictionaryAddValue(this, kSecReturnData, kCFBooleanTrue) + } + val data = memScoped { + val result = alloc() + val status = SecItemCopyMatching(query, result.ptr) + if (status != errSecSuccess) { + return null + } + + CFBridgingRelease(result.value) as NSData + } + val dataString = NSString.create(data, NSUTF8StringEncoding) as String + CFBridgingRelease(query) + dataString } } - actual suspend fun setProfileId(profileId: String) { + private fun deleteData(account: String) { + memScoped { + val query = CFDictionaryCreateMutable(null, 3, null, null).apply { + val accountBytes = NSData.dataWithBytes( + bytes = account.cstr.ptr, + length = account.length.toULong() + ) + CFDictionaryAddValue(this, kSecClass, kSecClassGenericPassword) + CFDictionaryAddValue(this, kSecAttrAccount, CFBridgingRetain(accountBytes)) + CFDictionaryAddValue(this, kSecReturnData, kCFBooleanTrue) + } + SecItemDelete(query) + } } private companion object { - const val KEY_TOKEN = "token" - const val KEY_REFRESH_TOKEN = "refresh_token" + const val KEY_TOKEN = "argos-token" + const val KEY_REFRESH_TOKEN = "argos-refresh-token" + const val KEY_PROFILE_ID = "argos-profile-id" } } \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/dev/xinto/argos/local/settings/ArgosSettings.kt b/shared/src/iosMain/kotlin/dev/xinto/argos/local/settings/ArgosSettings.kt index 6db85e2..739feaf 100644 --- a/shared/src/iosMain/kotlin/dev/xinto/argos/local/settings/ArgosSettings.kt +++ b/shared/src/iosMain/kotlin/dev/xinto/argos/local/settings/ArgosSettings.kt @@ -5,7 +5,7 @@ import kotlinx.coroutines.flow.flowOf actual class ArgosSettings { - actual fun observeLanguage(): Flow = flowOf() + actual fun observeLanguage(): Flow = flowOf(ArgosLanguage.Ka) actual suspend fun getLanguage(): ArgosLanguage = ArgosLanguage.Ka diff --git a/shared/src/iosMain/kotlin/dev/xinto/argos/util/ExposedTypes.kt b/shared/src/iosMain/kotlin/dev/xinto/argos/util/ExposedTypes.kt new file mode 100644 index 0000000..6b85e12 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/xinto/argos/util/ExposedTypes.kt @@ -0,0 +1,14 @@ +package dev.xinto.argos.util + +import androidx.paging.ItemSnapshotList +import androidx.paging.PagingDataEvent +import androidx.paging.PagingDataPresenter + +@Suppress("unused") +fun exposed( + a: PagingDataPresenter<*>, + b: PagingDataEvent<*>, + c: ItemSnapshotList<*> +) { + throw NotImplementedError() +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/dev/xinto/argos/util/IosPagingDataPresenter.kt b/shared/src/iosMain/kotlin/dev/xinto/argos/util/IosPagingDataPresenter.kt new file mode 100644 index 0000000..b1b3b9c --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/xinto/argos/util/IosPagingDataPresenter.kt @@ -0,0 +1,96 @@ +package dev.xinto.argos.util + +import androidx.paging.CombinedLoadStates +import androidx.paging.LoadState +import androidx.paging.LoadStates +import androidx.paging.PagingData +import androidx.paging.PagingDataEvent +import androidx.paging.PagingDataPresenter +import co.touchlab.skie.configuration.annotations.FlowInterop +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +// FIXME https://github.com/touchlab/SKIE/issues/97 +@Throws(Exception::class) +@FlowInterop.Disabled +fun CreateIosPagingItems(flow: Flow>): IosPagingItems { + return IosPagingItems(flow) +} + +class IosPagingItems( + @FlowInterop.Disabled + private val flow: Flow> +) { + + private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + private val pagingDataPresenter = object : PagingDataPresenter( + mainContext = Dispatchers.Main, + cachedPagingData = (flow as? SharedFlow>)?.replayCache?.firstOrNull() + ) { + override suspend fun presentPagingDataEvent(event: PagingDataEvent) { + updateSnapshotList() + } + } + + private val _snapshotList = MutableStateFlow(pagingDataPresenter.snapshot()) + val snapshotList = _snapshotList.asStateFlow() as StateFlow> + + val loadState = pagingDataPresenter.loadStateFlow + .filterNotNull() + .stateIn( + scope = coroutineScope, + initialValue = CombinedLoadStates( + refresh = LoadState.Loading, + prepend = LoadState.NotLoading(false), + append = LoadState.NotLoading(false), + source = LoadStates( + refresh = LoadState.Loading, + prepend = LoadState.NotLoading(false), + append = LoadState.NotLoading(false), + ) + ), + started = SharingStarted.Eagerly + ) + + fun updateSnapshotList() { + _snapshotList.value = pagingDataPresenter.snapshot() + } + + operator fun get(index: Int): T { + pagingDataPresenter[index] + return snapshotList.value[index] + } + + fun refresh() { + pagingDataPresenter.refresh() + } + + fun retry() { + pagingDataPresenter.retry() + } + + fun dispose() { + coroutineScope.cancel() + } + + init { + coroutineScope.launch { + flow.collectLatest { + pagingDataPresenter.collectFrom(it) + } + } + } +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/dev/xinto/argos/util/NumberFormat.kt b/shared/src/iosMain/kotlin/dev/xinto/argos/util/NumberFormat.kt index 48c4e3e..485e650 100644 --- a/shared/src/iosMain/kotlin/dev/xinto/argos/util/NumberFormat.kt +++ b/shared/src/iosMain/kotlin/dev/xinto/argos/util/NumberFormat.kt @@ -2,11 +2,14 @@ package dev.xinto.argos.util import platform.Foundation.NSNumber import platform.Foundation.NSNumberFormatter +import platform.Foundation.NSNumberFormatterCurrencyAccountingStyle +import platform.Foundation.NSNumberFormatterCurrencyISOCodeStyle +import platform.Foundation.NSNumberFormatterCurrencyPluralStyle import platform.Foundation.NSNumberFormatterCurrencyStyle actual fun Int.formatCurrency(currency: String): String { return NSNumberFormatter().apply { numberStyle = NSNumberFormatterCurrencyStyle currencyCode = currency - }.stringFromNumber(NSNumber(this))!! + }.stringFromNumber(NSNumber(this / 100))!! } \ No newline at end of file