From 7256a71b7a5c52126f727ea7e3c0d52ee811224b Mon Sep 17 00:00:00 2001 From: Muhammad Umer Date: Wed, 8 Nov 2023 14:26:43 +0500 Subject: [PATCH] chore: course dates feature (#149) * chore: update view top margin, handle case for today dat with the blocks, refactoring * chore: address feedback, disable block if content is verified only * refactor: remove unused import --- Course/Course/Data/CourseRepository.swift | 28 +-- Course/Course/Domain/Model/CourseDates.swift | 45 ++--- .../Presentation/Dates/CourseDatesView.swift | 182 ++++++++++-------- .../Unit/CourseDateViewModelTests.swift | 38 ++-- 4 files changed, 158 insertions(+), 135 deletions(-) diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 80ec3c3ef..e07f75bb8 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -1085,7 +1085,7 @@ And there are various ways of describing it-- call it oral poetry or }, { "assignment_type": "Problem Set", - "complete": false, + "complete": true, "date": "2023-09-14T23:30:00Z", "date_type": "assignment-due-date", "description": "", @@ -1096,19 +1096,19 @@ And there are various ways of describing it-- call it oral poetry or "extra_info": null, "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530" }, - { - "assignment_type": "Problem Set", - "complete": false, - "date": "2023-09-14T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@ca19e125470846f2a36ad1225410e39aa", - "link_text": "", - "title": "Problem Set 1.1", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530a" - }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-14T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Problem Set 1.1", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530a" + }, { "assignment_type": "Problem Set", "complete": false, diff --git a/Course/Course/Domain/Model/CourseDates.swift b/Course/Course/Domain/Model/CourseDates.swift index e60265941..0909610d4 100644 --- a/Course/Course/Domain/Model/CourseDates.swift +++ b/Course/Course/Domain/Model/CourseDates.swift @@ -19,9 +19,14 @@ public struct CourseDates { var hasToday = false let today = Date.today + let calendar = Calendar.current + let todayComponents = calendar.dateComponents([.year, .month, .day], from: .today) + for block in courseDateBlocks { let date = block.date - if date == today { + let dateComponents = calendar.dateComponents([.year, .month, .day], from: date) + + if dateComponents == todayComponents { hasToday = true } @@ -66,7 +71,10 @@ extension Date { } var isToday: Bool { - return Date.compare(self, to: .today) == .orderedSame + let calendar = Calendar.current + let selfComponents = calendar.dateComponents([.year, .month, .day], from: self) + let todayComponents = calendar.dateComponents([.year, .month, .day], from: .today) + return selfComponents == todayComponents } var isInFuture: Bool { @@ -74,7 +82,9 @@ extension Date { } } -public struct CourseDateBlock { +public struct CourseDateBlock: Identifiable { + public let id: UUID = UUID() + let assignmentType: String? let complete: Bool? let date: Date @@ -86,12 +96,8 @@ public struct CourseDateBlock { let extraInfo: String? let firstComponentBlockID: String - var blockTitle: String { - if isToday { - return CoreLocalization.CourseDates.today - } else { - return blockStatus.title - } + var formattedDate: String { + return date.dateToString(style: .shortWeekdayMonthDayYear) } var isInPast: Bool { @@ -138,6 +144,10 @@ public struct CourseDateBlock { return !isUnreleased && isLearnerAssignment } + var isAvailable: Bool { + return learnerHasAccess && (!isUnreleased || !isLearnerAssignment) + } + var blockStatus: BlockStatus { if isComplete { return .completed @@ -191,21 +201,4 @@ public enum BlockStatus { default: return .event } } - - var title: String { - switch self { - case .completed: - return CoreLocalization.CourseDates.completed - case .pastDue: - return CoreLocalization.CourseDates.pastDue - case .dueNext: - return CoreLocalization.CourseDates.dueNext - case .unreleased: - return CoreLocalization.CourseDates.unreleased - case .verifiedOnly: - return CoreLocalization.CourseDates.verifiedOnly - default: - return "" - } - } } diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index 34774e1f0..f3a7d9d59 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -21,7 +21,7 @@ public struct CourseDatesView: View { viewModel: CourseDatesViewModel ) { self.courseID = courseID - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self._viewModel = StateObject(wrappedValue: viewModel) } public var body: some View { @@ -35,6 +35,7 @@ public struct CourseDatesView: View { } } else if let courseDates = viewModel.courseDates, !courseDates.courseDateBlocks.isEmpty { CourseDateListView(viewModel: viewModel, courseDates: courseDates) + .padding(.top, 10) } } if viewModel.showError { @@ -59,7 +60,6 @@ public struct CourseDatesView: View { Theme.Colors.background .ignoresSafeArea() ) - .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -74,28 +74,23 @@ struct Line: Shape { } struct TimeLineView: View { + let block: CourseDateBlock let date: Date let firstDate: Date? let lastDate: Date? + let allHaveSameStatus: Bool var body: some View { ZStack(alignment: .top) { - if lastDate == date { - VStack { - Line() - .stroke(style: StrokeStyle(lineWidth: 1)) - .frame(maxHeight: 10.0, alignment: .top) - Spacer() - } - } else if firstDate == date { - Line() - .stroke(style: StrokeStyle(lineWidth: 1)) - .frame(maxHeight: .infinity, alignment: .top) - .padding(.top, 10) - } else { + VStack { Line() .stroke(style: StrokeStyle(lineWidth: 1)) - .frame(maxHeight: .infinity, alignment: .top) + .frame(maxHeight: lastDate == date ? 10 : .infinity, alignment: .top) + .padding(.top, firstDate == date && lastDate != date ? 10 : 0) + + if lastDate == date { + Spacer() + } } Circle() @@ -104,9 +99,17 @@ struct TimeLineView: View { if date.isToday { return Theme.Colors.warning } else if date.isInPast { - return Color.gray - } else { + switch block.blockStatus { + case .completed: return allHaveSameStatus ? Color.white : Color.gray + case .courseStartDate: return Color.white + case .verifiedOnly: return Color.black + case .pastDue: return Color.gray + default: return Color.gray + } + } else if date.isInFuture { return Color.black + } else { + return Color.white } }()) .overlay(Circle().stroke(Color.black, lineWidth: 1)) @@ -126,18 +129,19 @@ struct CourseDateListView: View { VStack(alignment: .leading, spacing: 0) { ForEach(viewModel.sortedDates, id: \.self) { date in let blocks = courseDates.sortedDateToCourseDateBlockDict[date]! + let block = blocks[0] HStack(alignment: .center) { - TimeLineView(date: date, - firstDate: viewModel.sortedDates.first, - lastDate: viewModel.sortedDates.last) - let ignoredStatuses: [BlockStatus] = [.courseStartDate, .courseEndDate] - let block = blocks[0] let allHaveSameStatus = blocks .filter { !ignoredStatuses.contains($0.blockStatus) } .allSatisfy { $0.blockStatus == block.blockStatus } + TimeLineView(block: block, date: date, + firstDate: viewModel.sortedDates.first, + lastDate: viewModel.sortedDates.last, + allHaveSameStatus: allHaveSameStatus) + BlockStatusView(block: block, allHaveSameStatus: allHaveSameStatus, blocks: blocks) @@ -161,13 +165,13 @@ struct BlockStatusView: View { var body: some View { VStack(alignment: .leading) { HStack { - Text(block.date.dateToString(style: .shortWeekdayMonthDayYear)) - .font(.subheadline) + Text(block.formattedDate) + .font(Theme.Fonts.bodyLarge) .bold() if block.isToday { - Text(block.blockTitle) - .font(.footnote) + Text(CoreLocalization.CourseDates.today) + .font(Theme.Fonts.bodySmall) .foregroundColor(Color.black) .padding(EdgeInsets(top: 2, leading: 6, bottom: 2, trailing: 8)) .background(Theme.Colors.warning) @@ -176,91 +180,113 @@ struct BlockStatusView: View { if allHaveSameStatus { let lockImageText = block.isVerifiedOnly ? Text(Image(systemName: "lock.fill")) : Text("") - Text("\(lockImageText) \(block.blockTitle)") - .font(.footnote) - .foregroundColor(determineForegroundColor(for: block.blockStatus)) + Text("\(lockImageText) \(block.blockStatus.title)") + .font(Theme.Fonts.bodySmall) + .foregroundColor(block.blockStatus.foregroundColor) .padding(EdgeInsets(top: 2, leading: 6, bottom: 2, trailing: 8)) - .background(determineBackgroundColor(for: block.blockStatus)) + .background(block.blockStatus.backgroundColor) .cornerRadius(5) } } - ForEach(blocks, id: \.firstComponentBlockID) { block in + ForEach(blocks) { block in styleBlock(block: block, allHaveSameStatus: allHaveSameStatus) } + .padding(.top, 0.2) } .padding(.vertical, 0) .padding(.leading, 5) .padding(.bottom, 10) } - private func determineForegroundColor(for status: BlockStatus) -> Color { - switch status { - case .verifiedOnly: return Color.white - case .pastDue: return Color.black - case .dueNext: return Color.white - default: return Color.white.opacity(0) - } - } - - private func determineBackgroundColor(for status: BlockStatus) -> Color { - switch status { - case .verifiedOnly: return Color.black.opacity(0.5) - case .pastDue: return Color.gray.opacity(0.4) - case .dueNext: return Color.black.opacity(0.5) - default: return Color.white.opacity(0) - } - } - func styleBlock(block: CourseDateBlock, allHaveSameStatus: Bool) -> some View { - var attrString = AttributedString("") + var attributedString = AttributedString("") if let prefix = block.assignmentType, !prefix.isEmpty { - attrString += AttributedString("\(prefix): ") + attributedString += AttributedString("\(prefix): ") } - attrString += block.canShowLink ? getAttributedUnderlineString(string: block.title) : AttributedString(block.title) + attributedString += styleTitle(block: block) if !allHaveSameStatus { - attrString += " " - let (status, foregroundColor, backgroundColor) = getStatusDetails(for: block.blockStatus) - attrString += getAttributedString(string: status, forgroundColor: foregroundColor, backgroundColor: backgroundColor) + attributedString.appendSpaces(2) + attributedString += applyStyle( + string: block.blockStatus.title, + forgroundColor: block.blockStatus.foregroundColor, + backgroundColor: block.blockStatus.backgroundColor) } - return Text(attrString).padding(.bottom, 2).font(.footnote) + return Text(attributedString) + .font(Theme.Fonts.bodyMedium) + .foregroundColor({ + if block.isAssignment { + return block.isAvailable ? Color.black : Color.gray.opacity(0.6) + } else { + return Color.black + } + }()) + .onTapGesture { + + } } - func getStatusDetails(for blockStatus: BlockStatus) -> (String, Color, Color) { - switch blockStatus { - case .verifiedOnly: - return (CoreLocalization.CourseDates.verifiedOnly, Color.white, Color.black.opacity(0.5)) - case .pastDue: - return (CoreLocalization.CourseDates.pastDue, Color.black, Color.gray.opacity(0.4)) - case .dueNext: - return (CoreLocalization.CourseDates.dueNext, Color.white, Color.black.opacity(0.5)) - case .unreleased: - return (CoreLocalization.CourseDates.unreleased, Color.white.opacity(0), Color.white.opacity(0)) - default: - return ("", Color.white.opacity(0), Color.white.opacity(0)) + func styleTitle(block: CourseDateBlock) -> AttributedString { + var attributedString = AttributedString(block.title) + attributedString.font = Theme.Fonts.bodyMedium + if block.canShowLink && !block.firstComponentBlockID.isEmpty { + attributedString.underlineStyle = .single } - } - - func getAttributedUnderlineString(string: String) -> AttributedString { - var attributedString = AttributedString(string) - attributedString.font = .footnote - attributedString.underlineStyle = .single return attributedString } - - func getAttributedString(string: String, forgroundColor: Color, backgroundColor: Color) -> AttributedString { + + func applyStyle(string: String, forgroundColor: Color, backgroundColor: Color) -> AttributedString { var attributedString = AttributedString(string) - attributedString.font = .footnote + attributedString.font = Theme.Fonts.bodySmall attributedString.foregroundColor = forgroundColor attributedString.backgroundColor = backgroundColor return attributedString } } +fileprivate extension BlockStatus { + var title: String { + switch self { + case .completed: return CoreLocalization.CourseDates.completed + case .pastDue: return CoreLocalization.CourseDates.pastDue + case .dueNext: return CoreLocalization.CourseDates.dueNext + case .unreleased: return CoreLocalization.CourseDates.unreleased + case .verifiedOnly: return CoreLocalization.CourseDates.verifiedOnly + default: return "" + } + } + + var foregroundColor: Color { + switch self { + case .completed: return Color.white + case .verifiedOnly: return Color.white + case .pastDue: return Color.black + case .dueNext: return Color.white + default: return Color.white.opacity(0) + } + } + + var backgroundColor: Color { + switch self { + case .completed: return Color.black.opacity(0.5) + case .verifiedOnly: return Color.black.opacity(0.5) + case .pastDue: return Color.gray.opacity(0.4) + case .dueNext: return Color.black.opacity(0.5) + default: return Color.white.opacity(0) + } + } +} + +fileprivate extension AttributedString { + mutating func appendSpaces(_ count: Int = 1) { + self += AttributedString(String(repeating: " ", count: count)) + } +} + #if DEBUG struct CourseDatesView_Previews: PreviewProvider { static var previews: some View { diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift index 0b6f6f9bf..690b93325 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -5,12 +5,11 @@ // Created by Muhammad Umer on 10/24/23. // -import SwiftyMocky import XCTest +import Alamofire +import SwiftyMocky @testable import Core @testable import Course -import Alamofire -import SwiftUI final class CourseDateViewModelTests: XCTestCase { func testGetCourseDatesSuccess() async throws { @@ -70,7 +69,7 @@ final class CourseDateViewModelTests: XCTestCase { Verify(interactor, .getCourseDates(courseID: .any)) XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError, "Error view should be shown on unknown error.") } func testNoInternetConnectionError() async throws { @@ -95,7 +94,7 @@ final class CourseDateViewModelTests: XCTestCase { Verify(interactor, .getCourseDates(courseID: .any)) XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection, "Error message should be set to 'slow or no internet connection'.") } func testSortedDateTodayToCourseDateBlockDict() { @@ -195,7 +194,7 @@ final class CourseDateViewModelTests: XCTestCase { let block = CourseDateBlock( assignmentType: nil, complete: nil, - date: Date(), + date: Date.today, dateType: "assignment-due-date", description: "", learnerHasAccess: true, @@ -219,12 +218,12 @@ final class CourseDateViewModelTests: XCTestCase { learnerHasAccess: false, link: "www.example.com", linkText: nil, - title: "TestBlock", + title: CoreLocalization.CourseDates.today, extraInfo: nil, firstComponentBlockID: "blockIDTest" ) - XCTAssertEqual(block.blockTitle, "Today", "Block title for 'today' should be 'Today'") + XCTAssertEqual(block.title, "Today", "Block title for 'today' should be 'Today'") } func testBadgeLogicForCompleted() { @@ -366,7 +365,7 @@ final class CourseDateViewModelTests: XCTestCase { firstComponentBlockID: "blockIDTest" ) - XCTAssertTrue(block.isVerifiedOnly) + XCTAssertTrue(block.isVerifiedOnly, "Block should be identified as 'verified only' when the learner has no access.") } func testIsCompleted() { @@ -384,7 +383,7 @@ final class CourseDateViewModelTests: XCTestCase { firstComponentBlockID: "blockIDTest" ) - XCTAssertTrue(block.isComplete) + XCTAssertTrue(block.isComplete, "Block should be marked as completed.") } func testBadgeLogicForUnreleasedAssignment() { @@ -402,7 +401,7 @@ final class CourseDateViewModelTests: XCTestCase { firstComponentBlockID: "blockIDTest" ) - XCTAssertEqual(block.blockStatus, .unreleased) + XCTAssertEqual(block.blockStatus, .unreleased, "Block status should be set to 'unreleased' for unreleased assignments.") } func testNoLinkForUnavailableAssignment() { @@ -420,7 +419,8 @@ final class CourseDateViewModelTests: XCTestCase { firstComponentBlockID: "blockIDTest" ) - XCTAssertFalse(block.canShowLink) + XCTAssertEqual(block.blockStatus, .verifiedOnly) + XCTAssertFalse(block.canShowLink, "Block should not show a link if the assignment is unavailable.") } func testNoLinkAvailableForUnreleasedAssignment() { @@ -430,7 +430,7 @@ final class CourseDateViewModelTests: XCTestCase { date: Date.today, dateType: "assignment-due-date", description: "", - learnerHasAccess: false, + learnerHasAccess: true, link: "", linkText: nil, title: "TestBlock", @@ -438,23 +438,27 @@ final class CourseDateViewModelTests: XCTestCase { firstComponentBlockID: "blockIDTest" ) - XCTAssertFalse(block.canShowLink) + XCTAssertEqual(block.blockStatus, .unreleased) + XCTAssertFalse(block.canShowLink, "Block should not show a link if the assignment is unreleased.") } func testTodayProperty() { let today = Date.today let currentDay = Calendar.current.startOfDay(for: Date()) + XCTAssertTrue(today.isToday, "The today property should return true for isToday.") XCTAssertEqual(today, currentDay, "The today property should equal the start of the current day.") } func testDateIsInPastProperty() { let pastDate = Date().addingTimeInterval(-100000) - XCTAssertTrue(pastDate.isInPast) + XCTAssertTrue(pastDate.isInPast, "The past date should return true for isInPast.") + XCTAssertFalse(pastDate.isToday, "The past date should return false for isInPast.") } func testDateIsInFutureProperty() { let futureDate = Date().addingTimeInterval(100000) - XCTAssertTrue(futureDate.isInFuture) + XCTAssertTrue(futureDate.isInFuture, "The future date should return false for isInFuture.") + XCTAssertFalse(futureDate.isToday, "The future date should return false for isInFuture.") } func testBlockStatusMapping() { @@ -464,6 +468,6 @@ final class CourseDateViewModelTests: XCTestCase { XCTAssertEqual(BlockStatus.status(of: "verification-deadline-date"), .verificationDeadlineDate, "Incorrect mapping for 'verification-deadline-date'") XCTAssertEqual(BlockStatus.status(of: "verified-upgrade-deadline"), .verifiedUpgradeDeadline, "Incorrect mapping for 'verified-upgrade-deadline'") XCTAssertEqual(BlockStatus.status(of: "assignment-due-date"), .assignment, "Incorrect mapping for 'assignment-due-date'") - XCTAssertEqual(BlockStatus.status(of: ""), .event, "Incorrect mapping for ''") + XCTAssertEqual(BlockStatus.status(of: ""), .event, "Incorrect mapping for 'event'") } }