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 index 5c27c44a4..2b01aa43a 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDates.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDates.kt @@ -4,7 +4,9 @@ import com.google.gson.annotations.SerializedName import org.openedx.core.domain.model.DatesSection import org.openedx.core.utils.TimeUtils import org.openedx.core.utils.addDays +import org.openedx.core.utils.clearTime import org.openedx.core.utils.isToday +import java.util.Date import org.openedx.core.domain.model.CourseDateBlock as DomainCourseDateBlock data class CourseDates( @@ -24,7 +26,7 @@ data class CourseDates( val verifiedUpgradeLink: String? = "", ) { fun getStructuredCourseDates(): LinkedHashMap> { - val currentDate = TimeUtils.getCurrentDate() + val currentDate = Date() val courseDatesResponse: LinkedHashMap> = LinkedHashMap() val datesList = mapToDomain() @@ -36,43 +38,44 @@ data class CourseDates( datesList.filter { currentDate.after(it.date) }.also { datesList.removeAll(it) } courseDatesResponse[DatesSection.TODAY] = - datesList.filter { it.date != null && it.date.isToday() } - .also { datesList.removeAll(it) } + datesList.filter { it.date.isToday() }.also { datesList.removeAll(it) } + + //Update the date for upcoming comparison without time + currentDate.clearTime() // for current week except today courseDatesResponse[DatesSection.THIS_WEEK] = datesList.filter { - it.date != null && it.date.after(currentDate) && - it.date.before(currentDate.addDays(8)) + it.date.after(currentDate) && it.date.before(currentDate.addDays(8)) }.also { datesList.removeAll(it) } // for coming week courseDatesResponse[DatesSection.NEXT_WEEK] = datesList.filter { - it.date != null && - it.date.after(currentDate.addDays(7)) && - it.date.before(currentDate.addDays(15)) + it.date.after(currentDate.addDays(7)) && it.date.before(currentDate.addDays(15)) }.also { datesList.removeAll(it) } // for upcoming courseDatesResponse[DatesSection.UPCOMING] = datesList.filter { - it.date != null && it.date.after(currentDate.addDays(14)) + it.date.after(currentDate.addDays(14)) }.also { datesList.removeAll(it) } return courseDatesResponse } private fun mapToDomain(): MutableList { - return courseDateBlocks.map { item -> - 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, - assignmentType = item.assignmentType - ) - }.sortedBy { it.date }.filter { it.date != null }.toMutableList() + return courseDateBlocks.mapNotNull { item -> + TimeUtils.iso8601ToDate(item.date)?.let { date -> + DomainCourseDateBlock( + title = item.title, + description = item.description, + link = item.link, + blockId = item.blockId, + date = date, + complete = item.complete, + learnerHasAccess = item.learnerHasAccess, + dateType = item.dateType, + assignmentType = item.assignmentType + ) + } + }.sortedBy { it.date }.toMutableList() } } 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 index e9af5256b..8604286d7 100644 --- a/core/src/main/java/org/openedx/core/data/model/DateType.kt +++ b/core/src/main/java/org/openedx/core/data/model/DateType.kt @@ -1,31 +1,32 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName +import org.openedx.core.R -enum class DateType { +enum class DateType(val drawableResId: Int? = null) { @SerializedName("todays-date") - TODAY_DATE, + TODAY_DATE(R.drawable.core_ic_calendar), @SerializedName("course-start-date") - COURSE_START_DATE, + COURSE_START_DATE(R.drawable.core_ic_start_end), @SerializedName("course-end-date") - COURSE_END_DATE, + COURSE_END_DATE(R.drawable.core_ic_start_end), @SerializedName("course-expired-date") - COURSE_EXPIRED_DATE, + COURSE_EXPIRED_DATE(R.drawable.core_ic_course_expire), @SerializedName("assignment-due-date") - ASSIGNMENT_DUE_DATE, + ASSIGNMENT_DUE_DATE(R.drawable.core_ic_assignment), @SerializedName("certificate-available-date") - CERTIFICATE_AVAILABLE_DATE, + CERTIFICATE_AVAILABLE_DATE(R.drawable.core_ic_certificate), @SerializedName("verified-upgrade-deadline") - VERIFIED_UPGRADE_DEADLINE, + VERIFIED_UPGRADE_DEADLINE(R.drawable.core_ic_calendar), @SerializedName("verification-deadline-date") - VERIFICATION_DEADLINE_DATE, + VERIFICATION_DEADLINE_DATE(R.drawable.core_ic_calendar), 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 index 62a1ed81d..7e91c59fa 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt @@ -1,6 +1,8 @@ package org.openedx.core.domain.model import org.openedx.core.data.model.DateType +import org.openedx.core.utils.isTimeLessThan24Hours +import org.openedx.core.utils.isToday import java.util.Date data class CourseDateBlock( @@ -10,7 +12,7 @@ data class CourseDateBlock( val blockId: String = "", val learnerHasAccess: Boolean = false, val complete: Boolean = false, - val date: Date?, + val date: Date, val dateType: DateType = DateType.NONE, val assignmentType: String? = "", ) { @@ -19,7 +21,12 @@ data class CourseDateBlock( DateType.COURSE_START_DATE, DateType.COURSE_END_DATE, DateType.CERTIFICATE_AVAILABLE_DATE, - DateType.VERIFICATION_DEADLINE_DATE - ) && date?.before(Date()) == true) + DateType.VERIFIED_UPGRADE_DEADLINE, + DateType.VERIFICATION_DEADLINE_DATE, + ) && date.before(Date())) + } + + fun isTimeDifferenceLessThan24Hours(): Boolean { + return (date.isToday() && date.before(Date())) || date.isTimeLessThan24Hours() } } 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 9964e9705..e85397491 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -22,15 +22,6 @@ object TimeUtils { private const val SEVEN_DAYS_IN_MILLIS = 604800000L - /** - * This method used to get the current date - * @return The current date with time set to midnight. - */ - fun getCurrentDate(): Date { - val calendar = Calendar.getInstance().also { it.clearTimeComponents() } - return calendar.time - } - fun getCurrentTime(): Long { return Calendar.getInstance().timeInMillis } @@ -53,15 +44,6 @@ object TimeUtils { } } - /** - * 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()) @@ -75,17 +57,13 @@ object TimeUtils { } } - fun dateToCourseDate(resourceManager: ResourceManager, date: Date?): String { + private 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 { + private fun formatDate(format: String, date: Date?): String { if (date == null) { return "" } @@ -93,13 +71,6 @@ object TimeUtils { 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. * @@ -200,6 +171,21 @@ object TimeUtils { return formattedDate } + /** + * Method to get the formatted time string in terms of relative time with minimum resolution of minutes. + * For example, if the time difference is 1 minute, it will return "1m ago". + * + * @param date Date object to be formatted. + */ + fun getFormattedTime(date: Date): String { + return DateUtils.getRelativeTimeSpanString( + date.time, + getCurrentTime(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_TIME + ).toString() + } + /** * Returns a formatted date string for the given date. */ @@ -266,7 +252,10 @@ object TimeUtils { } } -// Extension function to clear time components +/** + * Extension function to clear time components of a calendar. + * for example, if the time is 10:30:45, it will set the time to 00:00:00 + */ fun Calendar.clearTimeComponents() { this.set(Calendar.HOUR_OF_DAY, 0) this.set(Calendar.MINUTE, 0) @@ -274,15 +263,20 @@ fun Calendar.clearTimeComponents() { this.set(Calendar.MILLISECOND, 0) } -// Extension function to check if a date is today +/** + * Extension function to check if the given date is today. + */ fun Date.isToday(): Boolean { val calendar = Calendar.getInstance() calendar.time = this calendar.clearTimeComponents() - return calendar.time == TimeUtils.getCurrentDate() + return calendar.time == Date().clearTime() } -// Extension function to add number of days to a date +/** + * Extension function to add days to a date. + * for example, if the date is 2020-01-01 10:30:45, and days is 2, it will return 2020-01-03 00:00:00 + */ fun Date.addDays(days: Int): Date { val calendar = Calendar.getInstance() calendar.time = this @@ -290,3 +284,24 @@ fun Date.addDays(days: Int): Date { calendar.add(Calendar.DATE, days) return calendar.time } + +/** + * Extension function to clear time components of a date. + * for example, if the date is 2020-01-01 10:30:45, it will return 2020-01-01 00:00:00 + */ +fun Date.clearTime(): Date { + val calendar = Calendar.getInstance() + calendar.time = this + calendar.clearTimeComponents() + return calendar.time +} + +/** + * Extension function to check if the time difference between the given date and the current date is less than 24 hours. + */ +fun Date.isTimeLessThan24Hours(): Boolean { + val calendar = Calendar.getInstance() + calendar.time = this + val timeInMillis = (calendar.timeInMillis - TimeUtils.getCurrentTime()).unaryPlus() + return timeInMillis < TimeUnit.DAYS.toMillis(1) +} diff --git a/core/src/main/res/drawable/core_ic_assignment.xml b/core/src/main/res/drawable/core_ic_assignment.xml new file mode 100644 index 000000000..fdd07fd45 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_assignment.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/core/src/main/res/drawable/core_ic_calendar.xml b/core/src/main/res/drawable/core_ic_calendar.xml new file mode 100644 index 000000000..8dcd8c896 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_calendar.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/src/main/res/drawable/core_ic_certificate.xml b/core/src/main/res/drawable/core_ic_certificate.xml new file mode 100644 index 000000000..917b002f7 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_certificate.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/src/main/res/drawable/core_ic_course_expire.xml b/core/src/main/res/drawable/core_ic_course_expire.xml new file mode 100644 index 000000000..f9e6a11ad --- /dev/null +++ b/core/src/main/res/drawable/core_ic_course_expire.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/src/main/res/drawable/core_ic_lock.xml b/core/src/main/res/drawable/core_ic_lock.xml new file mode 100644 index 000000000..d0e0ba4c7 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/src/main/res/drawable/core_ic_start_end.xml b/core/src/main/res/drawable/core_ic_start_end.xml new file mode 100644 index 000000000..e881ea5ca --- /dev/null +++ b/core/src/main/res/drawable/core_ic_start_end.xml @@ -0,0 +1,12 @@ + + + + + + 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 index 45995eed6..53a959a9e 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt @@ -45,9 +45,13 @@ 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.* +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 @@ -56,6 +60,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -73,19 +78,28 @@ 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.UIMessage +import org.openedx.core.data.model.DateType import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.DatesSection import org.openedx.core.extension.isNotEmptyThenLet import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.ui.* +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.R as coreR +import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.TimeUtils +import org.openedx.core.utils.clearTime import org.openedx.course.R import org.openedx.course.presentation.CourseRouter +import org.openedx.core.R as coreR class CourseDatesFragment : Fragment() { @@ -329,7 +343,7 @@ fun ExpandableView( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.appColors.cardViewBackground, MaterialTheme.shapes.medium) - .border(2.dp, MaterialTheme.appColors.cardViewBorder, MaterialTheme.shapes.medium) + .border(0.75.dp, MaterialTheme.appColors.cardViewBorder, MaterialTheme.shapes.medium) ) { Row(modifier = Modifier .fillMaxWidth() @@ -433,7 +447,7 @@ private fun CourseDateBlockSection( if (sectionKey != DatesSection.COMPLETED) { DateBullet(section = sectionKey) } - DateBlock(section = sectionKey, sectionDates, onItemClick) + DateBlock(dateBlocks = sectionDates, onItemClick = onItemClick) } } } @@ -464,7 +478,6 @@ private fun DateBullet( @Composable private fun DateBlock( - section: DatesSection = DatesSection.NONE, dateBlocks: List, onItemClick: (String) -> Unit, ) { @@ -474,9 +487,9 @@ private fun DateBlock( .wrapContentHeight() .padding(start = 8.dp, end = 8.dp), ) { - var lastAssignmentDate = dateBlocks.firstOrNull()?.date - var canShowDate = (section == DatesSection.TODAY).not() + var lastAssignmentDate = dateBlocks.first().date.clearTime() dateBlocks.forEachIndexed { index, dateBlock -> + var canShowDate = index == 0 if (index != 0) { canShowDate = (lastAssignmentDate != dateBlock.date) } @@ -501,9 +514,14 @@ private fun CourseDateItem( if (isMiddleChild) { Spacer(modifier = Modifier.height(20.dp)) } - if (canShowDate && dateBlock.date != null) { + if (canShowDate) { + val timeTitle = if (dateBlock.isTimeDifferenceLessThan24Hours()) { + TimeUtils.getFormattedTime(dateBlock.date) + } else { + TimeUtils.getCourseFormattedDate(LocalContext.current, dateBlock.date) + } Text( - text = TimeUtils.getCourseFormattedDate(LocalContext.current, dateBlock.date!!), + text = timeTitle, style = TextStyle( fontSize = 12.sp, lineHeight = 16.sp, @@ -517,13 +535,24 @@ private fun CourseDateItem( Row( modifier = Modifier .fillMaxWidth() + .padding(end = 4.dp) .clickable(enabled = dateBlock.blockId.isNotEmpty() && dateBlock.learnerHasAccess, onClick = { onItemClick(dateBlock.blockId) }) ) { + dateBlock.dateType.drawableResId?.let { icon -> + Icon( + modifier = Modifier + .padding(end = 4.dp) + .align(Alignment.CenterVertically), + painter = painterResource(id = if (dateBlock.learnerHasAccess.not()) coreR.drawable.core_ic_lock else icon), + contentDescription = null, + tint = MaterialTheme.appColors.textDark + ) + } Text( modifier = Modifier .weight(1f) - .padding(end = 8.dp), + .align(Alignment.CenterVertically), // append assignment type if available with title text = if (dateBlock.assignmentType.isNullOrEmpty().not()) { "${dateBlock.assignmentType}: ${dateBlock.title}" @@ -537,10 +566,10 @@ private fun CourseDateItem( color = MaterialTheme.appColors.textDark, letterSpacing = 0.15.sp, ), - maxLines = 2, + maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(7.dp)) if (dateBlock.blockId.isNotEmpty() && dateBlock.learnerHasAccess) { Icon( @@ -553,6 +582,21 @@ private fun CourseDateItem( ) } } + if (dateBlock.description.isNotEmpty()) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + text = dateBlock.description, + style = TextStyle( + fontSize = 11.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Normal, + color = MaterialTheme.appColors.textPrimaryVariant, + letterSpacing = 0.5.sp, + ), + ) + } } } @@ -595,7 +639,7 @@ private val mockedResponse: LinkedHashMap> = CourseDateBlock( title = "Homework 1: ABCD", description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z"), + date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z")!!, ) ) ), Pair( @@ -603,7 +647,7 @@ private val mockedResponse: LinkedHashMap> = CourseDateBlock( title = "Homework 1: ABCD", description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z"), + date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z")!!, ) ) ), Pair( @@ -611,7 +655,8 @@ private val mockedResponse: LinkedHashMap> = CourseDateBlock( title = "Homework 1: ABCD", description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z"), + date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z")!!, + dateType = DateType.ASSIGNMENT_DUE_DATE, ) ) ), Pair( @@ -619,7 +664,7 @@ private val mockedResponse: LinkedHashMap> = CourseDateBlock( title = "Homework 2: ABCD", description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-21T15:08:07Z"), + date = TimeUtils.iso8601ToDate("2023-10-21T15:08:07Z")!!, ) ) ), Pair( @@ -627,15 +672,17 @@ private val mockedResponse: LinkedHashMap> = CourseDateBlock( title = "Assignment Due: ABCD", description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-22T15:08:07Z"), + date = TimeUtils.iso8601ToDate("2023-10-22T15:08:07Z")!!, + dateType = DateType.ASSIGNMENT_DUE_DATE, ), CourseDateBlock( title = "Assignment Due", description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-23T15:08:07Z"), + date = TimeUtils.iso8601ToDate("2023-10-23T15:08:07Z")!!, + dateType = DateType.ASSIGNMENT_DUE_DATE, ), CourseDateBlock( title = "Surprise Assignment", description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-24T15:08:07Z"), + date = TimeUtils.iso8601ToDate("2023-10-24T15:08:07Z")!!, ) ) ), Pair( @@ -643,7 +690,7 @@ private val mockedResponse: LinkedHashMap> = CourseDateBlock( title = "Homework 5: ABCD", description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-25T15:08:07Z"), + date = TimeUtils.iso8601ToDate("2023-10-25T15:08:07Z")!!, ) ) ), Pair( @@ -651,8 +698,9 @@ private val mockedResponse: LinkedHashMap> = CourseDateBlock( title = "Last Assignment", description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-26T15:08:07Z"), - assignmentType = "Module 1" + date = TimeUtils.iso8601ToDate("2023-10-26T15:08:07Z")!!, + assignmentType = "Module 1", + dateType = DateType.VERIFICATION_DEADLINE_DATE, ) ) )