diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 0efcb7d54..6ad466e90 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -320,6 +320,15 @@ class AnalyticsManager(context: Context) : DashboardAnalytics, AuthAnalytics, Ap ) } + override fun datesTabClickedEvent(courseId: String, courseName: String) { + logEvent( + Event.DATES_TAB_CLICKED, bundleOf( + Key.COURSE_ID.keyName to courseId, + Key.COURSE_NAME.keyName to courseName + ) + ) + } + override fun handoutsTabClickedEvent(courseId: String, courseName: String) { logEvent( Event.HANDOUTS_TAB_CLICKED, bundleOf( @@ -402,6 +411,7 @@ private enum class Event(val eventName: String) { COURSE_TAB_CLICKED("Course_Outline_Course_tab_Clicked"), VIDEO_TAB_CLICKED("Course_Outline_Videos_tab_Clicked"), DISCUSSION_TAB_CLICKED("Course_Outline_Discussion_tab_Clicked"), + DATES_TAB_CLICKED("Course_Outline_Dates_tab_Clicked"), HANDOUTS_TAB_CLICKED("Course_Outline_Handouts_tab_Clicked"), DISCUSSION_ALL_POSTS_CLICKED("Discussion_All_Posts_Clicked"), DISCUSSION_FOLLOWING_CLICKED("Discussion_Following_Clicked"), diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 979b9589c..94c219d88 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -15,11 +15,14 @@ import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogView import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.container.CourseContainerViewModel +import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.detail.CourseDetailsViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel import org.openedx.course.presentation.outline.CourseOutlineViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel +import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel +import org.openedx.course.presentation.unit.video.VideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoViewModel import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.dashboard.data.repository.DashboardRepository @@ -41,14 +44,12 @@ import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel import org.openedx.profile.data.repository.ProfileRepository import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Account +import org.openedx.profile.presentation.anothers_account.AnothersProfileViewModel import org.openedx.profile.presentation.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel import org.openedx.profile.presentation.profile.ProfileViewModel import org.openedx.profile.presentation.settings.video.VideoQualityViewModel import org.openedx.profile.presentation.settings.video.VideoSettingsViewModel -import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel -import org.openedx.course.presentation.unit.video.VideoUnitViewModel -import org.openedx.profile.presentation.anothers_account.AnothersProfileViewModel import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { @@ -91,6 +92,7 @@ val screenModule = module { viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get()) } viewModel { (courseId: String) -> VideoUnitViewModel(courseId, get(), get(), get(), get()) } viewModel { (courseId: String, blockId: String) -> EncodedVideoUnitViewModel(courseId, blockId, get(), get(), get(), get(), get(), get()) } + viewModel { (courseId: String) -> CourseDatesViewModel(courseId, get(), get(), get()) } viewModel { (courseId:String, handoutsType: String) -> HandoutsViewModel(courseId, handoutsType, get()) } viewModel { CourseSearchViewModel(get(), get(), get()) } viewModel { SelectDialogViewModel(get()) } diff --git a/build.gradle b/build.gradle index 81792e08f..7f14613b6 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,8 @@ ext { window_version = '1.1.0' + extented_spans_version = "1.3.0" + //testing mockk_version = '1.13.3' android_arch_version = '2.2.0' diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 89538252d..9ea1ead5a 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -1,7 +1,7 @@ package org.openedx.core.data.api -import org.openedx.core.data.model.* import okhttp3.ResponseBody +import org.openedx.core.data.model.* import retrofit2.http.* interface CourseApi { @@ -65,6 +65,9 @@ interface CourseApi { blocksCompletionBody: BlocksCompletionBody ) + @GET("/api/course_home/v1/dates/{course_id}") + suspend fun getCourseDates(@Path("course_id") courseId: String): CourseDates + @GET("/api/mobile/v1/course_info/{course_id}/handouts") suspend fun getHandouts(@Path("course_id") courseId: String): HandoutsModel diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt new file mode 100644 index 000000000..887112845 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt @@ -0,0 +1,28 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import java.util.* + +data class CourseDateBlock( + @SerializedName("complete") + val complete: Boolean = false, + @SerializedName("date") + val date: String = "", // ISO 8601 compliant format + @SerializedName("assignment_type") + val assignmentType: String? = "", + @SerializedName("date_type") + val dateType: DateType = DateType.NONE, + @SerializedName("description") + val description: String = "", + @SerializedName("learner_has_access") + val learnerHasAccess: Boolean = false, + @SerializedName("link") + val link: String = "", + @SerializedName("link_text") + val linkText: String = "", + @SerializedName("title") + val title: String = "", + // component blockId in-case of navigating inside the app for component available in mobile + @SerializedName("first_component_block_id") + val blockId: String = "", +) diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDates.kt b/core/src/main/java/org/openedx/core/data/model/CourseDates.kt new file mode 100644 index 000000000..b1c9ca951 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDates.kt @@ -0,0 +1,163 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.presentation.course.CourseDatesBadge +import org.openedx.core.utils.TimeUtils +import java.util.Date +import org.openedx.core.domain.model.CourseDateBlock as DomainCourseDateBlock + +data class CourseDates( + @SerializedName("dates_banner_info") + val datesBannerInfo: CourseDatesBannerInfo?, + @SerializedName("course_date_blocks") + val courseDateBlocks: List, + @SerializedName("missed_deadlines") + val missedDeadlines: Boolean = false, + @SerializedName("missed_gated_content") + val missedGatedContent: Boolean = false, + @SerializedName("learner_is_full_access") + val learnerIsFullAccess: Boolean = false, + @SerializedName("user_timezone") + val userTimezone: String? = "", + @SerializedName("verified_upgrade_link") + val verifiedUpgradeLink: String? = "", +) { + fun mapToDomain(): LinkedHashMap> { + var courseDatesDomain = organiseCourseDatesInBlock() + if (isContainToday().not()) { + // Adding today's date block manually if not present in the date + val todayBlock = DomainCourseDateBlock.getTodayDateBlock() + courseDatesDomain[TimeUtils.formatDate(TimeUtils.FORMAT_DATE, todayBlock.date)] = + arrayListOf(todayBlock) + } + // Sort the map entries date keys wise + courseDatesDomain = LinkedHashMap(courseDatesDomain.toSortedMap(compareBy { + TimeUtils.stringToDate(TimeUtils.FORMAT_DATE, it) + })) + reviseDateBlockBadge(courseDatesDomain) + return courseDatesDomain + } + + /** + * Map the date blocks according to dates and stack all the blocks of same date against one key + */ + private fun organiseCourseDatesInBlock(): LinkedHashMap> { + val courseDates = + LinkedHashMap>() + courseDateBlocks.forEach { item -> + val key = + TimeUtils.formatDate(TimeUtils.FORMAT_DATE, TimeUtils.iso8601ToDate(item.date)) + val dateBlock = DomainCourseDateBlock( + title = item.title, + description = item.description, + link = item.link, + blockId = item.blockId, + date = TimeUtils.iso8601ToDate(item.date), + complete = item.complete, + learnerHasAccess = item.learnerHasAccess, + dateType = item.dateType, + dateBlockBadge = CourseDatesBadge.BLANK + ) + if (courseDates.containsKey(key)) { + (courseDates[key] as ArrayList).add(dateBlock) + } else { + courseDates[key] = arrayListOf(dateBlock) + } + } + return courseDates + } + + /** + * Utility method to check that list contains today's date block or not. + */ + private fun isContainToday(): Boolean { + val today = Date() + return courseDateBlocks.any { blockDate -> + TimeUtils.iso8601ToDate(blockDate.date) == today + } + } + + /** + * Set the Date Block Badge based on the date block data + */ + private fun reviseDateBlockBadge(courseDatesDomain: LinkedHashMap>) { + var dueNextCount = 0 + courseDatesDomain.keys.forEach { key -> + courseDatesDomain[key]?.forEach { item -> + var dateBlockTag: CourseDatesBadge = getDateTypeBadge(item) + //Setting Due Next only for first occurrence + if (dateBlockTag == CourseDatesBadge.DUE_NEXT) { + if (dueNextCount == 0) + dueNextCount += 1 + else + dateBlockTag = CourseDatesBadge.BLANK + } + item.dateBlockBadge = dateBlockTag + } + } + } + + /** + * Return Pill/Badge type of date block based on data + */ + private fun getDateTypeBadge(item: DomainCourseDateBlock): CourseDatesBadge { + val dateBlockTag: CourseDatesBadge + val currentDate = Date() + val componentDate: Date = item.date ?: return CourseDatesBadge.BLANK + when (item.dateType) { + DateType.TODAY_DATE -> { + dateBlockTag = CourseDatesBadge.TODAY + } + + DateType.COURSE_START_DATE, + DateType.COURSE_END_DATE -> { + dateBlockTag = CourseDatesBadge.BLANK + } + + DateType.ASSIGNMENT_DUE_DATE -> { + when { + item.complete -> { + dateBlockTag = CourseDatesBadge.COMPLETED + } + + item.learnerHasAccess -> { + dateBlockTag = when { + item.link.isEmpty() -> { + CourseDatesBadge.NOT_YET_RELEASED + } + + TimeUtils.isDueDate(currentDate, componentDate) -> { + CourseDatesBadge.DUE_NEXT + } + + TimeUtils.isDatePassed(currentDate, componentDate) -> { + CourseDatesBadge.PAST_DUE + } + + else -> { + CourseDatesBadge.BLANK + } + } + } + + else -> { + dateBlockTag = CourseDatesBadge.VERIFIED_ONLY + } + } + } + + DateType.COURSE_EXPIRED_DATE -> { + dateBlockTag = CourseDatesBadge.COURSE_EXPIRED_DATE + } + + else -> { + // dateBlockTag is BLANK for all other cases + // DateTypes.CERTIFICATE_AVAILABLE_DATE, + // DateTypes.VERIFIED_UPGRADE_DEADLINE, + // DateTypes.VERIFICATION_DEADLINE_DATE + dateBlockTag = CourseDatesBadge.BLANK + } + } + return dateBlockTag + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt b/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt new file mode 100644 index 000000000..f3363dfed --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt @@ -0,0 +1,36 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName + +data class CourseDatesBannerInfo( + @SerializedName("missed_deadlines") + val missedDeadlines: Boolean = false, + @SerializedName("missed_gated_content") + val missedGatedContent: Boolean = false, + @SerializedName("verified_upgrade_link") + val verifiedUpgradeLink: String = "", + @SerializedName("content_type_gating_enabled") + val contentTypeGatingEnabled: Boolean = false, +) { + fun getCourseBannerType(): CourseBannerType = when { + upgradeToGraded() -> CourseBannerType.UPGRADE_TO_GRADED + upgradeToReset() -> CourseBannerType.UPGRADE_TO_RESET + resetDates() -> CourseBannerType.RESET_DATES + showBannerInfo() -> CourseBannerType.INFO_BANNER + else -> CourseBannerType.BLANK + } + + private fun showBannerInfo(): Boolean = missedDeadlines.not() + + private fun upgradeToGraded(): Boolean = contentTypeGatingEnabled && missedDeadlines.not() + + private fun upgradeToReset(): Boolean = + upgradeToGraded().not() && missedDeadlines && missedGatedContent + + private fun resetDates(): Boolean = + upgradeToGraded().not() && missedDeadlines && missedGatedContent.not() +} + +enum class CourseBannerType { + BLANK, INFO_BANNER, UPGRADE_TO_GRADED, UPGRADE_TO_RESET, RESET_DATES; +} diff --git a/core/src/main/java/org/openedx/core/data/model/DateType.kt b/core/src/main/java/org/openedx/core/data/model/DateType.kt new file mode 100644 index 000000000..e9af5256b --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/DateType.kt @@ -0,0 +1,31 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName + +enum class DateType { + @SerializedName("todays-date") + TODAY_DATE, + + @SerializedName("course-start-date") + COURSE_START_DATE, + + @SerializedName("course-end-date") + COURSE_END_DATE, + + @SerializedName("course-expired-date") + COURSE_EXPIRED_DATE, + + @SerializedName("assignment-due-date") + ASSIGNMENT_DUE_DATE, + + @SerializedName("certificate-available-date") + CERTIFICATE_AVAILABLE_DATE, + + @SerializedName("verified-upgrade-deadline") + VERIFIED_UPGRADE_DEADLINE, + + @SerializedName("verification-deadline-date") + VERIFICATION_DEADLINE_DATE, + + NONE, +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt new file mode 100644 index 000000000..60f3a49ba --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt @@ -0,0 +1,26 @@ +package org.openedx.core.domain.model + +import org.openedx.core.data.model.DateType +import org.openedx.core.presentation.course.CourseDatesBadge +import java.util.Date + +data class CourseDateBlock( + val title: String = "", + val description: String = "", + val link: String = "", + val blockId: String = "", + val learnerHasAccess: Boolean = false, + val complete: Boolean = false, + val date: Date?, + val dateType: DateType = DateType.NONE, + var dateBlockBadge: CourseDatesBadge = CourseDatesBadge.BLANK, +) { + companion object { + fun getTodayDateBlock() = + CourseDateBlock( + date = Date(), + dateType = DateType.TODAY_DATE, + dateBlockBadge = CourseDatesBadge.TODAY + ) + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/course/CourseDatesBadge.kt b/core/src/main/java/org/openedx/core/presentation/course/CourseDatesBadge.kt new file mode 100644 index 000000000..5c5da0b42 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/course/CourseDatesBadge.kt @@ -0,0 +1,26 @@ +package org.openedx.core.presentation.course + +import org.openedx.core.R + +/** + * This enum defines the Date type of Course Dates + */ +enum class CourseDatesBadge { + TODAY, BLANK, VERIFIED_ONLY, COMPLETED, PAST_DUE, DUE_NEXT, NOT_YET_RELEASED, + COURSE_EXPIRED_DATE; + + /** + * @return The string resource's ID if it's a valid enum inside [CourseDatesBadge], otherwise -1. + */ + fun getStringResIdForDateType(): Int { + return when (this) { + TODAY -> R.string.core_date_type_today + VERIFIED_ONLY -> R.string.core_date_type_verified_only + COMPLETED -> R.string.core_date_type_completed + PAST_DUE -> R.string.core_date_type_past_due + DUE_NEXT -> R.string.core_date_type_due_next + NOT_YET_RELEASED -> R.string.core_date_type_not_yet_released + else -> -1 + } + } +} diff --git a/core/src/main/java/org/openedx/core/ui/theme/Color.kt b/core/src/main/java/org/openedx/core/ui/theme/Color.kt index b84774bd1..2cfcd1bad 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Color.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Color.kt @@ -31,7 +31,15 @@ data class AppColors( val warning: Color, val info: Color, - val accessGreen:Color + val accessGreen: Color, + + val datesBadgeDefault: Color, + val datesBadgeTextDefault: Color, + val datesBadgePastDue: Color, + val datesBadgeToday: Color, + val datesBadgeTextToday: Color, // also used for locked date block + val datesBadgeDue: Color, // Also used for not release date block text and stoke + val datesBadgeTextDue: Color // Also used for locked date block color ) { val primary: Color get() = material.primary val primaryVariant: Color get() = material.primaryVariant diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index c7ee45e61..96711ca6c 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -52,7 +52,15 @@ private val DarkColorPalette = AppColors( warning = Color(0xFFFFC248), info = Color(0xFF0095FF), - accessGreen = Color(0xFF23BCA0) + accessGreen = Color(0xFF23BCA0), + + datesBadgeDefault = Color(0xFFF2F0EF), + datesBadgeTextDefault = Color(0xFF454545), + datesBadgePastDue = Color(0xFFD7D3D1), + datesBadgeToday = Color(0xFFD6B600), + datesBadgeTextToday = Color(0xFF000000), + datesBadgeDue = Color(0xFF707070), + datesBadgeTextDue = Color(0xFFFFFFFF) ) private val LightColorPalette = AppColors( @@ -96,7 +104,15 @@ private val LightColorPalette = AppColors( warning = Color(0xFFFFC94D), info = Color(0xFF42AAFF), - accessGreen = Color(0xFF23BCA0) + accessGreen = Color(0xFF23BCA0), + + datesBadgeDefault = Color(0xFFF2F0EF), + datesBadgeTextDefault = Color(0xFF454545), + datesBadgePastDue = Color(0xFFD7D3D1), + datesBadgeToday = Color(0xFFD6B600), + datesBadgeTextToday = Color(0xFF000000), + datesBadgeDue = Color(0xFF707070), + datesBadgeTextDue = Color(0xFFFFFFFF) ) val MaterialTheme.appColors: AppColors diff --git a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index dc31f7102..b70d9c7b7 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -2,26 +2,30 @@ package org.openedx.core.utils import android.content.Context import android.text.format.DateUtils +import com.google.gson.internal.bind.util.ISO8601Utils import org.openedx.core.R import org.openedx.core.domain.model.StartType import org.openedx.core.system.ResourceManager import java.text.ParseException +import java.text.ParsePosition import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale object TimeUtils { private const val FORMAT_ISO_8601 = "yyyy-MM-dd'T'HH:mm:ss'Z'" private const val FORMAT_ISO_8601_WITH_TIME_ZONE = "yyyy-MM-dd'T'HH:mm:ssXXX" private const val FORMAT_APPLICATION = "dd.MM.yyyy HH:mm" - private const val FORMAT_DATE = "dd MMM, yyyy" + const val FORMAT_DATE = "dd MMM, yyyy" + const val FORMAT_DATE_TAB = "EEE, MMM dd, yyyy" private const val SEVEN_DAYS_IN_MILLIS = 604800000L fun iso8601ToDate(text: String): Date? { return try { - val sdf = SimpleDateFormat(FORMAT_ISO_8601, Locale.getDefault()) - sdf.parse(text) + val parsePosition = ParsePosition(0) + return ISO8601Utils.parse(text, parsePosition) } catch (e: ParseException) { null } @@ -36,10 +40,21 @@ object TimeUtils { } } - fun iso8601ToDateWithTime(context: Context,text: String): String { + /** + * This method used to convert the date to ISO 8601 compliant format date string + * @param date [Date]needs to be converted + * @return The current date and time in a ISO 8601 compliant format. + */ + fun dateToIso8601(date: Date?): String { + return ISO8601Utils.format(date, true) + } + + fun iso8601ToDateWithTime(context: Context, text: String): String { return try { val courseDateFormat = SimpleDateFormat(FORMAT_ISO_8601, Locale.getDefault()) - val applicationDateFormat = SimpleDateFormat(context.getString(R.string.core_full_date_with_time), Locale.getDefault()) + val applicationDateFormat = SimpleDateFormat( + context.getString(R.string.core_full_date_with_time), Locale.getDefault() + ) applicationDateFormat.format(courseDateFormat.parse(text)!!) } catch (e: Exception) { e.printStackTrace() @@ -48,16 +63,30 @@ object TimeUtils { } fun dateToCourseDate(resourceManager: ResourceManager, date: Date?): String { + return formatDate( + format = resourceManager.getString(R.string.core_date_format_MMMM_dd), date = date + ) + } + + fun formatDate(format: String, date: String): String { + return formatDate(format, iso8601ToDate(date)) + } + + fun formatDate(format: String, date: Date?): String { if (date == null) { return "" } - val sdf = SimpleDateFormat( - resourceManager.getString(R.string.core_date_format_MMMM_dd), - Locale.getDefault() - ) + val sdf = SimpleDateFormat(format, Locale.getDefault()) return sdf.format(date) } + fun stringToDate(dateFormat: String, date: String): Date? { + if (dateFormat.isEmpty() || date.isEmpty()) { + return null + } + return SimpleDateFormat(dateFormat, Locale.getDefault()).parse(date) + } + /** * Checks if the given date is past today. * @@ -70,6 +99,25 @@ object TimeUtils { return otherDate != null && today.after(otherDate) } + /** + * This function compare the provide date with current date + * @param today Today's date. + * @param otherDate Other date to cross-match with today's date. + * @return true if the other date is due today, + */ + fun isDueDate(today: Date, otherDate: Date?): Boolean { + return otherDate != null && today.before(otherDate) + } + + /** + * This function compare the provide date are same + * @return true if the provided date are same else false + */ + fun areDatesSame(date: Date?, otherDate: Date?): Boolean { + return date != null && otherDate != null && + formatDate(FORMAT_DATE, date) == formatDate(FORMAT_DATE, otherDate) + } + fun getCourseFormattedDate( context: Context, today: Date, @@ -79,7 +127,7 @@ object TimeUtils { startType: String, startDisplay: String ): String { - var formattedDate = "" + val formattedDate: String val resourceManager = ResourceManager(context) if (isDatePassed(today, start)) { @@ -98,8 +146,10 @@ object TimeUtils { ) } else { val timeSpan = DateUtils.getRelativeTimeSpanString( - expiry.time, today.time, - DateUtils.SECOND_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE + expiry.time, + today.time, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE ).toString() resourceManager.getString(R.string.core_label_expired, timeSpan) } @@ -111,8 +161,10 @@ object TimeUtils { ) } else { val timeSpan = DateUtils.getRelativeTimeSpanString( - expiry.time, today.time, - DateUtils.SECOND_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE + expiry.time, + today.time, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE ).toString() resourceManager.getString(R.string.core_label_expires, timeSpan) } @@ -131,13 +183,11 @@ object TimeUtils { } } else if (isDatePassed(today, end)) { resourceManager.getString( - R.string.core_label_ended, - dateToCourseDate(resourceManager, end) + R.string.core_label_ended, dateToCourseDate(resourceManager, end) ) } else { resourceManager.getString( - R.string.core_label_ending, - dateToCourseDate(resourceManager, end) + R.string.core_label_ending, dateToCourseDate(resourceManager, end) ) } } @@ -155,5 +205,4 @@ object TimeUtils { } return formattedDate } - -} \ No newline at end of file +} diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index f2584d60a..e4ed83ff7 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - + @string/platform_name Results @@ -54,6 +54,21 @@ Tap to update to version %1$s Tap to install required app update + + + Today + + Verified Only + + Completed + + Past Due + + Due Next + + Not Yet Released + + %1$s profile image - \ No newline at end of file + diff --git a/course/build.gradle b/course/build.gradle index fdf14b83d..f746f4d09 100644 --- a/course/build.gradle +++ b/course/build.gradle @@ -65,6 +65,7 @@ dependencies { implementation "androidx.media3:media3-exoplayer-hls:$media3_version" implementation "androidx.media3:media3-ui:$media3_version" implementation "androidx.media3:media3-cast:$media3_version" + implementation "me.saket.extendedspans:extendedspans:$extented_spans_version" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index f31d31bdf..637a5ad7b 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -1,5 +1,7 @@ package org.openedx.course.data.repository +import kotlinx.coroutines.flow.map +import okhttp3.ResponseBody import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.EnrollBody @@ -9,8 +11,6 @@ import org.openedx.core.domain.model.* import org.openedx.core.exception.NoCachedDataException import org.openedx.core.module.db.DownloadDao import org.openedx.course.data.storage.CourseDao -import kotlinx.coroutines.flow.map -import okhttp3.ResponseBody class CourseRepository( private val api: CourseApi, @@ -99,9 +99,10 @@ class CourseRepository( return api.markBlocksCompletion(blocksCompletionBody) } + suspend fun getCourseDates(courseId: String) = api.getCourseDates(courseId).mapToDomain() + suspend fun getHandouts(courseId: String) = api.getHandouts(courseId).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/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index ac8d163ec..8bcd2c40a 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -71,6 +71,8 @@ class CourseInteractor( suspend fun getCourseStatus(courseId: String) = repository.getCourseStatus(courseId) + suspend fun getCourseDates(courseId: String) = repository.getCourseDates(courseId) + suspend fun getHandouts(courseId: String) = repository.getHandouts(courseId) suspend fun getAnnouncements(courseId: String) = repository.getAnnouncements(courseId) diff --git a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt index 5cea3d63f..cdae67678 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -15,5 +15,6 @@ interface CourseAnalytics { fun courseTabClickedEvent(courseId: String, courseName: String) fun videoTabClickedEvent(courseId: String, courseName: String) fun discussionTabClickedEvent(courseId: String, courseName: String) + fun datesTabClickedEvent(courseId: String, courseName: String) fun handoutsTabClickedEvent(courseId: String, courseName: String) -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index 17363a01b..acefd4020 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -7,17 +7,18 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.snackbar.Snackbar +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf import org.openedx.core.presentation.global.viewBinding import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.dates.CourseDatesFragment import org.openedx.course.presentation.handouts.HandoutsFragment import org.openedx.course.presentation.outline.CourseOutlineFragment import org.openedx.course.presentation.videos.CourseVideosFragment import org.openedx.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 class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -64,9 +65,14 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { binding.viewPager.setCurrentItem(2, false) } + R.id.dates -> { + viewModel.datesTabClickedEvent() + binding.viewPager.setCurrentItem(3, false) + } + R.id.resources -> { viewModel.handoutsTabClickedEvent() - binding.viewPager.setCurrentItem(3, false) + binding.viewPager.setCurrentItem(4, false) } } true @@ -110,13 +116,14 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun initViewPager() { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL - binding.viewPager.offscreenPageLimit = 4 adapter = CourseContainerAdapter(this).apply { addFragment(CourseOutlineFragment.newInstance(viewModel.courseId, courseTitle)) addFragment(CourseVideosFragment.newInstance(viewModel.courseId, courseTitle)) addFragment(DiscussionTopicsFragment.newInstance(viewModel.courseId, courseTitle)) + addFragment(CourseDatesFragment.newInstance(viewModel.courseId, courseTitle)) addFragment(HandoutsFragment.newInstance(viewModel.courseId)) } + binding.viewPager.offscreenPageLimit = adapter?.itemCount ?: 1 binding.viewPager.adapter = adapter binding.viewPager.isUserInputEnabled = false } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index 1fb6e9aa5..33b79e9c1 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -100,6 +100,10 @@ class CourseContainerViewModel( analytics.discussionTabClickedEvent(courseId, courseName) } + fun datesTabClickedEvent() { + analytics.datesTabClickedEvent(courseId, courseName) + } + fun handoutsTabClickedEvent() { analytics.handoutsTabClickedEvent(courseId, courseName) } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt new file mode 100644 index 000000000..89607a653 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt @@ -0,0 +1,693 @@ +package org.openedx.course.presentation.dates + +import android.content.res.Configuration +import android.os.Bundle +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +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.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +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.text.withStyle +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import me.saket.extendedspans.ExtendedSpans +import me.saket.extendedspans.RoundedCornerSpanPainter +import me.saket.extendedspans.drawBehind +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.presentation.course.CourseDatesBadge +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.core.utils.TimeUtils +import org.openedx.course.R +import java.util.Date + +class CourseDatesFragment : Fragment() { + + private val viewModel by viewModel { + parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(viewModel) + with(requireArguments()) { + viewModel.courseTitle = getString(ARG_TITLE, "") + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val uiState by viewModel.uiState.observeAsState() + val uiMessage by viewModel.uiMessage.observeAsState() + val refreshing by viewModel.updating.observeAsState(false) + + CourseDatesScreen(windowSize = windowSize, + uiState = uiState, + courseTitle = viewModel.courseTitle, + uiMessage = uiMessage, + refreshing = refreshing, + hasInternetConnection = viewModel.hasInternetConnection, + onReloadClick = { + viewModel.getCourseDates() + }, + onSwipeRefresh = { + viewModel.getCourseDates() + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }) + } + } + } + + companion object { + private const val ARG_COURSE_ID = "courseId" + private const val ARG_TITLE = "title" + fun newInstance(courseId: String, title: String): CourseDatesFragment { + val fragment = CourseDatesFragment() + fragment.arguments = bundleOf(ARG_COURSE_ID to courseId, ARG_TITLE to title) + return fragment + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun CourseDatesScreen( + windowSize: WindowSize, + uiState: DatesUIState?, + courseTitle: String, + uiMessage: UIMessage?, + refreshing: Boolean, + hasInternetConnection: Boolean, + onReloadClick: () -> Unit, + onSwipeRefresh: () -> Unit, + onBackClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + val pullRefreshState = + rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) + + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + val modifierScreenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + val listBottomPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(bottom = 24.dp), + compact = PaddingValues(bottom = 24.dp) + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .statusBarsInset() + .displayCutoutForLandscape(), contentAlignment = Alignment.TopCenter + ) { + Column( + modifierScreenWidth + ) { + Box( + Modifier + .fillMaxWidth() + .zIndex(1f), contentAlignment = Alignment.CenterStart + ) { + BackBtn { + onBackClick() + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 56.dp), + text = courseTitle, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + Spacer(Modifier.height(6.dp)) + Surface( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.screenBackgroundShape + ) { + Box( + Modifier + .fillMaxWidth() + .pullRefresh(pullRefreshState) + ) { + uiState?.let { + when (uiState) { + is DatesUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + is DatesUIState.Dates -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(10.dp), + contentPadding = listBottomPadding + ) { + itemsIndexed(uiState.courseDates.keys.toList()) { dateIndex, _ -> + CourseDateBlockSection( + courseDates = uiState.courseDates, + dateIndex = dateIndex + ) + } + } + } + + DatesUIState.Empty -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.course_dates_unavailable_message), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) + } + } + } + } + PullRefreshIndicator( + refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter) + ) + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onReloadClick() + }) + } + } + } + } + } + } +} + +@Composable +private fun CourseDateBlockSection( + courseDates: LinkedHashMap>, dateIndex: Int +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(intrinsicSize = IntrinsicSize.Min) // this make height of all cards to the tallest card. + .background(MaterialTheme.appColors.background) + ) { + val dateBlockKey = courseDates.keys.toList()[dateIndex] + val dateBlocks = courseDates[dateBlockKey] + dateBlocks?.let { + val dateBlockItem = courseDates[dateBlockKey]?.get(0) + dateBlockItem?.let { + DateBullet( + isFirstIndex = dateIndex == 0, + isLastIndex = dateIndex == courseDates.size - 1, + dateBlock = dateBlockItem + ) + DateBlock(dateBlocks) + } + } + } +} + +@Composable +private fun DateBullet( + isFirstIndex: Boolean = false, + isLastIndex: Boolean = false, + dateBlock: CourseDateBlock +) { + Column( + modifier = Modifier + .width(40.dp) + .padding(start = 6.dp) + ) { + if (!isFirstIndex) { + Box( + modifier = Modifier + .width(1.dp) + .height(6.dp) + .background(color = MaterialTheme.appColors.datesBadgeTextToday) + .align(Alignment.CenterHorizontally) + ) + } else { + Spacer(modifier = Modifier.height(6.dp)) + } + var circleColor: Color = MaterialTheme.appColors.datesBadgeDefault + var circleSize: Dp = 10.dp + when (dateBlock.dateBlockBadge) { + + CourseDatesBadge.TODAY -> { + circleColor = MaterialTheme.appColors.datesBadgeToday + circleSize = 14.dp + } + + CourseDatesBadge.PAST_DUE -> { + circleColor = MaterialTheme.appColors.datesBadgePastDue + } + + CourseDatesBadge.BLANK, + CourseDatesBadge.COMPLETED, + CourseDatesBadge.DUE_NEXT, + CourseDatesBadge.NOT_YET_RELEASED, + CourseDatesBadge.COURSE_EXPIRED_DATE, + CourseDatesBadge.VERIFIED_ONLY -> { + var isDatePassed = false + dateBlock.date?.let { + isDatePassed = TimeUtils.isDatePassed(Date(), it) + } + circleColor = + if (isDatePassed && (dateBlock.dateBlockBadge == CourseDatesBadge.VERIFIED_ONLY).not()) { + MaterialTheme.appColors.datesBadgePastDue + } else { + MaterialTheme.appColors.datesBadgeTextToday + } + } + + else -> {} + } + Box( + modifier = Modifier + .size(circleSize) + .border(1.dp, MaterialTheme.appColors.datesBadgeTextToday, CircleShape) + .clip(CircleShape) + .background(circleColor) + .align(Alignment.CenterHorizontally) + ) + if (!isLastIndex) { + Box( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(color = MaterialTheme.appColors.datesBadgeTextToday) + .align(Alignment.CenterHorizontally) + ) + } + } +} + +@Composable +private fun DateBlock(dateBlocks: ArrayList) { + Column( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(start = 16.dp, bottom = 30.dp) + ) { + val firstDateBlock = dateBlocks[0] + PlaceDateBadge( + title = TimeUtils.formatDate(TimeUtils.FORMAT_DATE_TAB, firstDateBlock.date), + titleSize = 18.sp, + blockBadge = firstDateBlock.dateBlockBadge + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + val parentBadgeAdded = hasSameDateTypes(dateBlocks) + dateBlocks.forEach { courseDateItem -> + CourseDateItem(courseDateItem, parentBadgeAdded) + } + } + } +} + +/** + * Method to create the Date badge as per given DateType + */ +@Composable +private fun PlaceDateBadge(title: String, titleSize: TextUnit, blockBadge: CourseDatesBadge) { + var badgeBackground: Color = Color.Transparent + var textAppearance: Color = Color.Transparent + var badgeStrokeColor: Color = Color.Transparent + var badgeIcon: Painter? = null + when (blockBadge) { + CourseDatesBadge.TODAY -> { + badgeBackground = MaterialTheme.appColors.datesBadgeToday + textAppearance = MaterialTheme.appColors.datesBadgeTextToday + } + + CourseDatesBadge.VERIFIED_ONLY -> { + badgeBackground = MaterialTheme.appColors.datesBadgeTextToday + textAppearance = MaterialTheme.appColors.datesBadgeTextDue + badgeIcon = painterResource(R.drawable.ic_lock) + } + + CourseDatesBadge.COMPLETED -> { + badgeBackground = MaterialTheme.appColors.datesBadgeDefault + textAppearance = MaterialTheme.appColors.datesBadgeTextDefault + } + + CourseDatesBadge.PAST_DUE -> { + badgeBackground = MaterialTheme.appColors.datesBadgePastDue + textAppearance = MaterialTheme.appColors.datesBadgeTextDefault + } + + CourseDatesBadge.DUE_NEXT -> { + badgeBackground = MaterialTheme.appColors.datesBadgeDue + textAppearance = MaterialTheme.appColors.datesBadgeTextDue + } + + CourseDatesBadge.NOT_YET_RELEASED -> { + badgeBackground = Color.Transparent + textAppearance = MaterialTheme.appColors.datesBadgeDue + badgeStrokeColor = MaterialTheme.appColors.datesBadgeDue + } + + else -> {} + } + val extendedSpans = remember { + ExtendedSpans( + RoundedCornerSpanPainter( + cornerRadius = 6.sp, + padding = RoundedCornerSpanPainter.TextPaddingValues( + horizontal = 8.sp, + vertical = 6.sp + ), topMargin = 5.sp, + bottomMargin = 4.sp, + stroke = RoundedCornerSpanPainter.Stroke( + color = badgeStrokeColor + ) + ) + ) + } + val titleWithBadge = buildAnnotatedString { + append(title) + append(" ") + withStyle( + SpanStyle( + color = textAppearance, + background = badgeBackground, + fontWeight = FontWeight.SemiBold, + fontStyle = FontStyle.Italic, + fontSize = 16.sp + ) + ) { + if (badgeIcon != null) { + appendInlineContent("icon_id") + append(" ") + } + val badgeTitle = blockBadge.getStringResIdForDateType() + if (badgeTitle != -1) { + append(stringResource(id = badgeTitle)) + } + } + } + val inlineContent = HashMap() + badgeIcon?.let { + inlineContent["icon_id"] = InlineTextContent( + Placeholder( + width = 16.sp, + height = 16.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + Icon(badgeIcon, "", tint = MaterialTheme.appColors.datesBadgeTextDue) + } + } + Text( + modifier = Modifier.drawBehind(extendedSpans), + text = remember(titleWithBadge) { + extendedSpans.extend(titleWithBadge) + }, + onTextLayout = { result -> + extendedSpans.onTextLayout(result) + }, + inlineContent = inlineContent, + fontSize = titleSize, + fontWeight = FontWeight.SemiBold, + lineHeight = 22.sp + ) +} + +@Composable +private fun CourseDateItem(courseDateItem: CourseDateBlock, parentBadgeAdded: Boolean) { + Column( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + if (!parentBadgeAdded) { + // Set update badge with sub date items + PlaceDateBadge(courseDateItem.title, 16.sp, courseDateItem.dateBlockBadge) + } else { + Text( + text = courseDateItem.title, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + lineHeight = 20.sp + ) + } + if (!TextUtils.isEmpty(courseDateItem.description)) { + Text( + text = courseDateItem.description, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + lineHeight = 18.sp + ) + } + } +} + +/** + * Method to check that all Date Items have same badge status or not + * + * @return true if all the date items have update badge status else false + * */ +private fun hasSameDateTypes(dateBlockItems: ArrayList?): Boolean { + if (!dateBlockItems.isNullOrEmpty() && dateBlockItems.size > 1) { + val dateType = dateBlockItems.first().dateBlockBadge + for (i in 1 until dateBlockItems.size) { + if (dateBlockItems[i].dateBlockBadge != dateType && dateBlockItems[i].dateBlockBadge != CourseDatesBadge.BLANK) { + return false + } + } + } + return true +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseDatesScreenPreview() { + OpenEdXTheme { + CourseDatesScreen(windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = DatesUIState.Dates(mockedCourseDates), + courseTitle = "Course Dates", + uiMessage = null, + hasInternetConnection = true, + refreshing = false, + onSwipeRefresh = {}, + onReloadClick = {}, + onBackClick = {}) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun CourseDatesScreenTabletPreview() { + OpenEdXTheme { + CourseDatesScreen(windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = DatesUIState.Dates(mockedCourseDates), + courseTitle = "Course Dates", + uiMessage = null, + hasInternetConnection = true, + refreshing = false, + onSwipeRefresh = {}, + onReloadClick = {}, + onBackClick = {}) + } +} + +private var mockedCourseDates = linkedMapOf( + Pair( + "2023-10-20T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Course Start", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.PAST_DUE + ) + ) + ), Pair( + "2023-10-21T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Today", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-21T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.TODAY + ) + ) + ), + Pair( + "2023-10-22T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Due Next", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-22T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.DUE_NEXT + ) + ) + ), Pair( + "2023-10-23T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Assignment Due", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-23T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.VERIFIED_ONLY + ) + ) + ), Pair( + "2023-10-24T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Not Yet Released", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-24T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.NOT_YET_RELEASED + ) + ) + ), Pair( + "2023-10-25T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Blank", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-25T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.BLANK + ) + ) + ), Pair( + "2023-10-26T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Course End", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-26T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.COMPLETED + ) + ) + ) +) diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt new file mode 100644 index 000000000..9f29c223b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -0,0 +1,71 @@ +package org.openedx.course.presentation.dates + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.SingleEventLiveData +import org.openedx.core.UIMessage +import org.openedx.core.extension.isInternetError +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.course.domain.interactor.CourseInteractor + +class CourseDatesViewModel( + val courseId: String, + private val interactor: CourseInteractor, + private val networkConnection: NetworkConnection, + private val resourceManager: ResourceManager, +) : BaseViewModel() { + + private val _uiState = MutableLiveData(DatesUIState.Loading) + val uiState: LiveData + get() = _uiState + + private val _uiMessage = SingleEventLiveData() + val uiMessage: LiveData + get() = _uiMessage + + private val _updating = MutableLiveData() + val updating: LiveData + get() = _updating + + var courseTitle = "" + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + init { + getCourseDates() + } + + fun getCourseDates() { + _uiState.value = DatesUIState.Loading + loadingCourseDatesInternal() + } + + private fun loadingCourseDatesInternal() { + viewModelScope.launch { + try { + _updating.value = true + val datesResponse = interactor.getCourseDates(courseId = courseId) + if (datesResponse.isEmpty()) { + _uiState.value = DatesUIState.Empty + } else { + _uiState.value = DatesUIState.Dates(datesResponse) + } + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.value = + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + } else { + _uiMessage.value = + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + } + } + _updating.value = false + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt new file mode 100644 index 000000000..975748ae4 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt @@ -0,0 +1,11 @@ +package org.openedx.course.presentation.dates + +import org.openedx.core.domain.model.CourseDateBlock + +sealed class DatesUIState { + data class Dates(val courseDates: LinkedHashMap>) : + DatesUIState() + + object Empty : DatesUIState() + object Loading : DatesUIState() +} diff --git a/course/src/main/res/drawable/ic_calendar_month.xml b/course/src/main/res/drawable/ic_calendar_month.xml new file mode 100644 index 000000000..434cf9907 --- /dev/null +++ b/course/src/main/res/drawable/ic_calendar_month.xml @@ -0,0 +1,5 @@ + + + diff --git a/course/src/main/res/drawable/ic_lock.xml b/course/src/main/res/drawable/ic_lock.xml new file mode 100644 index 000000000..68cb9c1f5 --- /dev/null +++ b/course/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/menu/bottom_course_container_menu.xml b/course/src/main/res/menu/bottom_course_container_menu.xml index 8ee927041..e65c6ea5e 100644 --- a/course/src/main/res/menu/bottom_course_container_menu.xml +++ b/course/src/main/res/menu/bottom_course_container_menu.xml @@ -19,6 +19,12 @@ android:enabled="true" android:icon="@drawable/ic_course_navigation_discussions"/> + + - + Enroll now View course Course details @@ -47,4 +47,10 @@ Continue with: Continue To proceed with \"%s\" press \"Next section\". - \ No newline at end of file + Dates + + + + Course dates are not currently available. + + diff --git a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt new file mode 100644 index 000000000..d65f1fa4b --- /dev/null +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -0,0 +1,135 @@ +package org.openedx.course.presentation.dates + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.data.model.DateType +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.course.domain.interactor.CourseInteractor +import java.net.UnknownHostException +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class CourseDatesViewModelTest { + @get:Rule + val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() + + private val dispatcher = StandardTestDispatcher() + + private val resourceManager = mockk() + private val interactor = mockk() + private val networkConnection = mockk() + + private val noInternet = "Slow or no internet connection" + private val somethingWrong = "Something went wrong" + + private val dateBlock = CourseDateBlock( + complete = false, + date = Date(), + dateType = DateType.TODAY_DATE, + description = "Mocked Course Date Description" + ) + private val mockDateBlocks = linkedMapOf( + Pair( + "2023-10-20T15:08:07Z", + arrayListOf(dateBlock, dateBlock) + ), + Pair( + "2023-10-30T15:08:07Z", + arrayListOf(dateBlock, dateBlock) + ) + ) + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet + every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `getCourseDates no internet connection exception`() = runTest { + val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + + Assert.assertEquals(noInternet, message?.message) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Loading) + } + + @Test + fun `getCourseDates unknown exception`() = runTest { + val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } throws Exception() + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + + Assert.assertEquals(somethingWrong, message?.message) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Loading) + } + + @Test + fun `getCourseDates success with internet`() = runTest { + val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } returns mockDateBlocks + + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + assert(viewModel.uiMessage.value == null) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Dates) + } + + @Test + fun `getCourseDates success with EmptyList`() = runTest { + val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } returns linkedMapOf() + + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + assert(viewModel.uiMessage.value == null) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Empty) + } +}