diff --git a/README.md b/README.md index b5a8baca5..939583e08 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,15 @@ Modern vision of the mobile application for the Open EdX platform from Raccoon G 6. Click the **Run** button. +## API plugin +This project uses custom APIs to improve performance and reduce the number of requests to the server. + +You can find the plugin with the API and installation guide [here](https://github.com/raccoongang/mobile-api-extensions). + ## Roadmap Please feel welcome to develop any of the suggested features below and submit a pull request. -- Migrate to the new APIs +- ✅ ~~Migrate to the new APIs~~ - Recent searches - Migrate to the Olive and JWT token - UnAuth User mode diff --git a/app/build.gradle b/app/build.gradle index 52af57186..44305da69 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -76,7 +76,6 @@ dependencies { kapt "androidx.room:room-compiler:$room_version" - implementation "androidx.window:window:1.0.0" implementation 'androidx.core:core-splashscreen:1.0.0' testImplementation 'junit:junit:4.13.2' diff --git a/app/src/main/java/com/raccoongang/newedx/AppActivity.kt b/app/src/main/java/com/raccoongang/newedx/AppActivity.kt index 6ce983701..b70455c1e 100644 --- a/app/src/main/java/com/raccoongang/newedx/AppActivity.kt +++ b/app/src/main/java/com/raccoongang/newedx/AppActivity.kt @@ -15,6 +15,8 @@ import androidx.window.layout.WindowMetricsCalculator import com.raccoongang.auth.presentation.signin.SignInFragment import com.raccoongang.core.data.storage.PreferencesManager import com.raccoongang.core.extension.requestApplyInsetsWhenAttached +import com.raccoongang.core.presentation.global.AppData +import com.raccoongang.core.presentation.global.AppDataHolder import com.raccoongang.core.presentation.global.InsetHolder import com.raccoongang.core.presentation.global.WindowSizeHolder import com.raccoongang.core.ui.WindowSize @@ -24,7 +26,7 @@ import com.raccoongang.profile.presentation.ProfileRouter import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { +class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder, AppDataHolder { override val topInset: Int get() = _insetTop @@ -34,6 +36,9 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { override val windowSize: WindowSize get() = _windowSize + override val appData: AppData + get() = AppData(BuildConfig.VERSION_NAME) + private lateinit var binding: ActivityAppBinding private val preferencesManager by inject() private val viewModel by viewModel() diff --git a/app/src/main/java/com/raccoongang/newedx/AppRouter.kt b/app/src/main/java/com/raccoongang/newedx/AppRouter.kt index aeb0a17e3..756643997 100644 --- a/app/src/main/java/com/raccoongang/newedx/AppRouter.kt +++ b/app/src/main/java/com/raccoongang/newedx/AppRouter.kt @@ -8,13 +8,12 @@ import com.raccoongang.auth.presentation.signin.SignInFragment import com.raccoongang.auth.presentation.signup.SignUpFragment import com.raccoongang.core.FragmentViewType import com.raccoongang.core.domain.model.Account -import com.raccoongang.core.domain.model.Certificate import com.raccoongang.core.domain.model.CoursewareAccess import com.raccoongang.core.presentation.course.CourseViewMode import com.raccoongang.course.presentation.CourseRouter import com.raccoongang.course.presentation.container.CourseContainerFragment import com.raccoongang.course.presentation.container.NoAccessCourseContainerFragment -import com.raccoongang.course.presentation.detail.CourseDetailFragment +import com.raccoongang.course.presentation.detail.CourseDetailsFragment import com.raccoongang.course.presentation.handouts.HandoutsType import com.raccoongang.course.presentation.handouts.WebViewFragment import com.raccoongang.discovery.presentation.search.CourseSearchFragment @@ -38,7 +37,7 @@ import com.raccoongang.profile.presentation.delete.DeleteProfileFragment import com.raccoongang.profile.presentation.edit.EditProfileFragment import com.raccoongang.profile.presentation.settings.video.VideoQualityFragment import com.raccoongang.profile.presentation.settings.video.VideoSettingsFragment -import java.util.Date +import java.util.* class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, DiscussionRouter, ProfileRouter { @@ -62,7 +61,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di //region DiscoveryRouter override fun navigateToCourseDetail(fm: FragmentManager, courseId: String) { - replaceFragmentWithBackStack(fm, CourseDetailFragment.newInstance(courseId)) + replaceFragmentWithBackStack(fm, CourseDetailsFragment.newInstance(courseId)) } override fun navigateToCourseSearch(fm: FragmentManager) { @@ -71,26 +70,26 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di //endregion //region DashboardRouter + override fun navigateToCourseOutline( fm: FragmentManager, courseId: String, - title: String, - image: String, - certificate: Certificate, - coursewareAccess: CoursewareAccess, - auditAccessExpires: Date? + courseTitle: String ) { - val destinationFragment = if (coursewareAccess.hasAccess) { - CourseContainerFragment.newInstance(courseId, title, image, certificate) - } else { - NoAccessCourseContainerFragment.newInstance(title, coursewareAccess, auditAccessExpires) - } - replaceFragmentWithBackStack( fm, - destinationFragment + CourseContainerFragment.newInstance(courseId, courseTitle) ) } + + override fun navigateToNoAccess( + fm: FragmentManager, + title: String, + coursewareAccess: CoursewareAccess, + auditAccessExpires: Date? + ) { + replaceFragment(fm, NoAccessCourseContainerFragment.newInstance(title,coursewareAccess, auditAccessExpires)) + } //endregion //region CourseRouter @@ -194,10 +193,14 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ) } - override fun navigateToDiscussionResponses(fm: FragmentManager, comment: DiscussionComment) { + override fun navigateToDiscussionResponses( + fm: FragmentManager, + comment: DiscussionComment, + isClosed: Boolean + ) { replaceFragmentWithBackStack( fm, - DiscussionResponsesFragment.newInstance(comment) + DiscussionResponsesFragment.newInstance(comment, isClosed) ) } diff --git a/app/src/main/java/com/raccoongang/newedx/di/AppModule.kt b/app/src/main/java/com/raccoongang/newedx/di/AppModule.kt index 0df1ed077..7dc0a89e5 100644 --- a/app/src/main/java/com/raccoongang/newedx/di/AppModule.kt +++ b/app/src/main/java/com/raccoongang/newedx/di/AppModule.kt @@ -7,6 +7,7 @@ import com.google.gson.GsonBuilder import com.raccoongang.auth.presentation.AuthRouter import com.raccoongang.core.data.storage.PreferencesManager import com.raccoongang.core.module.DownloadWorkerController +import com.raccoongang.core.module.TranscriptManager import com.raccoongang.core.module.download.FileDownloader import com.raccoongang.core.system.AppCookieManager import com.raccoongang.core.system.ResourceManager @@ -99,4 +100,6 @@ val appModule = module { single { DownloadWorkerController(get(), get(), get()) } + + single { TranscriptManager(get()) } } \ No newline at end of file diff --git a/app/src/main/java/com/raccoongang/newedx/di/ScreenModule.kt b/app/src/main/java/com/raccoongang/newedx/di/ScreenModule.kt index ed8b390b6..924880a76 100644 --- a/app/src/main/java/com/raccoongang/newedx/di/ScreenModule.kt +++ b/app/src/main/java/com/raccoongang/newedx/di/ScreenModule.kt @@ -7,6 +7,7 @@ import com.raccoongang.auth.presentation.signin.SignInViewModel import com.raccoongang.auth.presentation.signup.SignUpViewModel import com.raccoongang.core.Validator import com.raccoongang.core.domain.model.Account +import com.raccoongang.core.presentation.dialog.SelectDialogViewModel import com.raccoongang.course.data.repository.CourseRepository import com.raccoongang.course.domain.interactor.CourseInteractor import com.raccoongang.course.presentation.container.CourseContainerViewModel @@ -81,12 +82,13 @@ val screenModule = module { viewModel { (courseId: String) -> CourseOutlineViewModel(courseId, get(), get(), get(), get(), get(), get(), get()) } viewModel { CourseUnitsViewModel(get(), get(),get(), get(), get()) } viewModel { (courseId: String) -> CourseSectionViewModel(get(), get(), get(), get(), get(), get(), courseId) } - viewModel { (courseId: String) -> CourseUnitContainerViewModel(get(), courseId) } + viewModel { (courseId: String) -> CourseUnitContainerViewModel(get(), get(), courseId) } viewModel { (courseId: String) -> CourseVideoViewModel(courseId, get(), get(), get(), get(), get(), get(), get()) } viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get()) } - viewModel { (courseId: String) -> VideoUnitViewModel(courseId, get(), get(), get(), get()) } + viewModel { (courseId: String) -> VideoUnitViewModel(courseId, get(), get(), get(), get(), get()) } viewModel { (courseId:String, handoutsType: String) -> HandoutsViewModel(courseId, handoutsType, get()) } viewModel { CourseSearchViewModel(get(), get()) } + viewModel { SelectDialogViewModel(get()) } single { DiscussionRepository(get()) } factory { DiscussionInteractor(get()) } diff --git a/app/src/main/java/com/raccoongang/newedx/room/AppDatabase.kt b/app/src/main/java/com/raccoongang/newedx/room/AppDatabase.kt index 6457f5889..e8acf7994 100644 --- a/app/src/main/java/com/raccoongang/newedx/room/AppDatabase.kt +++ b/app/src/main/java/com/raccoongang/newedx/room/AppDatabase.kt @@ -4,10 +4,10 @@ import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import com.raccoongang.core.data.model.room.CourseEntity +import com.raccoongang.core.data.model.room.CourseStructureEntity import com.raccoongang.core.data.model.room.discovery.EnrolledCourseEntity import com.raccoongang.core.module.db.DownloadDao import com.raccoongang.core.module.db.DownloadModelEntity -import com.raccoongang.course.data.model.BlockDbEntity import com.raccoongang.course.data.storage.CourseConverter import com.raccoongang.course.data.storage.CourseDao import com.raccoongang.dashboard.data.DashboardDao @@ -21,7 +21,7 @@ const val DATABASE_NAME = "newEdx_db" entities = [ CourseEntity::class, EnrolledCourseEntity::class, - BlockDbEntity::class, + CourseStructureEntity::class, DownloadModelEntity::class ], version = DATABASE_VERSION, diff --git a/auth/src/main/java/com/raccoongang/auth/presentation/restore/RestorePasswordFragment.kt b/auth/src/main/java/com/raccoongang/auth/presentation/restore/RestorePasswordFragment.kt index 93fe5e5b0..e716393fa 100644 --- a/auth/src/main/java/com/raccoongang/auth/presentation/restore/RestorePasswordFragment.kt +++ b/auth/src/main/java/com/raccoongang/auth/presentation/restore/RestorePasswordFragment.kt @@ -224,7 +224,7 @@ private fun RestorePasswordScreen( .height(42.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator(color = MaterialTheme.colors.primary) + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } else { NewEdxButton( diff --git a/auth/src/main/java/com/raccoongang/auth/presentation/signin/SignInFragment.kt b/auth/src/main/java/com/raccoongang/auth/presentation/signin/SignInFragment.kt index d5e4c00bf..9f53771ac 100644 --- a/auth/src/main/java/com/raccoongang/auth/presentation/signin/SignInFragment.kt +++ b/auth/src/main/java/com/raccoongang/auth/presentation/signin/SignInFragment.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -254,7 +255,7 @@ private fun AuthForm( } if (isLoading) { - CircularProgressIndicator(color = MaterialTheme.colors.primary) + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } else { NewEdxButton( width = buttonWidth, @@ -274,7 +275,11 @@ private fun PasswordTextField( onValueChanged: (String) -> Unit, onPressDone: () -> Unit, ) { - var passwordTextFieldValue by rememberSaveable { mutableStateOf("") } + var passwordTextFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue("") + ) + } val focusManager = LocalFocusManager.current Text( modifier = Modifier.fillMaxWidth(), @@ -288,7 +293,7 @@ private fun PasswordTextField( value = passwordTextFieldValue, onValueChange = { passwordTextFieldValue = it - onValueChanged(it.trim()) + onValueChanged(it.text.trim()) }, colors = TextFieldDefaults.outlinedTextFieldColors( unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, diff --git a/auth/src/main/java/com/raccoongang/auth/presentation/signup/SignUpFragment.kt b/auth/src/main/java/com/raccoongang/auth/presentation/signup/SignUpFragment.kt index 7a1b99790..80ef362ab 100644 --- a/auth/src/main/java/com/raccoongang/auth/presentation/signup/SignUpFragment.kt +++ b/auth/src/main/java/com/raccoongang/auth/presentation/signup/SignUpFragment.kt @@ -290,7 +290,7 @@ internal fun RegistrationScreen( Modifier .fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } is SignUpUIState.Fields -> { @@ -380,7 +380,7 @@ internal fun RegistrationScreen( .height(42.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } else { NewEdxButton( diff --git a/auth/src/main/java/com/raccoongang/auth/presentation/ui/AuthUI.kt b/auth/src/main/java/com/raccoongang/auth/presentation/ui/AuthUI.kt index f07973dc8..415fd7ad2 100644 --- a/auth/src/main/java/com/raccoongang/auth/presentation/ui/AuthUI.kt +++ b/auth/src/main/java/com/raccoongang/auth/presentation/ui/AuthUI.kt @@ -25,10 +25,7 @@ import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.input.* import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.raccoongang.auth.R @@ -194,8 +191,10 @@ fun LoginTextField( imeAction: ImeAction = ImeAction.Next, keyboardActions: (FocusManager) -> Unit = { it.moveFocus(FocusDirection.Down) } ) { - var loginTextFieldValue by rememberSaveable { - mutableStateOf("") + var loginTextFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue("") + ) } val focusManager = LocalFocusManager.current Text( @@ -209,7 +208,7 @@ fun LoginTextField( value = loginTextFieldValue, onValueChange = { loginTextFieldValue = it - onValueChanged(it.trim()) + onValueChanged(it.text.trim()) }, colors = TextFieldDefaults.outlinedTextFieldColors( unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, diff --git a/build.gradle b/build.gradle index 589d82e24..2831e9883 100644 --- a/build.gradle +++ b/build.gradle @@ -41,6 +41,8 @@ ext { work_version = '2.8.0' + window_version = '1.0.0' + //testing mockk_version = '1.13.3' android_arch_version = '2.1.0' diff --git a/core/build.gradle b/core/build.gradle index b738d79db..c3e03c43a 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -47,6 +47,7 @@ android { resValue "string", "privacy_policy_link", envUrls.privacyPolicy resValue "string", "terms_of_service_link", envUrls.termsOfService resValue "string", "contact_us_link", envUrls.contactUs + resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS } develop { def envMap = config.environments.find { it.key == "DEV" } @@ -60,6 +61,7 @@ android { resValue "string", "privacy_policy_link", envUrls.privacyPolicy resValue "string", "terms_of_service_link", envUrls.termsOfService resValue "string", "contact_us_link", envUrls.contactUs + resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS } stage { def envMap = config.environments.find { it.key == "STAGE" } @@ -73,6 +75,7 @@ android { resValue "string", "privacy_policy_link", envUrls.privacyPolicy resValue "string", "terms_of_service_link", envUrls.termsOfService resValue "string", "contact_us_link", envUrls.contactUs + resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS } } @@ -114,6 +117,7 @@ dependencies { api "androidx.fragment:fragment-ktx:$fragment_version" api "androidx.constraintlayout:constraintlayout:$constraintlayout_version" api "androidx.viewpager2:viewpager2:$viewpager2_version" + api "androidx.window:window:$window_version" //Android Jetpack api "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" diff --git a/core/libs/subtitleConvert-1.0.2.jar b/core/libs/subtitleConvert-1.0.2.jar new file mode 100644 index 000000000..0f4d3afa9 Binary files /dev/null and b/core/libs/subtitleConvert-1.0.2.jar differ diff --git a/core/src/main/java/com/raccoongang/core/AppDataConstants.kt b/core/src/main/java/com/raccoongang/core/AppDataConstants.kt index dc7b7bf4b..d41752b3a 100644 --- a/core/src/main/java/com/raccoongang/core/AppDataConstants.kt +++ b/core/src/main/java/com/raccoongang/core/AppDataConstants.kt @@ -3,7 +3,7 @@ package com.raccoongang.core import java.util.* object AppDataConstants { - const val USER_MIN_YEAR = 18 + const val USER_MIN_YEAR = 13 const val USER_MAX_YEAR = 77 const val DEFAULT_MIME_TYPE = "image/jpeg" val defaultLocale = Locale("en") diff --git a/core/src/main/java/com/raccoongang/core/data/api/CourseApi.kt b/core/src/main/java/com/raccoongang/core/data/api/CourseApi.kt index 7a1149264..2dfe736d6 100644 --- a/core/src/main/java/com/raccoongang/core/data/api/CourseApi.kt +++ b/core/src/main/java/com/raccoongang/core/data/api/CourseApi.kt @@ -6,31 +6,37 @@ import retrofit2.http.* interface CourseApi { - @GET("/api/mobile/v1/users/{username}/course_enrollments") + @GET("/mobile_api_extensions/v1/users/{username}/course_enrollments") suspend fun getEnrolledCourses( @Header("Cache-Control") cacheControlHeaderParam: String? = null, @Path("username") username: String, @Query("org") org: String? = null, - ): List + @Query("page") page: Int + ): DashboardCourseList - @GET("/api/courses/v1/courses/") + @GET("/mobile_api_extensions/courses/v1/courses/") suspend fun getCourseList( @Query("search_term") searchQuery: String? = null, @Query("page") page: Int, @Query("mobile") mobile: Boolean, @Query("username") username: String? = null, @Query("org") org: String? = null, - @Query("permissions") permission: List = listOf("enroll","see_in_catalog","see_about_page") + @Query("permissions") permission: List = listOf( + "enroll", + "see_in_catalog", + "see_about_page" + ) ): CourseList - @GET("/api/courses/v1/courses/{course_id}") + @GET("/mobile_api_extensions/v1/courses/{course_id}") suspend fun getCourseDetail( @Path("course_id") courseId: String?, @Query("username") username: String? = null, + @Query("is_enrolled") isEnrolled: Boolean = true, ): CourseDetails @GET( - "/api/courses/{api_version}/blocks/?" + + "/mobile_api_extensions/{api_version}/blocks/?" + "depth=all&" + "requested_fields=contains_gated_content,show_gated_sections,special_exam_info,graded,format,student_view_multi_device,due,completion&" + "student_view_data=video,discussion&" + @@ -59,9 +65,9 @@ interface CourseApi { blocksCompletionBody: BlocksCompletionBody ) - @GET - suspend fun getHandouts(@Url url: String): HandoutsModel + @GET("/api/mobile/v1/course_info/{course_id}/handouts") + suspend fun getHandouts(@Path("course_id") courseId: String): HandoutsModel - @GET - suspend fun getAnnouncements(@Url url: String): List + @GET("/api/mobile/v1/course_info/{course_id}/updates") + suspend fun getAnnouncements(@Path("course_id") courseId: String): List } diff --git a/core/src/main/java/com/raccoongang/core/data/model/Block.kt b/core/src/main/java/com/raccoongang/core/data/model/Block.kt index 06a2c29d4..fac04a00b 100644 --- a/core/src/main/java/com/raccoongang/core/data/model/Block.kt +++ b/core/src/main/java/com/raccoongang/core/data/model/Block.kt @@ -57,7 +57,7 @@ data class StudentViewData( @SerializedName("duration") var duration: Any?, @SerializedName("transcripts") - var transcripts: Transcripts?, + var transcripts: HashMap?, @SerializedName("encoded_videos") var encodedVideos: EncodedVideos?, @SerializedName("all_sources") @@ -69,22 +69,13 @@ data class StudentViewData( return com.raccoongang.core.domain.model.StudentViewData( onlyOnWeb = onlyOnWeb ?: false, duration = duration ?: "", - transcripts = transcripts?.mapToDomain(), + transcripts = transcripts, encodedVideos = encodedVideos?.mapToDomain(), topicId = topicId ?: "" ) } } -data class Transcripts( - @SerializedName("en") - var en: String? -) { - fun mapToDomain(): com.raccoongang.core.domain.model.Transcripts { - return com.raccoongang.core.domain.model.Transcripts(en = en ?: "") - } -} - data class EncodedVideos( @SerializedName("youtube") var videoInfo: VideoInfo?, diff --git a/core/src/main/java/com/raccoongang/core/data/model/CourseDetails.kt b/core/src/main/java/com/raccoongang/core/data/model/CourseDetails.kt index 1845f068c..b8de035a6 100644 --- a/core/src/main/java/com/raccoongang/core/data/model/CourseDetails.kt +++ b/core/src/main/java/com/raccoongang/core/data/model/CourseDetails.kt @@ -45,7 +45,9 @@ data class CourseDetails( @SerializedName("start_type") val startType: String?, @SerializedName("overview") - val overview: String? + val overview: String?, + @SerializedName("is_enrolled") + val isEnrolled: Boolean?, ) { fun mapToDomain(): Course { @@ -69,6 +71,7 @@ data class CourseDetails( startType = startType ?: "", pacing = pacing ?: "", overview = overview ?: "", + isEnrolled = isEnrolled ?: false, media = media?.mapToDomain() ?: com.raccoongang.core.domain.model.Media() ) } diff --git a/core/src/main/java/com/raccoongang/core/data/model/CourseStructureModel.kt b/core/src/main/java/com/raccoongang/core/data/model/CourseStructureModel.kt index f86b6eda1..3471ae731 100644 --- a/core/src/main/java/com/raccoongang/core/data/model/CourseStructureModel.kt +++ b/core/src/main/java/com/raccoongang/core/data/model/CourseStructureModel.kt @@ -1,20 +1,79 @@ package com.raccoongang.core.data.model import com.google.gson.annotations.SerializedName +import com.raccoongang.core.data.model.room.BlockDb +import com.raccoongang.core.data.model.room.CourseStructureEntity +import com.raccoongang.core.data.model.room.MediaDb import com.raccoongang.core.domain.model.CourseStructure +import com.raccoongang.core.utils.TimeUtils data class CourseStructureModel( @SerializedName("root") val root: String, @SerializedName("blocks") - val blockData: Map + val blockData: Map, + @SerializedName("id") + var id: String?, + @SerializedName("name") + var name: String?, + @SerializedName("number") + var number: String?, + @SerializedName("org") + var org: String?, + @SerializedName("start") + var start: String?, + @SerializedName("start_display") + var startDisplay: String?, + @SerializedName("start_type") + var startType: String?, + @SerializedName("end") + var end: String?, + @SerializedName("courseware_access") + var coursewareAccess: CoursewareAccess?, + @SerializedName("media") + var media: Media?, + @SerializedName("certificate") + val certificate: Certificate?, + @SerializedName("is_self_paced") + var isSelfPaced: Boolean? ) { fun mapToDomain(): CourseStructure { return CourseStructure( root = root, blockData = blockData.map { - it.key to it.value.mapToDomain() - }.toMap() + it.value.mapToDomain() + }, + id = id ?: "", + name = name ?: "", + number = number ?: "", + org = org ?: "", + start = TimeUtils.iso8601ToDate(start ?: ""), + startDisplay = startDisplay ?: "", + startType = startType ?: "", + end = TimeUtils.iso8601ToDate(end ?: ""), + coursewareAccess = coursewareAccess?.mapToDomain()!!, + media = media?.mapToDomain(), + certificate = certificate?.mapToDomain(), + isSelfPaced = isSelfPaced ?: false + ) + } + + fun mapToRoomEntity(): CourseStructureEntity { + return CourseStructureEntity( + root, + blocks = blockData.map { BlockDb.createFrom(it.value) }, + id = id ?: "", + name = name ?: "", + number = number ?: "", + org = org ?: "", + start = start ?: "", + startDisplay = startDisplay ?: "", + startType = startType ?: "", + end = end ?: "", + coursewareAccess = coursewareAccess?.mapToRoomEntity()!!, + media = MediaDb.createFrom(media), + certificate = certificate?.mapToRoomEntity(), + isSelfPaced = isSelfPaced ?: false ) } } diff --git a/core/src/main/java/com/raccoongang/core/data/model/DashboardCourseList.kt b/core/src/main/java/com/raccoongang/core/data/model/DashboardCourseList.kt new file mode 100644 index 000000000..eb1e64785 --- /dev/null +++ b/core/src/main/java/com/raccoongang/core/data/model/DashboardCourseList.kt @@ -0,0 +1,33 @@ +package com.raccoongang.core.data.model + +import com.google.gson.annotations.SerializedName +import com.raccoongang.core.domain.model.DashboardCourseList + +data class DashboardCourseList( + @SerializedName("next") + val next: String?, + @SerializedName("previous") + val previous: String?, + @SerializedName("count") + val count: Int, + @SerializedName("num_pages") + val numPages: Int, + @SerializedName("current_page") + val currentPage: Int, + @SerializedName("results") + val results: List +) { + + fun mapToDomain(): DashboardCourseList { + return DashboardCourseList( + com.raccoongang.core.domain.model.Pagination( + count, + next ?: "", + numPages, + previous ?: "" + ), + results.map { it.mapToDomain() } + ) + } + +} \ No newline at end of file diff --git a/core/src/main/java/com/raccoongang/core/data/model/EnrolledCourse.kt b/core/src/main/java/com/raccoongang/core/data/model/EnrolledCourse.kt index b3b8f9eaa..550182c2a 100644 --- a/core/src/main/java/com/raccoongang/core/data/model/EnrolledCourse.kt +++ b/core/src/main/java/com/raccoongang/core/data/model/EnrolledCourse.kt @@ -4,7 +4,6 @@ import com.google.gson.annotations.SerializedName import com.raccoongang.core.data.model.room.discovery.EnrolledCourseEntity import com.raccoongang.core.domain.model.EnrolledCourse import com.raccoongang.core.utils.TimeUtils -import java.sql.Time data class EnrolledCourse( @SerializedName("audit_access_expires") @@ -22,7 +21,7 @@ data class EnrolledCourse( ) { fun mapToDomain(): EnrolledCourse { return EnrolledCourse( - auditAccessExpires = TimeUtils.iso8601ToDate(auditAccessExpires?:""), + auditAccessExpires = TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), created = created ?: "", mode = mode ?: "", isActive = isActive ?: false, diff --git a/course/src/main/java/com/raccoongang/course/data/model/BlockDbEntity.kt b/core/src/main/java/com/raccoongang/core/data/model/room/BlockDb.kt similarity index 85% rename from course/src/main/java/com/raccoongang/course/data/model/BlockDbEntity.kt rename to core/src/main/java/com/raccoongang/core/data/model/room/BlockDb.kt index f021a6be6..00aa62d93 100644 --- a/course/src/main/java/com/raccoongang/course/data/model/BlockDbEntity.kt +++ b/core/src/main/java/com/raccoongang/core/data/model/room/BlockDb.kt @@ -1,17 +1,11 @@ -package com.raccoongang.course.data.model +package com.raccoongang.core.data.model.room import androidx.room.ColumnInfo import androidx.room.Embedded -import androidx.room.Entity -import androidx.room.PrimaryKey import com.raccoongang.core.BlockType import com.raccoongang.core.domain.model.* -@Entity(tableName = "course_blocks_table") -data class BlockDbEntity( - @ColumnInfo("courseId") - val courseId: String, - @PrimaryKey +data class BlockDb( @ColumnInfo("id") val id: String, @ColumnInfo("blockId") @@ -60,12 +54,10 @@ data class BlockDbEntity( companion object { fun createFrom( - block: com.raccoongang.core.data.model.Block, - courseId: String - ): BlockDbEntity { + block: com.raccoongang.core.data.model.Block + ): BlockDb { with(block) { - return BlockDbEntity( - courseId = courseId, + return BlockDb( id = id ?: "", blockId = blockId ?: "", lmsWebUrl = lmsWebUrl ?: "", @@ -93,7 +85,7 @@ data class StudentViewDataDb( @ColumnInfo("topicId") val topicId: String, @Embedded - val transcripts: TranscriptsDb?, + val transcripts: HashMap?, @Embedded val encodedVideos: EncodedVideosDb? ) { @@ -101,7 +93,7 @@ data class StudentViewDataDb( return StudentViewData( onlyOnWeb, duration, - transcripts?.mapToDomain(), + transcripts, encodedVideos?.mapToDomain(), topicId ) @@ -113,7 +105,7 @@ data class StudentViewDataDb( return StudentViewDataDb( onlyOnWeb = studentViewData?.onlyOnWeb ?: false, duration = studentViewData?.duration.toString(), - transcripts = TranscriptsDb.createFrom(studentViewData?.transcripts), + transcripts = studentViewData?.transcripts, encodedVideos = EncodedVideosDb.createFrom(studentViewData?.encodedVideos), topicId = studentViewData?.topicId ?: "" ) @@ -122,19 +114,6 @@ data class StudentViewDataDb( } } -data class TranscriptsDb( - @ColumnInfo("en") - val en: String -) { - fun mapToDomain() = Transcripts(en) - - companion object { - fun createFrom(transcripts: com.raccoongang.core.data.model.Transcripts?): TranscriptsDb { - return TranscriptsDb(transcripts?.en ?: "") - } - } -} - data class EncodedVideosDb( @ColumnInfo("youtube") val youtube: VideoInfoDb?, @@ -205,4 +184,4 @@ data class BlockCountsDb( return BlockCountsDb(blocksCounts?.video ?: 0) } } -} \ No newline at end of file +} diff --git a/core/src/main/java/com/raccoongang/core/data/model/room/CourseEntity.kt b/core/src/main/java/com/raccoongang/core/data/model/room/CourseEntity.kt index bd2a6d0a1..821370eb7 100644 --- a/core/src/main/java/com/raccoongang/core/data/model/room/CourseEntity.kt +++ b/core/src/main/java/com/raccoongang/core/data/model/room/CourseEntity.kt @@ -50,7 +50,9 @@ data class CourseEntity( @ColumnInfo("startType") val startType: String, @ColumnInfo("overview") - val overview: String + val overview: String, + @ColumnInfo("isEnrolled") + val isEnrolled: Boolean ) { fun mapToDomain(): Course { @@ -75,6 +77,7 @@ data class CourseEntity( startDisplay = startDisplay, startType = startType, overview = overview, + isEnrolled = isEnrolled ) } @@ -101,7 +104,8 @@ data class CourseEntity( startType = startType ?: "", pacing = pacing ?: "", overview = overview ?: "", - media = MediaDb.createFrom(media) + media = MediaDb.createFrom(media), + isEnrolled = isEnrolled ?: false ) } } diff --git a/core/src/main/java/com/raccoongang/core/data/model/room/CourseStructureEntity.kt b/core/src/main/java/com/raccoongang/core/data/model/room/CourseStructureEntity.kt new file mode 100644 index 000000000..1af4d2e7c --- /dev/null +++ b/core/src/main/java/com/raccoongang/core/data/model/room/CourseStructureEntity.kt @@ -0,0 +1,64 @@ +package com.raccoongang.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.raccoongang.core.data.model.room.discovery.CertificateDb +import com.raccoongang.core.data.model.room.discovery.CoursewareAccessDb +import com.raccoongang.core.domain.model.CourseStructure +import com.raccoongang.core.utils.TimeUtils + +@Entity(tableName = "course_structure_table") +data class CourseStructureEntity( + @ColumnInfo("root") + val root: String, + @PrimaryKey + @ColumnInfo("id") + val id: String, + @ColumnInfo("blocks") + val blocks: List, + @ColumnInfo("name") + val name: String, + @ColumnInfo("number") + val number: String, + @ColumnInfo("org") + val org: String, + @ColumnInfo("start") + val start: String?, + @ColumnInfo("startDisplay") + val startDisplay: String, + @ColumnInfo("startType") + val startType: String, + @ColumnInfo("end") + val end: String?, + @Embedded + val coursewareAccess: CoursewareAccessDb, + @Embedded + val media: MediaDb?, + @Embedded + val certificate: CertificateDb?, + @ColumnInfo("isSelfPaced") + val isSelfPaced: Boolean +) { + + fun mapToDomain(): CourseStructure { + return CourseStructure( + root, + blocks.map { it.mapToDomain() }, + id, + name, + number, + org, + TimeUtils.iso8601ToDate(start ?: ""), + startDisplay, + startType, + TimeUtils.iso8601ToDate(end ?: ""), + coursewareAccess.mapToDomain(), + media?.mapToDomain(), + certificate?.mapToDomain(), + isSelfPaced + ) + } + +} \ No newline at end of file diff --git a/core/src/main/java/com/raccoongang/core/domain/model/Account.kt b/core/src/main/java/com/raccoongang/core/domain/model/Account.kt index 384f8f7ba..944b88a80 100644 --- a/core/src/main/java/com/raccoongang/core/domain/model/Account.kt +++ b/core/src/main/java/com/raccoongang/core/domain/model/Account.kt @@ -2,6 +2,7 @@ package com.raccoongang.core.domain.model import android.os.Parcelable import com.google.gson.annotations.SerializedName +import com.raccoongang.core.AppDataConstants.USER_MIN_YEAR import kotlinx.parcelize.Parcelize import java.util.* @@ -48,4 +49,11 @@ data class Account( ALL_USERS } + fun isLimited() = accountPrivacy == Privacy.PRIVATE + + fun isOlderThanMinAge() : Boolean { + val currentYear = Calendar.getInstance().get(Calendar.YEAR) + return yearOfBirth != null && currentYear - yearOfBirth > USER_MIN_YEAR + } + } diff --git a/core/src/main/java/com/raccoongang/core/domain/model/Block.kt b/core/src/main/java/com/raccoongang/core/domain/model/Block.kt index 6f18eecd6..501947f67 100644 --- a/core/src/main/java/com/raccoongang/core/domain/model/Block.kt +++ b/core/src/main/java/com/raccoongang/core/domain/model/Block.kt @@ -52,15 +52,11 @@ data class Block( data class StudentViewData( val onlyOnWeb: Boolean, val duration: Any, - val transcripts: Transcripts?, + val transcripts: HashMap?, val encodedVideos: EncodedVideos?, val topicId: String ) -data class Transcripts( - val en: String -) - data class EncodedVideos( val youtube: VideoInfo?, var hls: VideoInfo?, @@ -70,11 +66,11 @@ data class EncodedVideos( var mobileLow: VideoInfo? ) { val hasDownloadableVideo: Boolean - get() = (youtube == null && (!hls?.url.isNullOrEmpty() || - !fallback?.url.isNullOrEmpty() || - !desktopMp4?.url.isNullOrEmpty() || - !mobileHigh?.url.isNullOrEmpty() || - !mobileLow?.url.isNullOrEmpty())) + get() = isPreferredVideoInfo(hls) || + isPreferredVideoInfo(fallback) || + isPreferredVideoInfo(desktopMp4) || + isPreferredVideoInfo(mobileHigh) || + isPreferredVideoInfo(mobileLow) fun getPreferredVideoInfoForDownloading(preferredVideoQuality: VideoQuality): VideoInfo? { var preferredVideoInfo = when (preferredVideoQuality) { @@ -134,4 +130,4 @@ data class VideoInfo( data class BlockCounts( val video: Int -) \ No newline at end of file +) diff --git a/core/src/main/java/com/raccoongang/core/domain/model/Course.kt b/core/src/main/java/com/raccoongang/core/domain/model/Course.kt index 496bfc425..3d41341f3 100644 --- a/core/src/main/java/com/raccoongang/core/domain/model/Course.kt +++ b/core/src/main/java/com/raccoongang/core/domain/model/Course.kt @@ -23,5 +23,6 @@ data class Course( val end: String, val startDisplay: String, val startType: String, - val overview : String + val overview : String, + val isEnrolled: Boolean ) \ No newline at end of file diff --git a/core/src/main/java/com/raccoongang/core/domain/model/CourseStructure.kt b/core/src/main/java/com/raccoongang/core/domain/model/CourseStructure.kt index a75cf2579..e6391dc09 100644 --- a/core/src/main/java/com/raccoongang/core/domain/model/CourseStructure.kt +++ b/core/src/main/java/com/raccoongang/core/domain/model/CourseStructure.kt @@ -1,6 +1,20 @@ package com.raccoongang.core.domain.model -class CourseStructure( +import java.util.* + +data class CourseStructure( val root: String, - val blockData: Map + val blockData: List, + val id: String, + val name: String, + val number: String, + val org: String, + val start: Date?, + val startDisplay: String, + val startType: String, + val end: Date?, + val coursewareAccess: CoursewareAccess, + val media: Media?, + val certificate: Certificate?, + val isSelfPaced: Boolean ) \ No newline at end of file diff --git a/core/src/main/java/com/raccoongang/core/domain/model/DashboardCourseList.kt b/core/src/main/java/com/raccoongang/core/domain/model/DashboardCourseList.kt new file mode 100644 index 000000000..ed3c2c38e --- /dev/null +++ b/core/src/main/java/com/raccoongang/core/domain/model/DashboardCourseList.kt @@ -0,0 +1,6 @@ +package com.raccoongang.core.domain.model + +data class DashboardCourseList( + val pagination: Pagination, + val courses: List +) diff --git a/core/src/main/java/com/raccoongang/core/extension/BundleExt.kt b/core/src/main/java/com/raccoongang/core/extension/BundleExt.kt index 28c5aefc3..186c72c72 100644 --- a/core/src/main/java/com/raccoongang/core/extension/BundleExt.kt +++ b/core/src/main/java/com/raccoongang/core/extension/BundleExt.kt @@ -1,8 +1,11 @@ +@file:Suppress("NOTHING_TO_INLINE") + package com.raccoongang.core.extension import android.os.Build.VERSION.SDK_INT import android.os.Bundle import android.os.Parcelable +import com.google.gson.Gson import java.io.Serializable inline fun Bundle.parcelable(key: String): T? = when { @@ -13,4 +16,19 @@ inline fun Bundle.parcelable(key: String): T? = when { inline fun Bundle.serializable(key: String): T? = when { SDK_INT >= 33 -> getSerializable(key, T::class.java) else -> @Suppress("DEPRECATION") getSerializable(key) as? T -} \ No newline at end of file +} + +inline fun Bundle.parcelableArrayList(key: String): ArrayList? = when { + SDK_INT >= 33 -> getParcelableArrayList(key, T::class.java) + else -> @Suppress("DEPRECATION") getParcelableArrayList(key) +} + +inline fun objectToString(value: T): String = Gson().toJson(value) + +inline fun stringToObject(value: String): T? { + return try { + Gson().fromJson(value, genericType()) + } catch (e: Exception) { + null + } +} diff --git a/core/src/main/java/com/raccoongang/core/extension/FragmentExt.kt b/core/src/main/java/com/raccoongang/core/extension/FragmentExt.kt new file mode 100644 index 000000000..adc559577 --- /dev/null +++ b/core/src/main/java/com/raccoongang/core/extension/FragmentExt.kt @@ -0,0 +1,28 @@ +package com.raccoongang.core.extension + +import androidx.fragment.app.Fragment +import androidx.window.layout.WindowMetricsCalculator +import com.raccoongang.core.ui.WindowSize +import com.raccoongang.core.ui.WindowType + +fun Fragment.computeWindowSizeClasses(): WindowSize { + val metrics = WindowMetricsCalculator.getOrCreate() + .computeCurrentWindowMetrics(requireActivity()) + + val widthDp = metrics.bounds.width() / + resources.displayMetrics.density + val widthWindowSize = when { + widthDp < 600f -> WindowType.Compact + widthDp < 840f -> WindowType.Medium + else -> WindowType.Expanded + } + + val heightDp = metrics.bounds.height() / + resources.displayMetrics.density + val heightWindowSize = when { + heightDp < 480f -> WindowType.Compact + heightDp < 900f -> WindowType.Medium + else -> WindowType.Expanded + } + return WindowSize(widthWindowSize, heightWindowSize) +} \ No newline at end of file diff --git a/core/src/main/java/com/raccoongang/core/extension/StringExt.kt b/core/src/main/java/com/raccoongang/core/extension/StringExt.kt index ae8a61a27..6cd69752d 100644 --- a/core/src/main/java/com/raccoongang/core/extension/StringExt.kt +++ b/core/src/main/java/com/raccoongang/core/extension/StringExt.kt @@ -11,3 +11,20 @@ fun String.isEmailValid(): Boolean { } fun String.isLinkValid() = Patterns.WEB_URL.matcher(this).matches() + +fun String.replaceLinkTags(isDarkTheme: Boolean): String { + val linkColor = if (isDarkTheme) "879FF5" else "0000EE" + var text = ("" + + "" + + "" + this) + "" + var str: String + while (text.indexOf("\u0082") > 0) { + if (text.indexOf("\u0082") > 0 && text.indexOf("\u0083") > 0) { + str = text.substring(text.indexOf("\u0082") + 1, text.indexOf("\u0083")) + text = text.replace(("\u0082" + str + "\u0083").toRegex(), "$str") + } + } + return text +} diff --git a/core/src/main/java/com/raccoongang/core/module/TranscriptManager.kt b/core/src/main/java/com/raccoongang/core/module/TranscriptManager.kt new file mode 100644 index 000000000..8b9621782 --- /dev/null +++ b/core/src/main/java/com/raccoongang/core/module/TranscriptManager.kt @@ -0,0 +1,126 @@ +package com.raccoongang.core.module + +import android.content.Context +import com.raccoongang.core.module.download.AbstractDownloader +import com.raccoongang.core.utils.* +import okhttp3.OkHttpClient +import subtitleFile.FormatSRT +import subtitleFile.TimedTextObject +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.io.InputStream +import java.nio.charset.Charset +import java.util.concurrent.TimeUnit + +class TranscriptManager( + val context: Context +) { + + private val transcriptDownloader = object : AbstractDownloader() { + override val client: OkHttpClient + get() = OkHttpClient.Builder().build() + } + + var transcriptObject: TimedTextObject? = null + + fun has(url: String): Boolean { + val transcriptDir = getTranscriptDir() ?: return false + val hash = Sha1Util.SHA1(url) + val file = File(transcriptDir, hash) + return file.exists() && System.currentTimeMillis() - file.lastModified() < TimeUnit.HOURS.toMillis(5) + } + + fun get(url: String): String? { + val transcriptDir = getTranscriptDir() + ?: throw IOException("Transcript directory not found") + val hash = Sha1Util.SHA1(url) + val file = File(transcriptDir, hash) + if (!file.exists()) { + return null + } + return IOUtils.toString(file, Charset.defaultCharset()) + } + + fun getInputStream(url: String): InputStream? { + val transcriptDir = getTranscriptDir() + ?: throw IOException("Transcript directory not found") + val hash = Sha1Util.SHA1(url) + val file = File(transcriptDir, hash) + return if (!file.exists()) { + // not in cache + null + } else FileInputStream(file) + } + + private suspend fun startTranscriptDownload(downloadLink: String) { + if (!has(downloadLink)) { + val file = File(getTranscriptDir(), Sha1Util.SHA1(downloadLink)) + val result = transcriptDownloader.download( + downloadLink, + file.path + ) + if (result) { + getInputStream(downloadLink)?.let { + val transcriptTimedTextObject = + convertIntoTimedTextObject(it) + transcriptObject = transcriptTimedTextObject + } + } + } + } + + suspend fun downloadTranscriptsForVideo(transcriptUrl: String): TimedTextObject? { + transcriptObject = null + if (transcriptUrl.isEmpty()) return null + val transcriptInputStream = fetchTranscriptResponse(transcriptUrl) + if (transcriptInputStream != null) { + try { + transcriptObject = convertIntoTimedTextObject(transcriptInputStream) + } catch (e: Exception) { + e.printStackTrace() + } + } else { + startTranscriptDownload(transcriptUrl) + } + return transcriptObject + } + + suspend fun cancelTranscriptDownloading() { + transcriptDownloader.cancelDownloading() + } + + private fun convertIntoTimedTextObject(inputStream: InputStream): TimedTextObject? { + val timedTextObject = FormatSRT().parseFile("temp.srt", inputStream) + inputStream.close() + return timedTextObject + } + + fun fetchTranscriptResponse(url: String?): InputStream? { + if (url == null) { + return null + } + val response: InputStream? + try { + if (has(url)) { + response = getInputStream(url) + return response + } + } catch (e: IOException) { + e.printStackTrace() + } + return null + } + + private fun getTranscriptDir(): File? { + val externalAppDir: File = FileUtil.getExternalAppDir(context) + if (externalAppDir != null) { + val videosDir = File(externalAppDir, Directories.VIDEOS.name) + val transcriptDir = File(videosDir, Directories.SUBTITLES.name) + transcriptDir.mkdirs() + return transcriptDir + } + return null + } + +} \ No newline at end of file diff --git a/core/src/main/java/com/raccoongang/core/module/download/AbstractDownloader.kt b/core/src/main/java/com/raccoongang/core/module/download/AbstractDownloader.kt new file mode 100644 index 000000000..54a614fa2 --- /dev/null +++ b/core/src/main/java/com/raccoongang/core/module/download/AbstractDownloader.kt @@ -0,0 +1,79 @@ +package com.raccoongang.core.module.download + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream + +abstract class AbstractDownloader { + + protected abstract val client: OkHttpClient + + private val downloadApi: DownloadApi by lazy { + Retrofit.Builder() + .baseUrl(com.raccoongang.core.BuildConfig.BASE_URL) + .client(client) + .build() + .create(DownloadApi::class.java) + } + + private var currentDownloadingFilePath: String? = null + + var isCanceled = false + + private var input: InputStream? = null + + open suspend fun download( + url: String, + path: String + ): Boolean { + isCanceled = false + return try { + val response = downloadApi.downloadFile(url).body() + if (response != null) { + val file = File(path) + if (file.exists()) { + file.delete() + } + file.createNewFile() + input = response.byteStream() + currentDownloadingFilePath = path + val fos = FileOutputStream(file) + fos.use { output -> + val buffer = ByteArray(4 * 1024) + var read: Int + while (input!!.read(buffer).also { read = it } != -1) { + output.write(buffer, 0, read) + } + output.flush() + } + true + } else { + false + } + } catch (e: Exception) { + e.printStackTrace() + false + } finally { + input?.close() + } + } + + + suspend fun cancelDownloading() { + isCanceled = true + withContext(Dispatchers.IO) { + input?.close() + } + currentDownloadingFilePath?.let { + val file = File(it) + if (file.exists()) { + file.delete() + } + } + } + +} \ No newline at end of file diff --git a/core/src/main/java/com/raccoongang/core/module/download/FileDownloader.kt b/core/src/main/java/com/raccoongang/core/module/download/FileDownloader.kt index 17d197f31..d77fe3fa0 100644 --- a/core/src/main/java/com/raccoongang/core/module/download/FileDownloader.kt +++ b/core/src/main/java/com/raccoongang/core/module/download/FileDownloader.kt @@ -1,25 +1,19 @@ package com.raccoongang.core.module.download import android.util.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Response import okhttp3.ResponseBody -import retrofit2.Retrofit import retrofit2.http.GET import retrofit2.http.Streaming import retrofit2.http.Url -import java.io.File -import java.io.FileOutputStream -import java.io.InputStream -class FileDownloader : ProgressListener { +class FileDownloader : AbstractDownloader(), ProgressListener { private var firstUpdate = true - private val client: OkHttpClient = OkHttpClient.Builder() + override val client: OkHttpClient = OkHttpClient.Builder() .addNetworkInterceptor(Interceptor { chain: Interceptor.Chain -> val originalResponse: Response = chain.proceed(chain.request()) originalResponse.newBuilder() @@ -27,71 +21,8 @@ class FileDownloader : ProgressListener { .build() }) .build() - - private val downloadApi = Retrofit.Builder() - .baseUrl(com.raccoongang.core.BuildConfig.BASE_URL) - .client(client) - .build() - .create(DownloadApi::class.java) - - private var currentDownloadingFilePath: String? = null - var progressListener: CurrentProgress? = null - var isCanceled = false - - private var input: InputStream? = null - - suspend fun download( - url: String, - path: String - ): Boolean { - isCanceled = false - return try { - val response = downloadApi.downloadFile(url).body() - if (response != null) { - val file = File(path) - if (file.exists()) { - file.delete() - } - file.createNewFile() - input = response.byteStream() - currentDownloadingFilePath = path - val fos = FileOutputStream(file) - fos.use { output -> - val buffer = ByteArray(4 * 1024) - var read: Int - while (input!!.read(buffer).also { read = it } != -1) { - output.write(buffer, 0, read) - } - output.flush() - } - true - } else { - false - } - } catch (e: Exception) { - e.printStackTrace() - false - } finally { - input?.close() - } - } - - - suspend fun cancelDownloading() { - isCanceled = true - withContext(Dispatchers.IO) { - input?.close() - } - currentDownloadingFilePath?.let { - val file = File(it) - if (file.exists()) { - file.delete() - } - } - } - override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { if (done) { Log.d("DownloadProgress", "done") diff --git a/core/src/main/java/com/raccoongang/core/presentation/dialog/SelectBottomDialogFragment.kt b/core/src/main/java/com/raccoongang/core/presentation/dialog/SelectBottomDialogFragment.kt new file mode 100644 index 000000000..7de379ad9 --- /dev/null +++ b/core/src/main/java/com/raccoongang/core/presentation/dialog/SelectBottomDialogFragment.kt @@ -0,0 +1,105 @@ +package com.raccoongang.core.presentation.dialog + +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.DialogFragment +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.raccoongang.core.R +import com.raccoongang.core.domain.model.RegistrationField +import com.raccoongang.core.extension.parcelableArrayList +import com.raccoongang.core.ui.SheetContent +import com.raccoongang.core.ui.px +import com.raccoongang.core.ui.rememberWindowSize +import com.raccoongang.core.ui.theme.BottomSheetShape +import com.raccoongang.core.ui.theme.NewEdxTheme +import com.raccoongang.core.ui.theme.appColors +import com.raccoongang.core.ui.windowSizeValue +import org.koin.androidx.viewmodel.ext.android.viewModel + +class SelectBottomDialogFragment() : BottomSheetDialogFragment() { + + private val viewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.values = requireArguments().parcelableArrayList(ARG_LIST_VALUES)!! + setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialog) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + if (dialog != null && dialog!!.window != null) { + dialog!!.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + (dialog as? BottomSheetDialog)?.behavior?.apply { + state = BottomSheetBehavior.STATE_EXPANDED + } + } + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + NewEdxTheme { + val windowSize = rememberWindowSize() + val configuration = LocalConfiguration.current + val listState = rememberLazyListState() + val bottomSheetWeight by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) 0.8f else 0.6f, + compact = 1f + ) + ) + } + + Surface( + shape = BottomSheetShape( + width = configuration.screenWidthDp.px, + height = configuration.screenHeightDp.px, + weight = bottomSheetWeight + ), + color = MaterialTheme.appColors.background + ) { + SheetContent( + expandedList = viewModel.values, + onItemClick = { item -> + viewModel.sendCourseEventChanged(item.value) + dismiss() + }, + listState = listState + ) + } + } + } + } + + companion object { + private const val ARG_LIST_VALUES = "argListValues" + + fun newInstance( + values: List + ): SelectBottomDialogFragment { + val dialog = SelectBottomDialogFragment() + dialog.arguments = Bundle().apply { + putParcelableArrayList(ARG_LIST_VALUES, ArrayList(values)) + } + return dialog + } + } + +} \ No newline at end of file diff --git a/core/src/main/java/com/raccoongang/core/presentation/dialog/SelectDialogViewModel.kt b/core/src/main/java/com/raccoongang/core/presentation/dialog/SelectDialogViewModel.kt new file mode 100644 index 000000000..07c2050f5 --- /dev/null +++ b/core/src/main/java/com/raccoongang/core/presentation/dialog/SelectDialogViewModel.kt @@ -0,0 +1,22 @@ +package com.raccoongang.core.presentation.dialog + +import androidx.lifecycle.viewModelScope +import com.raccoongang.core.BaseViewModel +import com.raccoongang.core.domain.model.RegistrationField +import com.raccoongang.core.system.notifier.CourseNotifier +import com.raccoongang.core.system.notifier.CourseSubtitleLanguageChanged +import kotlinx.coroutines.launch + +class SelectDialogViewModel( + private val notifier: CourseNotifier +) : BaseViewModel() { + + var values = mutableListOf() + + fun sendCourseEventChanged(value: String) { + viewModelScope.launch { + notifier.send(CourseSubtitleLanguageChanged(value)) + } + } + +} diff --git a/core/src/main/java/com/raccoongang/core/presentation/global/AppDataHolder.kt b/core/src/main/java/com/raccoongang/core/presentation/global/AppDataHolder.kt new file mode 100644 index 000000000..4a82a928f --- /dev/null +++ b/core/src/main/java/com/raccoongang/core/presentation/global/AppDataHolder.kt @@ -0,0 +1,9 @@ +package com.raccoongang.core.presentation.global + +interface AppDataHolder { + val appData: AppData +} + +data class AppData( + val versionName: String +) \ No newline at end of file diff --git a/core/src/main/java/com/raccoongang/core/system/notifier/CourseNotifier.kt b/core/src/main/java/com/raccoongang/core/system/notifier/CourseNotifier.kt index 444a70ef5..a2a002672 100644 --- a/core/src/main/java/com/raccoongang/core/system/notifier/CourseNotifier.kt +++ b/core/src/main/java/com/raccoongang/core/system/notifier/CourseNotifier.kt @@ -13,5 +13,7 @@ class CourseNotifier { suspend fun send(event: CourseVideoPositionChanged) = channel.emit(event) suspend fun send(event: CourseStructureUpdated) = channel.emit(event) suspend fun send(event: CourseDashboardUpdate) = channel.emit(event) + suspend fun send(event: CoursePauseVideo) = channel.emit(event) + suspend fun send(event: CourseSubtitleLanguageChanged) = channel.emit(event) } \ No newline at end of file diff --git a/core/src/main/java/com/raccoongang/core/system/notifier/CoursePauseVideo.kt b/core/src/main/java/com/raccoongang/core/system/notifier/CoursePauseVideo.kt new file mode 100644 index 000000000..b47475da9 --- /dev/null +++ b/core/src/main/java/com/raccoongang/core/system/notifier/CoursePauseVideo.kt @@ -0,0 +1,3 @@ +package com.raccoongang.core.system.notifier + +class CoursePauseVideo: CourseEvent \ No newline at end of file diff --git a/core/src/main/java/com/raccoongang/core/system/notifier/CourseSubtitleLanguageChanged.kt b/core/src/main/java/com/raccoongang/core/system/notifier/CourseSubtitleLanguageChanged.kt new file mode 100644 index 000000000..43a3bc243 --- /dev/null +++ b/core/src/main/java/com/raccoongang/core/system/notifier/CourseSubtitleLanguageChanged.kt @@ -0,0 +1,5 @@ +package com.raccoongang.core.system.notifier + +data class CourseSubtitleLanguageChanged( + val value: String +) : CourseEvent diff --git a/core/src/main/java/com/raccoongang/core/ui/ComposeCommon.kt b/core/src/main/java/com/raccoongang/core/ui/ComposeCommon.kt index 28f759104..a058d7b2b 100644 --- a/core/src/main/java/com/raccoongang/core/ui/ComposeCommon.kt +++ b/core/src/main/java/com/raccoongang/core/ui/ComposeCommon.kt @@ -35,15 +35,12 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.* import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp @@ -94,7 +91,7 @@ fun StaticSearchBar( Text( modifier = Modifier.fillMaxWidth(), text = text, - color = MaterialTheme.appColors.textSecondary + color = MaterialTheme.appColors.textFieldHint ) } } @@ -105,11 +102,11 @@ fun StaticSearchBar( @Composable fun SearchBar( modifier: Modifier, - searchValue: String, + searchValue: TextFieldValue, requestFocus: Boolean = false, label: String = stringResource(id = R.string.core_search), keyboardActions: () -> Unit, - onValueChanged: (String) -> Unit = {}, + onValueChanged: (TextFieldValue) -> Unit = {}, onClearValue: () -> Unit, ) { val keyboardController = LocalSoftwareKeyboardController.current @@ -123,6 +120,9 @@ fun SearchBar( var isFocused by rememberSaveable { mutableStateOf(false) } + var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(searchValue) + } OutlinedTextField( modifier = Modifier .focusRequester(focusRequester) @@ -132,9 +132,14 @@ fun SearchBar( .clip(MaterialTheme.appShapes.textFieldShape) .then(modifier), shape = MaterialTheme.appShapes.textFieldShape, - value = searchValue, + value = textFieldValue, onValueChange = { - onValueChanged(it) + if (it.text != textFieldValue.text) { + textFieldValue = it + onValueChanged(textFieldValue) + } else { + textFieldValue = it + } }, colors = TextFieldDefaults.outlinedTextFieldColors( textColor = MaterialTheme.appColors.textPrimary, @@ -161,8 +166,9 @@ fun SearchBar( ) }, trailingIcon = { - if (searchValue.isNotEmpty()) { + if (searchValue.text.isNotEmpty()) { IconButton(onClick = { + textFieldValue = TextFieldValue("") onClearValue() }) { Icon( @@ -279,6 +285,7 @@ fun HyperlinkText( @Composable fun HyperlinkImageText( modifier: Modifier = Modifier, + title: String = "", imageText: LinkedImageText, textStyle: TextStyle = TextStyle.Default, linkTextColor: Color = MaterialTheme.appColors.primary, @@ -289,6 +296,10 @@ fun HyperlinkImageText( val fullText = imageText.text val hyperLinks = imageText.links val annotatedString = buildAnnotatedString { + if(title.isNotEmpty()) { + append(title) + append("\n\n") + } append(fullText) addStyle( style = SpanStyle( @@ -296,11 +307,11 @@ fun HyperlinkImageText( fontSize = fontSize ), start = 0, - end = fullText.length + end = this.length ) for ((key, value) in hyperLinks) { - val startIndex = fullText.indexOf(key) + val startIndex = this.toString().indexOf(key) if (startIndex == -1) continue val endIndex = startIndex + key.length addStyle( @@ -320,8 +331,19 @@ fun HyperlinkImageText( end = endIndex ) } + if (title.isNotEmpty()) { + addStyle( + style = SpanStyle( + color = MaterialTheme.appColors.textPrimary, + fontSize = MaterialTheme.appTypography.titleLarge.fontSize, + fontWeight = MaterialTheme.appTypography.titleLarge.fontWeight + ), + start = 0, + end = title.length + ) + } for (item in imageText.headers) { - val startIndex = fullText.indexOf(item) + val startIndex = this.toString().indexOf(item) if (startIndex == -1) continue val endIndex = startIndex + item.length addStyle( @@ -339,7 +361,7 @@ fun HyperlinkImageText( fontSize = fontSize ), start = 0, - end = fullText.length + end = this.length ) } @@ -476,8 +498,10 @@ fun NewEdxOutlinedTextField( keyboardActions: (FocusManager) -> Unit = { it.moveFocus(FocusDirection.Down) }, onValueChanged: (String) -> Unit, ) { - var inputFieldValue by rememberSaveable { - mutableStateOf("") + var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue("") + ) } val focusManager = LocalFocusManager.current @@ -502,14 +526,14 @@ fun NewEdxOutlinedTextField( value = inputFieldValue, onValueChange = { inputFieldValue = it - onValueChanged(it) + onValueChanged(it.text) }, colors = TextFieldDefaults.outlinedTextFieldColors( unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, textColor = MaterialTheme.appColors.textFieldText, backgroundColor = MaterialTheme.appColors.textFieldBackground, errorBorderColor = MaterialTheme.appColors.error, - ), + ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { Text( @@ -670,19 +694,20 @@ fun IconText( @Composable fun IconText( + modifier: Modifier = Modifier, text: String, painter: Painter, color: Color, textStyle: TextStyle = MaterialTheme.appTypography.bodySmall, onClick: (() -> Unit)? = null, ) { - val modifier = if (onClick == null) { + val modifierClickable = if (onClick == null) { Modifier } else { Modifier.noRippleClickable { onClick.invoke() } } Row( - modifier = modifier, + modifier = modifier.then(modifierClickable), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { @@ -724,6 +749,34 @@ fun TextIcon( } } +@Composable +fun TextIcon( + text: String, + painter: Painter, + color: Color, + textStyle: TextStyle = MaterialTheme.appTypography.bodySmall, + onClick: (() -> Unit)? = null, +) { + val modifier = if (onClick == null) { + Modifier + } else { + Modifier.noRippleClickable { onClick.invoke() } + } + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(text = text, color = color, style = textStyle) + Icon( + modifier = Modifier.size((textStyle.fontSize.value + 4).dp), + painter = painter, + contentDescription = null, + tint = color + ) + } +} + @Composable fun OfflineModeDialog( modifier: Modifier, @@ -774,15 +827,17 @@ fun NewEdxButton( backgroundColor: Color = MaterialTheme.appColors.buttonBackground, content: (@Composable RowScope.() -> Unit)? = null ) { - Button(modifier = Modifier - .then(width) - .height(42.dp), + Button( + modifier = Modifier + .then(width) + .height(42.dp), shape = MaterialTheme.appShapes.buttonShape, colors = ButtonDefaults.buttonColors( backgroundColor = backgroundColor ), enabled = enabled, - onClick = onClick) { + onClick = onClick + ) { if (content == null) { Text( text = text, @@ -859,7 +914,7 @@ private fun SearchBarPreview() { modifier = Modifier .fillMaxWidth() .height(48.dp), - searchValue = "", + searchValue = TextFieldValue(), keyboardActions = {}, onClearValue = {} ) diff --git a/core/src/main/java/com/raccoongang/core/ui/ComposeExtensions.kt b/core/src/main/java/com/raccoongang/core/ui/ComposeExtensions.kt index 9926a09f2..be94fea6c 100644 --- a/core/src/main/java/com/raccoongang/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/com/raccoongang/core/ui/ComposeExtensions.kt @@ -1,5 +1,6 @@ package com.raccoongang.core.ui +import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding @@ -13,6 +14,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import com.raccoongang.core.presentation.global.InsetHolder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch inline val isPreview: Boolean @ReadOnlyComposable @@ -76,3 +80,17 @@ fun rememberSaveableMap(init: () -> MutableMap): MutableMa } } +fun LazyListState.disableScrolling(scope: CoroutineScope) { + scope.launch { + scroll(scrollPriority = MutatePriority.PreventUserInput) { + awaitCancellation() + } + } +} + +fun LazyListState.reEnableScrolling(scope: CoroutineScope) { + scope.launch { + scroll(scrollPriority = MutatePriority.PreventUserInput) {} + } +} + diff --git a/core/src/main/java/com/raccoongang/core/ui/theme/Color.kt b/core/src/main/java/com/raccoongang/core/ui/theme/Color.kt index adfcaf8cb..d183f5159 100644 --- a/core/src/main/java/com/raccoongang/core/ui/theme/Color.kt +++ b/core/src/main/java/com/raccoongang/core/ui/theme/Color.kt @@ -23,6 +23,7 @@ data class AppColors( val cardViewBackground: Color, val cardViewBorder: Color, + val divider: Color, val certificateForeground: Color, val bottomSheetToggle: Color, diff --git a/core/src/main/java/com/raccoongang/core/ui/theme/Theme.kt b/core/src/main/java/com/raccoongang/core/ui/theme/Theme.kt index 06cafdc86..0967ebac3 100644 --- a/core/src/main/java/com/raccoongang/core/ui/theme/Theme.kt +++ b/core/src/main/java/com/raccoongang/core/ui/theme/Theme.kt @@ -41,8 +41,9 @@ private val DarkColorPalette = AppColors( buttonBackground = Color(0xFF5478F9), buttonText = Color.White, - cardViewBackground = Color(0xE2242A38), - cardViewBorder = Color(0xFF273346), + cardViewBackground = Color(0xFF273346), + cardViewBorder = Color(0xFF4E5A70), + divider = Color(0xFF4E5A70), certificateForeground = Color(0xD92EB865), bottomSheetToggle = Color(0xFF4E5A70), @@ -81,8 +82,9 @@ private val LightColorPalette = AppColors( buttonBackground = Color(0xFF3C68FF), buttonText = Color.White, - cardViewBackground = Color.White, + cardViewBackground = Color(0xFFF9FAFB), cardViewBorder = Color(0xFFCCD4E0), + divider = Color(0xFFCCD4E0), certificateForeground = Color(0xD94BD191), bottomSheetToggle = Color(0xFF4E5A70), diff --git a/core/src/main/java/com/raccoongang/core/utils/EmailUtil.kt b/core/src/main/java/com/raccoongang/core/utils/EmailUtil.kt new file mode 100644 index 000000000..e11360e80 --- /dev/null +++ b/core/src/main/java/com/raccoongang/core/utils/EmailUtil.kt @@ -0,0 +1,66 @@ +package com.raccoongang.core.utils + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.os.Build +import android.widget.Toast +import com.raccoongang.core.R + +object EmailUtil { + + fun showFeedbackScreen( + context: Context, + subject: String, + appVersion: String + ) { + val NEW_LINE = "\n" + val to = context.getString(R.string.feedback_email_address) + val body = StringBuilder() + with(body) { + append("${context.getString(R.string.core_android_os_version)} ${Build.VERSION.RELEASE}") + append(NEW_LINE) + append("${context.getString(R.string.core_app_version)} $appVersion") + append(NEW_LINE) + append("${context.getString(R.string.core_android_device_model)} ${Build.MODEL}") + append(NEW_LINE) + append(NEW_LINE) + append(context.getString(R.string.core_insert_feedback)) + } + sendEmailIntent(context, to, subject, body.toString()) + } + + fun sendEmailIntent( + context: Context?, + to: String, + subject: String, + email: String + ) { + val emailIntent = Intent(Intent.ACTION_SEND) + emailIntent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) + emailIntent.putExtra(Intent.EXTRA_SUBJECT, subject) + emailIntent.putExtra(Intent.EXTRA_TEXT, email) + emailIntent.type = "plain/text" + try { + emailIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context?.let { + // add flag to make sure this call works from non-activity context + val targetIntent = Intent.createChooser( + emailIntent, + it.getString(R.string.core_email_chooser_header) + ) + targetIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + it.startActivity(targetIntent) + } + } catch (ex: ActivityNotFoundException) { + //There is no activity which can perform the intended share Intent + context?.let { + Toast.makeText( + it, it.getString(R.string.core_email_client_not_present), + Toast.LENGTH_SHORT + ).show() + } + } + } + +} \ No newline at end of file diff --git a/core/src/main/java/com/raccoongang/core/utils/FileUtil.kt b/core/src/main/java/com/raccoongang/core/utils/FileUtil.kt new file mode 100644 index 000000000..7f8b83dad --- /dev/null +++ b/core/src/main/java/com/raccoongang/core/utils/FileUtil.kt @@ -0,0 +1,21 @@ +package com.raccoongang.core.utils + +import android.content.Context +import java.io.File + +object FileUtil { + + fun getExternalAppDir(context: Context): File { + val dir = context.externalCacheDir.toString() + File.separator + + context.getString(com.raccoongang.core.R.string.app_name).replace(Regex("\\s"), "_") + val file = File(dir) + file.mkdirs() + return file + } + + +} + +enum class Directories { + VIDEOS, SUBTITLES +} diff --git a/core/src/main/java/com/raccoongang/core/utils/IOUtils.kt b/core/src/main/java/com/raccoongang/core/utils/IOUtils.kt new file mode 100644 index 000000000..33e660b79 --- /dev/null +++ b/core/src/main/java/com/raccoongang/core/utils/IOUtils.kt @@ -0,0 +1,21 @@ +package com.raccoongang.core.utils + +import okio.buffer +import okio.sink +import okio.source +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.nio.charset.Charset + +object IOUtils { + + fun toString(file: File, charset: Charset): String { + return file.source().buffer().readString(charset) + } + + fun copy(input: InputStream, out: OutputStream) { + out.sink().buffer().writeAll(input.source()) + } + +} diff --git a/core/src/main/java/com/raccoongang/core/utils/LocaleUtils.kt b/core/src/main/java/com/raccoongang/core/utils/LocaleUtils.kt index f4474fddd..c60517c55 100644 --- a/core/src/main/java/com/raccoongang/core/utils/LocaleUtils.kt +++ b/core/src/main/java/com/raccoongang/core/utils/LocaleUtils.kt @@ -3,7 +3,6 @@ package com.raccoongang.core.utils import com.raccoongang.core.AppDataConstants.USER_MAX_YEAR import com.raccoongang.core.AppDataConstants.defaultLocale import com.raccoongang.core.domain.model.RegistrationField -import com.raccoongang.core.utils.LocaleUtils.getLanguages import java.util.* object LocaleUtils { @@ -28,6 +27,10 @@ object LocaleUtils { fun getLanguages() = getAvailableLanguages() + fun getLanguages(languages: List) = getAvailableLanguages().filter { + languages.contains(it.value) + } + fun getCountryByCountryCode(code: String): String? { val countryISO = Locale.getISOCountries().firstOrNull { it == code } return countryISO?.let { @@ -60,8 +63,8 @@ object LocaleUtils { .sortedBy { it.name } .toList() -} + fun getDisplayLanguage(languageCode: String): String { + return Locale(languageCode, "").getDisplayLanguage(defaultLocale) + } -fun main() { - println(getLanguages()) } diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index b9511606d..8de28cc5b 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -29,7 +29,13 @@ Перезавантажити Завантаження у процесі. Обліковий запис користувача не активовано. Будь ласка, спочатку активуйте свій обліковий запис. - + Надіслати електронний лист за допомогою ... + Не встановлено жодного поштового клієнта + Версія додатку: + Android OS: + Модель пристрою: + Відгук: + Відгук клієнта dd MMMM dd MMM yyyy HH:mm diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 44aae1c99..7ce2d1303 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -31,6 +31,13 @@ 540p 720p (Best quality) User account is not activated. Please activate your account first. + Send email using... + No e-mail clients installed + App Version: + Android OS: + Device Model: + Feedback: + Customer Feedback MMMM dd dd MMM yyyy hh:mm aaa diff --git a/core/src/main/res/values/values.xml b/core/src/main/res/values/values.xml new file mode 100644 index 000000000..1c985aac9 --- /dev/null +++ b/core/src/main/res/values/values.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/course/src/main/java/com/raccoongang/course/data/repository/CourseRepository.kt b/course/src/main/java/com/raccoongang/course/data/repository/CourseRepository.kt index a6aacf6c4..9ee3b5766 100644 --- a/course/src/main/java/com/raccoongang/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/com/raccoongang/course/data/repository/CourseRepository.kt @@ -8,7 +8,6 @@ import com.raccoongang.core.data.storage.PreferencesManager import com.raccoongang.core.domain.model.* import com.raccoongang.core.exception.NoCachedDataException import com.raccoongang.core.module.db.DownloadDao -import com.raccoongang.course.data.model.BlockDbEntity import com.raccoongang.course.data.storage.CourseDao import kotlinx.coroutines.flow.map import okhttp3.ResponseBody @@ -19,7 +18,7 @@ class CourseRepository( private val downloadDao: DownloadDao, private val preferencesManager: PreferencesManager, ) { - private val blocksList = mutableListOf() + private var courseStructure: CourseStructure? = null suspend fun getCourseDetail(id: String): Course { val course = api.getCourseDetail(id) @@ -56,47 +55,30 @@ class CourseRepository( preferencesManager.user?.username, courseId ) - val courseStructure = response.mapToDomain() - courseDao.insertCourseBlocks( - *response.blockData.values - .map { - BlockDbEntity.createFrom(it, courseId) - }.toTypedArray() - ) - blocksList.clear() - blocksList.addAll(courseStructure.blockData.values.toList()) + courseDao.insertCourseStructureEntity(response.mapToRoomEntity()) + courseStructure = null + courseStructure = response.mapToDomain() } suspend fun preloadCourseStructureFromCache(courseId: String) { - val response = courseDao.getCourseBlocksById(courseId) - blocksList.clear() - if (!response.isNullOrEmpty()) { - blocksList.addAll(response.map { it.mapToDomain() }) + val cachedCourseStructure = courseDao.getCourseStructureById(courseId) + courseStructure = null + if (cachedCourseStructure != null) { + courseStructure = cachedCourseStructure.mapToDomain() } else { throw NoCachedDataException() } } @Throws(IllegalStateException::class) - fun getCourseStructureFromCache(): List { - if (blocksList.isNotEmpty()) { - return blocksList + fun getCourseStructureFromCache(): CourseStructure { + if (courseStructure != null) { + return courseStructure!! } else { throw IllegalStateException("Course structure is empty") } } - suspend fun getEnrolledCourseById(courseId: String): EnrolledCourse? { - val user = preferencesManager.user - val enrolledCourse = api.getEnrolledCourses( - username = user?.username ?: "" - ) - val course = enrolledCourse.find { - it.course?.id == courseId - } - return course?.mapToDomain() - } - suspend fun getEnrolledCourseFromCacheById(courseId: String): EnrolledCourse? { val course = courseDao.getEnrolledCourseById(courseId) return course?.mapToDomain() @@ -117,8 +99,9 @@ class CourseRepository( return api.markBlocksCompletion(blocksCompletionBody) } - suspend fun getHandouts(url: String) = api.getHandouts(url).mapToDomain() + suspend fun getHandouts(courseId: String) = api.getHandouts(courseId).mapToDomain() - suspend fun getAnnouncements(url: String) = api.getAnnouncements(url).map { it.mapToDomain() } + suspend fun getAnnouncements(courseId: String) = + api.getAnnouncements(courseId).map { it.mapToDomain() } } \ No newline at end of file diff --git a/course/src/main/java/com/raccoongang/course/data/storage/CourseConverter.kt b/course/src/main/java/com/raccoongang/course/data/storage/CourseConverter.kt index c432df2e3..504742ee2 100644 --- a/course/src/main/java/com/raccoongang/course/data/storage/CourseConverter.kt +++ b/course/src/main/java/com/raccoongang/course/data/storage/CourseConverter.kt @@ -2,9 +2,9 @@ package com.raccoongang.course.data.storage import androidx.room.TypeConverter import com.google.gson.Gson +import com.raccoongang.core.data.model.room.BlockDb +import com.raccoongang.core.data.model.room.VideoInfoDb import com.raccoongang.core.extension.genericType -import com.raccoongang.course.data.model.BlockDbEntity -import com.raccoongang.course.data.model.VideoInfoDb class CourseConverter { @@ -34,15 +34,27 @@ class CourseConverter { } @TypeConverter - fun fromListOfBlockDbEntity(value: List): String { + fun fromListOfBlockDbEntity(value: List): String { val json = Gson().toJson(value) return json.toString() } @TypeConverter - fun toListOfBlockDbEntity(value: String): List { - val type = genericType>() + fun toListOfBlockDbEntity(value: String): List { + val type = genericType>() return Gson().fromJson(value, type) } -} \ No newline at end of file + @TypeConverter + fun fromStringToMap(value: String?): Map { + val mapType = genericType>() + return Gson().fromJson(value, mapType) + } + + @TypeConverter + fun fromMapToString(map: Map): String { + val gson = Gson() + return gson.toJson(map) + } + +} diff --git a/course/src/main/java/com/raccoongang/course/data/storage/CourseDao.kt b/course/src/main/java/com/raccoongang/course/data/storage/CourseDao.kt index 03a35a014..cbb9a7fbd 100644 --- a/course/src/main/java/com/raccoongang/course/data/storage/CourseDao.kt +++ b/course/src/main/java/com/raccoongang/course/data/storage/CourseDao.kt @@ -5,8 +5,8 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.raccoongang.core.data.model.room.CourseEntity +import com.raccoongang.core.data.model.room.CourseStructureEntity import com.raccoongang.core.data.model.room.discovery.EnrolledCourseEntity -import com.raccoongang.course.data.model.BlockDbEntity @Dao interface CourseDao { @@ -17,15 +17,12 @@ interface CourseDao { @Query("SELECT * FROM course_enrolled_table WHERE id=:id") suspend fun getEnrolledCourseById(id: String): EnrolledCourseEntity? - @Query("SELECT * FROM course_blocks_table WHERE courseId=:id") - suspend fun getCourseBlocksById(id: String): List? - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertCourseBlocks(vararg BlockDbEntityEntity: BlockDbEntity) + @Query("SELECT * FROM course_structure_table WHERE id=:id") + suspend fun getCourseStructureById(id: String): CourseStructureEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseEntity(vararg courseEntity: CourseEntity) - @Query("DELETE FROM course_blocks_table") - suspend fun clearAllCourseBlocks() -} \ No newline at end of file + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseStructureEntity(vararg courseStructureEntity: CourseStructureEntity) +} diff --git a/course/src/main/java/com/raccoongang/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/com/raccoongang/course/domain/interactor/CourseInteractor.kt index 6e607cee9..b901fc78b 100644 --- a/course/src/main/java/com/raccoongang/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/com/raccoongang/course/domain/interactor/CourseInteractor.kt @@ -2,7 +2,7 @@ package com.raccoongang.course.domain.interactor import com.raccoongang.core.BlockType import com.raccoongang.core.domain.model.Block -import com.raccoongang.core.module.db.DownloadModel +import com.raccoongang.core.domain.model.CourseStructure import com.raccoongang.course.data.repository.CourseRepository class CourseInteractor( @@ -27,8 +27,9 @@ class CourseInteractor( fun getCourseStructureFromCache() = repository.getCourseStructureFromCache() @Throws(IllegalStateException::class) - fun getCourseStructureForVideos(): List { - val blocks = repository.getCourseStructureFromCache() + fun getCourseStructureForVideos(): CourseStructure { + val courseStructure = repository.getCourseStructureFromCache() + val blocks = courseStructure.blockData val videoBlocks = blocks.filter { it.type == BlockType.VIDEO } val resultBlocks = ArrayList() videoBlocks.forEach { videoBlock -> @@ -62,19 +63,17 @@ class CourseInteractor( } } - return resultBlocks.toList() + return courseStructure.copy(blockData = resultBlocks.toList()) } - suspend fun getEnrolledCourseById(courseId: String) = repository.getEnrolledCourseById(courseId) - suspend fun getEnrolledCourseFromCacheById(courseId: String) = repository.getEnrolledCourseFromCacheById(courseId) suspend fun getCourseStatus(courseId: String) = repository.getCourseStatus(courseId) - suspend fun getHandouts(url: String) = repository.getHandouts(url) + suspend fun getHandouts(courseId: String) = repository.getHandouts(courseId) - suspend fun getAnnouncements(url: String) = repository.getAnnouncements(url) + suspend fun getAnnouncements(courseId: String) = repository.getAnnouncements(courseId) suspend fun removeDownloadModel(id: String) = repository.removeDownloadModel(id) diff --git a/course/src/main/java/com/raccoongang/course/presentation/CourseRouter.kt b/course/src/main/java/com/raccoongang/course/presentation/CourseRouter.kt index 6519b2d66..b0c61b932 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/CourseRouter.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/CourseRouter.kt @@ -1,7 +1,6 @@ package com.raccoongang.course.presentation import androidx.fragment.app.FragmentManager -import com.raccoongang.core.domain.model.Certificate import com.raccoongang.core.domain.model.CoursewareAccess import com.raccoongang.core.presentation.course.CourseViewMode import com.raccoongang.course.presentation.handouts.HandoutsType @@ -12,12 +11,16 @@ interface CourseRouter { fun navigateToCourseOutline( fm: FragmentManager, courseId: String, + courseTitle: String + ) + + fun navigateToNoAccess( + fm: FragmentManager, title: String, - image: String, - certificate: Certificate, coursewareAccess: CoursewareAccess, auditAccessExpires: Date? ) + fun navigateToCourseUnits( fm: FragmentManager, courseId: String, diff --git a/course/src/main/java/com/raccoongang/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/com/raccoongang/course/presentation/container/CourseContainerFragment.kt index 62be4cf56..1ae1a62b9 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/container/CourseContainerFragment.kt @@ -7,15 +7,15 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.snackbar.Snackbar -import com.raccoongang.core.domain.model.Certificate -import com.raccoongang.core.extension.parcelable import com.raccoongang.core.presentation.global.viewBinding import com.raccoongang.course.R import com.raccoongang.course.databinding.FragmentCourseContainerBinding +import com.raccoongang.course.presentation.CourseRouter import com.raccoongang.course.presentation.handouts.HandoutsFragment import com.raccoongang.course.presentation.outline.CourseOutlineFragment import com.raccoongang.course.presentation.videos.CourseVideosFragment import com.raccoongang.discussion.presentation.topics.DiscussionTopicsFragment +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf @@ -25,19 +25,17 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private val viewModel by viewModel { parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) } + private val router by inject() + private var adapter: CourseNavigationFragmentAdapter? = null private var courseTitle = "" - private var courseImage = "" - private var courseCertificate = Certificate("") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) with(requireArguments()) { - courseImage = getString(ARG_IMAGE, "") courseTitle = getString(ARG_TITLE, "") - courseCertificate = parcelable(ARG_CERTIFICATE)!! } viewModel.preloadCourseStructure() } @@ -66,11 +64,20 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } private fun observe() { - viewModel.dataReady.observe(viewLifecycleOwner) { ready -> - if (ready) { - binding.viewPager.isVisible = true - binding.bottomNavView.isVisible = true - initViewPager() + viewModel.dataReady.observe(viewLifecycleOwner) { coursewareAccess -> + if (coursewareAccess != null) { + if (coursewareAccess.hasAccess) { + binding.viewPager.isVisible = true + binding.bottomNavView.isVisible = true + initViewPager() + } else { + router.navigateToNoAccess( + requireActivity().supportFragmentManager, + courseTitle, + coursewareAccess, + null + ) + } } } viewModel.errorMessage.observe(viewLifecycleOwner) { @@ -90,8 +97,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL binding.viewPager.offscreenPageLimit = 4 adapter = CourseNavigationFragmentAdapter(this).apply { - addFragment(CourseOutlineFragment.newInstance(viewModel.courseId,courseTitle, courseImage, courseCertificate)) - addFragment(CourseVideosFragment.newInstance(viewModel.courseId,courseTitle, courseImage, courseCertificate)) + addFragment(CourseOutlineFragment.newInstance(viewModel.courseId, courseTitle)) + addFragment(CourseVideosFragment.newInstance(viewModel.courseId, courseTitle)) addFragment(DiscussionTopicsFragment.newInstance(viewModel.courseId)) addFragment(HandoutsFragment.newInstance(viewModel.courseId)) } @@ -99,27 +106,21 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { binding.viewPager.isUserInputEnabled = false } - fun updateCourseStructure(withSwipeRefresh: Boolean){ + fun updateCourseStructure(withSwipeRefresh: Boolean) { viewModel.updateData(withSwipeRefresh) } companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_TITLE = "title" - private const val ARG_IMAGE = "image" - private const val ARG_CERTIFICATE = "certificate" fun newInstance( courseId: String, - title: String, - image: String, - certificate: Certificate + courseTitle: String ): CourseContainerFragment { val fragment = CourseContainerFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, - ARG_TITLE to title, - ARG_IMAGE to image, - ARG_CERTIFICATE to certificate + ARG_TITLE to courseTitle ) return fragment } diff --git a/course/src/main/java/com/raccoongang/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/com/raccoongang/course/presentation/container/CourseContainerViewModel.kt index 27e032a77..ebf3c24a6 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/container/CourseContainerViewModel.kt @@ -7,6 +7,7 @@ import com.raccoongang.core.BaseViewModel import com.raccoongang.core.system.connection.NetworkConnection import com.raccoongang.core.R import com.raccoongang.core.SingleEventLiveData +import com.raccoongang.core.domain.model.CoursewareAccess import com.raccoongang.core.exception.NoCachedDataException import com.raccoongang.core.extension.isInternetError import com.raccoongang.core.system.ResourceManager @@ -23,8 +24,8 @@ class CourseContainerViewModel( private val networkConnection: NetworkConnection ) : BaseViewModel() { - private val _dataReady = MutableLiveData() - val dataReady: LiveData + private val _dataReady = MutableLiveData() + val dataReady: LiveData get() = _dataReady private val _errorMessage = SingleEventLiveData() @@ -48,7 +49,8 @@ class CourseContainerViewModel( } else { interactor.preloadCourseStructureFromCache(courseId) } - _dataReady.value = true + val courseStructure = interactor.getCourseStructureFromCache() + _dataReady.value = courseStructure.coursewareAccess } catch (e: Exception) { if (e.isInternetError() || e is NoCachedDataException) { _errorMessage.value = diff --git a/course/src/main/java/com/raccoongang/course/presentation/detail/CourseDetailsFragment.kt b/course/src/main/java/com/raccoongang/course/presentation/detail/CourseDetailsFragment.kt index b42a03d0b..9780acc05 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/detail/CourseDetailsFragment.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/detail/CourseDetailsFragment.kt @@ -1,10 +1,13 @@ package com.raccoongang.course.presentation.detail import android.annotation.SuppressLint +import android.content.Intent import android.content.res.Configuration.* +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup +import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.* @@ -19,10 +22,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.shadow -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.* +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -36,14 +38,15 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import com.raccoongang.core.BuildConfig import com.raccoongang.core.UIMessage -import com.raccoongang.core.domain.model.Certificate import com.raccoongang.core.domain.model.Course import com.raccoongang.core.domain.model.Media +import com.raccoongang.core.extension.isEmailValid import com.raccoongang.core.ui.* import com.raccoongang.core.ui.theme.NewEdxTheme import com.raccoongang.core.ui.theme.appColors import com.raccoongang.core.ui.theme.appShapes import com.raccoongang.core.ui.theme.appTypography +import com.raccoongang.core.utils.EmailUtil import com.raccoongang.course.R import com.raccoongang.course.presentation.CourseRouter import com.raccoongang.course.presentation.ui.CourseImageHeader @@ -54,7 +57,7 @@ import java.nio.charset.StandardCharsets import java.util.* import com.raccoongang.course.R as courseR -class CourseDetailFragment : Fragment() { +class CourseDetailsFragment : Fragment() { private val viewModel by viewModel { parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) @@ -95,15 +98,11 @@ class CourseDetailFragment : Fragment() { onButtonClick = { val currentState = uiState if (currentState is CourseDetailsUIState.CourseData) { - if (currentState.enrolledCourse != null) { + if (currentState.course.isEnrolled) { router.navigateToCourseOutline( requireActivity().supportFragmentManager, currentState.course.courseId, - currentState.enrolledCourse.course.name, - currentState.enrolledCourse.course.courseImage, - currentState.enrolledCourse.certificate ?: Certificate(""), - currentState.enrolledCourse.course.coursewareAccess, - currentState.enrolledCourse.auditAccessExpires + currentState.course.name ) } else { viewModel.enrollInACourse(currentState.course.courseId) @@ -116,8 +115,8 @@ class CourseDetailFragment : Fragment() { companion object { private const val ARG_COURSE_ID = "courseId" - fun newInstance(courseId: String): CourseDetailFragment { - val fragment = CourseDetailFragment() + fun newInstance(courseId: String): CourseDetailsFragment { + val fragment = CourseDetailsFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId ) @@ -224,7 +223,7 @@ internal fun CourseDetailsScreen( .padding(it), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } is CourseDetailsUIState.CourseData -> { @@ -233,7 +232,6 @@ internal fun CourseDetailsScreen( CourseDetailNativeContentLandscape( windowSize = windowSize, course = uiState.course, - uiState.enrolledCourse != null, onButtonClick = { onButtonClick() } @@ -242,7 +240,6 @@ internal fun CourseDetailsScreen( CourseDetailNativeContent( windowSize = windowSize, course = uiState.course, - uiState.enrolledCourse != null, onButtonClick = { onButtonClick() } @@ -259,7 +256,7 @@ internal fun CourseDetailsScreen( .padding(top = 20.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } Surface( @@ -305,9 +302,9 @@ internal fun CourseDetailsScreen( private fun CourseDetailNativeContent( windowSize: WindowSize, course: Course, - isEnrolled: Boolean, onButtonClick: () -> Unit, ) { + val uriHandler = LocalUriHandler.current val buttonWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -317,15 +314,6 @@ private fun CourseDetailNativeContent( ) } - val imageHeight by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = 260.dp, - compact = 200.dp - ) - ) - } - val contentHorizontalPadding by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -335,21 +323,36 @@ private fun CourseDetailNativeContent( ) } - val buttonText = if (isEnrolled) { + val buttonText = if (course.isEnrolled) { stringResource(id = R.string.course_view_course) } else { stringResource(id = R.string.course_enroll_now) } Column { - CourseImageHeader( - modifier = Modifier - .fillMaxWidth() - .height(imageHeight) - .padding(6.dp), - courseImage = course.media.image?.large, - courseCertificate = null - ) + Box(contentAlignment = Alignment.Center) { + CourseImageHeader( + modifier = Modifier + .aspectRatio(1.86f) + .padding(6.dp), + courseImage = course.media.image?.large, + courseCertificate = null + ) + if (!course.media.courseVideo?.uri.isNullOrEmpty()) { + IconButton( + onClick = { + uriHandler.openUri(course.media.courseVideo?.uri!!) + } + ) { + Icon( + modifier = Modifier.size(40.dp), + painter = painterResource(courseR.drawable.course_ic_play), + contentDescription = null, + tint = Color.LightGray + ) + } + } + } Spacer(Modifier.height(16.dp)) Column( Modifier @@ -359,6 +362,7 @@ private fun CourseDetailNativeContent( val enrollmentEnd = course.enrollmentEnd if (enrollmentEnd != null && Date() > enrollmentEnd) { EnrollOverLabel() + Spacer(Modifier.height(24.dp)) } Text( text = course.shortDescription, @@ -394,9 +398,9 @@ private fun CourseDetailNativeContent( private fun CourseDetailNativeContentLandscape( windowSize: WindowSize, course: Course, - isEnrolled: Boolean, onButtonClick: () -> Unit, ) { + val uriHandler = LocalUriHandler.current val buttonWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -406,7 +410,7 @@ private fun CourseDetailNativeContentLandscape( ) } - val buttonText = if (isEnrolled) { + val buttonText = if (course.isEnrolled) { stringResource(id = R.string.course_view_course) } else { stringResource(id = R.string.course_enroll_now) @@ -455,18 +459,39 @@ private fun CourseDetailNativeContentLandscape( } } Spacer(Modifier.width(24.dp)) - CourseImageHeader( - modifier = Modifier - .width(263.dp) - .height(200.dp), - courseImage = course.media.image?.large, - courseCertificate = null - ) + Box(contentAlignment = Alignment.Center) { + CourseImageHeader( + modifier = Modifier + .width(263.dp) + .height(200.dp), + courseImage = course.media.image?.large, + courseCertificate = null + ) + if (!course.media.courseVideo?.uri.isNullOrEmpty()) { + IconButton( + onClick = { + uriHandler.openUri(course.media.courseVideo?.uri!!) + } + ) { + Icon( + modifier = Modifier.size(40.dp), + painter = painterResource(courseR.drawable.course_ic_play), + contentDescription = null, + tint = Color.LightGray + ) + } + } + } } } @Composable private fun EnrollOverLabel() { + val borderColor = if (!isSystemInDarkTheme()) { + MaterialTheme.appColors.cardViewBorder + } else { + MaterialTheme.appColors.surface + } Box( Modifier .fillMaxWidth() @@ -480,7 +505,7 @@ private fun EnrollOverLabel() { ) .border( 1.dp, - MaterialTheme.appColors.cardViewBorder, + borderColor, MaterialTheme.appShapes.material.medium ) ) { @@ -523,6 +548,30 @@ private fun CourseDescription( super.onPageCommitVisible(view, url) onWebPageLoaded() } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + val clickUrl = request?.url?.toString() ?: "" + return if (clickUrl.isNotEmpty() && + (clickUrl.startsWith("http://") || + clickUrl.startsWith("https://")) + ) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(clickUrl))) + true + } else if (clickUrl.startsWith("mailto:")) { + val email = clickUrl.replace("mailto:", "") + if (email.isEmailValid()) { + EmailUtil.sendEmailIntent(context, email, "", "") + true + } else { + false + } + } else { + false + } + } } with(settings) { javaScriptEnabled = true @@ -548,7 +597,7 @@ private fun CourseDetailNativeContentPreview() { NewEdxTheme { CourseDetailsScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = CourseDetailsUIState.CourseData(mockCourse, null), + uiState = CourseDetailsUIState.CourseData(mockCourse), uiMessage = null, hasInternetConnection = false, htmlBody = "Preview text", @@ -566,7 +615,7 @@ private fun CourseDetailNativeContentTabletPreview() { NewEdxTheme { CourseDetailsScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = CourseDetailsUIState.CourseData(mockCourse, null), + uiState = CourseDetailsUIState.CourseData(mockCourse), uiMessage = null, hasInternetConnection = false, htmlBody = "Preview text", @@ -597,5 +646,6 @@ private val mockCourse = Course( end = "end", startDisplay = "startDisplay", startType = "startType", - overview = "" -) \ No newline at end of file + overview = "", + isEnrolled = false +) diff --git a/course/src/main/java/com/raccoongang/course/presentation/detail/CourseDetailsUIState.kt b/course/src/main/java/com/raccoongang/course/presentation/detail/CourseDetailsUIState.kt index 4cfb6d06b..1a2148f53 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/detail/CourseDetailsUIState.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/detail/CourseDetailsUIState.kt @@ -1,10 +1,8 @@ package com.raccoongang.course.presentation.detail import com.raccoongang.core.domain.model.Course -import com.raccoongang.core.domain.model.EnrolledCourse - sealed class CourseDetailsUIState { - data class CourseData(val course: Course, val enrolledCourse: EnrolledCourse?) : CourseDetailsUIState() + data class CourseData(val course: Course) : CourseDetailsUIState() object Loading : CourseDetailsUIState() } \ No newline at end of file diff --git a/course/src/main/java/com/raccoongang/course/presentation/detail/CourseDetailsViewModel.kt b/course/src/main/java/com/raccoongang/course/presentation/detail/CourseDetailsViewModel.kt index c8ee96b5b..ede39fe73 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/detail/CourseDetailsViewModel.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/detail/CourseDetailsViewModel.kt @@ -8,17 +8,13 @@ import com.raccoongang.core.R import com.raccoongang.core.SingleEventLiveData import com.raccoongang.core.UIMessage import com.raccoongang.core.domain.model.Course -import com.raccoongang.core.domain.model.EnrolledCourse import com.raccoongang.core.extension.isInternetError import com.raccoongang.core.system.ResourceManager import com.raccoongang.core.system.connection.NetworkConnection import com.raccoongang.core.system.notifier.CourseDashboardUpdate import com.raccoongang.core.system.notifier.CourseNotifier import com.raccoongang.course.domain.interactor.CourseInteractor -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope class CourseDetailsViewModel( private val courseId: String, @@ -48,28 +44,12 @@ class CourseDetailsViewModel( _uiState.value = CourseDetailsUIState.Loading viewModelScope.launch { try { - supervisorScope { - val courseJob = async { - if (networkConnection.isOnline()) { - interactor.getCourseDetails(courseId) - } else { - interactor.getCourseDetailsFromCache(courseId) - } - } - val enrolledCourse = async { - if (networkConnection.isOnline()) { - interactor.getEnrolledCourseById(courseId) - } else { - interactor.getEnrolledCourseFromCacheById(courseId) - } - } - val data = awaitAll(courseJob, enrolledCourse) - course = data[0] as Course - _uiState.value = CourseDetailsUIState.CourseData( - course = course!!, - data[1] as EnrolledCourse? - ) + course = if (networkConnection.isOnline()) { + interactor.getCourseDetails(courseId) + } else { + interactor.getCourseDetailsFromCache(courseId) } + _uiState.value = CourseDetailsUIState.CourseData(course = course!!) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = @@ -86,10 +66,10 @@ class CourseDetailsViewModel( viewModelScope.launch { try { interactor.enrollInACourse(id) - val enrolledCourse = interactor.getEnrolledCourseById(id) + val course = interactor.getCourseDetails(id) val courseData = _uiState.value if (courseData is CourseDetailsUIState.CourseData) { - _uiState.value = courseData.copy(enrolledCourse = enrolledCourse) + _uiState.value = courseData.copy(course = course) notifier.send(CourseDashboardUpdate()) } } catch (e: Exception) { diff --git a/course/src/main/java/com/raccoongang/course/presentation/handouts/HandoutsFragment.kt b/course/src/main/java/com/raccoongang/course/presentation/handouts/HandoutsFragment.kt index 2fffd7250..4cb2e798d 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/handouts/HandoutsFragment.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/handouts/HandoutsFragment.kt @@ -8,17 +8,16 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Campaign -import androidx.compose.material.icons.filled.Description import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -34,6 +33,7 @@ import com.raccoongang.core.ui.theme.NewEdxTheme import com.raccoongang.core.ui.theme.appColors import com.raccoongang.core.ui.theme.appTypography import com.raccoongang.course.presentation.CourseRouter +import com.raccoongang.course.presentation.ui.CardArrow import org.koin.android.ext.android.inject import com.raccoongang.course.R as courseR @@ -112,10 +112,11 @@ private fun HandoutsScreen( ) } - Box(modifier = Modifier - .fillMaxWidth() - .padding(it) - .statusBarsInset(), + Box( + modifier = Modifier + .fillMaxWidth() + .padding(it) + .statusBarsInset(), contentAlignment = Alignment.TopCenter ) { Column(screenWidth) { @@ -146,64 +147,23 @@ private fun HandoutsScreen( ) { LazyColumn( Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 10.dp) + contentPadding = PaddingValues(vertical = 10.dp, horizontal = 24.dp) ) { item { - Row( - Modifier - .fillMaxWidth() - .clickable { onHandoutsClick() } - .padding(vertical = 16.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Filled.Description, - contentDescription = null - ) - Spacer(modifier = Modifier.width(16.dp)) - Column( - verticalArrangement = Arrangement.Center - ) { - Text( - text = stringResource(id = courseR.string.course_handouts), - style = MaterialTheme.appTypography.titleLarge, - color = MaterialTheme.appColors.textPrimary - ) - Text( - text = stringResource(id = courseR.string.course_find_important_info), - style = MaterialTheme.appTypography.bodySmall, - color = MaterialTheme.appColors.textPrimary - ) - } - } - Divider() + HandoutsItem( + title = stringResource(id = courseR.string.course_handouts), + description = stringResource(id = courseR.string.course_find_important_info), + painter = painterResource(id = courseR.drawable.course_ic_handouts), + onClick = onHandoutsClick + ) } item { - Row( - Modifier - .fillMaxWidth() - .clickable { onAnnouncementsClick() } - .padding(vertical = 16.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = Icons.Filled.Campaign, contentDescription = null) - Spacer(modifier = Modifier.width(16.dp)) - Column( - verticalArrangement = Arrangement.Center - ) { - Text( - text = stringResource(id = courseR.string.course_announcements), - style = MaterialTheme.appTypography.titleLarge, - color = MaterialTheme.appColors.textPrimary - ) - Text( - text = stringResource(id = courseR.string.course_latest_news), - style = MaterialTheme.appTypography.bodySmall, - color = MaterialTheme.appColors.textPrimary - ) - } - } - Divider() + HandoutsItem( + title = stringResource(id = courseR.string.course_announcements), + description = stringResource(id = courseR.string.course_latest_news), + painter = painterResource(id = courseR.drawable.course_ic_announcements), + onClick = onAnnouncementsClick + ) } } } @@ -212,6 +172,48 @@ private fun HandoutsScreen( } } +@Composable +private fun HandoutsItem( + title: String, + description: String, + painter: Painter, + onClick: () -> Unit +) { + Row( + Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(vertical = 16.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painter, + contentDescription = null, + tint = MaterialTheme.appColors.textPrimary + ) + Spacer(modifier = Modifier.width(12.dp)) + Column( + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary + ) + Text( + text = description, + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textFieldHint + ) + } + } + CardArrow(degrees = 0f) + } + Divider() +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/course/src/main/java/com/raccoongang/course/presentation/handouts/HandoutsViewModel.kt b/course/src/main/java/com/raccoongang/course/presentation/handouts/HandoutsViewModel.kt index 5ee62ef84..9a9d8f2c9 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/handouts/HandoutsViewModel.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/handouts/HandoutsViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.raccoongang.core.BaseViewModel import com.raccoongang.core.domain.model.AnnouncementModel -import com.raccoongang.core.domain.model.EnrolledCourse import com.raccoongang.core.domain.model.HandoutsModel import com.raccoongang.course.domain.interactor.CourseInteractor import kotlinx.coroutines.launch @@ -16,8 +15,6 @@ class HandoutsViewModel( private val interactor: CourseInteractor ) : BaseViewModel() { - private var course: EnrolledCourse? = null - private val _htmlContent = MutableLiveData() val htmlContent: LiveData get() = _htmlContent @@ -29,14 +26,11 @@ class HandoutsViewModel( private fun getEnrolledCourse() { viewModelScope.launch { try { - if (course == null) { - course = interactor.getEnrolledCourseFromCacheById(courseId) - } if (HandoutsType.valueOf(handoutsType) == HandoutsType.Handouts) { - val handouts = interactor.getHandouts(course!!.course.courseHandouts) + val handouts = interactor.getHandouts(courseId) _htmlContent.value = handoutsToHtml(handouts) } else { - val announcements = interactor.getAnnouncements(course!!.course.courseUpdates) + val announcements = interactor.getAnnouncements(courseId) _htmlContent.value = announcementsToHtml(announcements) } } catch (e: Exception) { @@ -63,6 +57,7 @@ class HandoutsViewModel( append("") for (model in announcements) { append("
") + append("
") append(model.date) append("
") append("
") diff --git a/course/src/main/java/com/raccoongang/course/presentation/handouts/WebViewFragment.kt b/course/src/main/java/com/raccoongang/course/presentation/handouts/WebViewFragment.kt index 2dd5433b4..c2e4d5538 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/handouts/WebViewFragment.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/handouts/WebViewFragment.kt @@ -1,13 +1,17 @@ package com.raccoongang.course.presentation.handouts import android.annotation.SuppressLint +import android.content.Intent import android.content.res.Configuration +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup +import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -30,10 +34,13 @@ import androidx.compose.ui.zIndex import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import com.raccoongang.core.BuildConfig +import com.raccoongang.core.extension.isEmailValid +import com.raccoongang.core.extension.replaceLinkTags import com.raccoongang.core.ui.* import com.raccoongang.core.ui.theme.NewEdxTheme import com.raccoongang.core.ui.theme.appColors import com.raccoongang.core.ui.theme.appTypography +import com.raccoongang.core.utils.EmailUtil import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import java.nio.charset.StandardCharsets @@ -176,7 +183,7 @@ private fun WebContentScreen( .zIndex(1f), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } @@ -189,6 +196,7 @@ private fun WebContentScreen( @SuppressLint("SetJavaScriptEnabled") private fun HandoutsContent(body: String, onWebPageLoaded: () -> Unit) { val context = LocalContext.current + val isDarkTheme = isSystemInDarkTheme() AndroidView(modifier = Modifier, factory = { WebView(context).apply { webViewClient = object : WebViewClient() { @@ -196,6 +204,30 @@ private fun HandoutsContent(body: String, onWebPageLoaded: () -> Unit) { super.onPageCommitVisible(view, url) onWebPageLoaded() } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + val clickUrl = request?.url?.toString() ?: "" + return if (clickUrl.isNotEmpty() && + (clickUrl.startsWith("http://") || + clickUrl.startsWith("https://")) + ) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(clickUrl))) + true + } else if (clickUrl.startsWith("mailto:")) { + val email = clickUrl.replace("mailto:", "") + if (email.isEmailValid()) { + EmailUtil.sendEmailIntent(context, email, "", "") + true + } else { + false + } + } else { + false + } + } } with(settings) { javaScriptEnabled = true @@ -208,12 +240,20 @@ private fun HandoutsContent(body: String, onWebPageLoaded: () -> Unit) { isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false loadDataWithBaseURL( - BuildConfig.BASE_URL, body, "text/html", StandardCharsets.UTF_8.name(), null + BuildConfig.BASE_URL, + body.replaceLinkTags(isDarkTheme), + "text/html", + StandardCharsets.UTF_8.name(), + null ) } }, update = { it.loadDataWithBaseURL( - BuildConfig.BASE_URL, body, "text/html", StandardCharsets.UTF_8.name(), null + BuildConfig.BASE_URL, + body.replaceLinkTags(isDarkTheme), + "text/html", + StandardCharsets.UTF_8.name(), + null ) }) } diff --git a/course/src/main/java/com/raccoongang/course/presentation/outline/CourseOutlineFragment.kt b/course/src/main/java/com/raccoongang/course/presentation/outline/CourseOutlineFragment.kt index f901419a8..10abb8254 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/outline/CourseOutlineFragment.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/outline/CourseOutlineFragment.kt @@ -5,13 +5,10 @@ import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -22,8 +19,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices @@ -37,7 +34,6 @@ import com.raccoongang.core.BlockType import com.raccoongang.core.R import com.raccoongang.core.UIMessage import com.raccoongang.core.domain.model.* -import com.raccoongang.core.extension.parcelable import com.raccoongang.core.presentation.course.CourseViewMode import com.raccoongang.core.ui.* import com.raccoongang.core.ui.theme.NewEdxTheme @@ -47,6 +43,7 @@ import com.raccoongang.course.presentation.CourseRouter import com.raccoongang.course.presentation.container.CourseContainerFragment import com.raccoongang.course.presentation.ui.CourseImageHeader import com.raccoongang.course.presentation.ui.CourseSectionCard +import com.raccoongang.course.presentation.units.CourseUnitsFragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf @@ -64,11 +61,8 @@ class CourseOutlineFragment : Fragment() { super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) with(requireArguments()) { - viewModel.courseImage = getString(ARG_IMAGE, "") viewModel.courseTitle = getString(ARG_TITLE, "") - viewModel.courseCertificate = parcelable(ARG_CERTIFICATE)!! } - viewModel.getCourseData() } override fun onCreateView( @@ -88,9 +82,7 @@ class CourseOutlineFragment : Fragment() { CourseOutlineScreen( windowSize = windowSize, uiState = uiState!!, - courseImage = viewModel.courseImage, courseTitle = viewModel.courseTitle, - courseCertificate = viewModel.courseCertificate, uiMessage = uiMessage, refreshing = refreshing, onSwipeRefresh = { @@ -111,13 +103,24 @@ class CourseOutlineFragment : Fragment() { ) }, onResumeClick = { blockId -> - router.navigateToCourseContainer( - requireActivity().supportFragmentManager, - blockId = blockId, - courseId = viewModel.courseId, - courseName = viewModel.courseTitle, - mode = CourseViewMode.FULL - ) + viewModel.resumeSectionBlock?.let { sequential -> + router.navigateToCourseSubsections( + requireActivity().supportFragmentManager, + viewModel.courseId, + sequential.id, + sequential.displayName, + CourseViewMode.FULL + ) + viewModel.resumeVerticalBlock?.let { vertical -> + router.navigateToCourseUnits( + requireActivity().supportFragmentManager, + viewModel.courseId, + vertical.id, + vertical.displayName, + CourseViewMode.FULL + ) + } + } }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() @@ -146,20 +149,14 @@ class CourseOutlineFragment : Fragment() { companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_TITLE = "title" - private const val ARG_IMAGE = "image" - private const val ARG_CERTIFICATE = "certificate" fun newInstance( courseId: String, - title: String, - image: String, - certificate: Certificate + title: String ): CourseOutlineFragment { val fragment = CourseOutlineFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, - ARG_TITLE to title, - ARG_IMAGE to image, - ARG_CERTIFICATE to certificate + ARG_TITLE to title ) return fragment } @@ -173,8 +170,6 @@ internal fun CourseOutlineScreen( windowSize: WindowSize, uiState: CourseOutlineUIState, courseTitle: String, - courseImage: String, - courseCertificate: Certificate, uiMessage: UIMessage?, refreshing: Boolean, hasInternetConnection: Boolean, @@ -212,8 +207,8 @@ internal fun CourseOutlineScreen( val imageHeight by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( - expanded = 260.dp, - compact = 200.dp + expanded = 300.dp, + compact = 250.dp ) ) } @@ -279,7 +274,7 @@ internal fun CourseOutlineScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } is CourseOutlineUIState.CourseData -> { @@ -289,17 +284,33 @@ internal fun CourseOutlineScreen( ) { CourseImageHeader( modifier = Modifier - .fillMaxWidth() - .height(imageHeight) + .aspectRatio(1.86f) .padding(6.dp), - courseImage = courseImage, - courseCertificate = courseCertificate + courseImage = uiState.courseStructure.media?.image?.large + ?: "", + courseCertificate = uiState.courseStructure.certificate ) LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = listPadding ) { - items(uiState.blocks) { block -> + if (uiState.resumeBlock != null) { + item { + Spacer(Modifier.height(28.dp)) + if (windowSize.isTablet) { + ResumeCourseTablet( + block = uiState.resumeBlock, + onResumeClick = onResumeClick + ) + } else { + ResumeCourse( + block = uiState.resumeBlock, + onResumeClick = onResumeClick + ) + } + } + } + items(uiState.courseStructure.blockData) { block -> if (block.type == BlockType.CHAPTER) { Text( modifier = Modifier.padding( @@ -357,42 +368,99 @@ private fun ResumeCourse( block: Block, onResumeClick: (String) -> Unit, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.appColors.secondaryVariant) - .padding(horizontal = 20.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically + Column( + modifier = Modifier.fillMaxWidth() ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(id = com.raccoongang.course.R.string.course_resume_unit_title), - style = MaterialTheme.appTypography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.appColors.textPrimary + Text( + text = stringResource(id = com.raccoongang.course.R.string.course_continue_with), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + Spacer(Modifier.height(6.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(id = CourseUnitsFragment.getUnitBlockIcon(block)), + contentDescription = null, + tint = MaterialTheme.appColors.textPrimary ) Text( text = block.displayName, - style = MaterialTheme.appTypography.bodyLarge, - color = MaterialTheme.appColors.textPrimary + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } - NewEdxOutlinedButton( - modifier = Modifier, - borderColor = MaterialTheme.appColors.textFieldBorder, - textColor = MaterialTheme.appColors.textPrimary, - text = stringResource(id = com.raccoongang.course.R.string.course_resume_unit_btn), + Spacer(Modifier.height(24.dp)) + NewEdxButton( + text = stringResource(id = com.raccoongang.course.R.string.course_continue), onClick = { onResumeClick(block.id) }, content = { - Text(text = stringResource(id = com.raccoongang.course.R.string.course_resume_unit_btn).uppercase()) + TextIcon( + text = stringResource(id = com.raccoongang.course.R.string.course_continue), + painter = painterResource(id = R.drawable.core_ic_forward), + color = MaterialTheme.appColors.buttonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } +} + + +@Composable +private fun ResumeCourseTablet( + block: Block, + onResumeClick: (String) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(Modifier.weight(1f).padding(end = 35.dp)) { + Text( + text = stringResource(id = com.raccoongang.course.R.string.course_continue_with), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + Spacer(Modifier.height(6.dp)) + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { Icon( - imageVector = Icons.Filled.ChevronRight, + modifier = Modifier.size((MaterialTheme.appTypography.titleMedium.fontSize.value + 4).dp), + painter = painterResource(id = CourseUnitsFragment.getUnitBlockIcon(block)), contentDescription = null, - modifier = Modifier - .size(20.dp) - .offset(x = 4.dp) + tint = MaterialTheme.appColors.textPrimary + ) + Text( + text = block.displayName, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + overflow = TextOverflow.Ellipsis, + maxLines = 4 + ) + } + } + NewEdxButton( + width = Modifier.width(194.dp), + text = stringResource(id = com.raccoongang.course.R.string.course_continue), + onClick = { + onResumeClick(block.id) + }, + content = { + TextIcon( + text = stringResource(id = com.raccoongang.course.R.string.course_continue), + painter = painterResource(id = R.drawable.core_ic_forward), + color = MaterialTheme.appColors.buttonText, + textStyle = MaterialTheme.appTypography.labelLarge ) } ) @@ -407,15 +475,11 @@ private fun CourseOutlineScreenPreview() { CourseOutlineScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = CourseOutlineUIState.CourseData( - listOf( - mockSequentialBlock, mockSequentialBlock - ), + mockCourseStructure, mapOf(), mockChapterBlock ), courseTitle = "", - courseImage = "", - courseCertificate = Certificate(""), uiMessage = null, refreshing = false, hasInternetConnection = true, @@ -437,15 +501,11 @@ private fun CourseOutlineScreenTabletPreview() { CourseOutlineScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = CourseOutlineUIState.CourseData( - listOf( - mockSequentialBlock, mockSequentialBlock - ), + mockCourseStructure, mapOf(), mockChapterBlock ), courseTitle = "", - courseImage = "", - courseCertificate = Certificate(""), uiMessage = null, refreshing = false, hasInternetConnection = true, @@ -468,42 +528,6 @@ private fun ResumeCoursePreview() { } } -private val mockCourse = EnrolledCourse( - auditAccessExpires = Date(), - created = "created", - certificate = Certificate(""), - mode = "mode", - isActive = true, - course = EnrolledCourseData( - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - dynamicUpgradeDeadline = "", - subscriptionId = "", - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - courseImage = "", - courseAbout = "", - courseSharingUtmParameters = CourseSharingUtmParameters("", ""), - courseUpdates = "", - courseHandouts = "", - discussionUrl = "", - videoOutline = "", - isSelfPaced = false - ) -) private val mockChapterBlock = Block( id = "id", blockId = "blockId", @@ -534,3 +558,27 @@ private val mockSequentialBlock = Block( descendants = emptyList(), completion = 0.0 ) + +private val mockCourseStructure = CourseStructure( + root = "", + blockData = listOf(mockSequentialBlock, mockSequentialBlock), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false +) diff --git a/course/src/main/java/com/raccoongang/course/presentation/outline/CourseOutlineUIState.kt b/course/src/main/java/com/raccoongang/course/presentation/outline/CourseOutlineUIState.kt index f91077956..bcf9a78b9 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/outline/CourseOutlineUIState.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/outline/CourseOutlineUIState.kt @@ -1,11 +1,12 @@ package com.raccoongang.course.presentation.outline import com.raccoongang.core.domain.model.Block +import com.raccoongang.core.domain.model.CourseStructure import com.raccoongang.core.module.db.DownloadedState sealed class CourseOutlineUIState { data class CourseData( - val blocks: List, + val courseStructure: CourseStructure, val downloadedState: Map, val resumeBlock: Block? ) : CourseOutlineUIState() diff --git a/course/src/main/java/com/raccoongang/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/com/raccoongang/course/presentation/outline/CourseOutlineViewModel.kt index 2976571ba..16c2736f2 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/outline/CourseOutlineViewModel.kt @@ -10,7 +10,6 @@ import com.raccoongang.core.SingleEventLiveData import com.raccoongang.core.UIMessage import com.raccoongang.core.data.storage.PreferencesManager import com.raccoongang.core.domain.model.Block -import com.raccoongang.core.domain.model.Certificate import com.raccoongang.core.domain.model.CourseComponentStatus import com.raccoongang.core.extension.isInternetError import com.raccoongang.core.module.DownloadWorkerController @@ -48,8 +47,11 @@ class CourseOutlineViewModel( get() = _isUpdating var courseTitle = "" - var courseImage = "" - var courseCertificate = Certificate("") + + var resumeSectionBlock: Block? = null + private set + var resumeVerticalBlock: Block? = null + private set val hasInternetConnection: Boolean get() = networkConnection.isOnline() @@ -71,7 +73,7 @@ class CourseOutlineViewModel( if (_uiState.value is CourseOutlineUIState.CourseData) { val state = _uiState.value as CourseOutlineUIState.CourseData _uiState.value = CourseOutlineUIState.CourseData( - blocks = state.blocks, + courseStructure = state.courseStructure, downloadedState = it.toMap(), resumeBlock = state.resumeBlock ) @@ -80,6 +82,10 @@ class CourseOutlineViewModel( } } + init { + getCourseData() + } + fun setIsUpdating() { _isUpdating.value = true } @@ -109,22 +115,23 @@ class CourseOutlineViewModel( private fun getCourseDataInternal() { viewModelScope.launch { - val blocks = interactor.getCourseStructureFromCache() - try { + var courseStructure = interactor.getCourseStructureFromCache() + val blocks = courseStructure.blockData + val courseStatus = if (networkConnection.isOnline()) { interactor.getCourseStatus(courseId) } else { CourseComponentStatus("") } setBlocks(blocks) - val list = sortBlocks(blocks) + courseStructure = courseStructure.copy(blockData = sortBlocks(blocks)) initDownloadModelsStatus() _uiState.value = CourseOutlineUIState.CourseData( - blocks = list, + courseStructure = courseStructure, downloadedState = getDownloadModelsStatus(), - resumeBlock = blocks.firstOrNull { it.id == courseStatus.lastVisitedBlockId } + resumeBlock = getResumeBlock(blocks, courseStatus.lastVisitedBlockId) ) } catch (e: Exception) { if (e.isInternetError()) { @@ -158,4 +165,18 @@ class CourseOutlineViewModel( return resultBlocks.toList() } + private fun getResumeBlock( + blocks: List, + continueBlockId: String + ): Block? { + val resumeBlock = blocks.firstOrNull { it.id == continueBlockId } + resumeVerticalBlock = blocks.find { + it.descendants.contains(resumeBlock?.id) && it.type == BlockType.VERTICAL + } + resumeSectionBlock = blocks.find { + it.descendants.contains(resumeVerticalBlock?.id) && it.type == BlockType.SEQUENTIAL + } + return resumeVerticalBlock + } + } \ No newline at end of file diff --git a/course/src/main/java/com/raccoongang/course/presentation/section/CourseSectionFragment.kt b/course/src/main/java/com/raccoongang/course/presentation/section/CourseSectionFragment.kt index 0bd6aa3d6..e555deba0 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/section/CourseSectionFragment.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/section/CourseSectionFragment.kt @@ -221,7 +221,7 @@ private fun CourseSectionScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } is CourseSectionUIState.Blocks -> { @@ -315,7 +315,10 @@ private fun CourseSubsectionItem( } else if (downloadedState != null) { Box(contentAlignment = Alignment.Center) { if (downloadedState == DownloadedState.DOWNLOADING || downloadedState == DownloadedState.WAITING) { - CircularProgressIndicator(modifier = Modifier.size(34.dp)) + CircularProgressIndicator( + modifier = Modifier.size(34.dp), + color = MaterialTheme.appColors.primary + ) } IconButton(modifier = iconModifier, onClick = { onDownloadClick(block) }) { diff --git a/course/src/main/java/com/raccoongang/course/presentation/section/CourseSectionViewModel.kt b/course/src/main/java/com/raccoongang/course/presentation/section/CourseSectionViewModel.kt index 31dcc5868..660771018 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/section/CourseSectionViewModel.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/section/CourseSectionViewModel.kt @@ -57,10 +57,11 @@ class CourseSectionViewModel( _uiState.value = CourseSectionUIState.Loading viewModelScope.launch { try { - val blocks = when (mode) { + val courseStructure = when (mode) { CourseViewMode.FULL -> interactor.getCourseStructureFromCache() CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos() } + val blocks = courseStructure.blockData setBlocks(blocks) val newList = getDescendantBlocks(blocks, blockId) initDownloadModelsStatus() diff --git a/course/src/main/java/com/raccoongang/course/presentation/ui/CourseUI.kt b/course/src/main/java/com/raccoongang/course/presentation/ui/CourseUI.kt index 7dfce2dd3..184c87c38 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/ui/CourseUI.kt @@ -5,13 +5,13 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -40,7 +40,10 @@ import com.raccoongang.core.ui.theme.appColors import com.raccoongang.core.ui.theme.appShapes import com.raccoongang.core.ui.theme.appTypography import com.raccoongang.course.R +import org.jsoup.Jsoup +import subtitleFile.TimedTextObject import java.util.* +import com.raccoongang.course.R as courseR @Composable fun CourseImageHeader( @@ -167,7 +170,10 @@ fun CourseSectionCard( } else if (downloadedState != null) { Box(contentAlignment = Alignment.Center) { if (downloadedState == DownloadedState.DOWNLOADING || downloadedState == DownloadedState.WAITING) { - CircularProgressIndicator(modifier = Modifier.size(34.dp)) + CircularProgressIndicator( + modifier = Modifier.size(34.dp), + color = MaterialTheme.appColors.primary + ) } IconButton(modifier = iconModifier, onClick = { onDownloadClick(block) }) { @@ -416,6 +422,72 @@ fun ConnectionErrorView( } } +@Composable +fun VideoSubtitles( + listState: LazyListState, + timedTextObject: TimedTextObject?, + subtitleLanguage: String, + showSubtitleLanguage: Boolean, + currentIndex: Int, + onSettingsClick: () -> Unit +) { + timedTextObject?.let { + LaunchedEffect(key1 = currentIndex) { + if (currentIndex > 1) { + listState.animateScrollToItem(currentIndex - 1) + } + } + val scaffoldState = rememberScaffoldState() + val subtitles = timedTextObject.captions.values.toList() + Scaffold(scaffoldState = scaffoldState) { + Column(Modifier.padding(it)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(id = courseR.string.course_subtitles), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + if (showSubtitleLanguage) { + IconText( + modifier = Modifier.noRippleClickable { + onSettingsClick() + }, + text = subtitleLanguage, + painter = painterResource(id = courseR.drawable.course_ic_cc), + color = MaterialTheme.appColors.textAccent, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + } + Spacer(Modifier.height(24.dp)) + LazyColumn( + state = listState, + userScrollEnabled = false + ) { + itemsIndexed(subtitles) { index, item -> + val textColor = + if (currentIndex == index) { + MaterialTheme.appColors.textPrimary + } else { + MaterialTheme.appColors.textFieldBorder + } + Text( + modifier = Modifier.fillMaxWidth(), + text = Jsoup.parse(item.content).text(), + color = textColor, + style = MaterialTheme.appTypography.bodyMedium + ) + Spacer(Modifier.height(16.dp)) + } + } + } + } + } +} @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) diff --git a/course/src/main/java/com/raccoongang/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/com/raccoongang/course/presentation/unit/container/CourseUnitContainerFragment.kt index 5ffc3a9ed..130b5d419 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -15,10 +15,9 @@ import com.raccoongang.core.domain.model.Block import com.raccoongang.core.extension.serializable import com.raccoongang.core.presentation.course.CourseViewMode import com.raccoongang.core.presentation.global.InsetHolder -import com.raccoongang.core.presentation.global.WindowSizeHolder import com.raccoongang.core.presentation.global.viewBinding import com.raccoongang.core.ui.BackBtn -import com.raccoongang.core.ui.WindowSize +import com.raccoongang.core.ui.rememberWindowSize import com.raccoongang.core.ui.theme.NewEdxTheme import com.raccoongang.course.R import com.raccoongang.course.databinding.FragmentCourseUnitContainerBinding @@ -41,8 +40,6 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) } - private var windowSize: WindowSize? = null - private var blockId: String = "" override fun onCreate(savedInstanceState: Bundle?) { @@ -51,7 +48,6 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta blockId = requireArguments().getString(ARG_BLOCK_ID, "") viewModel.loadBlocks(requireArguments().serializable(ARG_MODE)!!) viewModel.setupCurrentIndex(blockId) - windowSize = (requireActivity() as WindowSizeHolder).windowSize } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -85,6 +81,8 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta mutableStateOf(viewModel.hasNextBlock) } + val windowSize = rememberWindowSize() + updateNavigationButtons { prev, next, bool -> prevButtonText = prev nextButtonText = next @@ -92,7 +90,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta } NavigationUnitsButtons( - windowSize = windowSize!!, + windowSize = windowSize, prevButtonText = prevButtonText, nextButtonText = nextButtonText, hasNextBlock = hasNextBlock, @@ -120,6 +118,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta hasNextBlock = bool } } else { + viewModel.sendEventPauseVideo() val dialog = ChapterEndFragmentDialog.newInstance(block.displayName) dialog.show( requireActivity().supportFragmentManager, @@ -178,39 +177,42 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta } BlockType.VIDEO -> { val encodedVideos = block.studentViewData!!.encodedVideos!! - if (encodedVideos.youtube != null) { - YoutubeVideoUnitFragment.newInstance( - block.id, - viewModel.courseId, - encodedVideos.youtube?.url!!, - block.displayName - ) - } else { - with(encodedVideos) { - var isDownloaded = false - val videoUrl = if (viewModel.getDownloadModelById(block.id) != null) { - isDownloaded = true - viewModel.getDownloadModelById(block.id)!!.path - } else if (fallback != null) { - fallback!!.url - } else if (hls != null) { - hls!!.url - } else if (desktopMp4 != null) { - desktopMp4!!.url - } else if (mobileHigh != null) { - mobileHigh!!.url - } else if (mobileLow != null) { - mobileLow!!.url - } else { - "" - } + val transcripts = block.studentViewData!!.transcripts + with(encodedVideos) { + var isDownloaded = false + val videoUrl = if (viewModel.getDownloadModelById(block.id) != null) { + isDownloaded = true + viewModel.getDownloadModelById(block.id)!!.path + } else if (fallback != null) { + fallback!!.url + } else if (hls != null) { + hls!!.url + } else if (desktopMp4 != null) { + desktopMp4!!.url + } else if (mobileHigh != null) { + mobileHigh!!.url + } else if (mobileLow != null) { + mobileLow!!.url + } else { + "" + } + if (videoUrl.isNotEmpty()) { VideoUnitFragment.newInstance( block.id, viewModel.courseId, videoUrl, + transcripts?.toMap() ?: emptyMap(), block.displayName, isDownloaded ) + } else { + YoutubeVideoUnitFragment.newInstance( + block.id, + viewModel.courseId, + encodedVideos.youtube?.url!!, + transcripts?.toMap() ?: emptyMap(), + block.displayName + ) } } } diff --git a/course/src/main/java/com/raccoongang/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/com/raccoongang/course/presentation/unit/container/CourseUnitContainerViewModel.kt index e9190d9ae..afa83fe7a 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -1,18 +1,23 @@ package com.raccoongang.course.presentation.unit.container +import androidx.lifecycle.viewModelScope import com.raccoongang.core.BaseViewModel import com.raccoongang.core.BlockType import com.raccoongang.core.domain.model.Block import com.raccoongang.core.module.db.DownloadModel import com.raccoongang.core.module.db.DownloadedState import com.raccoongang.core.presentation.course.CourseViewMode +import com.raccoongang.core.system.notifier.CourseNotifier +import com.raccoongang.core.system.notifier.CoursePauseVideo import com.raccoongang.course.domain.interactor.CourseInteractor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking class CourseUnitContainerViewModel( private val interactor: CourseInteractor, + private val notifier: CourseNotifier, val courseId: String ) : BaseViewModel() { @@ -39,10 +44,11 @@ class CourseUnitContainerViewModel( fun loadBlocks(mode: CourseViewMode) { try { - val blocks = when (mode) { + val courseStructure = when (mode) { CourseViewMode.FULL -> interactor.getCourseStructureFromCache() CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos() } + val blocks = courseStructure.blockData this.blocks.clear() this.blocks.addAll(blocks) } catch (e: Exception) { @@ -89,6 +95,12 @@ class CourseUnitContainerViewModel( return null } + fun sendEventPauseVideo() { + viewModelScope.launch { + notifier.send(CoursePauseVideo()) + } + } + private fun updateVerticalIndex(blockId: String) { currentVerticalIndex = blocks.indexOfFirst { it.type == BlockType.VERTICAL && it.descendants.contains(blockId) } diff --git a/course/src/main/java/com/raccoongang/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/com/raccoongang/course/presentation/unit/html/HtmlUnitFragment.kt index bf3d7667c..ac8f4011e 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/unit/html/HtmlUnitFragment.kt @@ -1,6 +1,8 @@ package com.raccoongang.course.presentation.unit.html import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri import android.os.Bundle import android.util.Log import android.view.LayoutInflater @@ -12,7 +14,6 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext @@ -23,6 +24,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import com.raccoongang.core.extension.isEmailValid import com.raccoongang.core.system.AppCookieManager import com.raccoongang.core.system.connection.NetworkConnection import com.raccoongang.core.ui.WindowSize @@ -31,6 +33,7 @@ import com.raccoongang.core.ui.theme.NewEdxTheme import com.raccoongang.core.ui.theme.appColors import com.raccoongang.core.ui.theme.appShapes import com.raccoongang.core.ui.windowSizeValue +import com.raccoongang.core.utils.EmailUtil import com.raccoongang.course.presentation.ui.ConnectionErrorView import kotlinx.coroutines.launch import org.koin.android.ext.android.inject @@ -101,7 +104,7 @@ class HtmlUnitFragment : Fragment() { .zIndex(1f), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } @@ -162,6 +165,30 @@ private fun HTMLContentView( onWebPageLoaded() } + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + val clickUrl = request?.url?.toString() ?: "" + return if (clickUrl.isNotEmpty() && + (clickUrl.startsWith("http://") || + clickUrl.startsWith("https://")) + ) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(clickUrl))) + true + } else if (clickUrl.startsWith("mailto:")) { + val email = clickUrl.replace("mailto:", "") + if (email.isEmailValid()) { + EmailUtil.sendEmailIntent(context, email, "", "") + true + } else { + false + } + } else { + false + } + } + override fun onReceivedHttpError( view: WebView, request: WebResourceRequest, diff --git a/course/src/main/java/com/raccoongang/course/presentation/unit/video/VideoUnitFragment.kt b/course/src/main/java/com/raccoongang/course/presentation/unit/video/VideoUnitFragment.kt index f28cfa3a6..36e5227e8 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/unit/video/VideoUnitFragment.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/unit/video/VideoUnitFragment.kt @@ -2,12 +2,18 @@ package com.raccoongang.course.presentation.unit.video import android.graphics.Point import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.view.OrientationEventListener import android.view.View import android.widget.FrameLayout import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.core.os.bundleOf import androidx.core.view.isVisible @@ -15,17 +21,22 @@ import androidx.fragment.app.Fragment import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.Player +import com.raccoongang.core.extension.computeWindowSizeClasses import com.raccoongang.core.extension.dpToPixel -import com.raccoongang.core.presentation.global.WindowSizeHolder +import com.raccoongang.core.extension.objectToString +import com.raccoongang.core.extension.stringToObject +import com.raccoongang.core.presentation.dialog.SelectBottomDialogFragment import com.raccoongang.core.presentation.global.viewBinding import com.raccoongang.core.ui.WindowSize import com.raccoongang.core.ui.theme.NewEdxTheme import com.raccoongang.core.ui.theme.appColors +import com.raccoongang.core.utils.LocaleUtils import com.raccoongang.course.R import com.raccoongang.course.databinding.FragmentVideoUnitBinding import com.raccoongang.course.presentation.CourseRouter import com.raccoongang.course.presentation.ui.ConnectionErrorView import com.raccoongang.course.presentation.ui.VideoRotateView +import com.raccoongang.course.presentation.ui.VideoSubtitles import com.raccoongang.course.presentation.ui.VideoTitle import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -46,13 +57,34 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { private var orientationListener: OrientationEventListener? = null private var blockId = "" + private val handler = Handler(Looper.getMainLooper()) + private var videoTimeRunnable: Runnable = object : Runnable { + override fun run() { + exoPlayer?.let { + if (it.isPlaying) { + viewModel.setCurrentVideoTime(it.currentPosition) + } + } + handler.postDelayed(this, 200) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - windowSize = (requireActivity() as WindowSizeHolder).windowSize + windowSize = computeWindowSizeClasses() lifecycle.addObserver(viewModel) - viewModel.videoUrl = requireArguments().getString(ARG_VIDEO_URL, "") - viewModel.isDownloaded = requireArguments().getBoolean(ARG_DOWNLOADED) - blockId = requireArguments().getString(ARG_BLOCK_ID, "") + handler.post(videoTimeRunnable) + requireArguments().apply { + viewModel.videoUrl = getString(ARG_VIDEO_URL, "") + viewModel.transcripts = + stringToObject>( + getString(ARG_TRANSCRIPT_URL, "") + ) ?: emptyMap() + viewModel.isDownloaded = getBoolean(ARG_DOWNLOADED) + blockId = getString(ARG_BLOCK_ID, "") + } + viewModel.downloadSubtitles() orientationListener = object : OrientationEventListener(requireActivity()) { override fun onOrientationChanged(orientation: Int) { if (windowSize?.isTablet != true) { @@ -61,7 +93,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { router.navigateToFullScreenVideo( requireActivity().supportFragmentManager, viewModel.videoUrl, - exoPlayer?.currentPosition ?: viewModel.currentVideoTime, + exoPlayer?.currentPosition ?: viewModel.getCurrentVideoTime(), blockId, viewModel.courseId ) @@ -73,6 +105,10 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { } } } + + viewModel.isVideoPaused.observe(this) { + exoPlayer?.pause() + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -103,6 +139,31 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { } } + binding.subtitles.setContent { + NewEdxTheme { + val state = rememberLazyListState() + val currentIndex by viewModel.currentIndex.collectAsState(0) + val transcriptObject by viewModel.transcriptObject.observeAsState() + VideoSubtitles( + listState = state, + timedTextObject = transcriptObject, + subtitleLanguage = LocaleUtils.getDisplayLanguage(viewModel.transcriptLanguage), + showSubtitleLanguage = viewModel.transcripts.size > 1, + currentIndex = currentIndex, + onSettingsClick = { + exoPlayer?.pause() + val dialog = SelectBottomDialogFragment.newInstance( + LocaleUtils.getLanguages(viewModel.transcripts.keys.toList()) + ) + dialog.show( + requireActivity().supportFragmentManager, + SelectBottomDialogFragment::class.simpleName + ) + } + ) + } + } + binding.connectionError.isVisible = !viewModel.hasInternetConnection && !viewModel.isDownloaded val display = requireActivity().windowManager.defaultDisplay @@ -139,39 +200,34 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { if (exoPlayer == null) { exoPlayer = ExoPlayer.Builder(requireContext()) .build() - playerView.player = exoPlayer - playerView.setShowNextButton(false) - playerView.setShowPreviousButton(false) - val mediaItem = MediaItem.fromUri(viewModel.videoUrl) - exoPlayer?.setMediaItem(mediaItem, viewModel.currentVideoTime.toLong()) - exoPlayer?.prepare() - exoPlayer?.playWhenReady = true - - playerView.setFullscreenButtonClickListener { isFullScreen -> - router.navigateToFullScreenVideo( - requireActivity().supportFragmentManager, - viewModel.videoUrl, - exoPlayer?.currentPosition ?: 0L, - blockId, - viewModel.courseId - ) - viewModel.fullscreenHandled = true - } + } + playerView.player = exoPlayer + playerView.setShowNextButton(false) + playerView.setShowPreviousButton(false) + val mediaItem = MediaItem.fromUri(viewModel.videoUrl) + exoPlayer?.setMediaItem(mediaItem, viewModel.getCurrentVideoTime()) + exoPlayer?.prepare() + exoPlayer?.playWhenReady = !(viewModel.isVideoPaused.value ?: false) - exoPlayer?.addListener(object : Player.Listener { - override fun onPlaybackStateChanged(playbackState: Int) { - super.onPlaybackStateChanged(playbackState) - if (playbackState == Player.STATE_ENDED) { - viewModel.markBlockCompleted(blockId) - } - } - }) - } else { - val mediaItem = MediaItem.fromUri(viewModel.videoUrl) - exoPlayer?.setMediaItem(mediaItem, viewModel.currentVideoTime.toLong()) - exoPlayer?.prepare() - exoPlayer?.playWhenReady = true + playerView.setFullscreenButtonClickListener { isFullScreen -> + router.navigateToFullScreenVideo( + requireActivity().supportFragmentManager, + viewModel.videoUrl, + exoPlayer?.currentPosition ?: 0L, + blockId, + viewModel.courseId + ) + viewModel.fullscreenHandled = true } + + exoPlayer?.addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + if (playbackState == Player.STATE_ENDED) { + viewModel.markBlockCompleted(blockId) + } + } + }) } } @@ -190,12 +246,19 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { override fun onDestroyView() { exoPlayer?.release() + exoPlayer = null super.onDestroyView() } + override fun onDestroy() { + handler.removeCallbacks(videoTimeRunnable) + super.onDestroy() + } + companion object { private const val ARG_BLOCK_ID = "blockId" private const val ARG_VIDEO_URL = "videoUrl" + private const val ARG_TRANSCRIPT_URL = "transcriptUrl" private const val ARG_COURSE_ID = "courseId" private const val ARG_TITLE = "title" private const val ARG_DOWNLOADED = "isDownloaded" @@ -203,6 +266,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { blockId: String, courseId: String, videoUrl: String, + transcriptsUrl: Map, title: String, isDownloaded: Boolean ): VideoUnitFragment { @@ -211,6 +275,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { ARG_BLOCK_ID to blockId, ARG_COURSE_ID to courseId, ARG_VIDEO_URL to videoUrl, + ARG_TRANSCRIPT_URL to objectToString(transcriptsUrl), ARG_TITLE to title, ARG_DOWNLOADED to isDownloaded ) diff --git a/course/src/main/java/com/raccoongang/course/presentation/unit/video/VideoUnitViewModel.kt b/course/src/main/java/com/raccoongang/course/presentation/unit/video/VideoUnitViewModel.kt index 06b3cc155..52b068210 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/unit/video/VideoUnitViewModel.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/unit/video/VideoUnitViewModel.kt @@ -4,25 +4,39 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import com.raccoongang.core.AppDataConstants import com.raccoongang.core.BaseViewModel import com.raccoongang.core.data.storage.PreferencesManager +import com.raccoongang.core.module.TranscriptManager import com.raccoongang.core.system.connection.NetworkConnection import com.raccoongang.core.system.notifier.CourseNotifier +import com.raccoongang.core.system.notifier.CoursePauseVideo +import com.raccoongang.core.system.notifier.CourseSubtitleLanguageChanged import com.raccoongang.core.system.notifier.CourseVideoPositionChanged import com.raccoongang.course.data.repository.CourseRepository import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import subtitleFile.TimedTextObject class VideoUnitViewModel( val courseId: String, private val courseRepository: CourseRepository, private val preferencesManager: PreferencesManager, private val notifier: CourseNotifier, - private val networkConnection: NetworkConnection + private val networkConnection: NetworkConnection, + private val transcriptManager: TranscriptManager ) : BaseViewModel() { var videoUrl = "" - var currentVideoTime = 0L + var transcripts = emptyMap() + var transcriptLanguage = AppDataConstants.defaultLocale.language ?: "en" + private set + + private val _currentVideoTime = MutableLiveData(0) + val currentVideoTime: LiveData + get() = _currentVideoTime var fullscreenHandled = false @@ -36,6 +50,19 @@ class VideoUnitViewModel( val isPopUpViewShow: LiveData get() = _isPopUpViewShow + private val _isVideoPaused = MutableLiveData() + val isVideoPaused: LiveData + get() = _isVideoPaused + + private val _currentIndex = MutableStateFlow(0) + val currentIndex = _currentIndex.asStateFlow() + + private val _transcriptObject = MutableLiveData() + val transcriptObject: LiveData + get() = _transcriptObject + + private var timeList: List? = emptyList() + val hasInternetConnection: Boolean get() = networkConnection.isOnline() @@ -50,15 +77,44 @@ class VideoUnitViewModel( super.onCreate(owner) viewModelScope.launch { notifier.notifier.collect { - _isUpdated.value = false if (it is CourseVideoPositionChanged && videoUrl == it.videoUrl) { - currentVideoTime = it.videoTime + _isUpdated.value = false + _currentVideoTime.value = it.videoTime _isUpdated.value = true + } else if (it is CoursePauseVideo) { + _isVideoPaused.value = true + } else if (it is CourseSubtitleLanguageChanged) { + transcriptLanguage = it.value + _transcriptObject.value = null + downloadSubtitles() } } } } + fun downloadSubtitles() { + viewModelScope.launch { + transcriptManager.downloadTranscriptsForVideo(getTranscriptUrl())?.let { result -> + _transcriptObject.value = result + timeList = result.captions.values.toList() + .map { it.start.mseconds.toLong() } + } + } + } + + private fun getTranscriptUrl(): String { + val defaultTranscripts = transcripts[transcriptLanguage] + if (!defaultTranscripts.isNullOrEmpty()) { + return defaultTranscripts + } + if (transcripts.values.isNotEmpty()) { + transcriptLanguage = transcripts.keys.toList().first() + return transcripts[transcriptLanguage]?:"" + } + return "" + } + + fun markBlockCompleted(blockId: String) { viewModelScope.launch { try { @@ -71,4 +127,19 @@ class VideoUnitViewModel( } } } + + fun setCurrentVideoTime(value: Long) { + _currentVideoTime.value = value + timeList?.let { + val index = it.indexOfLast { subtitleTime -> + subtitleTime < value + } + if (index != currentIndex.value) { + _currentIndex.value = index + } + } + } + + fun getCurrentVideoTime() = currentVideoTime.value ?: 0 + } \ No newline at end of file diff --git a/course/src/main/java/com/raccoongang/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt b/course/src/main/java/com/raccoongang/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt index a71b5258e..e73ac53f2 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt @@ -90,7 +90,7 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) { super.onCurrentSecond(youTubePlayer, second) - viewModel.currentVideoTime = second.toLong() + viewModel.currentVideoTime = (second * 1000f).toLong() } override fun onReady(youTubePlayer: YouTubePlayer) { diff --git a/course/src/main/java/com/raccoongang/course/presentation/unit/video/YoutubeVideoUnitFragment.kt b/course/src/main/java/com/raccoongang/course/presentation/unit/video/YoutubeVideoUnitFragment.kt index bf5836a36..ecf8a272f 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/unit/video/YoutubeVideoUnitFragment.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/unit/video/YoutubeVideoUnitFragment.kt @@ -5,7 +5,11 @@ import android.view.OrientationEventListener import android.view.View import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.core.os.bundleOf import androidx.core.view.isVisible @@ -15,16 +19,21 @@ import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options.IFramePlayerOptions import com.pierfrancescosoffritti.androidyoutubeplayer.core.ui.DefaultPlayerUiController -import com.raccoongang.core.presentation.global.WindowSizeHolder +import com.raccoongang.core.extension.computeWindowSizeClasses +import com.raccoongang.core.extension.objectToString +import com.raccoongang.core.extension.stringToObject +import com.raccoongang.core.presentation.dialog.SelectBottomDialogFragment import com.raccoongang.core.presentation.global.viewBinding import com.raccoongang.core.ui.WindowSize import com.raccoongang.core.ui.theme.NewEdxTheme import com.raccoongang.core.ui.theme.appColors +import com.raccoongang.core.utils.LocaleUtils import com.raccoongang.course.R import com.raccoongang.course.databinding.FragmentYoutubeVideoUnitBinding import com.raccoongang.course.presentation.CourseRouter import com.raccoongang.course.presentation.ui.ConnectionErrorView import com.raccoongang.course.presentation.ui.VideoRotateView +import com.raccoongang.course.presentation.ui.VideoSubtitles import com.raccoongang.course.presentation.ui.VideoTitle import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -40,15 +49,22 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) private var windowSize: WindowSize? = null private var orientationListener: OrientationEventListener? = null + private var _youTubePlayer: YouTubePlayer? = null private var blockId = "" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - windowSize = (requireActivity() as WindowSizeHolder).windowSize + windowSize = computeWindowSizeClasses() lifecycle.addObserver(viewModel) - viewModel.videoUrl = requireArguments().getString(ARG_VIDEO_URL, "") - blockId = requireArguments().getString(ARG_BLOCK_ID, "") + requireArguments().apply { + viewModel.videoUrl = getString(ARG_VIDEO_URL, "") + viewModel.transcripts = stringToObject>( + getString(ARG_TRANSCRIPT_URL, "") + ) ?: emptyMap() + blockId = getString(ARG_BLOCK_ID, "") + } + viewModel.downloadSubtitles() orientationListener = object : OrientationEventListener(requireActivity()) { override fun onOrientationChanged(orientation: Int) { if (windowSize?.isTablet != true) { @@ -57,7 +73,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) router.navigateToFullScreenYoutubeVideo( requireActivity().supportFragmentManager, viewModel.videoUrl, - viewModel.currentVideoTime, + viewModel.getCurrentVideoTime(), blockId, viewModel.courseId ) @@ -69,6 +85,10 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) } } } + + viewModel.isVideoPaused.observe(this) { + _youTubePlayer?.pause() + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -98,6 +118,32 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) } } + binding.subtitles.setContent { + NewEdxTheme { + val state = rememberLazyListState() + val currentIndex by viewModel.currentIndex.collectAsState(0) + val transcriptObject by viewModel.transcriptObject.observeAsState() + VideoSubtitles( + listState = state, + timedTextObject = transcriptObject, + subtitleLanguage = LocaleUtils.getDisplayLanguage(viewModel.transcriptLanguage), + showSubtitleLanguage = viewModel.transcripts.size > 1, + currentIndex = currentIndex, + onSettingsClick = { + _youTubePlayer?.pause() + val dialog = + SelectBottomDialogFragment.newInstance( + LocaleUtils.getLanguages(viewModel.transcripts.keys.toList()) + ) + dialog.show( + requireActivity().supportFragmentManager, + SelectBottomDialogFragment::class.simpleName + ) + } + ) + } + } + binding.connectionError.isVisible = !viewModel.hasInternetConnection lifecycle.addObserver(binding.youtubePlayerView) @@ -120,11 +166,12 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) { super.onCurrentSecond(youTubePlayer, second) - viewModel.currentVideoTime = second.toLong() + viewModel.setCurrentVideoTime((second * 1000f).toLong()) } override fun onReady(youTubePlayer: YouTubePlayer) { super.onReady(youTubePlayer) + _youTubePlayer = youTubePlayer val defPlayerUiController = DefaultPlayerUiController( binding.youtubePlayerView, youTubePlayer @@ -134,7 +181,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) router.navigateToFullScreenYoutubeVideo( requireActivity().supportFragmentManager, viewModel.videoUrl, - viewModel.currentVideoTime, + viewModel.getCurrentVideoTime(), blockId, viewModel.courseId ) @@ -142,7 +189,10 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) binding.youtubePlayerView.setCustomPlayerUi(defPlayerUiController.rootView) val videoId = viewModel.videoUrl.split("watch?v=")[1] - youTubePlayer.loadVideo(videoId, viewModel.currentVideoTime.toFloat()) + youTubePlayer.loadVideo(videoId, viewModel.getCurrentVideoTime().toFloat()) + if (viewModel.isVideoPaused.value == true) { + youTubePlayer.pause() + } } } @@ -150,7 +200,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) viewModel.isPopUpViewShow.observe(viewLifecycleOwner) { if (windowSize?.isTablet != true) { - binding.cvRotateHelper?.isVisible = it + binding.cvRotateHelper.isVisible = it } } } @@ -168,19 +218,24 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) } companion object { + private const val ARG_VIDEO_URL = "videoUrl" + private const val ARG_TRANSCRIPT_URL = "transcriptUrl" private const val ARG_BLOCK_ID = "blockId" private const val ARG_COURSE_ID = "courseId" private const val ARG_TITLE = "blockTitle" + fun newInstance( blockId: String, courseId: String, videoUrl: String, + transcriptsUrl: Map, blockTitle: String ): YoutubeVideoUnitFragment { val fragment = YoutubeVideoUnitFragment() fragment.arguments = bundleOf( ARG_VIDEO_URL to videoUrl, + ARG_TRANSCRIPT_URL to objectToString(transcriptsUrl), ARG_BLOCK_ID to blockId, ARG_COURSE_ID to courseId, ARG_TITLE to blockTitle diff --git a/course/src/main/java/com/raccoongang/course/presentation/units/CourseUnitsFragment.kt b/course/src/main/java/com/raccoongang/course/presentation/units/CourseUnitsFragment.kt index a3f928dce..e1410c8c5 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/units/CourseUnitsFragment.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/units/CourseUnitsFragment.kt @@ -47,6 +47,7 @@ import com.raccoongang.core.ui.theme.appTypography import com.raccoongang.course.R import com.raccoongang.course.presentation.CourseRouter import com.raccoongang.course.presentation.ui.CardArrow +import com.raccoongang.course.presentation.units.CourseUnitsFragment.Companion.getUnitBlockIcon import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import java.io.File @@ -139,6 +140,15 @@ class CourseUnitsFragment : Fragment() { ) return fragment } + + fun getUnitBlockIcon(block: Block): Int { + return when (block.type) { + BlockType.VIDEO -> R.drawable.ic_course_video + BlockType.PROBLEM -> R.drawable.ic_course_pen + BlockType.DISCUSSION -> R.drawable.ic_course_discussion + else -> R.drawable.ic_course_block + } + } } } @@ -223,7 +233,7 @@ private fun CourseUnitsScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } is CourseUnitsUIState.Blocks -> { @@ -317,7 +327,10 @@ private fun CourseUnitItem( } } else if (block.isDownloadable) { Box(contentAlignment = Alignment.Center) { - CircularProgressIndicator(modifier = Modifier.size(34.dp)) + CircularProgressIndicator( + modifier = Modifier.size(34.dp), + color = MaterialTheme.appColors.primary + ) IconButton(modifier = iconModifier, onClick = { onDownloadClick(block) }) { Icon( @@ -336,15 +349,6 @@ private fun CourseUnitItem( } } -private fun getUnitBlockIcon(block: Block): Int { - return when (block.type) { - BlockType.VIDEO -> R.drawable.ic_course_video - BlockType.PROBLEM -> R.drawable.ic_course_pen - BlockType.DISCUSSION -> R.drawable.ic_course_discussion - else -> R.drawable.ic_course_block - } -} - @Preview(uiMode = UI_MODE_NIGHT_NO) @Preview(uiMode = UI_MODE_NIGHT_YES) diff --git a/course/src/main/java/com/raccoongang/course/presentation/units/CourseUnitsViewModel.kt b/course/src/main/java/com/raccoongang/course/presentation/units/CourseUnitsViewModel.kt index 097576798..b17db9456 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/units/CourseUnitsViewModel.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/units/CourseUnitsViewModel.kt @@ -61,10 +61,11 @@ class CourseUnitsViewModel( _uiState.value = CourseUnitsUIState.Loading viewModelScope.launch { try { - val blocks = when (mode) { + val courseStructure = when (mode) { CourseViewMode.FULL -> interactor.getCourseStructureFromCache() CourseViewMode.VIDEOS -> interactor.getCourseStructureForVideos() } + val blocks = courseStructure.blockData val newList = getDescendantBlocks(blocks, blockId) _uiState.value = CourseUnitsUIState.Blocks(newList.map { block -> val downloadingBlock = getDownloadModels().first().find { it.id == block.id } diff --git a/course/src/main/java/com/raccoongang/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/com/raccoongang/course/presentation/videos/CourseVideoViewModel.kt index b329e4976..88347cae3 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/videos/CourseVideoViewModel.kt @@ -9,7 +9,6 @@ import com.raccoongang.core.SingleEventLiveData import com.raccoongang.core.UIMessage import com.raccoongang.core.data.storage.PreferencesManager import com.raccoongang.core.domain.model.Block -import com.raccoongang.core.domain.model.Certificate import com.raccoongang.core.module.DownloadWorkerController import com.raccoongang.core.module.db.DownloadDao import com.raccoongang.core.module.download.BaseDownloadViewModel @@ -32,15 +31,11 @@ class CourseVideoViewModel( workerController: DownloadWorkerController ) : BaseDownloadViewModel(downloadDao, preferencesManager, workerController) { - private val _uiState = MutableLiveData( - CourseVideosUIState.CourseData(emptyList(), emptyMap()) - ) + private val _uiState = MutableLiveData() val uiState: LiveData get() = _uiState var courseTitle = "" - var courseImage = "" - var courseCertificate = Certificate("") private val _isUpdating = MutableLiveData() val isUpdating: LiveData @@ -70,7 +65,7 @@ class CourseVideoViewModel( if (_uiState.value is CourseVideosUIState.CourseData) { val state = _uiState.value as CourseVideosUIState.CourseData _uiState.value = CourseVideosUIState.CourseData( - blocks = state.blocks, + courseStructure = state.courseStructure, downloadedState = it.toMap() ) } @@ -78,6 +73,10 @@ class CourseVideoViewModel( } } + init { + getVideos() + } + override fun saveDownloadModels(folder: String, id: String) { if (preferencesManager.videoSettings.wifiDownloadOnly) { if (networkConnection.isWifiConnected()) { @@ -102,16 +101,18 @@ class CourseVideoViewModel( fun getVideos() { viewModelScope.launch { - val blocks = interactor.getCourseStructureForVideos() + var courseStructure = interactor.getCourseStructureForVideos() + val blocks = courseStructure.blockData if (blocks.isEmpty()) { _uiState.value = CourseVideosUIState.Empty( message = resourceManager.getString(R.string.course_does_not_include_videos) ) } else { - setBlocks(blocks) - val list = sortBlocks(blocks) + setBlocks(courseStructure.blockData) + courseStructure = courseStructure.copy(blockData = sortBlocks(blocks)) initDownloadModelsStatus() - _uiState.value = CourseVideosUIState.CourseData(list, getDownloadModelsStatus()) + _uiState.value = + CourseVideosUIState.CourseData(courseStructure, getDownloadModelsStatus()) } } } diff --git a/course/src/main/java/com/raccoongang/course/presentation/videos/CourseVideosFragment.kt b/course/src/main/java/com/raccoongang/course/presentation/videos/CourseVideosFragment.kt index 2c3250838..5ad441a0c 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/videos/CourseVideosFragment.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/videos/CourseVideosFragment.kt @@ -31,10 +31,7 @@ import androidx.fragment.app.Fragment import com.raccoongang.core.BlockType import com.raccoongang.core.R import com.raccoongang.core.UIMessage -import com.raccoongang.core.domain.model.Block -import com.raccoongang.core.domain.model.BlockCounts -import com.raccoongang.core.domain.model.Certificate -import com.raccoongang.core.extension.parcelable +import com.raccoongang.core.domain.model.* import com.raccoongang.core.presentation.course.CourseViewMode import com.raccoongang.core.ui.* import com.raccoongang.core.ui.theme.NewEdxTheme @@ -49,6 +46,7 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import java.io.File +import java.util.* class CourseVideosFragment : Fragment() { @@ -61,11 +59,8 @@ class CourseVideosFragment : Fragment() { super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) with(requireArguments()) { - viewModel.courseImage = getString(ARG_IMAGE, "") viewModel.courseTitle = getString(ARG_TITLE, "") - viewModel.courseCertificate = parcelable(ARG_CERTIFICATE)!! } - viewModel.getVideos() } override fun onCreateView( @@ -78,17 +73,15 @@ class CourseVideosFragment : Fragment() { NewEdxTheme { val windowSize = rememberWindowSize() - val uiState by viewModel.uiState.observeAsState() + val uiState by viewModel.uiState.observeAsState(CourseVideosUIState.Loading) val uiMessage by viewModel.uiMessage.observeAsState() val isUpdating by viewModel.isUpdating.observeAsState(false) CourseVideosScreen( windowSize = windowSize, - uiState = uiState!!, + uiState = uiState, uiMessage = uiMessage, - courseImage = viewModel.courseImage, courseTitle = viewModel.courseTitle, - courseCertificate = viewModel.courseCertificate, hasInternetConnection = viewModel.hasInternetConnection, isUpdating = isUpdating, onSwipeRefresh = { @@ -133,20 +126,14 @@ class CourseVideosFragment : Fragment() { companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_TITLE = "title" - private const val ARG_IMAGE = "image" - private const val ARG_CERTIFICATE = "certificate" fun newInstance( courseId: String, - title: String, - image: String, - certificate: Certificate + title: String ): CourseVideosFragment { val fragment = CourseVideosFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, - ARG_TITLE to title, - ARG_IMAGE to image, - ARG_CERTIFICATE to certificate + ARG_TITLE to title ) return fragment } @@ -163,8 +150,6 @@ private fun CourseVideosScreen( hasInternetConnection: Boolean, onSwipeRefresh: () -> Unit, courseTitle: String, - courseImage: String, - courseCertificate: Certificate, onItemClick: (Block) -> Unit, onReloadClick: () -> Unit, onBackClick: () -> Unit, @@ -276,20 +261,28 @@ private fun CourseVideosScreen( ) } } + is CourseVideosUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } is CourseVideosUIState.CourseData -> { CourseImageHeader( modifier = Modifier - .fillMaxWidth() - .height(imageHeight) + .aspectRatio(1.86f) .padding(6.dp), - courseImage = courseImage, - courseCertificate = courseCertificate + courseImage = uiState.courseStructure.media?.image?.large + ?: "", + courseCertificate = uiState.courseStructure.certificate ) LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = listPadding ) { - items(uiState.blocks) { block -> + items(uiState.courseStructure.blockData) { block -> if (block.type == BlockType.CHAPTER) { Text( modifier = Modifier.padding( @@ -351,13 +344,9 @@ private fun CourseVideosScreenPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiMessage = null, uiState = CourseVideosUIState.CourseData( - listOf( - mockSequentialBlock, mockSequentialBlock - ), + mockCourseStructure, emptyMap() ), - courseCertificate = Certificate(""), - courseImage = "", courseTitle = "Course", onItemClick = { }, onBackClick = {}, @@ -381,8 +370,6 @@ private fun CourseVideosScreenEmptyPreview() { uiState = CourseVideosUIState.Empty( "This course does not include any videos." ), - courseCertificate = Certificate(""), - courseImage = "", courseTitle = "Course", onItemClick = { }, onBackClick = {}, @@ -404,13 +391,9 @@ private fun CourseVideosScreenTabletPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiMessage = null, uiState = CourseVideosUIState.CourseData( - listOf( - mockSequentialBlock, mockSequentialBlock - ), + mockCourseStructure, emptyMap() ), - courseCertificate = Certificate(""), - courseImage = "", courseTitle = "Course", onItemClick = { }, onBackClick = {}, @@ -454,4 +437,28 @@ private val mockSequentialBlock = Block( blockCounts = BlockCounts(1), descendants = emptyList(), completion = 0.0 +) + +private val mockCourseStructure = CourseStructure( + root = "", + blockData = listOf(mockSequentialBlock, mockChapterBlock), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false ) \ No newline at end of file diff --git a/course/src/main/java/com/raccoongang/course/presentation/videos/CourseVideosUIState.kt b/course/src/main/java/com/raccoongang/course/presentation/videos/CourseVideosUIState.kt index 964e173f3..882eba6ce 100644 --- a/course/src/main/java/com/raccoongang/course/presentation/videos/CourseVideosUIState.kt +++ b/course/src/main/java/com/raccoongang/course/presentation/videos/CourseVideosUIState.kt @@ -1,13 +1,14 @@ package com.raccoongang.course.presentation.videos -import com.raccoongang.core.domain.model.Block +import com.raccoongang.core.domain.model.CourseStructure import com.raccoongang.core.module.db.DownloadedState sealed class CourseVideosUIState { data class CourseData( - val blocks: List, + val courseStructure: CourseStructure, val downloadedState: Map, ) : CourseVideosUIState() data class Empty(val message: String) : CourseVideosUIState() + object Loading : CourseVideosUIState() } \ No newline at end of file diff --git a/course/src/main/res/drawable/course_ic_announcements.xml b/course/src/main/res/drawable/course_ic_announcements.xml new file mode 100644 index 000000000..99dcb4bcf --- /dev/null +++ b/course/src/main/res/drawable/course_ic_announcements.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/course/src/main/res/drawable/course_ic_cc.xml b/course/src/main/res/drawable/course_ic_cc.xml new file mode 100644 index 000000000..3f77d8be6 --- /dev/null +++ b/course/src/main/res/drawable/course_ic_cc.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/course/src/main/res/drawable/course_ic_handouts.xml b/course/src/main/res/drawable/course_ic_handouts.xml new file mode 100644 index 000000000..3aa877674 --- /dev/null +++ b/course/src/main/res/drawable/course_ic_handouts.xml @@ -0,0 +1,45 @@ + + + + + + + + + + diff --git a/course/src/main/res/drawable/course_ic_play.xml b/course/src/main/res/drawable/course_ic_play.xml new file mode 100644 index 000000000..a172ae772 --- /dev/null +++ b/course/src/main/res/drawable/course_ic_play.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/course/src/main/res/layout-w600dp-h480dp/fragment_video_unit.xml b/course/src/main/res/layout-w600dp-h480dp/fragment_video_unit.xml index 2d8c6c2ff..0ab81bdab 100644 --- a/course/src/main/res/layout-w600dp-h480dp/fragment_video_unit.xml +++ b/course/src/main/res/layout-w600dp-h480dp/fragment_video_unit.xml @@ -44,6 +44,19 @@ + + + + + + + + + android:layout_height="match_parent" + android:visibility="gone" /> \ No newline at end of file diff --git a/course/src/main/res/values-uk/strings.xml b/course/src/main/res/values-uk/strings.xml index ab9ca719a..0741f8b96 100644 --- a/course/src/main/res/values-uk/strings.xml +++ b/course/src/main/res/values-uk/strings.xml @@ -43,5 +43,7 @@ Ця інтерактивна компонента ще не доступна Досліджуйте інші частини цього курсу або перегляньте це на веб-сайті. Відкрити в браузері - + Субтитри + Остання активність: + Продовжити \ No newline at end of file diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 2ae076a58..c42b409cb 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -43,5 +43,7 @@ This interactive component isn’t yet available Explore other parts of this course or view this on web. Open in browser - + Subtitles + Continue with: + Continue \ No newline at end of file diff --git a/course/src/test/java/com/raccoongang/course/presentation/container/CourseContainerViewModelTest.kt b/course/src/test/java/com/raccoongang/course/presentation/container/CourseContainerViewModelTest.kt index d4de361dc..0265d4750 100644 --- a/course/src/test/java/com/raccoongang/course/presentation/container/CourseContainerViewModelTest.kt +++ b/course/src/test/java/com/raccoongang/course/presentation/container/CourseContainerViewModelTest.kt @@ -3,6 +3,8 @@ package com.raccoongang.course.presentation.container import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.raccoongang.core.system.connection.NetworkConnection import com.raccoongang.core.R +import com.raccoongang.core.domain.model.CourseStructure +import com.raccoongang.core.domain.model.CoursewareAccess import com.raccoongang.core.system.ResourceManager import com.raccoongang.core.system.notifier.CourseNotifier import com.raccoongang.core.system.notifier.CourseStructureUpdated @@ -35,6 +37,30 @@ class CourseContainerViewModelTest { private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" + private val courseStructure = CourseStructure( + root = "", + blockData = listOf(), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = null, + startDisplay = "", + startType = "", + end = null, + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false + ) + @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -84,6 +110,7 @@ class CourseContainerViewModelTest { val viewModel = CourseContainerViewModel("", interactor,resourceManager, notifier, networkConnection) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } returns Unit + every { interactor.getCourseStructureFromCache() } returns courseStructure viewModel.preloadCourseStructure() advanceUntilIdle() @@ -91,7 +118,7 @@ class CourseContainerViewModelTest { assert(viewModel.errorMessage.value == null) assert(viewModel.showProgress.value == false) - assert(viewModel.dataReady.value == true) + assert(viewModel.dataReady.value != null) } @Test @@ -99,6 +126,7 @@ class CourseContainerViewModelTest { val viewModel = CourseContainerViewModel("", interactor,resourceManager, notifier, networkConnection) every { networkConnection.isOnline() } returns false coEvery { interactor.preloadCourseStructureFromCache(any()) } returns Unit + every { interactor.getCourseStructureFromCache() } returns courseStructure viewModel.preloadCourseStructure() advanceUntilIdle() @@ -107,7 +135,7 @@ class CourseContainerViewModelTest { assert(viewModel.errorMessage.value == null) assert(viewModel.showProgress.value == false) - assert(viewModel.dataReady.value == true) + assert(viewModel.dataReady.value != null) } @Test diff --git a/course/src/test/java/com/raccoongang/course/presentation/detail/CourseDetailsViewModelTest.kt b/course/src/test/java/com/raccoongang/course/presentation/detail/CourseDetailsViewModelTest.kt index e82e75008..d69e22cbc 100644 --- a/course/src/test/java/com/raccoongang/course/presentation/detail/CourseDetailsViewModelTest.kt +++ b/course/src/test/java/com/raccoongang/course/presentation/detail/CourseDetailsViewModelTest.kt @@ -55,11 +55,9 @@ class CourseDetailsViewModelTest { CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } throws UnknownHostException() - coEvery { interactor.getEnrolledCourseById(any()) } throws UnknownHostException() advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseDetails(any()) } - coVerify(exactly = 1) { interactor.getEnrolledCourseById(any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -73,11 +71,9 @@ class CourseDetailsViewModelTest { CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } throws Exception() - coEvery { interactor.getEnrolledCourseById(any()) } throws Exception() advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseDetails(any()) } - coVerify(exactly = 1) { interactor.getEnrolledCourseById(any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -91,12 +87,10 @@ class CourseDetailsViewModelTest { CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } returns mockk() - coEvery { interactor.getEnrolledCourseById(any()) } returns mockk() advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseDetails(any()) } - coVerify(exactly = 1) { interactor.getEnrolledCourseById(any()) } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is CourseDetailsUIState.CourseData) @@ -108,14 +102,11 @@ class CourseDetailsViewModelTest { CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier) every { networkConnection.isOnline() } returns false coEvery { interactor.getCourseDetailsFromCache(any()) } returns mockk() - coEvery { interactor.getEnrolledCourseFromCacheById(any()) } returns mockk() advanceUntilIdle() coVerify(exactly = 0) { interactor.getCourseDetails(any()) } - coVerify(exactly = 0) { interactor.getEnrolledCourseById(any()) } coVerify(exactly = 1) { interactor.getCourseDetailsFromCache(any()) } - coVerify(exactly = 1) { interactor.getEnrolledCourseFromCacheById(any()) } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is CourseDetailsUIState.CourseData) @@ -126,7 +117,6 @@ class CourseDetailsViewModelTest { val viewModel = CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier) coEvery { interactor.enrollInACourse(any()) } throws UnknownHostException() - coEvery { interactor.getEnrolledCourseById(any()) } returns mockk() coEvery { notifier.send(CourseDashboardUpdate()) } returns Unit every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } returns mockk() @@ -147,7 +137,6 @@ class CourseDetailsViewModelTest { val viewModel = CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier) coEvery { interactor.enrollInACourse(any()) } throws Exception() - coEvery { interactor.getEnrolledCourseById(any()) } returns mockk() coEvery { notifier.send(CourseDashboardUpdate()) } returns Unit every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } returns mockk() @@ -168,7 +157,6 @@ class CourseDetailsViewModelTest { val viewModel = CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier) coEvery { interactor.enrollInACourse(any()) } returns Unit - coEvery { interactor.getEnrolledCourseById(any()) } returns mockk() coEvery { notifier.send(CourseDashboardUpdate()) } returns Unit every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } returns mockk() @@ -179,7 +167,6 @@ class CourseDetailsViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { interactor.enrollInACourse(any()) } - coVerify(exactly = 2) { interactor.getEnrolledCourseById(any()) } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is CourseDetailsUIState.CourseData) diff --git a/course/src/test/java/com/raccoongang/course/presentation/handouts/HandoutsViewModelTest.kt b/course/src/test/java/com/raccoongang/course/presentation/handouts/HandoutsViewModelTest.kt index 088f0aa16..cbd662459 100644 --- a/course/src/test/java/com/raccoongang/course/presentation/handouts/HandoutsViewModelTest.kt +++ b/course/src/test/java/com/raccoongang/course/presentation/handouts/HandoutsViewModelTest.kt @@ -28,44 +28,10 @@ class HandoutsViewModelTest { private val interactor = mockk() - //region mockEnrolledCourse - - private val mockCourseEnrolled = EnrolledCourse( - auditAccessExpires = Date(), - created = "created", - certificate = Certificate(""), - mode = "mode", - isActive = true, - course = EnrolledCourseData( - id = "id", - name = "name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = null, - dynamicUpgradeDeadline = "", - subscriptionId = "", - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - courseImage = "", - courseAbout = "", - courseSharingUtmParameters = CourseSharingUtmParameters("", ""), - courseUpdates = "", - courseHandouts = "", - discussionUrl = "", - videoOutline = "", - isSelfPaced = false - ) - ) + //region mockHandoutsModel + + private val handoutsModel = HandoutsModel("") + //endregion @Before @@ -80,36 +46,29 @@ class HandoutsViewModelTest { @Test fun `getEnrolledCourse no internet connection exception`() = runTest { - val viewModel = HandoutsViewModel("","Handouts", interactor) - coEvery { interactor.getEnrolledCourseFromCacheById(any()) } throws UnknownHostException() - coEvery { interactor.getAnnouncements(any()) } returns mockk() + val viewModel = HandoutsViewModel("", "Handouts", interactor) + coEvery { interactor.getHandouts(any()) } throws UnknownHostException() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getEnrolledCourseFromCacheById(any()) } assert(viewModel.htmlContent.value == null) } @Test fun `getEnrolledCourse unknown exception`() = runTest { - val viewModel = HandoutsViewModel("","Handouts", interactor) - coEvery { interactor.getEnrolledCourseFromCacheById(any()) } throws Exception() - coEvery { interactor.getAnnouncements(any()) } returns mockk() - + val viewModel = HandoutsViewModel("", "Handouts", interactor) + coEvery { interactor.getHandouts(any()) } throws Exception() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getEnrolledCourseFromCacheById(any()) } assert(viewModel.htmlContent.value == null) } @Test fun `getEnrolledCourse handouts success`() = runTest { - val viewModel = HandoutsViewModel("",HandoutsType.Handouts.name, interactor) - coEvery { interactor.getEnrolledCourseFromCacheById(any()) } returns mockCourseEnrolled + val viewModel = HandoutsViewModel("", HandoutsType.Handouts.name, interactor) coEvery { interactor.getHandouts(any()) } returns HandoutsModel("hello") advanceUntilIdle() - coVerify(exactly = 1) { interactor.getEnrolledCourseFromCacheById(any()) } coVerify(exactly = 1) { interactor.getHandouts(any()) } coVerify(exactly = 0) { interactor.getAnnouncements(any()) } @@ -118,12 +77,15 @@ class HandoutsViewModelTest { @Test fun `getEnrolledCourse announcements success`() = runTest { - val viewModel = HandoutsViewModel("",HandoutsType.Announcements.name, interactor) - coEvery { interactor.getEnrolledCourseFromCacheById(any()) } returns mockCourseEnrolled - coEvery { interactor.getAnnouncements(any()) } returns listOf(AnnouncementModel("date","content")) + val viewModel = HandoutsViewModel("", HandoutsType.Announcements.name, interactor) + coEvery { interactor.getAnnouncements(any()) } returns listOf( + AnnouncementModel( + "date", + "content" + ) + ) advanceUntilIdle() - coVerify(exactly = 1) { interactor.getEnrolledCourseFromCacheById(any()) } coVerify(exactly = 0) { interactor.getHandouts(any()) } coVerify(exactly = 1) { interactor.getAnnouncements(any()) } @@ -132,12 +94,19 @@ class HandoutsViewModelTest { @Test fun `injectDarkMode test`() = runTest { - val viewModel = HandoutsViewModel("",HandoutsType.Announcements.name, interactor) - coEvery { interactor.getEnrolledCourseFromCacheById(any()) } returns mockCourseEnrolled - coEvery { interactor.getAnnouncements(any()) } returns listOf(AnnouncementModel("date","content")) - viewModel.injectDarkMode(viewModel.htmlContent.value.toString(), ULong.MAX_VALUE, ULong.MAX_VALUE) + val viewModel = HandoutsViewModel("", HandoutsType.Announcements.name, interactor) + coEvery { interactor.getAnnouncements(any()) } returns listOf( + AnnouncementModel( + "date", + "content" + ) + ) + viewModel.injectDarkMode( + viewModel.htmlContent.value.toString(), + ULong.MAX_VALUE, + ULong.MAX_VALUE + ) advanceUntilIdle() - coVerify(exactly = 1) { interactor.getEnrolledCourseFromCacheById(any()) } coVerify(exactly = 0) { interactor.getHandouts(any()) } coVerify(exactly = 1) { interactor.getAnnouncements(any()) } diff --git a/course/src/test/java/com/raccoongang/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/com/raccoongang/course/presentation/outline/CourseOutlineViewModelTest.kt index 23b058e08..59e1c7688 100644 --- a/course/src/test/java/com/raccoongang/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/com/raccoongang/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -8,9 +8,7 @@ import com.raccoongang.core.BlockType import com.raccoongang.core.R import com.raccoongang.core.UIMessage import com.raccoongang.core.data.storage.PreferencesManager -import com.raccoongang.core.domain.model.Block -import com.raccoongang.core.domain.model.BlockCounts -import com.raccoongang.core.domain.model.CourseComponentStatus +import com.raccoongang.core.domain.model.* import com.raccoongang.core.module.DownloadWorkerController import com.raccoongang.core.module.db.* import com.raccoongang.core.system.ResourceManager @@ -31,6 +29,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import java.net.UnknownHostException +import java.util.* @OptIn(ExperimentalCoroutinesApi::class) class CourseOutlineViewModelTest { @@ -100,6 +99,30 @@ class CourseOutlineViewModelTest { ) ) + val courseStructure = CourseStructure( + root = "", + blockData = blocks, + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false + ) + private val downloadModel = DownloadModel( "id", "title", @@ -126,6 +149,10 @@ class CourseOutlineViewModelTest { @Test fun `getCourseDataInternal no internet connection exception`() = runTest { + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseStatus(any()) } throws UnknownHostException() + val viewModel = CourseOutlineViewModel( "", interactor, @@ -136,11 +163,7 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - every { interactor.getCourseStructureFromCache() } returns emptyList() - every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStatus(any()) } throws UnknownHostException() - viewModel.getCourseData() advanceUntilIdle() verify(exactly = 1) { interactor.getCourseStructureFromCache() } @@ -154,6 +177,9 @@ class CourseOutlineViewModelTest { @Test fun `getCourseDataInternal unknown exception`() = runTest { + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseStatus(any()) } throws Exception() val viewModel = CourseOutlineViewModel( "", interactor, @@ -164,11 +190,7 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - every { interactor.getCourseStructureFromCache() } returns emptyList() - every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStatus(any()) } throws Exception() - viewModel.getCourseData() advanceUntilIdle() verify(exactly = 1) { interactor.getCourseStructureFromCache() } @@ -182,17 +204,7 @@ class CourseOutlineViewModelTest { @Test fun `getCourseDataInternal success with internet connection`() = runTest { - val viewModel = CourseOutlineViewModel( - "", - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - downloadDao, - workerController - ) - every { interactor.getCourseStructureFromCache() } returns blocks + every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns true coEvery { downloadDao.readAllData() } returns flow { emit( @@ -205,7 +217,17 @@ class CourseOutlineViewModelTest { } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - viewModel.getCourseData() + val viewModel = CourseOutlineViewModel( + "", + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + downloadDao, + workerController + ) + advanceUntilIdle() verify(exactly = 1) { interactor.getCourseStructureFromCache() } @@ -218,17 +240,7 @@ class CourseOutlineViewModelTest { @Test fun `getCourseDataInternal success without internet connection`() = runTest { - val viewModel = CourseOutlineViewModel( - "", - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - downloadDao, - workerController - ) - every { interactor.getCourseStructureFromCache() } returns blocks + every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns false coEvery { downloadDao.readAllData() } returns flow { emit( @@ -241,7 +253,17 @@ class CourseOutlineViewModelTest { } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - viewModel.getCourseData() + val viewModel = CourseOutlineViewModel( + "", + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + downloadDao, + workerController + ) + advanceUntilIdle() verify(exactly = 1) { interactor.getCourseStructureFromCache() } @@ -254,17 +276,7 @@ class CourseOutlineViewModelTest { @Test fun `updateCourseData success with internet connection`() = runTest { - val viewModel = CourseOutlineViewModel( - "", - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - downloadDao, - workerController - ) - every { interactor.getCourseStructureFromCache() } returns blocks + every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns true coEvery { downloadDao.readAllData() } returns flow { emit( @@ -277,11 +289,22 @@ class CourseOutlineViewModelTest { } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") + val viewModel = CourseOutlineViewModel( + "", + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + downloadDao, + workerController + ) + viewModel.updateCourseData(false) advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 1) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFromCache() } + coVerify(exactly = 2) { interactor.getCourseStatus(any()) } assert(viewModel.uiMessage.value == null) assert(viewModel.isUpdating.value == false) @@ -301,7 +324,7 @@ class CourseOutlineViewModelTest { workerController ) coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("", false)) } - every { interactor.getCourseStructureFromCache() } returns blocks + every { interactor.getCourseStructureFromCache() } returns courseStructure every { downloadDao.readAllData() } returns flow { repeat(5) { delay(10000) @@ -319,12 +342,20 @@ class CourseOutlineViewModelTest { viewModel.setIsUpdating() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructureFromCache() } + coVerify(exactly = 2) { interactor.getCourseStructureFromCache() } coVerify(exactly = 1) { interactor.getCourseStatus(any()) } } @Test fun `saveDownloadModels test`() = runTest { + every { preferencesManager.videoSettings.wifiDownloadOnly } returns false + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { networkConnection.isWifiConnected() } returns true + every { networkConnection.isOnline() } returns true + coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") + coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } + val viewModel = CourseOutlineViewModel( "", interactor, @@ -335,9 +366,6 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - every { preferencesManager.videoSettings.wifiDownloadOnly } returns false - every { networkConnection.isWifiConnected() } returns true - coEvery { workerController.saveModels(*anyVararg()) } returns Unit viewModel.saveDownloadModels("", "") advanceUntilIdle() @@ -347,6 +375,15 @@ class CourseOutlineViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest { + every { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns true + every { networkConnection.isOnline() } returns true + coEvery { downloadDao.readAllData() } returns mockk() + coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } + val viewModel = CourseOutlineViewModel( "", interactor, @@ -357,9 +394,6 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns true - coEvery { workerController.saveModels(*anyVararg()) } returns Unit viewModel.saveDownloadModels("", "") advanceUntilIdle() @@ -369,6 +403,13 @@ class CourseOutlineViewModelTest { @Test fun `saveDownloadModels only wifi download, without connection`() = runTest { + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns false + every { networkConnection.isOnline() } returns false + coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } + val viewModel = CourseOutlineViewModel( "", interactor, @@ -379,10 +420,6 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns false - every { networkConnection.isOnline() } returns false - coEvery { workerController.saveModels(*anyVararg()) } returns Unit viewModel.saveDownloadModels("", "") diff --git a/course/src/test/java/com/raccoongang/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/com/raccoongang/course/presentation/section/CourseSectionViewModelTest.kt index b0b04edc5..64c3df5c1 100644 --- a/course/src/test/java/com/raccoongang/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/com/raccoongang/course/presentation/section/CourseSectionViewModelTest.kt @@ -10,6 +10,8 @@ import com.raccoongang.core.UIMessage import com.raccoongang.core.data.storage.PreferencesManager import com.raccoongang.core.domain.model.Block import com.raccoongang.core.domain.model.BlockCounts +import com.raccoongang.core.domain.model.CourseStructure +import com.raccoongang.core.domain.model.CoursewareAccess import com.raccoongang.core.module.DownloadWorkerController import com.raccoongang.core.module.db.* import com.raccoongang.core.presentation.course.CourseViewMode @@ -32,6 +34,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import java.net.UnknownHostException +import java.util.* @OptIn(ExperimentalCoroutinesApi::class) class CourseSectionViewModelTest { @@ -101,6 +104,30 @@ class CourseSectionViewModelTest { ) ) + private val courseStructure = CourseStructure( + root = "", + blockData = blocks, + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false + ) + private val downloadModel = DownloadModel( "id", "title", @@ -192,8 +219,8 @@ class CourseSectionViewModelTest { coEvery { downloadDao.readAllData() } returns flow { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } - coEvery { interactor.getCourseStructureFromCache() } returns emptyList() - coEvery { interactor.getCourseStructureForVideos() } returns blocks + coEvery { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructureForVideos() } returns courseStructure viewModel.getBlocks("id", CourseViewMode.VIDEOS) advanceUntilIdle() @@ -289,8 +316,8 @@ class CourseSectionViewModelTest { emit(emptyList()) } } - coEvery { interactor.getCourseStructureFromCache() } returns emptyList() - coEvery { interactor.getCourseStructureForVideos() } returns blocks + coEvery { interactor.getCourseStructureFromCache() } returns courseStructure + coEvery { interactor.getCourseStructureForVideos() } returns courseStructure val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) diff --git a/course/src/test/java/com/raccoongang/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/com/raccoongang/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index 4c589b037..af3f4413b 100644 --- a/course/src/test/java/com/raccoongang/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/com/raccoongang/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -4,7 +4,10 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.raccoongang.core.BlockType import com.raccoongang.core.domain.model.Block import com.raccoongang.core.domain.model.BlockCounts +import com.raccoongang.core.domain.model.CourseStructure +import com.raccoongang.core.domain.model.CoursewareAccess import com.raccoongang.core.presentation.course.CourseViewMode +import com.raccoongang.core.system.notifier.CourseNotifier import com.raccoongang.course.domain.interactor.CourseInteractor import io.mockk.every import io.mockk.mockk @@ -18,6 +21,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import java.net.UnknownHostException +import java.util.* @OptIn(ExperimentalCoroutinesApi::class) class CourseUnitContainerViewModelTest { @@ -28,6 +32,7 @@ class CourseUnitContainerViewModelTest { private val dispatcher = StandardTestDispatcher() private val interactor = mockk() + private val notifier = mockk() private val blocks = listOf( Block( @@ -93,6 +98,30 @@ class CourseUnitContainerViewModelTest { ) + private val courseStructure = CourseStructure( + root = "", + blockData = blocks, + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false + ) + @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -105,7 +134,7 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks no internet connection exception`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, "") + val viewModel = CourseUnitContainerViewModel(interactor, notifier, "") every { interactor.getCourseStructureFromCache() } throws UnknownHostException() every { interactor.getCourseStructureForVideos() } throws UnknownHostException() @@ -118,7 +147,7 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks unknown exception`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, "") + val viewModel = CourseUnitContainerViewModel(interactor, notifier, "") every { interactor.getCourseStructureFromCache() } throws UnknownHostException() every { interactor.getCourseStructureForVideos() } throws UnknownHostException() @@ -131,10 +160,10 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks unknown success`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, "") + val viewModel = CourseUnitContainerViewModel(interactor, notifier, "") - every { interactor.getCourseStructureFromCache() } returns blocks - every { interactor.getCourseStructureForVideos() } returns blocks + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { interactor.getCourseStructureForVideos() } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) @@ -146,9 +175,9 @@ class CourseUnitContainerViewModelTest { @Test fun `setupCurrentIndex`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, "") - every { interactor.getCourseStructureFromCache() } returns blocks - every { interactor.getCourseStructureForVideos() } returns blocks + val viewModel = CourseUnitContainerViewModel(interactor, notifier, "") + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { interactor.getCourseStructureForVideos() } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) viewModel.setupCurrentIndex("id") @@ -160,9 +189,9 @@ class CourseUnitContainerViewModelTest { @Test fun `prevVertical`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, "") - every { interactor.getCourseStructureFromCache() } returns blocks - every { interactor.getCourseStructureForVideos() } returns blocks + val viewModel = CourseUnitContainerViewModel(interactor, notifier, "") + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { interactor.getCourseStructureForVideos() } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) viewModel.setupCurrentIndex("id2") @@ -177,9 +206,9 @@ class CourseUnitContainerViewModelTest { @Test fun `nextVertical null`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, "") - every { interactor.getCourseStructureFromCache() } returns blocks - every { interactor.getCourseStructureForVideos() } returns blocks + val viewModel = CourseUnitContainerViewModel(interactor, notifier, "") + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { interactor.getCourseStructureForVideos() } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) viewModel.setupCurrentIndex("id2") @@ -194,9 +223,9 @@ class CourseUnitContainerViewModelTest { @Test fun `getCurrentBlock test`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, "") - every { interactor.getCourseStructureFromCache() } returns blocks - every { interactor.getCourseStructureForVideos() } returns blocks + val viewModel = CourseUnitContainerViewModel(interactor, notifier, "") + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { interactor.getCourseStructureForVideos() } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) viewModel.setupCurrentIndex("id") @@ -210,9 +239,9 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToPrevBlock null`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, "") - every { interactor.getCourseStructureFromCache() } returns blocks - every { interactor.getCourseStructureForVideos() } returns blocks + val viewModel = CourseUnitContainerViewModel(interactor, notifier, "") + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { interactor.getCourseStructureForVideos() } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) viewModel.setupCurrentIndex("id") @@ -226,9 +255,9 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToPrevBlock not null`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, "") - every { interactor.getCourseStructureFromCache() } returns blocks - every { interactor.getCourseStructureForVideos() } returns blocks + val viewModel = CourseUnitContainerViewModel(interactor, notifier, "") + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { interactor.getCourseStructureForVideos() } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) viewModel.setupCurrentIndex("id3") @@ -242,9 +271,9 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToNextBlock null`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, "") - every { interactor.getCourseStructureFromCache() } returns blocks - every { interactor.getCourseStructureForVideos() } returns blocks + val viewModel = CourseUnitContainerViewModel(interactor, notifier, "") + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { interactor.getCourseStructureForVideos() } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) viewModel.setupCurrentIndex("id3") @@ -258,9 +287,9 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToNextBlock not null`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, "") - every { interactor.getCourseStructureFromCache() } returns blocks - every { interactor.getCourseStructureForVideos() } returns blocks + val viewModel = CourseUnitContainerViewModel(interactor, notifier, "") + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { interactor.getCourseStructureForVideos() } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) viewModel.setupCurrentIndex("id") @@ -274,9 +303,9 @@ class CourseUnitContainerViewModelTest { @Test fun `currentIndex equals 0`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, "") - every { interactor.getCourseStructureFromCache() } returns blocks - every { interactor.getCourseStructureForVideos() } returns blocks + val viewModel = CourseUnitContainerViewModel(interactor, notifier, "") + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { interactor.getCourseStructureForVideos() } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) viewModel.setupCurrentIndex("id") @@ -292,9 +321,9 @@ class CourseUnitContainerViewModelTest { @Test fun `currentIndex isLastIndex`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, "") - every { interactor.getCourseStructureFromCache() } returns blocks - every { interactor.getCourseStructureForVideos() } returns blocks + val viewModel = CourseUnitContainerViewModel(interactor, notifier, "") + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { interactor.getCourseStructureForVideos() } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) viewModel.setupCurrentIndex("id3") diff --git a/course/src/test/java/com/raccoongang/course/presentation/unit/video/VideoUnitViewModelTest.kt b/course/src/test/java/com/raccoongang/course/presentation/unit/video/VideoUnitViewModelTest.kt index 80cf72840..db9c48f77 100644 --- a/course/src/test/java/com/raccoongang/course/presentation/unit/video/VideoUnitViewModelTest.kt +++ b/course/src/test/java/com/raccoongang/course/presentation/unit/video/VideoUnitViewModelTest.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import com.raccoongang.core.data.storage.PreferencesManager +import com.raccoongang.core.module.TranscriptManager import com.raccoongang.core.system.connection.NetworkConnection import com.raccoongang.core.system.notifier.CourseNotifier import com.raccoongang.core.system.notifier.CourseVideoPositionChanged @@ -34,6 +35,7 @@ class VideoUnitViewModelTest { private val notifier = mockk() private val preferencesManager = mockk() private val networkConnection = mockk() + private val transcriptManager = mockk() @Before @@ -48,7 +50,14 @@ class VideoUnitViewModelTest { @Test fun `markBlockCompleted exception`() = runTest { - val viewModel = VideoUnitViewModel("", courseRepository, preferencesManager, notifier, networkConnection) + val viewModel = VideoUnitViewModel( + "", + courseRepository, + preferencesManager, + notifier, + networkConnection, + transcriptManager + ) coEvery { courseRepository.markBlocksCompletion( any(), @@ -68,7 +77,14 @@ class VideoUnitViewModelTest { @Test fun `markBlockCompleted success`() = runTest { - val viewModel = VideoUnitViewModel("", courseRepository, preferencesManager, notifier, networkConnection) + val viewModel = VideoUnitViewModel( + "", + courseRepository, + preferencesManager, + notifier, + networkConnection, + transcriptManager + ) coEvery { courseRepository.markBlocksCompletion( any(), @@ -88,7 +104,14 @@ class VideoUnitViewModelTest { @Test fun `CourseVideoPositionChanged notifier test`() = runTest { - val viewModel = VideoUnitViewModel("", courseRepository, preferencesManager, notifier, networkConnection) + val viewModel = VideoUnitViewModel( + "", + courseRepository, + preferencesManager, + notifier, + networkConnection, + transcriptManager + ) coEvery { notifier.notifier } returns flow { emit(CourseVideoPositionChanged("", 10)) } val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -97,7 +120,7 @@ class VideoUnitViewModelTest { advanceUntilIdle() - assert(viewModel.currentVideoTime == 10L) + assert(viewModel.currentVideoTime.value == 10L) assert(viewModel.isUpdated.value == true) } diff --git a/course/src/test/java/com/raccoongang/course/presentation/units/CourseUnitsViewModelTest.kt b/course/src/test/java/com/raccoongang/course/presentation/units/CourseUnitsViewModelTest.kt index 2c1e267e6..0cacb2162 100644 --- a/course/src/test/java/com/raccoongang/course/presentation/units/CourseUnitsViewModelTest.kt +++ b/course/src/test/java/com/raccoongang/course/presentation/units/CourseUnitsViewModelTest.kt @@ -111,6 +111,15 @@ class CourseUnitsViewModelTest { ) ) + private val courseStructure = CourseStructure( + "", blocks, "", "", "", "", + null, "", "", null, + CoursewareAccess(false, "", "", "", "", ""), + null, + null, + false + ) + private val downloadModel = DownloadModel( "id", "title", @@ -189,8 +198,8 @@ class CourseUnitsViewModelTest { networkConnection, workerController ) - every { interactor.getCourseStructureFromCache() } returns blocks - every { interactor.getCourseStructureForVideos() } returns blocks + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { interactor.getCourseStructureForVideos() } returns courseStructure coEvery { interactor.getDownloadModels() } returns flow { emit(listOf(downloadModel)) } @@ -213,8 +222,8 @@ class CourseUnitsViewModelTest { networkConnection, workerController ) - every { interactor.getCourseStructureFromCache() } returns blocks - every { interactor.getCourseStructureForVideos() } returns blocks + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { interactor.getCourseStructureForVideos() } returns courseStructure coEvery { interactor.getDownloadModels() } returns flow { emit(listOf(downloadModel)) } @@ -237,8 +246,8 @@ class CourseUnitsViewModelTest { networkConnection, workerController ) - every { interactor.getCourseStructureFromCache() } returns blocks - every { interactor.getCourseStructureForVideos() } returns blocks + every { interactor.getCourseStructureFromCache() } returns courseStructure + every { interactor.getCourseStructureForVideos() } returns courseStructure coEvery { interactor.getDownloadModels() } returns flow { repeat(5) { delay(10000) diff --git a/course/src/test/java/com/raccoongang/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/com/raccoongang/course/presentation/videos/CourseVideoViewModelTest.kt index 4f25e69d7..579cf1e76 100644 --- a/course/src/test/java/com/raccoongang/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/com/raccoongang/course/presentation/videos/CourseVideoViewModelTest.kt @@ -8,6 +8,8 @@ import com.raccoongang.core.BlockType import com.raccoongang.core.data.storage.PreferencesManager import com.raccoongang.core.domain.model.Block import com.raccoongang.core.domain.model.BlockCounts +import com.raccoongang.core.domain.model.CourseStructure +import com.raccoongang.core.domain.model.CoursewareAccess import com.raccoongang.core.module.DownloadWorkerController import com.raccoongang.core.module.db.DownloadDao import com.raccoongang.core.system.ResourceManager @@ -27,6 +29,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import java.util.* @OptIn(ExperimentalCoroutinesApi::class) class CourseVideoViewModelTest { @@ -45,7 +48,6 @@ class CourseVideoViewModelTest { private val cantDownload = "You can download content only from Wi-fi" - private val blocks = listOf( Block( id = "id", @@ -94,6 +96,30 @@ class CourseVideoViewModelTest { ) ) + private val courseStructure = CourseStructure( + root = "", + blockData = blocks, + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false + ) + @Before fun setUp() { @@ -109,6 +135,9 @@ class CourseVideoViewModelTest { @Test fun `getVideos empty list`() = runTest { + every { interactor.getCourseStructureForVideos() } returns courseStructure.copy(blockData = emptyList()) + every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + val viewModel = CourseVideoViewModel( "", interactor, @@ -120,17 +149,18 @@ class CourseVideoViewModelTest { workerController ) - every { interactor.getCourseStructureForVideos() } returns emptyList() viewModel.getVideos() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 2) { interactor.getCourseStructureForVideos() } assert(viewModel.uiState.value is CourseVideosUIState.Empty) } @Test fun `getVideos success`() = runTest { + every { interactor.getCourseStructureForVideos() } returns courseStructure + every { downloadDao.readAllData() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( "", interactor, @@ -142,17 +172,25 @@ class CourseVideoViewModelTest { workerController ) - every { interactor.getCourseStructureForVideos() } returns blocks + viewModel.getVideos() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 2) { interactor.getCourseStructureForVideos() } assert(viewModel.uiState.value is CourseVideosUIState.CourseData) } @Test fun `updateVideos success`() = runTest { + every { interactor.getCourseStructureForVideos() } returns courseStructure + coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("", false)) } + every { downloadDao.readAllData() } returns flow { + repeat(5) { + delay(10000) + emit(emptyList()) + } + } val viewModel = CourseVideoViewModel( "", interactor, @@ -164,15 +202,6 @@ class CourseVideoViewModelTest { workerController ) - every { interactor.getCourseStructureForVideos() } returns blocks - coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("", false)) } - every { downloadDao.readAllData() } returns flow { - repeat(5) { - delay(10000) - emit(emptyList()) - } - } - val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) lifecycleRegistry.addObserver(viewModel) @@ -180,7 +209,7 @@ class CourseVideoViewModelTest { advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructureForVideos() } + coVerify(exactly = 2) { interactor.getCourseStructureForVideos() } assert(viewModel.uiState.value is CourseVideosUIState.CourseData) assert(viewModel.isUpdating.value == false) diff --git a/dashboard/src/main/java/com/raccoongang/dashboard/data/repository/DashboardRepository.kt b/dashboard/src/main/java/com/raccoongang/dashboard/data/repository/DashboardRepository.kt index 0432f9713..572f37cc2 100644 --- a/dashboard/src/main/java/com/raccoongang/dashboard/data/repository/DashboardRepository.kt +++ b/dashboard/src/main/java/com/raccoongang/dashboard/data/repository/DashboardRepository.kt @@ -2,6 +2,7 @@ package com.raccoongang.dashboard.data.repository import com.raccoongang.core.data.api.CourseApi import com.raccoongang.core.data.storage.PreferencesManager +import com.raccoongang.core.domain.model.DashboardCourseList import com.raccoongang.core.domain.model.EnrolledCourse import com.raccoongang.dashboard.data.DashboardDao @@ -11,16 +12,15 @@ class DashboardRepository( private val preferencesManager: PreferencesManager ) { - suspend fun getEnrolledCourses(): List { + suspend fun getEnrolledCourses(page: Int): DashboardCourseList { val user = preferencesManager.user val result = api.getEnrolledCourses( - username = user?.username ?: "" + username = user?.username ?: "", + page = page ) - dao.clearCachedData() - dao.insertEnrolledCourseEntity(*result.map { it.mapToRoomEntity() }.toTypedArray()) - return result.map { - it.mapToDomain() - } + if (page == 1) dao.clearCachedData() + dao.insertEnrolledCourseEntity(*result.results.map { it.mapToRoomEntity() }.toTypedArray()) + return result.mapToDomain() } suspend fun getEnrolledCoursesFromCache(): List { diff --git a/dashboard/src/main/java/com/raccoongang/dashboard/domain/interactor/DashboardInteractor.kt b/dashboard/src/main/java/com/raccoongang/dashboard/domain/interactor/DashboardInteractor.kt index ea5c97476..2226b9690 100644 --- a/dashboard/src/main/java/com/raccoongang/dashboard/domain/interactor/DashboardInteractor.kt +++ b/dashboard/src/main/java/com/raccoongang/dashboard/domain/interactor/DashboardInteractor.kt @@ -1,14 +1,14 @@ package com.raccoongang.dashboard.domain.interactor -import com.raccoongang.core.domain.model.EnrolledCourse +import com.raccoongang.core.domain.model.DashboardCourseList import com.raccoongang.dashboard.data.repository.DashboardRepository class DashboardInteractor( private val repository: DashboardRepository ) { - suspend fun getEnrolledCourses(): List { - return repository.getEnrolledCourses() + suspend fun getEnrolledCourses(page: Int): DashboardCourseList { + return repository.getEnrolledCourses(page) } suspend fun getEnrolledCoursesFromCache() = repository.getEnrolledCoursesFromCache() diff --git a/dashboard/src/main/java/com/raccoongang/dashboard/presentation/DashboardFragment.kt b/dashboard/src/main/java/com/raccoongang/dashboard/presentation/DashboardFragment.kt index 93ba74688..ef26afe64 100644 --- a/dashboard/src/main/java/com/raccoongang/dashboard/presentation/DashboardFragment.kt +++ b/dashboard/src/main/java/com/raccoongang/dashboard/presentation/DashboardFragment.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* import androidx.compose.material.icons.Icons @@ -25,7 +26,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource @@ -75,11 +75,13 @@ class DashboardFragment : Fragment() { val uiState by viewModel.uiState.observeAsState() val uiMessage by viewModel.uiMessage.observeAsState() val refreshing by viewModel.updating.observeAsState(false) + val canLoadMore by viewModel.canLoadMore.observeAsState(false) MyCoursesScreen( windowSize = windowSize, uiState!!, uiMessage, + canLoadMore = canLoadMore, refreshing = refreshing, hasInternetConnection = viewModel.hasInternetConnection, onReloadClick = { @@ -89,15 +91,14 @@ class DashboardFragment : Fragment() { router.navigateToCourseOutline( requireParentFragment().parentFragmentManager, it.course.id, - it.course.name, - it.course.courseImage, - it.certificate ?: Certificate(""), - it.course.coursewareAccess, - it.auditAccessExpires + it.course.name ) }, onSwipeRefresh = { viewModel.updateCourses() + }, + paginationCallback = { + viewModel.fetchMore() } ) } @@ -111,20 +112,25 @@ internal fun MyCoursesScreen( windowSize: WindowSize, state: DashboardUIState, uiMessage: UIMessage?, + canLoadMore: Boolean, refreshing: Boolean, hasInternetConnection: Boolean, onReloadClick: () -> Unit, onSwipeRefresh: () -> Unit, + paginationCallback: () -> Unit, onItemClick: (EnrolledCourse) -> Unit, ) { val scaffoldState = rememberScaffoldState() - val configuration = LocalConfiguration.current val pullRefreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) var isInternetConnectionShown by rememberSaveable { mutableStateOf(false) } + val scrollState = rememberLazyListState() + val firstVisibleIndex = remember { + mutableStateOf(scrollState.firstVisibleItemIndex) + } Scaffold( scaffoldState = scaffoldState, @@ -198,7 +204,7 @@ internal fun MyCoursesScreen( Modifier .fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } is DashboardUIState.Courses -> { @@ -210,6 +216,7 @@ internal fun MyCoursesScreen( modifier = Modifier .fillMaxHeight() .then(contentWidth), + state = scrollState, contentPadding = contentPaddings, content = { item() { @@ -234,7 +241,22 @@ internal fun MyCoursesScreen( }) Divider() } + item { + if (canLoadMore) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + } }) + if (scrollState.shouldLoadMore(firstVisibleIndex, 4)) { + paginationCallback() + } } } is DashboardUIState.Empty -> { @@ -467,7 +489,9 @@ private fun MyCoursesScreenDay() { onItemClick = {}, onReloadClick = {}, hasInternetConnection = true, - refreshing = false + refreshing = false, + canLoadMore = false, + paginationCallback = {} ) } } @@ -494,7 +518,9 @@ private fun MyCoursesScreenTabletPreview() { onItemClick = {}, onReloadClick = {}, hasInternetConnection = true, - refreshing = false + refreshing = false, + canLoadMore = false, + paginationCallback = {} ) } } diff --git a/dashboard/src/main/java/com/raccoongang/dashboard/presentation/DashboardRouter.kt b/dashboard/src/main/java/com/raccoongang/dashboard/presentation/DashboardRouter.kt index 9da404c27..e06048ffa 100644 --- a/dashboard/src/main/java/com/raccoongang/dashboard/presentation/DashboardRouter.kt +++ b/dashboard/src/main/java/com/raccoongang/dashboard/presentation/DashboardRouter.kt @@ -1,20 +1,13 @@ package com.raccoongang.dashboard.presentation import androidx.fragment.app.FragmentManager -import com.raccoongang.core.domain.model.Certificate -import com.raccoongang.core.domain.model.CoursewareAccess -import java.util.* interface DashboardRouter { fun navigateToCourseOutline( fm: FragmentManager, courseId: String, - title: String, - image: String, - certificate: Certificate, - coursewareAccess: CoursewareAccess, - auditAccessExpires: Date? + courseTitle: String ) } \ No newline at end of file diff --git a/dashboard/src/main/java/com/raccoongang/dashboard/presentation/DashboardViewModel.kt b/dashboard/src/main/java/com/raccoongang/dashboard/presentation/DashboardViewModel.kt index 4e330ede4..d108bea79 100644 --- a/dashboard/src/main/java/com/raccoongang/dashboard/presentation/DashboardViewModel.kt +++ b/dashboard/src/main/java/com/raccoongang/dashboard/presentation/DashboardViewModel.kt @@ -26,6 +26,8 @@ class DashboardViewModel( ) : BaseViewModel() { private val coursesList = mutableListOf() + private var page = 1 + private var isLoading = false private val _uiState = MutableLiveData(DashboardUIState.Loading) val uiState: LiveData get() = _uiState @@ -41,6 +43,10 @@ class DashboardViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + private val _canLoadMore = MutableLiveData() + val canLoadMore: LiveData + get() = _canLoadMore + override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) viewModelScope.launch { @@ -63,12 +69,21 @@ class DashboardViewModel( } fun updateCourses() { - _updating.value = true viewModelScope.launch { try { - val response = interactor.getEnrolledCourses() + _updating.value = true + isLoading = true + page = 1 + val response = interactor.getEnrolledCourses(page) + if (response.pagination.next.isNotEmpty() && page != response.pagination.numPages) { + _canLoadMore.value = true + page++ + } else { + _canLoadMore.value = false + page = -1 + } coursesList.clear() - coursesList.addAll(response.toList()) + coursesList.addAll(response.courses) if (coursesList.isEmpty()) { _uiState.value = DashboardUIState.Empty } else { @@ -85,18 +100,34 @@ class DashboardViewModel( } } _updating.value = false + isLoading = false } } private fun internalLoadingCourses() { viewModelScope.launch { try { - val response = if (networkConnection.isOnline()) { - interactor.getEnrolledCourses() + isLoading = true + val response = if (networkConnection.isOnline() || page > 1) { + interactor.getEnrolledCourses(page) + } else { + null + } + if (response !=null) { + if (response.pagination.next.isNotEmpty() && page != response.pagination.numPages) { + _canLoadMore.value = true + page++ + } else { + _canLoadMore.value = false + page = -1 + } + coursesList.addAll(response.courses) } else { - interactor.getEnrolledCoursesFromCache() + val cachedList = interactor.getEnrolledCoursesFromCache() + _canLoadMore.value = false + page = -1 + coursesList.addAll(cachedList) } - coursesList.addAll(response.toList()) if (coursesList.isEmpty()) { _uiState.value = DashboardUIState.Empty } else { @@ -112,6 +143,14 @@ class DashboardViewModel( UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) } } + _updating.value = false + isLoading = false + } + } + + fun fetchMore() { + if (!isLoading && page != -1) { + internalLoadingCourses() } } diff --git a/dashboard/src/test/java/com/raccoongang/dashboard/presentation/DashboardViewModelTest.kt b/dashboard/src/test/java/com/raccoongang/dashboard/presentation/DashboardViewModelTest.kt index ed761ac12..fc1d94966 100644 --- a/dashboard/src/test/java/com/raccoongang/dashboard/presentation/DashboardViewModelTest.kt +++ b/dashboard/src/test/java/com/raccoongang/dashboard/presentation/DashboardViewModelTest.kt @@ -7,6 +7,8 @@ import androidx.lifecycle.LifecycleRegistry import com.raccoongang.core.system.connection.NetworkConnection import com.raccoongang.core.R import com.raccoongang.core.UIMessage +import com.raccoongang.core.domain.model.DashboardCourseList +import com.raccoongang.core.domain.model.Pagination import com.raccoongang.core.system.ResourceManager import com.raccoongang.core.system.notifier.CourseDashboardUpdate import com.raccoongang.core.system.notifier.CourseNotifier @@ -33,7 +35,6 @@ class DashboardViewModelTest { @get:Rule val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() - private val dispatcher = StandardTestDispatcher() private val resourceManager = mockk() @@ -44,6 +45,11 @@ class DashboardViewModelTest { private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" + private val dashboardCourseList = DashboardCourseList( + Pagination(10, "", 3, ""), + listOf(mockk()) + ) + @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -60,11 +66,11 @@ class DashboardViewModelTest { fun `getCourses no internet connection`() = runTest { val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier) every { networkConnection.isOnline() } returns true - coEvery { interactor.getEnrolledCourses() } throws UnknownHostException() + coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getEnrolledCourses() } + coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -76,11 +82,11 @@ class DashboardViewModelTest { fun `getCourses unknown error`() = runTest { val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier) every { networkConnection.isOnline() } returns true - coEvery { interactor.getEnrolledCourses() } throws Exception() + coEvery { interactor.getEnrolledCourses(any()) } throws Exception() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getEnrolledCourses() } + coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -92,11 +98,35 @@ class DashboardViewModelTest { fun `getCourses from network`() = runTest { val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier) every { networkConnection.isOnline() } returns true - coEvery { interactor.getEnrolledCourses() } returns emptyList() + coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList + coEvery { interactor.getEnrolledCoursesFromCache() } returns listOf(mockk()) + + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } + coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } + + assert(viewModel.uiMessage.value == null) + assert(viewModel.uiState.value is DashboardUIState.Courses) + } + + @Test + fun `getCourses from network with next page`() = runTest { + val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList.copy( + Pagination( + 10, + "2", + 2, + "" + ) + ) + coEvery { interactor.getEnrolledCoursesFromCache() } returns listOf(mockk()) advanceUntilIdle() - coVerify(exactly = 1) { interactor.getEnrolledCourses() } + coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } assert(viewModel.uiMessage.value == null) @@ -106,13 +136,13 @@ class DashboardViewModelTest { @Test fun `getCourses from cache`() = runTest { every { networkConnection.isOnline() } returns false - coEvery { interactor.getEnrolledCoursesFromCache() } returns emptyList() + coEvery { interactor.getEnrolledCoursesFromCache() } returns listOf(mockk()) val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier) advanceUntilIdle() - coVerify(exactly = 0) { interactor.getEnrolledCourses() } + coVerify(exactly = 0) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 1) { interactor.getEnrolledCoursesFromCache() } assert(viewModel.uiMessage.value == null) @@ -122,14 +152,14 @@ class DashboardViewModelTest { @Test fun `updateCourses no internet error`() = runTest { every { networkConnection.isOnline() } returns true - coEvery { interactor.getEnrolledCourses() } returns emptyList() + coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier) - coEvery { interactor.getEnrolledCourses() } throws UnknownHostException() + coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() viewModel.updateCourses() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getEnrolledCourses() } + coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -141,15 +171,15 @@ class DashboardViewModelTest { @Test fun `updateCourses unknown exception`() = runTest { every { networkConnection.isOnline() } returns true - coEvery { interactor.getEnrolledCourses() } returns emptyList() + coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier) - coEvery { interactor.getEnrolledCourses() } throws Exception() + coEvery { interactor.getEnrolledCourses(any()) } throws Exception() viewModel.updateCourses() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getEnrolledCourses() } + coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -161,13 +191,30 @@ class DashboardViewModelTest { @Test fun `updateCourses success`() = runTest { every { networkConnection.isOnline() } returns true - coEvery { interactor.getEnrolledCourses() } returns emptyList() + coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList + val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier) + + viewModel.updateCourses() + advanceUntilIdle() + + coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } + coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } + + assert(viewModel.uiMessage.value == null) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DashboardUIState.Courses) + } + + @Test + fun `updateCourses success with next page`() = runTest { + every { networkConnection.isOnline() } returns true + coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList.copy(Pagination(10,"2",2,"")) val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier) viewModel.updateCourses() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getEnrolledCourses() } + coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } assert(viewModel.uiMessage.value == null) @@ -188,7 +235,7 @@ class DashboardViewModelTest { advanceUntilIdle() - coVerify(exactly = 1) { interactor.getEnrolledCourses() } + coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } } diff --git a/discovery/src/main/java/com/raccoongang/discovery/presentation/DiscoveryFragment.kt b/discovery/src/main/java/com/raccoongang/discovery/presentation/DiscoveryFragment.kt index 261491876..9688008ae 100644 --- a/discovery/src/main/java/com/raccoongang/discovery/presentation/DiscoveryFragment.kt +++ b/discovery/src/main/java/com/raccoongang/discovery/presentation/DiscoveryFragment.kt @@ -199,7 +199,7 @@ internal fun DiscoveryScreen( Modifier .fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } is DiscoveryUIState.Courses -> { @@ -247,7 +247,7 @@ internal fun DiscoveryScreen( .padding(vertical = 16.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } @@ -381,5 +381,6 @@ private val mockCourse = Course( end = "end", startDisplay = "startDisplay", startType = "startType", - overview = "" + overview = "", + isEnrolled = false ) \ No newline at end of file diff --git a/discovery/src/main/java/com/raccoongang/discovery/presentation/search/CourseSearchFragment.kt b/discovery/src/main/java/com/raccoongang/discovery/presentation/search/CourseSearchFragment.kt index 4526c6fb2..acb970bca 100644 --- a/discovery/src/main/java/com/raccoongang/discovery/presentation/search/CourseSearchFragment.kt +++ b/discovery/src/main/java/com/raccoongang/discovery/presentation/search/CourseSearchFragment.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices @@ -123,8 +124,8 @@ private fun CourseSearchScreen( val pullRefreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) - var searchText by rememberSaveable { - mutableStateOf("") + var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue("")) } val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current @@ -217,17 +218,17 @@ private fun CourseSearchScreen( .then(searchTab), label = "", requestFocus = true, - searchValue = searchText, + searchValue = textFieldValue, keyboardActions = { focusManager.clearFocus() }, onValueChanged = { text -> - searchText = text - onSearchTextChanged(searchText) + textFieldValue = text + onSearchTextChanged(textFieldValue.text) }, onClearValue = { - searchText = "" - onSearchTextChanged(searchText) + textFieldValue = TextFieldValue("") + onSearchTextChanged(textFieldValue.text) } ) Spacer(modifier = Modifier.height(20.dp)) @@ -241,7 +242,7 @@ private fun CourseSearchScreen( .pullRefresh(pullRefreshState) ) { val typingText = - if (searchText.isEmpty()) { + if (textFieldValue.text.isEmpty()) { stringResource(id = discoveryR.string.discovery_start_typing_to_find) } else { pluralStringResource( @@ -280,7 +281,7 @@ private fun CourseSearchScreen( .padding(vertical = 25.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } @@ -302,7 +303,7 @@ private fun CourseSearchScreen( .padding(vertical = 16.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } @@ -385,5 +386,6 @@ private val mockCourse = Course( end = "end", startDisplay = "startDisplay", startType = "startType", - overview = "" + overview = "", + isEnrolled = false ) \ No newline at end of file diff --git a/discovery/src/test/java/com/raccoongang/discovery/presentation/search/CourseSearchViewModelTest.kt b/discovery/src/test/java/com/raccoongang/discovery/presentation/search/CourseSearchViewModelTest.kt index 347575db6..916ca1a2d 100644 --- a/discovery/src/test/java/com/raccoongang/discovery/presentation/search/CourseSearchViewModelTest.kt +++ b/discovery/src/test/java/com/raccoongang/discovery/presentation/search/CourseSearchViewModelTest.kt @@ -60,7 +60,8 @@ class CourseSearchViewModelTest { end = "end", startDisplay = "startDisplay", startType = "startType", - overview = "" + overview = "", + false ) //endregion diff --git a/discussion/src/main/java/com/raccoongang/discussion/data/api/DiscussionApi.kt b/discussion/src/main/java/com/raccoongang/discussion/data/api/DiscussionApi.kt index ecf208071..d0756b8ee 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/data/api/DiscussionApi.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/data/api/DiscussionApi.kt @@ -20,6 +20,7 @@ interface DiscussionApi { @Query("following") following: Boolean?, @Query("topic_id") topicId: String?, @Query("order_by") orderBy: String, + @Query("view") view: String?, @Query("page") page: Int = 1, @Query("requested_fields") requestedFields: List = listOf("profile_image") ): ThreadsResponse @@ -96,10 +97,9 @@ interface DiscussionApi { @Query("requested_fields") requestedFields: List = listOf("profile_image") ): CommentsResponse - @POST("/api/discussion/v1/comments/") + @POST("/mobile_api_extensions/discussion/v1/comments/") suspend fun createComment( - @Body commentBody: CommentBody, - @Query("requested_fields") requestedFields: List = listOf("profile_image") + @Body commentBody: CommentBody ) : CommentResult @POST("/api/discussion/v1/threads/") diff --git a/discussion/src/main/java/com/raccoongang/discussion/data/model/response/CommentsResponse.kt b/discussion/src/main/java/com/raccoongang/discussion/data/model/response/CommentsResponse.kt index 2497f9237..9e483d4ec 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/data/model/response/CommentsResponse.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/data/model/response/CommentsResponse.kt @@ -2,6 +2,7 @@ package com.raccoongang.discussion.data.model.response import com.google.gson.annotations.SerializedName import com.raccoongang.core.data.model.Pagination +import com.raccoongang.core.data.model.ProfileImage import com.raccoongang.core.extension.TextConverter import com.raccoongang.discussion.domain.model.CommentsData import com.raccoongang.discussion.domain.model.DiscussionComment @@ -63,6 +64,8 @@ data class CommentResult( val children: List, @SerializedName("abuse_flagged_any_user") val abuseFlaggedAnyUser: String?, + @SerializedName("profile_image") + val profileImage: ProfileImage?, @SerializedName("users") val users: Map? ) { @@ -89,6 +92,7 @@ data class CommentResult( endorsedAt ?: "", childCount, children, + profileImage?.mapToDomain(), users?.entries?.associate { it.key to it.value.mapToDomain() } ) } diff --git a/discussion/src/main/java/com/raccoongang/discussion/data/model/response/ThreadsResponse.kt b/discussion/src/main/java/com/raccoongang/discussion/data/model/response/ThreadsResponse.kt index 595d8797b..e8c699318 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/data/model/response/ThreadsResponse.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/data/model/response/ThreadsResponse.kt @@ -19,7 +19,7 @@ data class ThreadsResponse( @SerializedName("id") val id: String, @SerializedName("author") - val author: String, + val author: String?, @SerializedName("author_label") val authorLabel: String?, @SerializedName("created_at") @@ -40,6 +40,10 @@ data class ThreadsResponse( val editableFields: List, @SerializedName("can_delete") val canDelete: Boolean, + @SerializedName("anonymous") + val anonymous: Boolean, + @SerializedName("anonymous_to_peers") + val anonymousToPeers: Boolean, @SerializedName("course_id") val courseId: String, @SerializedName("topic_id") @@ -70,6 +74,8 @@ data class ThreadsResponse( val read: Boolean, @SerializedName("has_endorsed") val hasEndorsed: Boolean, + @SerializedName("response_count") + val responseCount: Int, @SerializedName("users") val users: Map? ) { @@ -92,7 +98,7 @@ data class ThreadsResponse( fun mapToDomain(): com.raccoongang.discussion.domain.model.Thread { return com.raccoongang.discussion.domain.model.Thread( id, - author, + author ?: "", authorLabel ?: "", createdAt, updatedAt, @@ -119,7 +125,10 @@ data class ThreadsResponse( unreadCommentCount, read, hasEndorsed, - users?.entries?.associate { it.key to it.value.mapToDomain() } + users?.entries?.associate { it.key to it.value.mapToDomain() }, + responseCount, + anonymous, + anonymousToPeers ) } diff --git a/discussion/src/main/java/com/raccoongang/discussion/data/repository/DiscussionRepository.kt b/discussion/src/main/java/com/raccoongang/discussion/data/repository/DiscussionRepository.kt index 435d25438..c57af4537 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/data/repository/DiscussionRepository.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/data/repository/DiscussionRepository.kt @@ -33,9 +33,10 @@ class DiscussionRepository(private val api: DiscussionApi) { following: Boolean?, topicId: String?, orderBy: String, + view: String?, page: Int ): ThreadsData { - return api.getCourseThreads(courseId, following, topicId, orderBy, page).mapToDomain() + return api.getCourseThreads(courseId, following, topicId, orderBy, view, page).mapToDomain() } suspend fun searchThread( diff --git a/discussion/src/main/java/com/raccoongang/discussion/domain/interactor/DiscussionInteractor.kt b/discussion/src/main/java/com/raccoongang/discussion/domain/interactor/DiscussionInteractor.kt index 6d45593ea..1d08d49a9 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/domain/interactor/DiscussionInteractor.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/domain/interactor/DiscussionInteractor.kt @@ -10,19 +10,26 @@ class DiscussionInteractor( fun getCachedTopics(courseId: String) = repository.getCachedTopics(courseId) - suspend fun getAllThreads(courseId: String, orderBy: String, page: Int) = - repository.getCourseThreads(courseId, null, null, orderBy, page) + suspend fun getAllThreads(courseId: String, orderBy: String, view: String? = null, page: Int) = + repository.getCourseThreads(courseId, null, null, orderBy, view, page) suspend fun getFollowingThreads( courseId: String, following: Boolean, orderBy: String, + view: String? = null, page: Int ) = - repository.getCourseThreads(courseId, following, null, orderBy, page) + repository.getCourseThreads(courseId, following, null, orderBy, view, page) - suspend fun getThreads(courseId: String, topicId: String, orderBy: String, page: Int) = - repository.getCourseThreads(courseId, null, topicId, orderBy, page) + suspend fun getThreads( + courseId: String, + topicId: String, + orderBy: String, + view: String? = null, + page: Int + ) = + repository.getCourseThreads(courseId, null, topicId, orderBy, view, page) suspend fun searchThread(courseId: String, query: String, page: Int) = repository.searchThread(courseId, query, page) diff --git a/discussion/src/main/java/com/raccoongang/discussion/domain/model/DiscussionComment.kt b/discussion/src/main/java/com/raccoongang/discussion/domain/model/DiscussionComment.kt index 2fb957958..fe4b4ffef 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/domain/model/DiscussionComment.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/domain/model/DiscussionComment.kt @@ -1,6 +1,7 @@ package com.raccoongang.discussion.domain.model import android.os.Parcelable +import com.raccoongang.core.domain.model.ProfileImage import com.raccoongang.core.extension.LinkedImageText import kotlinx.parcelize.Parcelize @@ -27,5 +28,6 @@ data class DiscussionComment( val endorsedAt: String, val childCount: Int, val children: List, + val profileImage: ProfileImage?, val users: Map? ) : Parcelable \ No newline at end of file diff --git a/discussion/src/main/java/com/raccoongang/discussion/domain/model/Thread.kt b/discussion/src/main/java/com/raccoongang/discussion/domain/model/Thread.kt index 98886e483..c37a67600 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/domain/model/Thread.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/domain/model/Thread.kt @@ -3,6 +3,7 @@ package com.raccoongang.discussion.domain.model import android.os.Parcelable import com.raccoongang.core.domain.model.ProfileImage import com.raccoongang.core.extension.LinkedImageText +import com.raccoongang.discussion.R import kotlinx.parcelize.Parcelize @Parcelize @@ -35,7 +36,10 @@ data class Thread( val unreadCommentCount: Int, val read: Boolean, val hasEndorsed: Boolean, - val users: Map? + val users: Map?, + val responseCount: Int, + val anonymous: Boolean, + val anonymousToPeers: Boolean ) : Parcelable @Parcelize @@ -43,7 +47,10 @@ data class DiscussionProfile( val image: ProfileImage? ) : Parcelable -enum class DiscussionType(val value: String) { - QUESTION("question"), - DISCUSSION("discussion") +enum class DiscussionType( + val value: String, + val resId: Int +) { + QUESTION("question", R.string.discussion_question), + DISCUSSION("discussion", R.string.discussion_discussion) } diff --git a/discussion/src/main/java/com/raccoongang/discussion/presentation/DiscussionRouter.kt b/discussion/src/main/java/com/raccoongang/discussion/presentation/DiscussionRouter.kt index a834a7441..45f094211 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/presentation/DiscussionRouter.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/presentation/DiscussionRouter.kt @@ -22,7 +22,8 @@ interface DiscussionRouter { fun navigateToDiscussionResponses( fm: FragmentManager, - comment: DiscussionComment + comment: DiscussionComment, + isClosed: Boolean ) fun navigateToAddThread( diff --git a/discussion/src/main/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsFragment.kt b/discussion/src/main/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsFragment.kt index 98af93450..c22a1fd6a 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsFragment.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsFragment.kt @@ -8,6 +8,7 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -39,11 +40,13 @@ import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import com.raccoongang.core.UIMessage +import com.raccoongang.core.domain.model.ProfileImage import com.raccoongang.core.extension.TextConverter import com.raccoongang.core.extension.parcelable import com.raccoongang.core.ui.* import com.raccoongang.core.ui.theme.NewEdxTheme import com.raccoongang.core.ui.theme.appColors +import com.raccoongang.core.ui.theme.appShapes import com.raccoongang.core.ui.theme.appTypography import com.raccoongang.discussion.domain.model.DiscussionComment import com.raccoongang.discussion.domain.model.DiscussionType @@ -98,17 +101,24 @@ class DiscussionCommentsFragment : Fragment() { viewModel.fetchMore() }, onItemClick = { action, id, bool -> - when (action) { - ACTION_UPVOTE_COMMENT -> viewModel.setCommentUpvoted(id, bool) - ACTION_REPORT_COMMENT -> viewModel.setCommentReported(id, bool) - ACTION_UPVOTE_THREAD -> viewModel.setThreadUpvoted(bool) - ACTION_REPORT_THREAD -> viewModel.setThreadReported(bool) - ACTION_FOLLOW_THREAD -> viewModel.setThreadFollowed(bool) + if (!viewModel.thread.closed) { + when (action) { + ACTION_UPVOTE_COMMENT -> viewModel.setCommentUpvoted(id, bool) + ACTION_UPVOTE_THREAD -> viewModel.setThreadUpvoted(bool) + ACTION_FOLLOW_THREAD -> viewModel.setThreadFollowed(bool) + ACTION_REPORT_COMMENT -> viewModel.setCommentReported(id, bool) + ACTION_REPORT_THREAD -> viewModel.setThreadReported(bool) + } + } else { + when (action) { + ACTION_REPORT_COMMENT -> viewModel.setCommentReported(id, bool) + ACTION_REPORT_THREAD -> viewModel.setThreadReported(bool) + } } }, onCommentClick = { router.navigateToDiscussionResponses( - requireActivity().supportFragmentManager, it + requireActivity().supportFragmentManager, it, viewModel.thread.closed ) }, onAddResponseClick = { @@ -178,7 +188,7 @@ private fun DiscussionCommentsScreen( } val iconButtonColor = if (responseValue.isEmpty()) { - MaterialTheme.appColors.cardViewBorder + MaterialTheme.appColors.textFieldBackgroundVariant } else { Color.White } @@ -288,8 +298,8 @@ private fun DiscussionCommentsScreen( .padding(top = 24.dp, bottom = 4.dp), text = pluralStringResource( id = com.raccoongang.discussion.R.plurals.discussion_responses_capitalized, - uiState.thread.commentCount - 1, - uiState.thread.commentCount - 1 + uiState.count, + uiState.count ), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleLarge @@ -318,7 +328,7 @@ private fun DiscussionCommentsScreen( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } @@ -326,7 +336,9 @@ private fun DiscussionCommentsScreen( if (scrollState.shouldLoadMore(firstVisibleIndex, 4)) { paginationCallBack() } - Divider(color = MaterialTheme.appColors.cardViewBorder) + if (!isSystemInDarkTheme()) { + Divider(color = MaterialTheme.appColors.cardViewBorder) + } Box( Modifier .fillMaxWidth() @@ -350,6 +362,7 @@ private fun DiscussionCommentsScreen( onValueChange = { str -> responseValue = str }, + shape = MaterialTheme.appShapes.buttonShape, textStyle = MaterialTheme.appTypography.labelLarge, maxLines = 3, placeholder = { @@ -363,7 +376,8 @@ private fun DiscussionCommentsScreen( backgroundColor = MaterialTheme.appColors.textFieldBackgroundVariant, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, textColor = MaterialTheme.appColors.textFieldText - ) + ), + enabled = !uiState.thread.closed ) Box( modifier = Modifier @@ -394,7 +408,7 @@ private fun DiscussionCommentsScreen( Modifier .fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } @@ -421,7 +435,8 @@ private fun DiscussionCommentsScreenPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DiscussionCommentsUIState.Success( mockThread, - listOf(mockComment, mockComment) + listOf(mockComment, mockComment), + 2 ), uiMessage = null, title = "Test Screen", @@ -450,7 +465,8 @@ private fun DiscussionCommentsScreenTabletPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DiscussionCommentsUIState.Success( mockThread, - listOf(mockComment, mockComment) + listOf(mockComment, mockComment), + 2 ), uiMessage = null, title = "Test Screen", @@ -498,7 +514,10 @@ private val mockThread = com.raccoongang.discussion.domain.model.Thread( 4, false, false, - mapOf() + mapOf(), + 10, + false, + false ) private val mockComment = DiscussionComment( @@ -523,5 +542,6 @@ private val mockComment = DiscussionComment( "", 21, emptyList(), - emptyMap() + profileImage = ProfileImage("", "", "", "", false), + mapOf() ) \ No newline at end of file diff --git a/discussion/src/main/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsUIState.kt b/discussion/src/main/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsUIState.kt index 118eeaf88..afaf01a2e 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsUIState.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsUIState.kt @@ -6,7 +6,8 @@ import com.raccoongang.discussion.domain.model.DiscussionComment sealed class DiscussionCommentsUIState { data class Success( val thread: com.raccoongang.discussion.domain.model.Thread, - val commentsData: List + val commentsData: List, + val count: Int ) : DiscussionCommentsUIState() object Loading : DiscussionCommentsUIState() diff --git a/discussion/src/main/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsViewModel.kt b/discussion/src/main/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsViewModel.kt index 0c447bd97..bf0d59d5f 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsViewModel.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsViewModel.kt @@ -13,7 +13,6 @@ import com.raccoongang.core.extension.isInternetError import com.raccoongang.core.system.ResourceManager import com.raccoongang.discussion.domain.interactor.DiscussionInteractor import com.raccoongang.discussion.domain.model.DiscussionComment -import com.raccoongang.discussion.domain.model.DiscussionProfile import com.raccoongang.discussion.domain.model.DiscussionType import com.raccoongang.discussion.system.notifier.DiscussionCommentAdded import com.raccoongang.discussion.system.notifier.DiscussionCommentDataChanged @@ -29,10 +28,11 @@ class DiscussionCommentsViewModel( thread: com.raccoongang.discussion.domain.model.Thread, ) : BaseViewModel() { - val title = thread.title + val title = resourceManager.getString(thread.type.resId) var thread: com.raccoongang.discussion.domain.model.Thread private set + private var commentCount = 0 private val _uiState = MutableLiveData() val uiState: LiveData @@ -65,8 +65,11 @@ class DiscussionCommentsViewModel( if (it is DiscussionCommentAdded) { if (page == -1) { comments.add(it.comment) - _uiState.value = - DiscussionCommentsUIState.Success(thread, comments.toList()) + _uiState.value = DiscussionCommentsUIState.Success( + thread, + comments.toList(), + commentCount + ) _scrollToBottom.value = true } else { _uiMessage.value = @@ -80,8 +83,11 @@ class DiscussionCommentsViewModel( } if (index >= 0) { comments[index] = it.discussionComment - _uiState.value = - DiscussionCommentsUIState.Success(thread, comments.toList()) + _uiState.value = DiscussionCommentsUIState.Success( + thread, + comments.toList(), + commentCount + ) } } } @@ -115,8 +121,10 @@ class DiscussionCommentsViewModel( _canLoadMore.value = false page = -1 } + commentCount = response.pagination.count comments.addAll(response.results) - _uiState.value = DiscussionCommentsUIState.Success(thread, comments.toList()) + _uiState.value = + DiscussionCommentsUIState.Success(thread, comments.toList(), commentCount) if (markReadIfSuccessful) { markRead() @@ -140,7 +148,10 @@ class DiscussionCommentsViewModel( viewModelScope.launch { try { val response = interactor.setThreadRead(thread.id) - thread = thread.copy(read = response.read, unreadCommentCount = response.unreadCommentCount) + thread = thread.copy( + read = response.read, + unreadCommentCount = response.unreadCommentCount + ) sendThreadUpdated() } catch (e: Exception) { e.printStackTrace() @@ -171,7 +182,8 @@ class DiscussionCommentsViewModel( try { val response = interactor.setThreadVoted(thread.id, vote) thread = thread.copy(voted = response.voted, voteCount = response.voteCount) - _uiState.value = DiscussionCommentsUIState.Success(thread, comments.toList()) + _uiState.value = + DiscussionCommentsUIState.Success(thread, comments.toList(), commentCount) sendThreadUpdated() } catch (e: Exception) { if (e.isInternetError()) { @@ -190,7 +202,8 @@ class DiscussionCommentsViewModel( try { val response = interactor.setThreadFollowed(thread.id, followed) thread = thread.copy(following = response.following) - _uiState.value = DiscussionCommentsUIState.Success(thread, comments.toList()) + _uiState.value = + DiscussionCommentsUIState.Success(thread, comments.toList(), commentCount) sendThreadUpdated() } catch (e: Exception) { if (e.isInternetError()) { @@ -209,7 +222,8 @@ class DiscussionCommentsViewModel( try { val response = interactor.setThreadFlagged(thread.id, reported) thread = thread.copy(abuseFlagged = response.abuseFlagged) - _uiState.value = DiscussionCommentsUIState.Success(thread, comments.toList()) + _uiState.value = + DiscussionCommentsUIState.Success(thread, comments.toList(), commentCount) sendThreadUpdated() } catch (e: Exception) { if (e.isInternetError()) { @@ -232,7 +246,8 @@ class DiscussionCommentsViewModel( } comments[index] = comments[index].copy(voted = response.voted, voteCount = response.voteCount) - _uiState.value = DiscussionCommentsUIState.Success(thread, comments.toList()) + _uiState.value = + DiscussionCommentsUIState.Success(thread, comments.toList(), commentCount) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = @@ -253,7 +268,8 @@ class DiscussionCommentsViewModel( it.id == response.id } comments[index] = comments[index].copy(abuseFlagged = response.abuseFlagged) - _uiState.value = DiscussionCommentsUIState.Success(thread, comments.toList()) + _uiState.value = + DiscussionCommentsUIState.Success(thread, comments.toList(), commentCount) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = @@ -269,15 +285,7 @@ class DiscussionCommentsViewModel( fun createComment(rawBody: String) { viewModelScope.launch { try { - var response = interactor.createComment(thread.id, rawBody, null) - response = response.copy( - users = mapOf( - Pair( - preferencesManager.profile?.username ?: "", - DiscussionProfile(preferencesManager.profile?.profileImage) - ) - ) - ) + val response = interactor.createComment(thread.id, rawBody, null) thread = thread.copy(commentCount = thread.commentCount + 1) sendThreadUpdated() if (page == -1) { @@ -286,7 +294,8 @@ class DiscussionCommentsViewModel( _uiMessage.value = UIMessage.ToastMessage(resourceManager.getString(com.raccoongang.discussion.R.string.discussion_comment_added)) } - _uiState.value = DiscussionCommentsUIState.Success(thread, comments.toList()) + _uiState.value = + DiscussionCommentsUIState.Success(thread, comments.toList(), commentCount) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = diff --git a/discussion/src/main/java/com/raccoongang/discussion/presentation/responses/DiscussionResponsesFragment.kt b/discussion/src/main/java/com/raccoongang/discussion/presentation/responses/DiscussionResponsesFragment.kt index 21234bca2..de88ce103 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/presentation/responses/DiscussionResponsesFragment.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/presentation/responses/DiscussionResponsesFragment.kt @@ -8,6 +8,7 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -39,11 +40,13 @@ import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import com.raccoongang.core.UIMessage +import com.raccoongang.core.domain.model.ProfileImage import com.raccoongang.core.extension.TextConverter import com.raccoongang.core.extension.parcelable import com.raccoongang.core.ui.* import com.raccoongang.core.ui.theme.NewEdxTheme import com.raccoongang.core.ui.theme.appColors +import com.raccoongang.core.ui.theme.appShapes import com.raccoongang.core.ui.theme.appTypography import com.raccoongang.discussion.domain.model.DiscussionComment import com.raccoongang.discussion.presentation.comments.DiscussionCommentsFragment @@ -61,6 +64,7 @@ class DiscussionResponsesFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) + viewModel.isThreadClosed = requireArguments().getBoolean(ARG_IS_CLOSED, false) } override fun onCreateView( @@ -84,6 +88,7 @@ class DiscussionResponsesFragment : Fragment() { uiMessage = uiMessage, canLoadMore = canLoadMore, refreshing = refreshing, + isClosed = viewModel.isThreadClosed, onSwipeRefresh = { viewModel.updateCommentResponses() }, @@ -93,10 +98,12 @@ class DiscussionResponsesFragment : Fragment() { onItemClick = { action, id, bool -> when (action) { DiscussionCommentsFragment.ACTION_UPVOTE_COMMENT -> { - viewModel.setCommentUpvoted( - id, - bool - ) + if (!viewModel.isThreadClosed) { + viewModel.setCommentUpvoted( + id, + bool + ) + } } DiscussionCommentsFragment.ACTION_REPORT_COMMENT -> { viewModel.setCommentReported( @@ -119,11 +126,16 @@ class DiscussionResponsesFragment : Fragment() { companion object { private const val ARG_COMMENT = "comment" + private const val ARG_IS_CLOSED = "isClosed" - fun newInstance(comment: DiscussionComment): DiscussionResponsesFragment { + fun newInstance( + comment: DiscussionComment, + isClosed: Boolean + ): DiscussionResponsesFragment { val fragment = DiscussionResponsesFragment() fragment.arguments = bundleOf( - ARG_COMMENT to comment + ARG_COMMENT to comment, + ARG_IS_CLOSED to isClosed ) return fragment } @@ -138,11 +150,12 @@ private fun DiscussionResponsesScreen( uiMessage: UIMessage?, canLoadMore: Boolean, refreshing: Boolean, + isClosed: Boolean, onSwipeRefresh: () -> Unit, paginationCallBack: () -> Unit, onItemClick: (String, String, Boolean) -> Unit, addCommentClick: (String) -> Unit, - onBackClick: () -> Unit + onBackClick: () -> Unit, ) { val scaffoldState = rememberScaffoldState() val scrollState = rememberLazyListState() @@ -162,7 +175,7 @@ private fun DiscussionResponsesScreen( } val iconButtonColor = if (commentValue.isEmpty()) { - MaterialTheme.appColors.cardViewBorder + MaterialTheme.appColors.textFieldBackgroundVariant } else { Color.White } @@ -328,7 +341,7 @@ private fun DiscussionResponsesScreen( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } @@ -337,7 +350,9 @@ private fun DiscussionResponsesScreen( paginationCallBack() } } - Divider(color = MaterialTheme.appColors.cardViewBorder) + if (!isSystemInDarkTheme()) { + Divider(color = MaterialTheme.appColors.cardViewBorder) + } Box( Modifier .fillMaxWidth() @@ -363,6 +378,7 @@ private fun DiscussionResponsesScreen( }, textStyle = MaterialTheme.appTypography.labelLarge, maxLines = 3, + shape = MaterialTheme.appShapes.buttonShape, placeholder = { Text( text = stringResource(id = com.raccoongang.discussion.R.string.discussion_add_comment), @@ -374,7 +390,8 @@ private fun DiscussionResponsesScreen( backgroundColor = MaterialTheme.appColors.textFieldBackgroundVariant, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, textColor = MaterialTheme.appColors.textFieldText - ) + ), + enabled = !isClosed ) Box( modifier = Modifier @@ -405,7 +422,7 @@ private fun DiscussionResponsesScreen( Modifier .fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } @@ -438,6 +455,7 @@ private fun DiscussionResponsesScreenPreview() { uiMessage = null, canLoadMore = false, refreshing = false, + onSwipeRefresh = {}, paginationCallBack = { }, onItemClick = { _, _, _ -> @@ -446,7 +464,7 @@ private fun DiscussionResponsesScreenPreview() { }, onBackClick = {}, - onSwipeRefresh = {} + isClosed = false ) } } @@ -467,6 +485,7 @@ private fun DiscussionResponsesScreenTabletPreview() { uiMessage = null, canLoadMore = false, refreshing = false, + onSwipeRefresh = {}, paginationCallBack = { }, onItemClick = { _, _, _ -> @@ -475,7 +494,7 @@ private fun DiscussionResponsesScreenTabletPreview() { }, onBackClick = {}, - onSwipeRefresh = {} + isClosed = false ) } } @@ -502,7 +521,8 @@ private val mockComment = DiscussionComment( "", 21, emptyList(), - emptyMap() + ProfileImage("", "", "", "", false), + mapOf() ) diff --git a/discussion/src/main/java/com/raccoongang/discussion/presentation/responses/DiscussionResponsesViewModel.kt b/discussion/src/main/java/com/raccoongang/discussion/presentation/responses/DiscussionResponsesViewModel.kt index c2e1f2e4a..8b548730b 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/presentation/responses/DiscussionResponsesViewModel.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/presentation/responses/DiscussionResponsesViewModel.kt @@ -12,7 +12,6 @@ import com.raccoongang.core.extension.isInternetError import com.raccoongang.core.system.ResourceManager import com.raccoongang.discussion.domain.interactor.DiscussionInteractor import com.raccoongang.discussion.domain.model.DiscussionComment -import com.raccoongang.discussion.domain.model.DiscussionProfile import com.raccoongang.discussion.system.notifier.DiscussionCommentDataChanged import com.raccoongang.discussion.system.notifier.DiscussionNotifier import kotlinx.coroutines.launch @@ -41,6 +40,8 @@ class DiscussionResponsesViewModel( val isUpdating: LiveData get() = _isUpdating + var isThreadClosed: Boolean = false + private val comments = mutableListOf() private var page = 1 private var isLoading = false @@ -156,15 +157,7 @@ class DiscussionResponsesViewModel( fun createComment(rawBody: String) { viewModelScope.launch { try { - var response = interactor.createComment(comment.threadId, rawBody, comment.id) - response = response.copy( - users = mapOf( - Pair( - preferencesManager.profile?.username ?: "", - DiscussionProfile(preferencesManager.profile?.profileImage) - ) - ) - ) + val response = interactor.createComment(comment.threadId, rawBody, comment.id) comment = comment.copy(childCount = comment.childCount + 1) sendUpdatedComment() if (page == -1) { diff --git a/discussion/src/main/java/com/raccoongang/discussion/presentation/search/DiscussionSearchThreadFragment.kt b/discussion/src/main/java/com/raccoongang/discussion/presentation/search/DiscussionSearchThreadFragment.kt index af31a97e2..e72c006a7 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/presentation/search/DiscussionSearchThreadFragment.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/presentation/search/DiscussionSearchThreadFragment.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices @@ -142,8 +143,8 @@ private fun DiscussionSearchThreadScreen( val pullRefreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) - var searchText by rememberSaveable { - mutableStateOf("") + var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue("")) } val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current @@ -234,17 +235,17 @@ private fun DiscussionSearchThreadScreen( .then(searchTab), label = "", requestFocus = true, - searchValue = searchText, + searchValue = textFieldValue, keyboardActions = { focusManager.clearFocus() }, onValueChanged = { text -> - searchText = text - onSearchTextChanged(searchText) + textFieldValue = text + onSearchTextChanged(textFieldValue.text) }, onClearValue = { - searchText = "" - onSearchTextChanged(searchText) + textFieldValue = TextFieldValue("") + onSearchTextChanged(textFieldValue.text) } ) Spacer(modifier = Modifier.height(20.dp)) @@ -258,7 +259,7 @@ private fun DiscussionSearchThreadScreen( .pullRefresh(pullRefreshState) ) { val typingText = - if (searchText.isEmpty()) { + if (textFieldValue.text.isEmpty()) { stringResource(id = discussionR.string.discussion_start_typing_to_find) } else { pluralStringResource( @@ -297,7 +298,7 @@ private fun DiscussionSearchThreadScreen( .padding(vertical = 25.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } @@ -314,7 +315,7 @@ private fun DiscussionSearchThreadScreen( .padding(vertical = 16.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } @@ -408,5 +409,8 @@ private val mockThread = com.raccoongang.discussion.domain.model.Thread( 4, false, false, - mapOf() + mapOf(), + 10, + false, + false ) \ No newline at end of file diff --git a/discussion/src/main/java/com/raccoongang/discussion/presentation/threads/DiscussionAddThreadFragment.kt b/discussion/src/main/java/com/raccoongang/discussion/presentation/threads/DiscussionAddThreadFragment.kt index de576c9b1..958ba16f8 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/presentation/threads/DiscussionAddThreadFragment.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/presentation/threads/DiscussionAddThreadFragment.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + package com.raccoongang.discussion.presentation.threads import android.content.res.Configuration @@ -16,11 +18,13 @@ import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -121,6 +125,7 @@ private fun DiscussionAddThreadScreen( ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current + val keyboardController = LocalSoftwareKeyboardController.current val bottomSheetScaffoldState = rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden ) @@ -334,13 +339,7 @@ private fun DiscussionAddThreadScreen( imeAction = ImeAction.Done, keyboardActions = { focusManager -> focusManager.clearFocus() - onPostDiscussionClick( - discussionType, - topicData.second, - titleValue, - discussionValue, - followPost - ) + keyboardController?.hide() }, onValueChanged = { value -> discussionValue = value @@ -377,7 +376,7 @@ private fun DiscussionAddThreadScreen( } Spacer(Modifier.height(44.dp)) if (isLoading) { - CircularProgressIndicator(color = MaterialTheme.colors.primary) + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } else { NewEdxButton( width = buttonWidth, diff --git a/discussion/src/main/java/com/raccoongang/discussion/presentation/threads/DiscussionThreadsFragment.kt b/discussion/src/main/java/com/raccoongang/discussion/presentation/threads/DiscussionThreadsFragment.kt index 6601ea4aa..eab2ea97c 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/presentation/threads/DiscussionThreadsFragment.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/presentation/threads/DiscussionThreadsFragment.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.* import androidx.compose.material.pullrefresh.PullRefreshIndicator @@ -79,6 +80,7 @@ class DiscussionThreadsFragment : Fragment() { val uiState by viewModel.uiState.observeAsState(DiscussionThreadsUIState.Loading) val uiMessage by viewModel.uiMessage.observeAsState() + val canLoadMore by viewModel.canLoadMore.observeAsState(false) val refreshing by viewModel.isUpdating.observeAsState(false) DiscussionThreadsScreen( @@ -86,6 +88,7 @@ class DiscussionThreadsFragment : Fragment() { title = requireArguments().getString(ARG_TITLE, ""), uiState = uiState, uiMessage = uiMessage, + canLoadMore = canLoadMore, viewType = viewType, refreshing = refreshing, onSwipeRefresh = { @@ -111,6 +114,9 @@ class DiscussionThreadsFragment : Fragment() { viewModel.courseId ) }, + paginationCallback = { + viewModel.fetchMore() + }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() } @@ -153,6 +159,7 @@ private fun DiscussionThreadsScreen( title: String, uiState: DiscussionThreadsUIState, uiMessage: UIMessage?, + canLoadMore: Boolean, viewType: FragmentViewType, refreshing: Boolean, onSwipeRefresh: () -> Unit, @@ -160,6 +167,7 @@ private fun DiscussionThreadsScreen( updatedFilter: (String) -> Unit, onItemClick: (com.raccoongang.discussion.domain.model.Thread) -> Unit, onCreatePostClick: () -> Unit, + paginationCallback: () -> Unit, onBackClick: () -> Unit ) { @@ -171,6 +179,10 @@ private fun DiscussionThreadsScreen( val pullRefreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) val coroutine = rememberCoroutineScope() + val scrollState = rememberLazyListState() + val firstVisibleIndex = remember { + mutableStateOf(scrollState.firstVisibleItemIndex) + } val context = LocalContext.current var sortType by rememberSaveable { mutableStateOf( @@ -230,8 +242,13 @@ private fun DiscussionThreadsScreen( val listPadding by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( - expanded = PaddingValues(vertical = 24.dp), - compact = PaddingValues(24.dp) + expanded = PaddingValues(top = 24.dp, bottom = 80.dp), + compact = PaddingValues( + start = 24.dp, + end = 24.dp, + top = 24.dp, + bottom = 80.dp + ) ) ) } @@ -418,7 +435,8 @@ private fun DiscussionThreadsScreen( Divider() LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = listPadding + contentPadding = listPadding, + state = scrollState ) { item { Text( @@ -434,10 +452,31 @@ private fun DiscussionThreadsScreen( }) Divider() } + item { + if (canLoadMore) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + } + if (scrollState.shouldLoadMore( + firstVisibleIndex, + 4 + ) + ) { + paginationCallback() + } } } NewEdxButton( - width = Modifier.widthIn(184.dp, Dp.Unspecified), + width = Modifier + .padding(bottom = 24.dp) + .widthIn(184.dp, Dp.Unspecified), text = stringResource(id = discussionR.string.discussion_create_post), onClick = { onCreatePostClick() @@ -456,7 +495,6 @@ private fun DiscussionThreadsScreen( ) } ) - Spacer(Modifier.height(24.dp)) } } is DiscussionThreadsUIState.Loading -> { @@ -464,7 +502,7 @@ private fun DiscussionThreadsScreen( Modifier .fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } @@ -500,7 +538,9 @@ private fun DiscussionThreadsScreenPreview() { onCreatePostClick = {}, viewType = FragmentViewType.FULL_CONTENT, onSwipeRefresh = {}, - refreshing = false + paginationCallback = {}, + refreshing = false, + canLoadMore = false ) } } @@ -522,7 +562,9 @@ private fun DiscussionThreadsScreenTabletPreview() { onCreatePostClick = {}, viewType = FragmentViewType.FULL_CONTENT, onSwipeRefresh = {}, - refreshing = false + paginationCallback = {}, + refreshing = false, + canLoadMore = false, ) } } @@ -556,5 +598,8 @@ private val mockThread = com.raccoongang.discussion.domain.model.Thread( 4, false, false, - mapOf() + mapOf(), + 10, + false, + false ) \ No newline at end of file diff --git a/discussion/src/main/java/com/raccoongang/discussion/presentation/threads/DiscussionThreadsViewModel.kt b/discussion/src/main/java/com/raccoongang/discussion/presentation/threads/DiscussionThreadsViewModel.kt index 72369b444..49addb963 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/presentation/threads/DiscussionThreadsViewModel.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/presentation/threads/DiscussionThreadsViewModel.kt @@ -11,7 +11,6 @@ import com.raccoongang.core.UIMessage import com.raccoongang.core.extension.isInternetError import com.raccoongang.core.system.ResourceManager import com.raccoongang.discussion.domain.interactor.DiscussionInteractor -import com.raccoongang.discussion.domain.model.DiscussionType import com.raccoongang.discussion.presentation.topics.DiscussionTopicsFragment import com.raccoongang.discussion.system.notifier.DiscussionNotifier import com.raccoongang.discussion.system.notifier.DiscussionThreadAdded @@ -38,12 +37,17 @@ class DiscussionThreadsViewModel( val isUpdating: LiveData get() = _isUpdating + private val _canLoadMore = MutableLiveData() + val canLoadMore: LiveData + get() = _canLoadMore private val threadsList = mutableListOf() private var nextPage = 1 + private var isLoading = false var topicId = "" private var lastOrderBy = "" + private var filterType: String? = null override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) @@ -51,7 +55,7 @@ class DiscussionThreadsViewModel( notifier.notifier.collect { if (it is DiscussionThreadAdded) { if (lastOrderBy.isNotEmpty()) { - getThreadByType(lastOrderBy) + updateThread(lastOrderBy) } } else if (it is DiscussionThreadDataChanged) { val index = threadsList.indexOfFirst { thread -> @@ -73,10 +77,23 @@ class DiscussionThreadsViewModel( fun updateThread(orderBy: String) { _isUpdating.value = true + threadsList.clear() + nextPage = 1 internalLoadThreads(orderBy) } + fun fetchMore() { + if (!isLoading && nextPage != -1) { + isLoading = true + internalLoadThreads(lastOrderBy) + } + } + private fun internalLoadThreads(orderBy: String) { + if (lastOrderBy != orderBy) { + threadsList.clear() + nextPage = 1 + } lastOrderBy = orderBy when (threadType) { DiscussionTopicsFragment.ALL_POSTS -> { @@ -94,32 +111,45 @@ class DiscussionThreadsViewModel( } } - fun filterThreads(filter: String) { - when (filter) { - FilterType.ALL_POSTS.value -> { - _uiState.value = DiscussionThreadsUIState.Threads(threadsList.toList()) + fun filterThreads(filter: String?) { + if (filterType != filter || (filter != FilterType.ALL_POSTS.value && filterType.isNullOrEmpty())) { + threadsList.clear() + nextPage = 1 + } + filterType = if (filter == FilterType.ALL_POSTS.value) { + null + } else { + filter + } + when (threadType) { + DiscussionTopicsFragment.ALL_POSTS -> { + getAllThreads(lastOrderBy) } - FilterType.UNREAD.value -> { - _uiState.value = DiscussionThreadsUIState.Threads(threadsList.filter { !it.read }) + DiscussionTopicsFragment.FOLLOWING_POSTS -> { + getFollowingThreads(lastOrderBy) } - FilterType.UNANSWERED.value -> { - _uiState.value = DiscussionThreadsUIState.Threads(threadsList.filter { - it.type == DiscussionType.QUESTION && !it.hasEndorsed - }) + DiscussionTopicsFragment.TOPIC -> { + getThreads( + topicId, + lastOrderBy + ) } } } private fun getThreads(topicId: String, orderBy: String) { - nextPage = 1 - threadsList.clear() viewModelScope.launch { try { - while (nextPage > 0) { - val response = interactor.getThreads(courseId, topicId, orderBy, nextPage) - if (response.pagination.next.isNotEmpty()) nextPage++ else nextPage = -1 - threadsList.addAll(response.results) + val response = + interactor.getThreads(courseId, topicId, orderBy, filterType, nextPage) + if (response.pagination.next.isNotEmpty()) { + _canLoadMore.value = true + nextPage++ + } else { + _canLoadMore.value = false + nextPage = -1 } + threadsList.addAll(response.results) _uiState.value = DiscussionThreadsUIState.Threads(threadsList.toList()) } catch (e: Exception) { if (e.isInternetError()) { @@ -131,19 +161,22 @@ class DiscussionThreadsViewModel( } } _isUpdating.value = false + isLoading = false } } private fun getAllThreads(orderBy: String) { - nextPage = 1 - threadsList.clear() viewModelScope.launch { try { - while (nextPage > 0) { - val response = interactor.getAllThreads(courseId, orderBy, nextPage) - if (response.pagination.next.isNotEmpty()) nextPage++ else nextPage = -1 - threadsList.addAll(response.results) + val response = interactor.getAllThreads(courseId, orderBy, filterType, nextPage) + if (response.pagination.next.isNotEmpty()) { + _canLoadMore.value = true + nextPage++ + } else { + _canLoadMore.value = false + nextPage = -1 } + threadsList.addAll(response.results) _uiState.value = DiscussionThreadsUIState.Threads(threadsList.toList()) } catch (e: Exception) { if (e.isInternetError()) { @@ -155,19 +188,23 @@ class DiscussionThreadsViewModel( } } _isUpdating.value = false + isLoading = false } } private fun getFollowingThreads(orderBy: String) { - nextPage = 1 - threadsList.clear() viewModelScope.launch { try { - while (nextPage > 0) { - val response = interactor.getFollowingThreads(courseId, true, orderBy, nextPage) - if (response.pagination.next.isNotEmpty()) nextPage++ else nextPage = -1 - threadsList.addAll(response.results) + val response = + interactor.getFollowingThreads(courseId, true, orderBy, page = nextPage) + if (response.pagination.next.isNotEmpty()) { + _canLoadMore.value = true + nextPage++ + } else { + _canLoadMore.value = false + nextPage = -1 } + threadsList.addAll(response.results) _uiState.value = DiscussionThreadsUIState.Threads(threadsList.toList()) } catch (e: Exception) { if (e.isInternetError()) { @@ -179,6 +216,7 @@ class DiscussionThreadsViewModel( } } _isUpdating.value = false + isLoading = false } } } \ No newline at end of file diff --git a/discussion/src/main/java/com/raccoongang/discussion/presentation/topics/DiscussionTopicsFragment.kt b/discussion/src/main/java/com/raccoongang/discussion/presentation/topics/DiscussionTopicsFragment.kt index 699a62ef2..4af2b3ac2 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/presentation/topics/DiscussionTopicsFragment.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/presentation/topics/DiscussionTopicsFragment.kt @@ -316,7 +316,7 @@ private fun DiscussionTopicsScreen( Modifier .fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } } diff --git a/discussion/src/main/java/com/raccoongang/discussion/presentation/ui/DiscussionUI.kt b/discussion/src/main/java/com/raccoongang/discussion/presentation/ui/DiscussionUI.kt index 04a723a55..add2cd76e 100644 --- a/discussion/src/main/java/com/raccoongang/discussion/presentation/ui/DiscussionUI.kt +++ b/discussion/src/main/java/com/raccoongang/discussion/presentation/ui/DiscussionUI.kt @@ -13,16 +13,12 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.outlined.Flag import androidx.compose.material.icons.outlined.HelpOutline -import androidx.compose.material.icons.outlined.StarRate import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter @@ -35,6 +31,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.rememberAsyncImagePainter +import com.raccoongang.core.domain.model.ProfileImage import com.raccoongang.core.extension.TextConverter import com.raccoongang.core.ui.AutoSizeText import com.raccoongang.core.ui.HyperlinkImageText @@ -86,12 +83,14 @@ fun ThreadMainItem( ) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Image( - painter = rememberAsyncImagePainter(profileImageUrl), + painter = rememberAsyncImagePainter( + model = profileImageUrl, + error = painterResource(id = com.raccoongang.core.R.drawable.core_ic_default_profile_picture) + ), contentDescription = null, modifier = Modifier .size(48.dp) .clip(MaterialTheme.appShapes.material.medium) - .background(Color.Gray) ) Spacer(Modifier.width(16.dp)) Column( @@ -99,19 +98,19 @@ fun ThreadMainItem( verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = thread.author, + text = thread.author.ifEmpty { stringResource(id = R.string.discussion_anonymous) }, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) Text( text = TimeUtils.iso8601ToDateWithTime(context, thread.createdAt), style = MaterialTheme.appTypography.labelSmall, - color = MaterialTheme.appColors.textSecondary + color = MaterialTheme.appColors.textPrimaryVariant ) } IconText( text = stringResource(id = R.string.discussion_follow), - icon = if (thread.following) Icons.Filled.Star else Icons.Outlined.StarRate, + painter = painterResource(if (thread.following) R.drawable.discussion_star_filled else R.drawable.discussion_star), textStyle = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textPrimaryVariant, onClick = { @@ -120,6 +119,7 @@ fun ThreadMainItem( } Spacer(modifier = Modifier.height(24.dp)) HyperlinkImageText( + title = thread.title, imageText = thread.parsedRenderedBody, linkTextColor = MaterialTheme.appColors.primary ) @@ -145,7 +145,7 @@ fun ThreadMainItem( ) IconText( text = reportText, - icon = Icons.Outlined.Flag, + painter = painterResource(id = R.drawable.discussion_ic_report), textStyle = MaterialTheme.appTypography.labelLarge, color = reportColor, onClick = { @@ -167,7 +167,8 @@ fun CommentItem( onClick: (String, String, Boolean) -> Unit, onAddCommentClick: () -> Unit = {}, ) { - val profileImageUrl = comment.users?.get(comment.author)?.image?.imageUrlFull ?: "" + val profileImageUrl = comment.profileImage?.imageUrlFull + ?: comment.users?.get(comment.author)?.image?.imageUrlFull ?: "" val reportText = if (comment.abuseFlagged) { stringResource(id = R.string.discussion_unreport) @@ -215,12 +216,15 @@ fun CommentItem( verticalAlignment = Alignment.CenterVertically ) { Image( - painter = rememberAsyncImagePainter(profileImageUrl), + painter = rememberAsyncImagePainter( + model = profileImageUrl, + error = painterResource(id = com.raccoongang.core.R.drawable.core_ic_default_profile_picture) + ), contentDescription = null, modifier = Modifier .size(32.dp) .clip(CircleShape) - .background(Color.Gray) + ) Spacer(Modifier.width(12.dp)) Column( @@ -235,12 +239,12 @@ fun CommentItem( Text( text = TimeUtils.iso8601ToDateWithTime(context, comment.createdAt), style = MaterialTheme.appTypography.labelSmall, - color = MaterialTheme.appColors.textSecondary + color = MaterialTheme.appColors.textPrimaryVariant ) } IconText( text = reportText, - icon = Icons.Outlined.Flag, + painter = painterResource(id = R.drawable.discussion_ic_report), textStyle = MaterialTheme.appTypography.labelMedium, color = reportColor, onClick = { @@ -308,7 +312,8 @@ fun CommentMainItem( comment: DiscussionComment, onClick: (String, String, Boolean) -> Unit, ) { - val profileImageUrl = comment.users?.get(comment.author)?.image?.imageUrlFull ?: "" + val profileImageUrl = comment.profileImage?.imageUrlFull + ?: comment.users?.get(comment.author)?.image?.imageUrlFull ?: "" val reportText = if (comment.abuseFlagged) { stringResource(id = R.string.discussion_unreport) @@ -348,12 +353,15 @@ fun CommentMainItem( verticalAlignment = Alignment.CenterVertically ) { Image( - painter = rememberAsyncImagePainter(profileImageUrl), + painter = rememberAsyncImagePainter( + model = profileImageUrl, + error = painterResource(id = com.raccoongang.core.R.drawable.core_ic_default_profile_picture) + ), contentDescription = null, modifier = Modifier .size(32.dp) .clip(CircleShape) - .background(Color.Gray) + ) Spacer(Modifier.width(12.dp)) Column( @@ -368,7 +376,7 @@ fun CommentMainItem( Text( text = TimeUtils.iso8601ToDateWithTime(context, comment.createdAt), style = MaterialTheme.appTypography.labelSmall, - color = MaterialTheme.appColors.textSecondary + color = MaterialTheme.appColors.textPrimaryVariant ) } } @@ -403,7 +411,7 @@ fun CommentMainItem( ) IconText( text = reportText, - icon = Icons.Outlined.Flag, + painter = painterResource(id = R.drawable.discussion_ic_report), textStyle = MaterialTheme.appTypography.labelLarge, color = reportColor, onClick = { @@ -654,7 +662,8 @@ private val mockComment = DiscussionComment( "", 21, emptyList(), - emptyMap() + ProfileImage("", "", "", "", false), + mapOf() ) private val mockThread = com.raccoongang.discussion.domain.model.Thread( @@ -686,7 +695,10 @@ private val mockThread = com.raccoongang.discussion.domain.model.Thread( 4, false, false, - mapOf() + mapOf(), + 10, + false, + false ) private val mockTopic = Topic( diff --git a/discussion/src/main/res/drawable/discussion_ic_report.xml b/discussion/src/main/res/drawable/discussion_ic_report.xml new file mode 100644 index 000000000..7989a4e84 --- /dev/null +++ b/discussion/src/main/res/drawable/discussion_ic_report.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/discussion/src/main/res/drawable/discussion_star_filled.xml b/discussion/src/main/res/drawable/discussion_star_filled.xml new file mode 100644 index 000000000..a33d84d99 --- /dev/null +++ b/discussion/src/main/res/drawable/discussion_star_filled.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/discussion/src/main/res/values-uk/strings.xml b/discussion/src/main/res/values-uk/strings.xml index e21357a82..a396d06c4 100644 --- a/discussion/src/main/res/values-uk/strings.xml +++ b/discussion/src/main/res/values-uk/strings.xml @@ -32,6 +32,7 @@ Тема Результати пошуку Почніть вводити, щоб знайти тему + anonymous %1$d голос diff --git a/discussion/src/main/res/values/strings.xml b/discussion/src/main/res/values/strings.xml index 913f4fca8..a4f18d95c 100644 --- a/discussion/src/main/res/values/strings.xml +++ b/discussion/src/main/res/values/strings.xml @@ -31,6 +31,7 @@ Topic Search results Start typing to find the thread + anonymous diff --git a/discussion/src/test/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt b/discussion/src/test/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt index b806b0947..dad00d8da 100644 --- a/discussion/src/test/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt +++ b/discussion/src/test/java/com/raccoongang/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt @@ -73,7 +73,10 @@ class DiscussionCommentsViewModelTest { 4, false, false, - mapOf() + mapOf(), + 0, + false, + false ) //endregion @@ -102,7 +105,8 @@ class DiscussionCommentsViewModelTest { "", 21, emptyList(), - emptyMap() + null, + mapOf() ) //endregion @@ -129,6 +133,7 @@ class DiscussionCommentsViewModelTest { @Test fun `getThreadComments no internet connection exception`() = runTest { coEvery { interactor.getThreadComments(any(), any()) } throws UnknownHostException() + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, @@ -152,6 +157,7 @@ class DiscussionCommentsViewModelTest { @Test fun `getThreadComments unknown exception`() = runTest { coEvery { interactor.getThreadComments(any(), any()) } throws Exception() + every { resourceManager.getString(eq(DiscussionType.QUESTION.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, @@ -181,6 +187,8 @@ class DiscussionCommentsViewModelTest { Pagination(10, "2", 4, "1") ) coEvery { interactor.setThreadRead(any()) } returns mockThread + every { resourceManager.getString(eq(DiscussionType.QUESTION.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -209,6 +217,8 @@ class DiscussionCommentsViewModelTest { Pagination(10, "", 4, "1") ) coEvery { interactor.setThreadRead(any()) } returns mockThread + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -241,6 +251,8 @@ class DiscussionCommentsViewModelTest { Pagination(10, "", 4, "1") ) coEvery { interactor.setThreadRead(any()) } returns mockThread + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -275,6 +287,8 @@ class DiscussionCommentsViewModelTest { Pagination(10, "", 4, "1") ) coEvery { interactor.setThreadRead(any()) } throws UnknownHostException() + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -309,6 +323,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -341,6 +357,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -369,6 +387,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -396,6 +416,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -422,6 +444,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -450,6 +474,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -478,6 +504,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -505,6 +533,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -532,6 +562,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -559,6 +591,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -585,6 +619,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -612,6 +648,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -639,6 +677,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -666,6 +706,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -693,6 +735,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -721,6 +765,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -747,6 +793,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -781,6 +829,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -815,6 +865,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -847,6 +899,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -873,6 +927,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -900,6 +956,8 @@ class DiscussionCommentsViewModelTest { Pagination(10, "2", 4, "1") ) coEvery { interactor.setThreadRead(any()) } returns mockThread + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -909,14 +967,11 @@ class DiscussionCommentsViewModelTest { mockThread ) coEvery { interactor.createComment(any(), any(), any()) } returns mockComment - every { preferencesManager.profile?.username } returns "" - every { preferencesManager.profile?.profileImage } returns mockk() viewModel.createComment("") advanceUntilIdle() coVerify(exactly = 1) { interactor.createComment(any(), any(), any()) } - verify(exactly = 2) { preferencesManager.profile } assert(viewModel.uiMessage.value != null) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) @@ -928,6 +983,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -950,6 +1007,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, @@ -973,6 +1032,8 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) + every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + val viewModel = DiscussionCommentsViewModel( interactor, diff --git a/discussion/src/test/java/com/raccoongang/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt b/discussion/src/test/java/com/raccoongang/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt index 6ce6e6773..d586e431d 100644 --- a/discussion/src/test/java/com/raccoongang/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt +++ b/discussion/src/test/java/com/raccoongang/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt @@ -69,7 +69,10 @@ class DiscussionResponsesViewModelTest { 4, false, false, - mapOf() + mapOf(), + 0, + false, + false ) //endregion @@ -98,6 +101,7 @@ class DiscussionResponsesViewModelTest { "", 21, emptyList(), + null, emptyMap() ) @@ -516,14 +520,11 @@ class DiscussionResponsesViewModelTest { mockComment.copy(id = "0") ) coEvery { interactor.createComment(any(), any(), any()) } returns mockComment - every { preferencesManager.profile?.username } returns "" - every { preferencesManager.profile?.profileImage } returns mockk() viewModel.createComment("") advanceUntilIdle() coVerify(exactly = 1) { interactor.createComment(any(), any(), any()) } - verify(exactly = 2) { preferencesManager.profile } assert(viewModel.uiMessage.value != null) diff --git a/discussion/src/test/java/com/raccoongang/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt b/discussion/src/test/java/com/raccoongang/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt index cba85846f..fb2753180 100644 --- a/discussion/src/test/java/com/raccoongang/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt +++ b/discussion/src/test/java/com/raccoongang/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt @@ -77,7 +77,10 @@ class DiscussionSearchThreadViewModelTest { 4, false, false, - mapOf() + mapOf(), + 0, + false, + false ) //endregion diff --git a/discussion/src/test/java/com/raccoongang/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt b/discussion/src/test/java/com/raccoongang/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt index 972346a5e..f0559e59c 100644 --- a/discussion/src/test/java/com/raccoongang/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt +++ b/discussion/src/test/java/com/raccoongang/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt @@ -73,7 +73,10 @@ class DiscussionAddThreadViewModelTest { 4, false, false, - mapOf() + mapOf(), + 0, + false, + false ) //endregion @@ -102,6 +105,7 @@ class DiscussionAddThreadViewModelTest { "", 21, emptyList(), + null, emptyMap() ) @@ -127,6 +131,7 @@ class DiscussionAddThreadViewModelTest { "", 21, emptyList(), + null, mapOf("" to DiscussionProfile(ProfileImage("", "", "", "", false))) ) diff --git a/discussion/src/test/java/com/raccoongang/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt b/discussion/src/test/java/com/raccoongang/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt index f377fbf6f..14887621d 100644 --- a/discussion/src/test/java/com/raccoongang/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt +++ b/discussion/src/test/java/com/raccoongang/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt @@ -76,7 +76,10 @@ class DiscussionThreadsViewModelTest { 4, false, false, - mapOf() + mapOf(), + 0, + false, + false ) //endregion @@ -109,11 +112,18 @@ class DiscussionThreadsViewModelTest { "", DiscussionTopicsFragment.ALL_POSTS ) - coEvery { interactor.getAllThreads(any(), any(), any()) } throws UnknownHostException() + coEvery { + interactor.getAllThreads( + any(), + any(), + any(), + any() + ) + } throws UnknownHostException() viewModel.getThreadByType("") advanceUntilIdle() - coVerify(exactly = 1) { interactor.getAllThreads(any(), any(), any()) } + coVerify(exactly = 1) { interactor.getAllThreads(any(), any(), any(), any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -130,11 +140,11 @@ class DiscussionThreadsViewModelTest { "", DiscussionTopicsFragment.ALL_POSTS ) - coEvery { interactor.getAllThreads(any(), any(), any()) } throws Exception() + coEvery { interactor.getAllThreads(any(), any(), any(), any()) } throws Exception() viewModel.getThreadByType("") advanceUntilIdle() - coVerify(exactly = 1) { interactor.getAllThreads(any(), any(), any()) } + coVerify(exactly = 1) { interactor.getAllThreads(any(), any(), any(), any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -151,12 +161,12 @@ class DiscussionThreadsViewModelTest { "", DiscussionTopicsFragment.ALL_POSTS ) - coEvery { interactor.getAllThreads("", "", range(1, 2)) } returns ThreadsData( + coEvery { interactor.getAllThreads("", "", null, range(1, 2)) } returns ThreadsData( threads, "", Pagination(10, "2", 4, "1") ) - coEvery { interactor.getAllThreads("", "", eq(3)) } returns ThreadsData( + coEvery { interactor.getAllThreads("", "", null, eq(3)) } returns ThreadsData( threads, "", Pagination(10, "", 4, "1") @@ -164,7 +174,7 @@ class DiscussionThreadsViewModelTest { viewModel.getThreadByType("") advanceUntilIdle() - coVerify(exactly = 3) { interactor.getAllThreads(any(), any(), any()) } + coVerify(exactly = 1) { interactor.getAllThreads(any(), any(), any(), any()) } assert(viewModel.uiMessage.value == null) assert(viewModel.isUpdating.value == false) @@ -185,13 +195,14 @@ class DiscussionThreadsViewModelTest { any(), any(), any(), + any(), any() ) } throws UnknownHostException() viewModel.getThreadByType("") advanceUntilIdle() - coVerify(exactly = 1) { interactor.getFollowingThreads(any(), any(), any(), any()) } + coVerify(exactly = 1) { interactor.getFollowingThreads(any(), any(), any(), any(), any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -208,11 +219,19 @@ class DiscussionThreadsViewModelTest { "", DiscussionTopicsFragment.FOLLOWING_POSTS ) - coEvery { interactor.getFollowingThreads(any(), any(), any(), any()) } throws Exception() + coEvery { + interactor.getFollowingThreads( + any(), + any(), + any(), + any(), + any() + ) + } throws Exception() viewModel.getThreadByType("") advanceUntilIdle() - coVerify(exactly = 1) { interactor.getFollowingThreads(any(), any(), any(), any()) } + coVerify(exactly = 1) { interactor.getFollowingThreads(any(), any(), any(), any(), any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -229,12 +248,20 @@ class DiscussionThreadsViewModelTest { "", DiscussionTopicsFragment.FOLLOWING_POSTS ) - coEvery { interactor.getFollowingThreads("", any(), "", range(1, 2)) } returns ThreadsData( + coEvery { + interactor.getFollowingThreads( + "", + any(), + "", + null, + range(1, 2) + ) + } returns ThreadsData( threads, "", Pagination(10, "2", 4, "1") ) - coEvery { interactor.getFollowingThreads("", any(), "", eq(3)) } returns ThreadsData( + coEvery { interactor.getFollowingThreads("", any(), "", null, eq(3)) } returns ThreadsData( threads, "", Pagination(10, "", 4, "1") @@ -242,7 +269,7 @@ class DiscussionThreadsViewModelTest { viewModel.getThreadByType("") advanceUntilIdle() - coVerify(exactly = 3) { interactor.getFollowingThreads(any(), any(), any(), any()) } + coVerify(exactly = 1) { interactor.getFollowingThreads(any(), any(), any(), any(), any()) } assert(viewModel.uiMessage.value == null) assert(viewModel.isUpdating.value == false) @@ -258,11 +285,19 @@ class DiscussionThreadsViewModelTest { "", DiscussionTopicsFragment.TOPIC ) - coEvery { interactor.getThreads(any(), any(), any(), any()) } throws UnknownHostException() + coEvery { + interactor.getThreads( + any(), + any(), + any(), + any(), + any() + ) + } throws UnknownHostException() viewModel.getThreadByType("") advanceUntilIdle() - coVerify(exactly = 1) { interactor.getThreads(any(), any(), any(), any()) } + coVerify(exactly = 1) { interactor.getThreads(any(), any(), any(), any(), any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -279,11 +314,11 @@ class DiscussionThreadsViewModelTest { "", DiscussionTopicsFragment.TOPIC ) - coEvery { interactor.getThreads(any(), any(), any(), any()) } throws Exception() + coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } throws Exception() viewModel.getThreadByType("") advanceUntilIdle() - coVerify(exactly = 1) { interactor.getThreads(any(), any(), any(), any()) } + coVerify(exactly = 1) { interactor.getThreads(any(), any(), any(), any(), any()) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -300,12 +335,12 @@ class DiscussionThreadsViewModelTest { "", DiscussionTopicsFragment.TOPIC ) - coEvery { interactor.getThreads("", any(), "", range(1, 2)) } returns ThreadsData( + coEvery { interactor.getThreads("", any(), "", null, range(1, 2)) } returns ThreadsData( threads, "", Pagination(10, "2", 4, "1") ) - coEvery { interactor.getThreads("", any(), "", eq(3)) } returns ThreadsData( + coEvery { interactor.getThreads("", any(), "", null, eq(3)) } returns ThreadsData( threads, "", Pagination(10, "", 4, "1") @@ -313,7 +348,7 @@ class DiscussionThreadsViewModelTest { viewModel.getThreadByType("") advanceUntilIdle() - coVerify(exactly = 3) { interactor.getThreads(any(), any(), any(), any()) } + coVerify(exactly = 1) { interactor.getThreads(any(), any(), any(), any(), any()) } assert(viewModel.uiMessage.value == null) assert(viewModel.isUpdating.value == false) @@ -329,6 +364,11 @@ class DiscussionThreadsViewModelTest { "", DiscussionTopicsFragment.TOPIC ) + coEvery { interactor.getThreads(any(),any(), any(), any(), any()) } returns ThreadsData( + threads, + "", + pagination = Pagination(10,"", 2,"") + ) viewModel.filterThreads(FilterType.ALL_POSTS.value) advanceUntilIdle() assert(viewModel.uiState.value is DiscussionThreadsUIState.Threads) @@ -343,6 +383,11 @@ class DiscussionThreadsViewModelTest { "", DiscussionTopicsFragment.TOPIC ) + coEvery { interactor.getThreads(any(),any(), any(), any(), any()) } returns ThreadsData( + threads, + "", + pagination = Pagination(10,"", 2,"") + ) viewModel.filterThreads(FilterType.UNREAD.value) advanceUntilIdle() assert(viewModel.uiState.value is DiscussionThreadsUIState.Threads) @@ -357,6 +402,11 @@ class DiscussionThreadsViewModelTest { "", DiscussionTopicsFragment.TOPIC ) + coEvery { interactor.getThreads(any(),any(), any(), any(), any()) } returns ThreadsData( + threads, + "", + pagination = Pagination(10,"", 2,"") + ) viewModel.filterThreads(FilterType.UNANSWERED.value) advanceUntilIdle() assert(viewModel.uiState.value is DiscussionThreadsUIState.Threads) @@ -371,12 +421,12 @@ class DiscussionThreadsViewModelTest { "", DiscussionTopicsFragment.TOPIC ) - coEvery { interactor.getThreads("", any(), "", range(1, 2)) } returns ThreadsData( + coEvery { interactor.getThreads("", any(), "", null, range(1, 2)) } returns ThreadsData( threads, "", Pagination(10, "2", 4, "1") ) - coEvery { interactor.getThreads("", any(), "", eq(3)) } returns ThreadsData( + coEvery { interactor.getThreads("", any(), "", null, eq(3)) } returns ThreadsData( threads, "", Pagination(10, "", 4, "1") @@ -384,7 +434,7 @@ class DiscussionThreadsViewModelTest { viewModel.updateThread("") advanceUntilIdle() - coVerify(exactly = 3) { interactor.getThreads(any(), any(), any(), any()) } + coVerify(exactly = 1) { interactor.getThreads(any(), any(), any(), any(), any()) } assert(viewModel.uiMessage.value == null) assert(viewModel.isUpdating.value == false) @@ -400,12 +450,12 @@ class DiscussionThreadsViewModelTest { "", DiscussionTopicsFragment.TOPIC ) - coEvery { interactor.getThreads("", any(), any(), range(1, 2)) } returns ThreadsData( + coEvery { interactor.getThreads("", any(), any(), null, range(1, 2)) } returns ThreadsData( threads, "", Pagination(10, "2", 4, "1") ) - coEvery { interactor.getThreads("", any(), any(), eq(3)) } returns ThreadsData( + coEvery { interactor.getThreads("", any(), any(), null, eq(3)) } returns ThreadsData( threads, "", Pagination(10, "", 4, "1") @@ -425,7 +475,7 @@ class DiscussionThreadsViewModelTest { viewModel.updateThread("date") advanceUntilIdle() - coVerify(exactly = 6) { interactor.getThreads(any(), any(), any(), any()) } + coVerify(exactly = 2) { interactor.getThreads(any(), any(), any(), any(), any()) } } @Test @@ -437,12 +487,12 @@ class DiscussionThreadsViewModelTest { "", DiscussionTopicsFragment.TOPIC ) - coEvery { interactor.getThreads("", any(), any(), range(1, 2)) } returns ThreadsData( + coEvery { interactor.getThreads("", any(), any(), null,range(1, 2)) } returns ThreadsData( threads, "", Pagination(10, "2", 4, "1") ) - coEvery { interactor.getThreads("", any(), any(), eq(3)) } returns ThreadsData( + coEvery { interactor.getThreads("", any(), any(), null,eq(3)) } returns ThreadsData( threads, "", Pagination(10, "", 4, "1") @@ -462,7 +512,7 @@ class DiscussionThreadsViewModelTest { viewModel.updateThread("date") advanceUntilIdle() - coVerify(exactly = 3) { interactor.getThreads(any(), any(), any(), any()) } + coVerify(exactly = 1) { interactor.getThreads(any(), any(), any(), any(), any()) } } diff --git a/profile/src/main/java/com/raccoongang/profile/presentation/delete/DeleteProfileFragment.kt b/profile/src/main/java/com/raccoongang/profile/presentation/delete/DeleteProfileFragment.kt index 73e8bb716..03f769fcb 100644 --- a/profile/src/main/java/com/raccoongang/profile/presentation/delete/DeleteProfileFragment.kt +++ b/profile/src/main/java/com/raccoongang/profile/presentation/delete/DeleteProfileFragment.kt @@ -248,7 +248,8 @@ fun DeleteProfileScreen( Spacer(Modifier.height(38.dp)) NewEdxButton( text = stringResource(id = profileR.string.profile_yes_delete_account), - enabled = uiState !is DeleteProfileFragmentUIState.Loading, + enabled = uiState !is DeleteProfileFragmentUIState.Loading && password.isNotEmpty(), + backgroundColor = MaterialTheme.appColors.error, onClick = { onDeleteClick(password) } diff --git a/profile/src/main/java/com/raccoongang/profile/presentation/edit/EditProfileFragment.kt b/profile/src/main/java/com/raccoongang/profile/presentation/edit/EditProfileFragment.kt index 466ab6f2c..93ad7bdc5 100644 --- a/profile/src/main/java/com/raccoongang/profile/presentation/edit/EditProfileFragment.kt +++ b/profile/src/main/java/com/raccoongang/profile/presentation/edit/EditProfileFragment.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + package com.raccoongang.profile.presentation.edit import android.content.res.Configuration @@ -36,7 +38,6 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.* @@ -78,6 +79,8 @@ import java.io.File import java.io.FileOutputStream import com.raccoongang.profile.R as profileR +private const val BIO_TEXT_FIELD_LIMIT = 300 + class EditProfileFragment : Fragment() { private val viewModel by viewModel { @@ -118,7 +121,12 @@ class EditProfileFragment : Fragment() { NewEdxTheme { val windowSize = rememberWindowSize() - val uiState by viewModel.uiState.observeAsState(EditProfileUIState(viewModel.account)) + val uiState by viewModel.uiState.observeAsState( + EditProfileUIState( + viewModel.account, + isLimited = viewModel.isLimitedProfile + ) + ) val uiMessage by viewModel.uiMessage.observeAsState() val selectedImageUri by viewModel.selectedImageUri.observeAsState() val isImageDeleted by viewModel.deleteImage.observeAsState(false) @@ -174,6 +182,9 @@ class EditProfileFragment : Fragment() { }, onDeleteImageClick = { viewModel.deleteImage() + }, + onLimitedProfileChange = { + viewModel.isLimitedProfile = it } ) } @@ -263,6 +274,7 @@ private fun EditProfileScreen( leaveDialog: Boolean, onKeepEdit: () -> Unit, onDataChanged: (Boolean) -> Unit, + onLimitedProfileChange: (Boolean) -> Unit, onBackClick: (Boolean) -> Unit, onSaveClick: (Map) -> Unit, onDeleteClick: () -> Unit, @@ -272,12 +284,7 @@ private fun EditProfileScreen( val scaffoldState = rememberScaffoldState() val coroutine = rememberCoroutineScope() val configuration = LocalConfiguration.current - var accountPrivacy by rememberSaveable { - mutableStateOf(uiState.account.accountPrivacy) - } - var isLimited by rememberSaveable { - mutableStateOf(uiState.account.accountPrivacy == Account.Privacy.PRIVATE) - } + val bottomSheetScaffoldState = rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden ) @@ -286,7 +293,7 @@ private fun EditProfileScreen( var expandedList by rememberSaveable { mutableStateOf(emptyList()) } - var openDialog by rememberSaveable { + var openWarningMessageDialog by rememberSaveable { mutableStateOf(false) } @@ -299,7 +306,7 @@ private fun EditProfileScreen( Pair(LANGUAGE, uiState.account.languageProficiencies), Pair(COUNTRY, uiState.account.country), Pair(BIO, uiState.account.bio), - Pair(ACCOUNT_PRIVACY, accountPrivacy.name.lowercase()) + Pair(ACCOUNT_PRIVACY, uiState.account.accountPrivacy.name.lowercase()) ) } @@ -309,7 +316,7 @@ private fun EditProfileScreen( && uiState.account.bio == mapFields[BIO] && selectedImageUri == null && !isImageDeleted - && uiState.account.accountPrivacy == accountPrivacy) + && uiState.isLimited == uiState.account.isLimited()) onDataChanged(saveButtonEnabled) val serverFieldName = rememberSaveable { @@ -415,15 +422,11 @@ private fun EditProfileScreen( HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - if (isOpenChangeImageDialogState) { + if (isOpenChangeImageDialogState && uiState.account.isOlderThanMinAge()) { ChangeImageDialog( onSelectFromGalleryClick = { isOpenChangeImageDialogState = false - if (!isLimited) { - onSelectImageClick() - } else { - openDialog = true - } + onSelectImageClick() }, onRemoveImageClick = { onDeleteImageClick() @@ -433,6 +436,8 @@ private fun EditProfileScreen( isOpenChangeImageDialogState = false } ) + } else { + isOpenChangeImageDialogState = false } if (leaveDialog) { @@ -508,7 +513,7 @@ private fun EditProfileScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = stringResource(if (isLimited) profileR.string.profile_limited_profile else profileR.string.profile_full_profile), + text = stringResource(if (uiState.isLimited) profileR.string.profile_limited_profile else profileR.string.profile_full_profile), color = MaterialTheme.appColors.textSecondary, style = MaterialTheme.appTypography.titleSmall ) @@ -531,9 +536,12 @@ private fun EditProfileScreen( .padding(2.dp) .size(100.dp) .clip(CircleShape) - .background(Color.Gray) + .noRippleClickable { isOpenChangeImageDialogState = true + if (!uiState.account.isOlderThanMinAge()) { + openWarningMessageDialog = true + } } ) Icon( @@ -557,25 +565,24 @@ private fun EditProfileScreen( Text( modifier = Modifier.clickable { if (!LocaleUtils.isProfileLimited(mapFields[YEAR_OF_BIRTH].toString())) { - isLimited = !isLimited - accountPrivacy = - if (accountPrivacy == Account.Privacy.PRIVATE) { - Account.Privacy.ALL_USERS - } else { - Account.Privacy.PRIVATE - } - mapFields[ACCOUNT_PRIVACY] = accountPrivacy + val privacy = if (uiState.isLimited) { + Account.Privacy.ALL_USERS + } else { + Account.Privacy.PRIVATE + } + mapFields[ACCOUNT_PRIVACY] = privacy + onLimitedProfileChange(!uiState.isLimited) } else { - openDialog = true + openWarningMessageDialog = true } }, - text = stringResource(if (isLimited) profileR.string.profile_switch_to_full else profileR.string.profile_switch_to_limited), + text = stringResource(if (uiState.isLimited) profileR.string.profile_switch_to_full else profileR.string.profile_switch_to_limited), color = MaterialTheme.appColors.textAccent, style = MaterialTheme.appTypography.labelLarge ) Spacer(modifier = Modifier.height(20.dp)) ProfileFields( - disabled = isLimited, + disabled = uiState.isLimited, onFieldClick = { it -> if (it == YEAR_OF_BIRTH) { serverFieldName.value = YEAR_OF_BIRTH @@ -611,7 +618,8 @@ private fun EditProfileScreen( onValueChanged = { mapFields[BIO] = it }, - mapFields = mapFields + mapFields = mapFields, + onDoneClick = { onSaveClick(mapFields.toMap()) } ) Spacer(Modifier.height(40.dp)) IconText( @@ -624,11 +632,11 @@ private fun EditProfileScreen( }) Spacer(Modifier.height(52.dp)) } - if (openDialog) { + if (openWarningMessageDialog) { LimitedProfileDialog( modifier = popUpModifier ) { - openDialog = false + openWarningMessageDialog = false } } } @@ -780,6 +788,7 @@ private fun ProfileFields( mapFields: MutableMap, onFieldClick: (String) -> Unit, onValueChanged: (String) -> Unit, + onDoneClick: () -> Unit ) { val languageProficiency = (mapFields[LANGUAGE] as List) val lang = if (languageProficiency.isNotEmpty()) { @@ -814,7 +823,10 @@ private fun ProfileFields( .height(132.dp), name = stringResource(id = profileR.string.profile_about_me), initialValue = mapFields[BIO].toString(), - onValueChanged = onValueChanged + onValueChanged = { + onValueChanged(it.take(BIO_TEXT_FIELD_LIMIT)) + }, + onDoneClick = onDoneClick ) } } @@ -887,7 +899,9 @@ private fun InputEditField( initialValue: String, disabled: Boolean = false, onValueChanged: (String) -> Unit, + onDoneClick: () -> Unit ) { + val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current val keyboardType = KeyboardType.Text @@ -919,10 +933,12 @@ private fun InputEditField( }, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = keyboardType, - imeAction = ImeAction.Next + imeAction = ImeAction.Done ), keyboardActions = KeyboardActions { - focusManager.moveFocus(FocusDirection.Down) + keyboardController?.hide() + focusManager.clearFocus() + onDoneClick() }, textStyle = MaterialTheme.appTypography.bodyMedium, modifier = modifier @@ -1012,7 +1028,7 @@ private fun EditProfileScreenPreview() { NewEdxTheme { EditProfileScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = EditProfileUIState(account = mockAccount, isUpdating = false), + uiState = EditProfileUIState(account = mockAccount, isUpdating = false, false), selectedImageUri = null, uiMessage = null, isImageDeleted = true, @@ -1023,7 +1039,8 @@ private fun EditProfileScreenPreview() { onSelectImageClick = {}, onDeleteImageClick = {}, onDataChanged = {}, - onKeepEdit = {} + onKeepEdit = {}, + onLimitedProfileChange = {} ) } } @@ -1035,7 +1052,7 @@ private fun EditProfileScreenTabletPreview() { NewEdxTheme { EditProfileScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = EditProfileUIState(account = mockAccount, isUpdating = false), + uiState = EditProfileUIState(account = mockAccount, isUpdating = false, false), selectedImageUri = null, uiMessage = null, isImageDeleted = true, @@ -1046,7 +1063,8 @@ private fun EditProfileScreenTabletPreview() { onSelectImageClick = {}, onDeleteImageClick = {}, onDataChanged = {}, - onKeepEdit = {} + onKeepEdit = {}, + onLimitedProfileChange = {} ) } } diff --git a/profile/src/main/java/com/raccoongang/profile/presentation/edit/EditProfileUIState.kt b/profile/src/main/java/com/raccoongang/profile/presentation/edit/EditProfileUIState.kt index 17ed241ec..01337c0d0 100644 --- a/profile/src/main/java/com/raccoongang/profile/presentation/edit/EditProfileUIState.kt +++ b/profile/src/main/java/com/raccoongang/profile/presentation/edit/EditProfileUIState.kt @@ -2,6 +2,10 @@ package com.raccoongang.profile.presentation.edit import com.raccoongang.core.domain.model.Account -data class EditProfileUIState(val account: Account, val isUpdating :Boolean = false) +data class EditProfileUIState( + val account: Account, + val isUpdating: Boolean = false, + val isLimited: Boolean +) diff --git a/profile/src/main/java/com/raccoongang/profile/presentation/edit/EditProfileViewModel.kt b/profile/src/main/java/com/raccoongang/profile/presentation/edit/EditProfileViewModel.kt index c0f0d8bfd..dbcf8ca16 100644 --- a/profile/src/main/java/com/raccoongang/profile/presentation/edit/EditProfileViewModel.kt +++ b/profile/src/main/java/com/raccoongang/profile/presentation/edit/EditProfileViewModel.kt @@ -43,6 +43,11 @@ class EditProfileViewModel( get() = _deleteImage var profileDataChanged = false + var isLimitedProfile: Boolean = account.isLimited() + set(value) { + field = value + _uiState.value = EditProfileUIState(account, isLimited = value) + } private val _showLeaveDialog = MutableLiveData() val showLeaveDialog: LiveData @@ -50,18 +55,22 @@ class EditProfileViewModel( fun updateAccount(fields: Map) { - _uiState.value = EditProfileUIState(account, true) + _uiState.value = EditProfileUIState(account, true, isLimitedProfile) viewModelScope.launch { try { if (deleteImage.value == true) { interactor.deleteProfileImage() } - val account = interactor.updateAccount(fields) - _uiState.value = EditProfileUIState(account, isUpdating = false) + val updatedAccount = interactor.updateAccount(fields) + account = updatedAccount + isLimitedProfile = updatedAccount.isLimited() + _uiState.value = + EditProfileUIState(updatedAccount, isUpdating = false, isLimitedProfile) sendAccountUpdated() _deleteImage.value = false + _selectedImageUri.value = null } catch (e: Exception) { - _uiState.value = EditProfileUIState(account.copy()) + _uiState.value = EditProfileUIState(account.copy(), isLimited = isLimitedProfile) if (e.isInternetError()) { _uiMessage.value = UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) @@ -74,16 +83,19 @@ class EditProfileViewModel( } fun updateAccountAndImage(fields: Map, file: File, mimeType: String) { - _uiState.value = EditProfileUIState(account, true) + _uiState.value = EditProfileUIState(account, true, isLimitedProfile) viewModelScope.launch { try { interactor.setProfileImage(file, mimeType) val updatedAccount = interactor.updateAccount(fields) - _uiState.value = EditProfileUIState(updatedAccount, isUpdating = false) + account = updatedAccount + isLimitedProfile = updatedAccount.isLimited() + _uiState.value = + EditProfileUIState(updatedAccount, isUpdating = false, isLimitedProfile) _selectedImageUri.value = null sendAccountUpdated() } catch (e: Exception) { - _uiState.value = EditProfileUIState(account.copy()) + _uiState.value = EditProfileUIState(account.copy(), isLimited = isLimitedProfile) if (e.isInternetError()) { _uiMessage.value = UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) @@ -109,7 +121,6 @@ class EditProfileViewModel( _showLeaveDialog.value = value } - private suspend fun sendAccountUpdated() { notifier.send(AccountUpdated()) } diff --git a/profile/src/main/java/com/raccoongang/profile/presentation/profile/ProfileFragment.kt b/profile/src/main/java/com/raccoongang/profile/presentation/profile/ProfileFragment.kt index e9fc5e465..0c31ccc46 100644 --- a/profile/src/main/java/com/raccoongang/profile/presentation/profile/ProfileFragment.kt +++ b/profile/src/main/java/com/raccoongang/profile/presentation/profile/ProfileFragment.kt @@ -12,6 +12,9 @@ import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowForwardIos import androidx.compose.material.icons.filled.ExitToApp +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.saveable.rememberSaveable @@ -25,6 +28,8 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview @@ -37,11 +42,14 @@ import com.raccoongang.core.R import com.raccoongang.core.UIMessage import com.raccoongang.core.domain.model.Account import com.raccoongang.core.domain.model.ProfileImage +import com.raccoongang.core.presentation.global.AppData +import com.raccoongang.core.presentation.global.AppDataHolder import com.raccoongang.core.ui.* import com.raccoongang.core.ui.theme.NewEdxTheme import com.raccoongang.core.ui.theme.appColors import com.raccoongang.core.ui.theme.appShapes import com.raccoongang.core.ui.theme.appTypography +import com.raccoongang.core.utils.EmailUtil import com.raccoongang.profile.presentation.ProfileRouter import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -69,10 +77,14 @@ class ProfileFragment : Fragment() { val uiState by viewModel.uiState.observeAsState() val logoutSuccess by viewModel.successLogout.observeAsState(false) val uiMessage by viewModel.uiMessage.observeAsState() + val refreshing by viewModel.isUpdating.observeAsState(false) + ProfileScreen( windowSize = windowSize, uiState = uiState!!, uiMessage = uiMessage, + appData = (requireActivity() as AppDataHolder).appData, + refreshing = refreshing, logout = { viewModel.logout() }, @@ -82,6 +94,9 @@ class ProfileFragment : Fragment() { it ) }, + onSwipeRefresh = { + viewModel.updateAccount() + }, onVideoSettingsClick = { router.navigateToVideoSettings( requireParentFragment().parentFragmentManager @@ -100,18 +115,25 @@ class ProfileFragment : Fragment() { } +@OptIn(ExperimentalMaterialApi::class) @Composable private fun ProfileScreen( windowSize: WindowSize, uiState: ProfileUIState, + appData: AppData, uiMessage: UIMessage?, + refreshing: Boolean, onVideoSettingsClick: () -> Unit, logout: () -> Unit, + onSwipeRefresh: () -> Unit, editAccountClicked: (Account) -> Unit ) { val scaffoldState = rememberScaffoldState() var showLogoutDialog by rememberSaveable { mutableStateOf(false) } + val pullRefreshState = + rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) + Scaffold( modifier = Modifier.fillMaxSize(), scaffoldState = scaffoldState @@ -172,102 +194,115 @@ private fun ProfileScreen( style = MaterialTheme.appTypography.titleMedium ) - IconButton( + IconText( modifier = Modifier - .padding(end = 8.dp), + .height(48.dp) + .padding(end = 24.dp), + text = stringResource(com.raccoongang.profile.R.string.profile_edit), + painter = painterResource(id = R.drawable.core_ic_edit), + textStyle = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary, onClick = { if (uiState is ProfileUIState.Data) { editAccountClicked(uiState.account) } - }) { - Icon( - painter = painterResource(id = R.drawable.core_ic_edit), - tint = MaterialTheme.appColors.onBackground, - contentDescription = null - ) - } + } + ) } - Column( - modifier = Modifier - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + Surface( + color = MaterialTheme.appColors.background ) { - when (uiState) { - is ProfileUIState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - is ProfileUIState.Data -> { - Column( - Modifier - .fillMaxHeight() - .then(contentWidth) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - painter = rememberAsyncImagePainter( - model = uiState.account.profileImage.imageUrlFull, - placeholder = painterResource(id = R.drawable.core_ic_default_profile_picture), - error = painterResource(id = R.drawable.core_ic_default_profile_picture) - ), - contentDescription = null, - modifier = Modifier - .border( - 2.dp, - MaterialTheme.appColors.onSurface, - CircleShape + Box( + modifier = Modifier.pullRefresh(pullRefreshState), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (uiState) { + is ProfileUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + is ProfileUIState.Data -> { + Column( + Modifier + .fillMaxHeight() + .then(contentWidth) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = rememberAsyncImagePainter( + model = uiState.account.profileImage.imageUrlFull, + placeholder = painterResource(id = R.drawable.core_ic_default_profile_picture), + error = painterResource(id = R.drawable.core_ic_default_profile_picture) + ), + contentDescription = null, + modifier = Modifier + .border( + 2.dp, + MaterialTheme.appColors.onSurface, + CircleShape + ) + .padding(2.dp) + .size(100.dp) + .clip(CircleShape) ) - .padding(2.dp) - .size(100.dp) - .clip(CircleShape) - .background(Color.Gray) - ) - Spacer(modifier = Modifier.height(20.dp)) - Text( - text = uiState.account.name, - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.headlineSmall - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "@${uiState.account.username}", - color = MaterialTheme.appColors.textPrimaryVariant, - style = MaterialTheme.appTypography.labelLarge - ) - Spacer(modifier = Modifier.height(36.dp)) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = uiState.account.name, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.headlineSmall + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "@${uiState.account.username}", + color = MaterialTheme.appColors.textPrimaryVariant, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(modifier = Modifier.height(36.dp)) - Column( - Modifier - .fillMaxWidth() - ) { - ProfileInfoSection(uiState.account) + Column( + Modifier + .fillMaxWidth() + ) { + ProfileInfoSection(uiState.account) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(24.dp)) - SettingsSection(onVideoSettingsClick = { - onVideoSettingsClick() - }) + SettingsSection(onVideoSettingsClick = { + onVideoSettingsClick() + }) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(24.dp)) - SupportInfoSection() + SupportInfoSection(appData) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(24.dp)) - LogoutButton( - onClick = { showLogoutDialog = true } - ) + LogoutButton( + onClick = { showLogoutDialog = true } + ) - Spacer(Modifier.height(30.dp)) - } + Spacer(Modifier.height(30.dp)) + } + } + } } } + PullRefreshIndicator( + refreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) } } } @@ -276,46 +311,71 @@ private fun ProfileScreen( @Composable private fun ProfileInfoSection(account: Account) { - Column { - Text( - text = stringResource(id = com.raccoongang.profile.R.string.profile_prof_info), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textSecondary - ) - Spacer(modifier = Modifier.height(14.dp)) - Card( - modifier = Modifier.border( - 1.dp, - MaterialTheme.appColors.cardViewBorder, - MaterialTheme.appShapes.cardShape - ), - shape = MaterialTheme.appShapes.cardShape, - backgroundColor = MaterialTheme.appColors.cardViewBackground - ) { - Column( - Modifier - .fillMaxWidth() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + + if (account.yearOfBirth != null || account.bio.isNotEmpty()) { + Column { + Text( + text = stringResource(id = com.raccoongang.profile.R.string.profile_prof_info), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) + Spacer(modifier = Modifier.height(14.dp)) + Card( + modifier = Modifier, + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground ) { - Text( - text = stringResource( - id = com.raccoongang.profile.R.string.profile_year_of_birth, - if (account.yearOfBirth != null) { - account.yearOfBirth.toString() - } else "" - ), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) - Text( - text = stringResource( - id = com.raccoongang.profile.R.string.profile_bio, - account.bio - ), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) + Column( + Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (account.yearOfBirth != null) { + Text( + text = buildAnnotatedString { + val value = if (account.yearOfBirth != null) { + account.yearOfBirth.toString() + } else "" + val text = stringResource( + id = com.raccoongang.profile.R.string.profile_year_of_birth, + value + ) + append(text) + addStyle( + style = SpanStyle( + color = MaterialTheme.appColors.textPrimaryVariant + ), + start = 0, + end = text.length - value.length + ) + }, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + } + if (account.bio.isNotEmpty()) { + Text( + text = buildAnnotatedString { + val text = stringResource( + id = com.raccoongang.profile.R.string.profile_bio, + account.bio + ) + append(text) + addStyle( + style = SpanStyle( + color = MaterialTheme.appColors.textPrimaryVariant + ), + start = 0, + end = text.length - account.bio.length + ) + }, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + } + } } } } @@ -331,12 +391,9 @@ fun SettingsSection(onVideoSettingsClick: () -> Unit) { ) Spacer(modifier = Modifier.height(14.dp)) Card( - modifier = Modifier.border( - 1.dp, - MaterialTheme.appColors.cardViewBorder, - MaterialTheme.appShapes.cardShape - ), + modifier = Modifier, shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, backgroundColor = MaterialTheme.appColors.cardViewBackground ) { Column( @@ -355,7 +412,7 @@ fun SettingsSection(onVideoSettingsClick: () -> Unit) { } @Composable -private fun SupportInfoSection() { +private fun SupportInfoSection(appData: AppData) { val uriHandler = LocalUriHandler.current val context = LocalContext.current Column { @@ -366,12 +423,9 @@ private fun SupportInfoSection() { ) Spacer(modifier = Modifier.height(14.dp)) Card( - modifier = Modifier.border( - 1.dp, - MaterialTheme.appColors.cardViewBorder, - MaterialTheme.appShapes.cardShape - ), + modifier = Modifier, shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, backgroundColor = MaterialTheme.appColors.cardViewBackground ) { Column( @@ -383,15 +437,21 @@ private fun SupportInfoSection() { ProfileInfoItem( text = stringResource(id = com.raccoongang.profile.R.string.profile_contact_support), onClick = { - uriHandler.openUri(context.getString(R.string.contact_us_link)) + EmailUtil.showFeedbackScreen( + context, + context.getString(R.string.core_email_subject), + appData.versionName + ) } ) + Divider(color = MaterialTheme.appColors.divider) ProfileInfoItem( text = stringResource(id = R.string.core_terms_of_use), onClick = { uriHandler.openUri(context.getString(R.string.terms_of_service_link)) } ) + Divider(color = MaterialTheme.appColors.divider) ProfileInfoItem( text = stringResource(id = R.string.core_privacy_policy), onClick = { @@ -408,15 +468,11 @@ private fun LogoutButton(onClick: () -> Unit) { Card( modifier = Modifier .fillMaxWidth() - .border( - 1.dp, - MaterialTheme.appColors.cardViewBorder, - MaterialTheme.appShapes.cardShape - ) .clickable { onClick() }, shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, backgroundColor = MaterialTheme.appColors.cardViewBackground ) { Row( @@ -541,9 +597,12 @@ private fun ProfileScreenPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = ProfileUIState.Data(mockAccount), uiMessage = null, + refreshing = false, logout = {}, + onSwipeRefresh = {}, editAccountClicked = {}, - onVideoSettingsClick = {} + onVideoSettingsClick = {}, + appData = AppData("1") ) } } @@ -558,9 +617,12 @@ private fun ProfileScreenTabletPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = ProfileUIState.Data(mockAccount), uiMessage = null, + refreshing = false, logout = {}, + onSwipeRefresh = {}, editAccountClicked = {}, - onVideoSettingsClick = {} + onVideoSettingsClick = {}, + appData = AppData("1") ) } } diff --git a/profile/src/main/java/com/raccoongang/profile/presentation/profile/ProfileViewModel.kt b/profile/src/main/java/com/raccoongang/profile/presentation/profile/ProfileViewModel.kt index b7312b61c..74da654e9 100644 --- a/profile/src/main/java/com/raccoongang/profile/presentation/profile/ProfileViewModel.kt +++ b/profile/src/main/java/com/raccoongang/profile/presentation/profile/ProfileViewModel.kt @@ -42,6 +42,10 @@ class ProfileViewModel( val uiMessage: LiveData get() = _uiMessage + private val _isUpdating = MutableLiveData() + val isUpdating: LiveData + get() = _isUpdating + init { getAccount() } @@ -74,10 +78,17 @@ class ProfileViewModel( _uiMessage.value = UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) } + } finally { + _isUpdating.value = false } } } + fun updateAccount() { + _isUpdating.value = true + getAccount() + } + fun logout() { viewModelScope.launch { try { diff --git a/profile/src/main/res/drawable-night/profile_delete_box.xml b/profile/src/main/res/drawable-night/profile_delete_box.xml new file mode 100644 index 000000000..e1e54cf43 --- /dev/null +++ b/profile/src/main/res/drawable-night/profile_delete_box.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + diff --git a/profile/src/main/res/values-uk/strings.xml b/profile/src/main/res/values-uk/strings.xml index 691a8fed1..123d4aee6 100644 --- a/profile/src/main/res/values-uk/strings.xml +++ b/profile/src/main/res/values-uk/strings.xml @@ -11,6 +11,7 @@ Повний профіль Обмежений профіль Редагувати профіль + Редагувати Зберегти Видалити профіль Вам повинно бути не менше 13 років, щоб мати повний доступ до інформації в профілі diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 9800d9820..9ad0c47c9 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -8,16 +8,17 @@ Year of birth: %1$s Log out? Are you sure you want to log out? - Full Profile - Limited Profile - Edit Profile + Full profile + Limited profile + Edit profile + Edit Save - Delete Account + Delete account You must be over 13 years old to have a profile with full access to information Year of birth Location - About Me - Spoken Language + About me + Spoken language Switch to full profile Switch to limited profile Oh, sorry @@ -41,6 +42,6 @@ Leave profile? Leave Keep editing - Changes you may made may not be saved. + Changes you have made may not be saved. \ No newline at end of file diff --git a/profile/src/test/java/com/raccoongang/profile/presentation/edit/EditProfileViewModelTest.kt b/profile/src/test/java/com/raccoongang/profile/presentation/edit/EditProfileViewModelTest.kt index b687b798b..41ec01e2e 100644 --- a/profile/src/test/java/com/raccoongang/profile/presentation/edit/EditProfileViewModelTest.kt +++ b/profile/src/test/java/com/raccoongang/profile/presentation/edit/EditProfileViewModelTest.kt @@ -4,13 +4,12 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.raccoongang.core.R import com.raccoongang.core.UIMessage import com.raccoongang.core.domain.model.Account +import com.raccoongang.core.domain.model.ProfileImage import com.raccoongang.core.system.ResourceManager import com.raccoongang.profile.domain.interactor.ProfileInteractor +import com.raccoongang.profile.system.notifier.AccountUpdated import com.raccoongang.profile.system.notifier.ProfileNotifier -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk +import io.mockk.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.* @@ -35,7 +34,24 @@ class EditProfileViewModelTest { private val interactor = mockk() private val notifier = mockk() - private val account = mockk() + private val account = Account( + username = "thom84", + bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", + requiresParentalConsent = true, + name = "Thomas", + country = "Ukraine", + isActive = true, + profileImage = ProfileImage("", "", "", "", false), + yearOfBirth = 2000, + levelOfEducation = "Bachelor", + goals = "130", + languageProficiencies = emptyList(), + gender = "male", + mailingAddress = "", + "", + null, + accountPrivacy = Account.Privacy.ALL_USERS + ) private val file = mockk() private val noInternet = "Slow or no internet connection" @@ -57,7 +73,6 @@ class EditProfileViewModelTest { fun `updateAccount no internet connection`() = runTest { val viewModel = EditProfileViewModel(interactor, resourceManager, notifier, account) coEvery { interactor.updateAccount(any()) } throws UnknownHostException() - every { account.copy() } returns account viewModel.updateAccount(emptyMap()) advanceUntilIdle() @@ -72,7 +87,7 @@ class EditProfileViewModelTest { fun `updateAccount unknown exception`() = runTest { val viewModel = EditProfileViewModel(interactor, resourceManager, notifier, account) coEvery { interactor.updateAccount(any()) } throws Exception() - every { account.copy() } returns account + viewModel.updateAccount(emptyMap()) advanceUntilIdle() @@ -87,8 +102,7 @@ class EditProfileViewModelTest { fun `updateAccount success`() = runTest { val viewModel = EditProfileViewModel(interactor, resourceManager, notifier, account) coEvery { interactor.updateAccount(any()) } returns account - coEvery { notifier.send(any()) } returns Unit - every { account.copy() } returns account + coEvery { notifier.send(any()) } returns Unit viewModel.updateAccount(emptyMap()) advanceUntilIdle() @@ -103,8 +117,8 @@ class EditProfileViewModelTest { val viewModel = EditProfileViewModel(interactor, resourceManager, notifier, account) coEvery { interactor.setProfileImage(any(), any()) } throws UnknownHostException() coEvery { interactor.updateAccount(any()) } returns account - coEvery { notifier.send(any()) } returns Unit - every { account.copy() } returns account + coEvery { notifier.send(AccountUpdated()) } returns Unit + viewModel.updateAccountAndImage(emptyMap(), file, "") advanceUntilIdle() @@ -122,8 +136,8 @@ class EditProfileViewModelTest { val viewModel = EditProfileViewModel(interactor, resourceManager, notifier, account) coEvery { interactor.setProfileImage(any(), any()) } throws Exception() coEvery { interactor.updateAccount(any()) } returns account - coEvery { notifier.send(any()) } returns Unit - every { account.copy() } returns account + coEvery { notifier.send(AccountUpdated()) } returns Unit + viewModel.updateAccountAndImage(emptyMap(), file, "") advanceUntilIdle() @@ -141,8 +155,8 @@ class EditProfileViewModelTest { val viewModel = EditProfileViewModel(interactor, resourceManager, notifier, account) coEvery { interactor.setProfileImage(any(), any()) } returns Unit coEvery { interactor.updateAccount(any()) } returns account - coEvery { notifier.send(any()) } returns Unit - every { account.copy() } returns account + coEvery { notifier.send(any()) } returns Unit + viewModel.updateAccountAndImage(emptyMap(), file, "") advanceUntilIdle() diff --git a/profile/src/test/java/com/raccoongang/profile/presentation/profile/ProfileViewModelTest.kt b/profile/src/test/java/com/raccoongang/profile/presentation/profile/ProfileViewModelTest.kt index 33fe425e1..ae7a671fe 100644 --- a/profile/src/test/java/com/raccoongang/profile/presentation/profile/ProfileViewModelTest.kt +++ b/profile/src/test/java/com/raccoongang/profile/presentation/profile/ProfileViewModelTest.kt @@ -8,6 +8,7 @@ import com.raccoongang.core.R import com.raccoongang.core.UIMessage import com.raccoongang.core.data.storage.PreferencesManager import com.raccoongang.core.domain.model.Account +import com.raccoongang.core.module.DownloadWorkerController import com.raccoongang.core.system.AppCookieManager import com.raccoongang.core.system.ResourceManager import com.raccoongang.profile.domain.interactor.ProfileInteractor @@ -43,6 +44,7 @@ class ProfileViewModelTest { private val interactor = mockk() private val notifier = mockk() private val cookieManager = mockk() + private val workerController = mockk() private val account = mockk() @@ -64,7 +66,15 @@ class ProfileViewModelTest { @Test fun `getAccount no internetConnection`() = runTest { val viewModel = - ProfileViewModel(interactor, preferencesManager, resourceManager, notifier, dispatcher, cookieManager) + ProfileViewModel( + interactor, + preferencesManager, + resourceManager, + notifier, + dispatcher, + cookieManager, + workerController + ) coEvery { interactor.getAccount() } throws UnknownHostException() advanceUntilIdle() @@ -78,7 +88,15 @@ class ProfileViewModelTest { @Test fun `getAccount unknown exception`() = runTest { val viewModel = - ProfileViewModel(interactor, preferencesManager, resourceManager, notifier, dispatcher, cookieManager) + ProfileViewModel( + interactor, + preferencesManager, + resourceManager, + notifier, + dispatcher, + cookieManager, + workerController + ) coEvery { interactor.getAccount() } throws Exception() advanceUntilIdle() @@ -92,7 +110,15 @@ class ProfileViewModelTest { @Test fun `getAccount success`() = runTest { val viewModel = - ProfileViewModel(interactor, preferencesManager, resourceManager, notifier, dispatcher, cookieManager) + ProfileViewModel( + interactor, + preferencesManager, + resourceManager, + notifier, + dispatcher, + cookieManager, + workerController + ) coEvery { interactor.getAccount() } returns account every { preferencesManager.profile = any() } returns Unit advanceUntilIdle() @@ -106,8 +132,17 @@ class ProfileViewModelTest { @Test fun `logout no internet connection`() = runTest { val viewModel = - ProfileViewModel(interactor, preferencesManager, resourceManager, notifier, dispatcher, cookieManager) + ProfileViewModel( + interactor, + preferencesManager, + resourceManager, + notifier, + dispatcher, + cookieManager, + workerController + ) coEvery { interactor.logout() } throws UnknownHostException() + coEvery { workerController.cancelWork() } returns Unit viewModel.logout() advanceUntilIdle() @@ -122,8 +157,17 @@ class ProfileViewModelTest { @Test fun `logout unknown exception`() = runTest { val viewModel = - ProfileViewModel(interactor, preferencesManager, resourceManager, notifier, dispatcher, cookieManager) + ProfileViewModel( + interactor, + preferencesManager, + resourceManager, + notifier, + dispatcher, + cookieManager, + workerController + ) coEvery { interactor.logout() } throws Exception() + coEvery { workerController.cancelWork() } returns Unit viewModel.logout() advanceUntilIdle() @@ -142,11 +186,13 @@ class ProfileViewModelTest { resourceManager, notifier, dispatcherIO, - cookieManager + cookieManager, + workerController ) coEvery { interactor.getAccount() } returns mockk() every { preferencesManager.profile = any() } returns Unit coEvery { interactor.logout() } returns Unit + coEvery { workerController.cancelWork() } returns Unit every { cookieManager.clearWebViewCookie() } returns Unit viewModel.logout() advanceUntilIdle() @@ -165,7 +211,8 @@ class ProfileViewModelTest { resourceManager, notifier, dispatcherIO, - cookieManager + cookieManager, + workerController ) every { notifier.notifier } returns flow { emit(AccountUpdated()) } val mockLifeCycleOwner: LifecycleOwner = mockk()