diff --git a/Pods/Target Support Files/Pods-edX/Pods-edX-frameworks.sh b/Pods/Target Support Files/Pods-edX/Pods-edX-frameworks.sh new file mode 100755 index 0000000000..286b9369dc --- /dev/null +++ b/Pods/Target Support Files/Pods-edX/Pods-edX-frameworks.sh @@ -0,0 +1,204 @@ +#!/bin/sh +set -e +set -u +set -o pipefail + +function on_error { + echo "$(realpath -mq "${0}"):$1: error: Unexpected failure" +} +trap 'on_error $LINENO' ERR + +if [ -z ${FRAMEWORKS_FOLDER_PATH+x} ]; then + # If FRAMEWORKS_FOLDER_PATH is not set, then there's nowhere for us to copy + # frameworks to, so exit 0 (signalling the script phase was successful). + exit 0 +fi + +echo "mkdir -p ${CONFIGURATION_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" +mkdir -p "${CONFIGURATION_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" + +COCOAPODS_PARALLEL_CODE_SIGN="${COCOAPODS_PARALLEL_CODE_SIGN:-false}" +SWIFT_STDLIB_PATH="${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" +BCSYMBOLMAP_DIR="BCSymbolMaps" + + +# This protects against multiple targets copying the same framework dependency at the same time. The solution +# was originally proposed here: https://lists.samba.org/archive/rsync/2008-February/020158.html +RSYNC_PROTECT_TMP_FILES=(--filter "P .*.??????") + +# Copies and strips a vendored framework +install_framework() +{ + if [ -r "${BUILT_PRODUCTS_DIR}/$1" ]; then + local source="${BUILT_PRODUCTS_DIR}/$1" + elif [ -r "${BUILT_PRODUCTS_DIR}/$(basename "$1")" ]; then + local source="${BUILT_PRODUCTS_DIR}/$(basename "$1")" + elif [ -r "$1" ]; then + local source="$1" + fi + + local destination="${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}" + + if [ -L "${source}" ]; then + echo "Symlinked..." + source="$(readlink -f "${source}")" + fi + + if [ -d "${source}/${BCSYMBOLMAP_DIR}" ]; then + # Locate and install any .bcsymbolmaps if present, and remove them from the .framework before the framework is copied + find "${source}/${BCSYMBOLMAP_DIR}" -name "*.bcsymbolmap"|while read f; do + echo "Installing $f" + install_bcsymbolmap "$f" "$destination" + rm "$f" + done + rmdir "${source}/${BCSYMBOLMAP_DIR}" + fi + + # Use filter instead of exclude so missing patterns don't throw errors. + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter \"- CVS/\" --filter \"- .svn/\" --filter \"- .git/\" --filter \"- .hg/\" --filter \"- Headers\" --filter \"- PrivateHeaders\" --filter \"- Modules\" \"${source}\" \"${destination}\"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${source}" "${destination}" + + local basename + basename="$(basename -s .framework "$1")" + binary="${destination}/${basename}.framework/${basename}" + + if ! [ -r "$binary" ]; then + binary="${destination}/${basename}" + elif [ -L "${binary}" ]; then + echo "Destination binary is symlinked..." + dirname="$(dirname "${binary}")" + binary="${dirname}/$(readlink "${binary}")" + fi + + # Strip invalid architectures so "fat" simulator / device frameworks work on device + if [[ "$(file "$binary")" == *"dynamically linked shared library"* ]]; then + strip_invalid_archs "$binary" + fi + + # Resign the code if required by the build settings to avoid unstable apps + code_sign_if_enabled "${destination}/$(basename "$1")" + + # Embed linked Swift runtime libraries. No longer necessary as of Xcode 7. + if [ "${XCODE_VERSION_MAJOR}" -lt 7 ]; then + local swift_runtime_libs + swift_runtime_libs=$(xcrun otool -LX "$binary" | grep --color=never @rpath/libswift | sed -E s/@rpath\\/\(.+dylib\).*/\\1/g | uniq -u) + for lib in $swift_runtime_libs; do + echo "rsync -auv \"${SWIFT_STDLIB_PATH}/${lib}\" \"${destination}\"" + rsync -auv "${SWIFT_STDLIB_PATH}/${lib}" "${destination}" + code_sign_if_enabled "${destination}/${lib}" + done + fi +} +# Copies and strips a vendored dSYM +install_dsym() { + local source="$1" + warn_missing_arch=${2:-true} + if [ -r "$source" ]; then + # Copy the dSYM into the targets temp dir. + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter \"- CVS/\" --filter \"- .svn/\" --filter \"- .git/\" --filter \"- .hg/\" --filter \"- Headers\" --filter \"- PrivateHeaders\" --filter \"- Modules\" \"${source}\" \"${DERIVED_FILES_DIR}\"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${source}" "${DERIVED_FILES_DIR}" + + local basename + basename="$(basename -s .dSYM "$source")" + binary_name="$(ls "$source/Contents/Resources/DWARF")" + binary="${DERIVED_FILES_DIR}/${basename}.dSYM/Contents/Resources/DWARF/${binary_name}" + + # Strip invalid architectures from the dSYM. + if [[ "$(file "$binary")" == *"Mach-O "*"dSYM companion"* ]]; then + strip_invalid_archs "$binary" "$warn_missing_arch" + fi + if [[ $STRIP_BINARY_RETVAL == 0 ]]; then + # Move the stripped file into its final destination. + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter \"- CVS/\" --filter \"- .svn/\" --filter \"- .git/\" --filter \"- .hg/\" --filter \"- Headers\" --filter \"- PrivateHeaders\" --filter \"- Modules\" \"${DERIVED_FILES_DIR}/${basename}.framework.dSYM\" \"${DWARF_DSYM_FOLDER_PATH}\"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${DERIVED_FILES_DIR}/${basename}.dSYM" "${DWARF_DSYM_FOLDER_PATH}" + else + # The dSYM was not stripped at all, in this case touch a fake folder so the input/output paths from Xcode do not reexecute this script because the file is missing. + mkdir -p "${DWARF_DSYM_FOLDER_PATH}" + touch "${DWARF_DSYM_FOLDER_PATH}/${basename}.dSYM" + fi + fi +} + +# Used as a return value for each invocation of `strip_invalid_archs` function. +STRIP_BINARY_RETVAL=0 + +# Strip invalid architectures +strip_invalid_archs() { + binary="$1" + warn_missing_arch=${2:-true} + # Get architectures for current target binary + binary_archs="$(lipo -info "$binary" | rev | cut -d ':' -f1 | awk '{$1=$1;print}' | rev)" + # Intersect them with the architectures we are building for + intersected_archs="$(echo ${ARCHS[@]} ${binary_archs[@]} | tr ' ' '\n' | sort | uniq -d)" + # If there are no archs supported by this binary then warn the user + if [[ -z "$intersected_archs" ]]; then + if [[ "$warn_missing_arch" == "true" ]]; then + echo "warning: [CP] Vendored binary '$binary' contains architectures ($binary_archs) none of which match the current build architectures ($ARCHS)." + fi + STRIP_BINARY_RETVAL=1 + return + fi + stripped="" + for arch in $binary_archs; do + if ! [[ "${ARCHS}" == *"$arch"* ]]; then + # Strip non-valid architectures in-place + lipo -remove "$arch" -output "$binary" "$binary" + stripped="$stripped $arch" + fi + done + if [[ "$stripped" ]]; then + echo "Stripped $binary of architectures:$stripped" + fi + STRIP_BINARY_RETVAL=0 +} + +# Copies the bcsymbolmap files of a vendored framework +install_bcsymbolmap() { + local bcsymbolmap_path="$1" + local destination="${BUILT_PRODUCTS_DIR}" + echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${bcsymbolmap_path}" "${destination}"" + rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "${bcsymbolmap_path}" "${destination}" +} + +# Signs a framework with the provided identity +code_sign_if_enabled() { + if [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" -a "${CODE_SIGNING_REQUIRED:-}" != "NO" -a "${CODE_SIGNING_ALLOWED}" != "NO" ]; then + # Use the current code_sign_identity + echo "Code Signing $1 with Identity ${EXPANDED_CODE_SIGN_IDENTITY_NAME}" + local code_sign_cmd="/usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} ${OTHER_CODE_SIGN_FLAGS:-} --preserve-metadata=identifier,entitlements '$1'" + + if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then + code_sign_cmd="$code_sign_cmd &" + fi + echo "$code_sign_cmd" + eval "$code_sign_cmd" + fi +} + +if [[ "$CONFIGURATION" == "Debug" ]]; then + install_framework "${BUILT_PRODUCTS_DIR}/MSAL/MSAL.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBAEMKit/FBAEMKit.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKCoreKit/FBSDKCoreKit.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKCoreKit_Basics/FBSDKCoreKit_Basics.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKLoginKit/FBSDKLoginKit.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/NewRelicAgent/NewRelic.framework" +fi +if [[ "$CONFIGURATION" == "Profile" ]]; then + install_framework "${BUILT_PRODUCTS_DIR}/MSAL/MSAL.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBAEMKit/FBAEMKit.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKCoreKit/FBSDKCoreKit.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKCoreKit_Basics/FBSDKCoreKit_Basics.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKLoginKit/FBSDKLoginKit.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/NewRelicAgent/NewRelic.framework" +fi +if [[ "$CONFIGURATION" == "Release" ]]; then + install_framework "${BUILT_PRODUCTS_DIR}/MSAL/MSAL.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBAEMKit/FBAEMKit.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKCoreKit/FBSDKCoreKit.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKCoreKit_Basics/FBSDKCoreKit_Basics.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKLoginKit/FBSDKLoginKit.framework" + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/NewRelicAgent/NewRelic.framework" +fi +if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then + wait +fi diff --git a/Source/AccessibilityCLButton.swift b/Source/AccessibilityCLButton.swift index fef2b984b3..37aa97a944 100644 --- a/Source/AccessibilityCLButton.swift +++ b/Source/AccessibilityCLButton.swift @@ -34,9 +34,12 @@ class AccessibilityCLButton: CustomPlayerButton { } public override func draw(_ rect: CGRect) { - let r = UIBezierPath(ovalIn: rect) + let diameter = min(rect.width, rect.height) + let circleRect = CGRect(x: rect.origin.x, y: rect.origin.y, width: diameter, height: diameter) + let path = UIBezierPath(ovalIn: circleRect) UIColor.black.withAlphaComponent(0.65).setFill() - r.fill() + path.fill() + super.draw(rect) } } diff --git a/Source/CelebratoryModalViewController.swift b/Source/CelebratoryModalViewController.swift index c06586aae3..1939453c37 100644 --- a/Source/CelebratoryModalViewController.swift +++ b/Source/CelebratoryModalViewController.swift @@ -99,7 +99,7 @@ class CelebratoryModalViewController: UIViewController, InterfaceOrientationOver return message }() - private lazy var celebrationMessageLabel: UILabel = { + private lazy var shareMessageLabel: UILabel = { let message = UILabel() message.numberOfLines = 0 message.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) @@ -109,7 +109,6 @@ class CelebratoryModalViewController: UIViewController, InterfaceOrientationOver let messageStyle = OEXMutableTextStyle(weight: .normal, size: .base, color: environment.styles.neutralBlackT()) let messageAttributedString = messageStyle.attributedString(withText: Strings.Celebration.infoMessage) let compiledMessage = NSAttributedString.joinInNaturalLayout(attributedStrings: [earneditAttributedString, messageAttributedString]) - message.sizeToFit() message.attributedText = compiledMessage return message }() @@ -134,7 +133,7 @@ class CelebratoryModalViewController: UIViewController, InterfaceOrientationOver return imageView }() - private lazy var shareButtonView: UIButton = { + private lazy var shareButton: UIButton = { let button = UIButton() button.accessibilityLabel = Strings.Accessibility.shareACourse button.oex_removeAllActions() @@ -149,17 +148,26 @@ class CelebratoryModalViewController: UIViewController, InterfaceOrientationOver return button }() + private let shareContainer = UIView() + private lazy var courseURL: String? = { return environment.interface?.enrollmentForCourse(withID: courseID)?.course.course_about }() private lazy var celebrationImageSize: CGSize = { let margin: CGFloat = isiPad() ? 240 : 80 - let width = view.frame.size.width - margin + let width = UIScreen.main.bounds.width - margin let imageAspectRatio: CGFloat = 1.37 return CGSize(width: width, height: width / imageAspectRatio) }() + private lazy var celebrationImageSizeLandscape: CGSize = { + let margin: CGFloat = isiPad() ? 340 : 165 + let height = UIScreen.main.bounds.height - margin + let imageAspectRatio: CGFloat = 1.37 + return CGSize(width: height * imageAspectRatio, height: height) + }() + init(courseID: String, environment: Environment) { self.courseID = courseID self.environment = environment @@ -183,9 +191,8 @@ class CelebratoryModalViewController: UIViewController, InterfaceOrientationOver super.viewDidLoad() view.backgroundColor = environment.styles.neutralXXDark().withAlphaComponent(0.5) - view.setNeedsUpdateConstraints() - view.addSubview(modalView) + addSubviews() setIdentifiers() } @@ -200,6 +207,7 @@ class CelebratoryModalViewController: UIViewController, InterfaceOrientationOver updateViewConstraints() } + override func updateViewConstraints() { if isVerticallyCompact() { setupLandscapeView() @@ -215,269 +223,154 @@ class CelebratoryModalViewController: UIViewController, InterfaceOrientationOver NotificationCenter.default.removeObserver(self) } - private func removeViews() { - modalView.subviews.forEach { $0.removeFromSuperview() } - } - private func setIdentifiers() { - modalView.accessibilityIdentifier = "CelebratoryModalView:modal-container-view" + modalView.accessibilityIdentifier = "CelebratoryModalView:modal-view" titleLabel.accessibilityIdentifier = "CelebratoryModalView:label-title" - titleMessageLabel.accessibilityIdentifier = "CelebratoryModalView:label-title-message" - celebrationMessageLabel.accessibilityIdentifier = "CelebratoryModalView:label-celebration-message" + titleMessageLabel.accessibilityIdentifier = "CelebratoryModalView:title-message-label" + shareMessageLabel.accessibilityIdentifier = "CelebratoryModalView:share-message-label" congratulationImageView.accessibilityIdentifier = "CelebratoryModalView:congratulation-image-view" - shareButtonView.accessibilityIdentifier = "CelebratoryModalView:share-button-view" + shareButton.accessibilityIdentifier = "CelebratoryModalView:share-button" shareImageView.accessibilityIdentifier = "CelebratoryModalView:share-image-view" + shareContainer.accessibilityIdentifier = "CelebratoryModalView:share-container-view" } - private func setupPortraitView() { - removeViews() - let imageContainer = UIView() - let insideContainer = UIView() - let keepGoingButtonContainer = UIView() - let buttonContainer = UIView() - let textContainer = UIView() + private func addSubviews() { + view.addSubview(modalView) modalView.addSubview(titleLabel) modalView.addSubview(titleMessageLabel) - imageContainer.addSubview(congratulationImageView) - modalView.addSubview(imageContainer) - modalView.addSubview(insideContainer) - modalView.addSubview(keepGoingButtonContainer) - - imageContainer.accessibilityIdentifier = "CelebratoryModalView:image-cotainer-view" - insideContainer.accessibilityIdentifier = "CelebratoryModalView:share-inside-container-view" - keepGoingButtonContainer.accessibilityIdentifier = "CelebratoryModalView:keep-going-button-container-view" - buttonContainer.accessibilityIdentifier = "CelebratoryModalView:share-button-container-view" - textContainer.accessibilityIdentifier = "CelebratoryModalView:share-text-container-view" - - insideContainer.backgroundColor = environment.styles.infoXXLight() - insideContainer.addSubview(buttonContainer) - insideContainer.addSubview(textContainer) - insideContainer.addSubview(shareButtonView) + modalView.addSubview(congratulationImageView) + modalView.addSubview(shareContainer) + modalView.addSubview(keepGoingButton) - shareButtonView.superview?.bringSubviewToFront(shareButtonView) - - textContainer.addSubview(celebrationMessageLabel) - buttonContainer.addSubview(shareImageView) - keepGoingButtonContainer.addSubview(keepGoingButton) - + shareContainer.backgroundColor = environment.styles.infoXXLight() + shareContainer.addSubview(shareImageView) + shareContainer.addSubview(shareMessageLabel) + shareContainer.addSubview(shareButton) + shareButton.superview?.bringSubviewToFront(shareButton) + } + + private func setupPortraitView() { titleLabel.snp.remakeConstraints { make in make.top.equalTo(modalView).offset(StandardVerticalMargin*3) - make.centerX.equalTo(modalView) make.leading.equalTo(modalView).offset(StandardHorizontalMargin) make.trailing.equalTo(modalView).inset(StandardHorizontalMargin) - make.height.equalTo(titleLabelHeight) } - + titleMessageLabel.snp.remakeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(StandardVerticalMargin * 2) - make.centerX.equalTo(modalView) - make.width.equalTo(imageContainer.snp.width).inset(10) - make.height.equalTo(titleLabelMessageHeight) + make.leading.equalTo(congratulationImageView) + make.trailing.equalTo(congratulationImageView) } congratulationImageView.snp.remakeConstraints { make in - make.edges.equalTo(imageContainer) - } - - imageContainer.snp.remakeConstraints { make in make.top.equalTo(titleMessageLabel.snp.bottom).offset(StandardVerticalMargin * 2) make.centerX.equalTo(modalView) make.width.equalTo(celebrationImageSize.width) make.height.equalTo(celebrationImageSize.height) } - - insideContainer.snp.remakeConstraints { make in - make.top.equalTo(imageContainer.snp.bottom).offset(StandardVerticalMargin * 2) - make.centerX.equalTo(modalView) - make.width.equalTo(imageContainer.snp.width) - make.height.equalTo(shareButtonContainerHeight) - } - - buttonContainer.snp.remakeConstraints { make in - make.leading.equalTo(insideContainer) - make.top.equalTo(insideContainer).offset(StandardVerticalMargin * 2) - make.bottom.equalTo(insideContainer) + + shareContainer.snp.remakeConstraints { make in + make.top.equalTo(congratulationImageView.snp.bottom).offset(StandardVerticalMargin * 2) + make.centerX.equalTo(congratulationImageView) + make.leading.equalTo(congratulationImageView) + make.trailing.equalTo(congratulationImageView) } - - textContainer.snp.remakeConstraints { make in - make.top.equalTo(insideContainer).offset(StandardVerticalMargin * 2) - make.leading.equalTo(buttonContainer.snp.trailing).inset(StandardHorizontalMargin / 2) - make.trailing.equalTo(insideContainer).inset(StandardHorizontalMargin * 2) - make.bottom.equalTo(insideContainer).inset(StandardVerticalMargin * 2) + + shareButton.snp.remakeConstraints { make in + make.edges.equalTo(shareContainer) } - + shareImageView.snp.remakeConstraints { make in - make.top.equalTo(celebrationMessageLabel.snp.top) - make.leading.equalTo(buttonContainer).offset(StandardHorizontalMargin * 2) - make.trailing.equalTo(buttonContainer).inset(StandardHorizontalMargin) + make.top.equalTo(shareContainer).offset(StandardVerticalMargin * 2) + make.leading.equalTo(shareContainer).offset(StandardHorizontalMargin) make.width.equalTo(shareImageSize.width) make.height.equalTo(shareImageSize.height) } - - celebrationMessageLabel.snp.remakeConstraints { make in - make.centerX.equalTo(textContainer) - make.centerY.equalTo(textContainer) - make.leading.equalTo(textContainer) - make.trailing.equalTo(textContainer) - } - - shareButtonView.snp.makeConstraints { make in - make.edges.equalTo(insideContainer) - } - keepGoingButtonContainer.snp.remakeConstraints { make in - make.top.equalTo(insideContainer.snp.bottom).offset(StandardVerticalMargin * 3) - make.leading.equalTo(modalView).offset(StandardHorizontalMargin) - make.trailing.equalTo(modalView).inset(StandardHorizontalMargin) - make.height.equalTo(keepGoingButtonSize.height) + shareMessageLabel.snp.remakeConstraints { make in + make.top.equalTo(shareImageView) + make.leading.equalTo(shareImageView.snp.trailing).offset(StandardHorizontalMargin / 2) + make.trailing.equalTo(shareContainer).inset(StandardHorizontalMargin) + make.bottom.equalTo(shareContainer).inset(StandardVerticalMargin * 2) } keepGoingButton.snp.remakeConstraints { make in - make.centerX.equalTo(keepGoingButtonContainer) - make.height.equalTo(keepGoingButtonContainer) + make.top.equalTo(shareContainer.snp.bottom).offset(StandardVerticalMargin * 3) + make.bottom.equalTo(modalView).inset(StandardVerticalMargin * 3) + make.centerX.equalTo(modalView) + make.height.equalTo(keepGoingButtonSize.height) make.width.equalTo(keepGoingButtonSize.width) } - + modalView.snp.remakeConstraints { make in make.centerX.equalTo(view) make.centerY.equalTo(view) - let height = titleLabelHeight + titleLabelMessageHeight + celebrationImageSize.height + shareButtonContainerHeight + keepGoingButtonSize.height + (StandardVerticalMargin * 15) - make.height.equalTo(height) - make.width.equalTo(celebrationImageSize.width + StandardVerticalMargin * 5) + make.leading.equalTo(safeLeading).offset(StandardHorizontalMargin) + make.trailing.equalTo(safeTrailing).inset(StandardHorizontalMargin) } } private func setupLandscapeView() { - removeViews() - let stackView = UIStackView() - let rightStackView = UIStackView() - let rightContainer = UIView() - let insideContainer = UIView() - let buttonContainer = UIView() - let textContainer = UIView() - let keepGoingButtonContainer = UIView() - - stackView.accessibilityIdentifier = "CelebratoryModalView:stack-view" - rightStackView.accessibilityIdentifier = "CelebratoryModalView:stack-right-view" - rightContainer.accessibilityIdentifier = "CelebratoryModalView:stack-cotainer-right-view" - insideContainer.accessibilityIdentifier = "CelebratoryModalView:share-inside-container-view" - keepGoingButtonContainer.accessibilityIdentifier = "CelebratoryModalView:keep-going-button-container-view" - buttonContainer.accessibilityIdentifier = "CelebratoryModalView:share-button-container-view" - textContainer.accessibilityIdentifier = "CelebratoryModalView:share-text-container-view" - - stackView.alignment = .center - stackView.axis = .horizontal - stackView.distribution = .fillEqually - stackView.spacing = StandardVerticalMargin * 2 - insideContainer.backgroundColor = environment.styles.infoXXLight() - - modalView.addSubview(stackView) - textContainer.addSubview(celebrationMessageLabel) - buttonContainer.addSubview(shareImageView) - insideContainer.addSubview(shareButtonView) - insideContainer.addSubview(buttonContainer) - insideContainer.addSubview(textContainer) - - shareButtonView.superview?.bringSubviewToFront(shareButtonView) - - rightStackView.alignment = .fill - rightStackView.axis = .vertical - rightStackView.distribution = .equalSpacing - rightStackView.spacing = StandardVerticalMargin - - rightStackView.addArrangedSubview(titleLabel) - rightStackView.addArrangedSubview(titleMessageLabel) - rightStackView.addArrangedSubview(insideContainer) - rightStackView.addArrangedSubview(keepGoingButtonContainer) - - stackView.addArrangedSubview(congratulationImageView) - stackView.addArrangedSubview(rightContainer) - - rightContainer.addSubview(rightStackView) - keepGoingButtonContainer.addSubview(keepGoingButton) - - rightStackView.snp.makeConstraints { make in - make.edges.equalTo(rightContainer) - } - - rightContainer.snp.remakeConstraints { make in - make.height.equalTo(stackView) + congratulationImageView.snp.remakeConstraints { make in + make.top.equalTo(modalView).offset(StandardVerticalMargin * 2) + make.leading.equalTo(modalView).offset(StandardHorizontalMargin) + make.width.equalTo(celebrationImageSizeLandscape.width) + make.height.equalTo(celebrationImageSizeLandscape.height) + make.bottom.equalTo(modalView).inset(StandardVerticalMargin * 2) } titleLabel.snp.remakeConstraints { make in - make.height.equalTo(titleLabelHeight) + make.top.equalTo(congratulationImageView).offset(StandardVerticalMargin) + make.leading.equalTo(congratulationImageView.snp.trailing).offset(StandardHorizontalMargin) + make.trailing.equalTo(modalView).inset(StandardHorizontalMargin) } titleMessageLabel.snp.remakeConstraints { make in - make.height.equalTo(titleLabelMessageHeight) + make.top.equalTo(titleLabel.snp.bottom).offset(StandardVerticalMargin * 2) + make.leading.equalTo(titleLabel) + make.trailing.equalTo(titleLabel) + } + + shareContainer.snp.remakeConstraints { make in + make.top.greaterThanOrEqualTo(titleMessageLabel.snp.bottom).offset(StandardVerticalMargin) + make.leading.equalTo(titleLabel) + make.trailing.equalTo(titleLabel) } - insideContainer.snp.remakeConstraints { make in - make.height.equalTo(shareButtonContainerHeight) + shareButton.snp.remakeConstraints { make in + make.edges.equalTo(shareContainer) } shareImageView.snp.remakeConstraints { make in - make.top.equalTo(celebrationMessageLabel.snp.top) - make.leading.equalTo(buttonContainer).offset(StandardHorizontalMargin * 2) - make.trailing.equalTo(buttonContainer).inset(StandardHorizontalMargin) + make.top.equalTo(shareContainer).offset(StandardVerticalMargin * 2) + make.leading.equalTo(shareContainer).offset(StandardHorizontalMargin) make.width.equalTo(shareImageSize.width) make.height.equalTo(shareImageSize.height) } - - celebrationMessageLabel.snp.remakeConstraints { make in - make.centerX.equalTo(textContainer) - make.centerY.equalTo(textContainer) - make.leading.equalTo(textContainer) - make.trailing.equalTo(textContainer) - make.height.lessThanOrEqualTo(textContainer) - } - - shareButtonView.snp.makeConstraints { make in - make.edges.equalTo(insideContainer) - } - buttonContainer.snp.remakeConstraints { make in - make.leading.equalTo(insideContainer) - make.top.equalTo(insideContainer) - make.bottom.equalTo(insideContainer) - } - - textContainer.snp.remakeConstraints { make in - make.top.equalTo(insideContainer) - make.leading.equalTo(buttonContainer.snp.trailing).inset(StandardHorizontalMargin / 2) - make.trailing.equalTo(insideContainer).inset(StandardHorizontalMargin * 2) - make.bottom.equalTo(insideContainer).inset(StandardVerticalMargin) - } - - keepGoingButtonContainer.snp.remakeConstraints { make in - make.height.equalTo(keepGoingButtonSize.height) + shareMessageLabel.snp.remakeConstraints { make in + make.top.equalTo(shareImageView) + make.leading.equalTo(shareImageView.snp.trailing).offset(StandardHorizontalMargin / 2) + make.trailing.equalTo(shareContainer).inset(StandardHorizontalMargin) + make.bottom.equalTo(shareContainer).inset(StandardVerticalMargin * 2) } keepGoingButton.snp.remakeConstraints { make in - make.centerX.equalTo(keepGoingButtonContainer) - make.height.equalTo(keepGoingButtonContainer) + make.top.equalTo(shareContainer.snp.bottom).offset(StandardVerticalMargin * 2) + make.bottom.equalTo(modalView).inset(StandardVerticalMargin * 2) + make.centerX.equalTo(shareMessageLabel) + make.height.equalTo(keepGoingButtonSize.height) make.width.equalTo(keepGoingButtonSize.width) } + modalView.snp.remakeConstraints { make in - // For iPad the modal is streching to the end of the screen so we restricted the modal top, bottom, leading - // and trailing margin for iPad - - make.leading.equalTo(view).offset(isiPad() ? 100 : 40) - make.trailing.equalTo(view).inset(isiPad() ? 100 : 40) - - let top = isiPad() ? ((view.frame.size.height / 2.5 ) / 2) : ((view.frame.size.height / 4) / 2) - let bottom = isiPad() ? ((view.frame.size.width / 2.5 ) / 2) : ((view.frame.size.height / 4) / 2) - make.top.equalTo(view).offset(top) - make.bottom.equalTo(view).inset(bottom) make.centerX.equalTo(view) make.centerY.equalTo(view) - } - - stackView.snp.remakeConstraints { make in - make.edges.equalTo(modalView).inset(20) + make.leading.equalTo(safeLeading).offset(StandardHorizontalMargin) + make.trailing.equalTo(safeTrailing).inset(StandardHorizontalMargin) } } diff --git a/Source/ContainerNavigationController.swift b/Source/ContainerNavigationController.swift index 533e5b309c..09712bea10 100644 --- a/Source/ContainerNavigationController.swift +++ b/Source/ContainerNavigationController.swift @@ -113,6 +113,25 @@ extension UINavigationController { } } +extension UINavigationController { + public func presentViewControler(_ viewController: UIViewController, animated flag: Bool, completion: ((UIViewController) -> Void)? = nil) { + present(viewController, animated: flag) + guard flag, let coordinator = transitionCoordinator else { + DispatchQueue.main.async { [weak self] in + if let presentedController = self?.presentedViewController { + completion?(presentedController) + } + } + return + } + coordinator.animate(alongsideTransition: nil) { [weak self] _ in + if let presentedController = self?.presentedViewController { + completion?(presentedController) + } + } + } +} + /// https://stackoverflow.com/a/33767837 /// https://iganin.hatenablog.com/entry/2019/07/27/172911 extension UINavigationController { diff --git a/Source/Core/Code/NetworkManager.swift b/Source/Core/Code/NetworkManager.swift index 4deec96fea..9f9186e87b 100644 --- a/Source/Core/Code/NetworkManager.swift +++ b/Source/Core/Code/NetworkManager.swift @@ -30,7 +30,7 @@ private enum DeserializationResult { case queuedRequest(URLRequest: URLRequest, original: Data?) } -public typealias AuthenticateRequestCreator = (_ _networkManager: NetworkManager, _ _completion: @escaping (_ _success : Bool) -> Void) -> Void +public typealias AuthenticateRequestCreator = (_ _networkManager: NetworkManager, _ _completion: @escaping (_ _response: HTTPURLResponse?, _ _success : Bool) -> Void) -> Void public enum AuthenticationAction { case proceed @@ -386,9 +386,9 @@ open class NetworkManager : NSObject { if tokenStatus == .expired { if case .authenticate(let authenticateRequest) = authenticator?(nil, nil, true) { - authenticateRequest(self, { [weak self] success in + authenticateRequest(self, { [weak self] response, success in let request = self?.URLRequestWithRequest(base: base, networkRequest).value - self?.handleAuthenticationResponse(base: base, networkRequest: networkRequest, handler: handler, success: success, request: request, response: nil, baseData: nil, error: nil) + self?.handleAuthenticationResponse(base: base, networkRequest: networkRequest, handler: handler, success: success, request: request, response: response, baseData: nil, error: nil) }) } return nil @@ -449,7 +449,7 @@ open class NetworkManager : NSObject { Logger.logInfo(NetworkManager.NETWORK, "Response is \(String(describing: response))") handler(result) case let .some(.reauthenticationRequest(authHandler, originalData)): - authHandler(self, { [weak self] success in + authHandler(self, { [weak self] _, success in self?.handleAuthenticationResponse(base: base, networkRequest: networkRequest, handler: handler, success: success, request: request, response: response, baseData: originalData, error: error) }) case let .some(.queuedRequest(request, _)): diff --git a/Source/Core/Test/Code/NetworkManagerTests.swift b/Source/Core/Test/Code/NetworkManagerTests.swift index 962d73e79a..765c57a56c 100644 --- a/Source/Core/Test/Code/NetworkManagerTests.swift +++ b/Source/Core/Test/Code/NetworkManagerTests.swift @@ -296,7 +296,7 @@ class NetworkManagerTests: XCTestCase { if response!.statusCode == 401 { return AuthenticationAction.authenticate({ (networkManager, completion) in OHHTTPStubs.removeStub(stub401Response) - return completion(true) + return completion(nil, true) })} else { OHHTTPStubs.removeStub(stub200Response) @@ -331,7 +331,7 @@ class NetworkManagerTests: XCTestCase { manager.authenticator = { (response, data, _) -> AuthenticationAction in return AuthenticationAction.authenticate({ (networkManager, completion) in OHHTTPStubs.removeStub(stub401Response) - return completion(false) + return completion(nil, false) }) } diff --git a/Source/CourseAccessHelper.swift b/Source/CourseAccessHelper.swift new file mode 100644 index 0000000000..a81620af38 --- /dev/null +++ b/Source/CourseAccessHelper.swift @@ -0,0 +1,91 @@ +// +// CourseAccessHelper.swift +// edX +// +// Created by MuhammadUmer on 05/01/2023. +// Copyright © 2023 edX. All rights reserved. +// + +enum CourseAccessErrorHelperType { + case isEndDateOld + case startDateError + case auditExpired + case upgradeable +} + +class CourseAccessHelper { + private let course: OEXCourse + private let enrollment: UserCourseEnrollment? + + init(course: OEXCourse, enrollment: UserCourseEnrollment? = nil) { + self.course = course + self.enrollment = enrollment + } + + var type: CourseAccessErrorHelperType? { + guard let enrollment = enrollment else { return nil } + + if course.isEndDateOld { + if enrollment.isUpgradeable { + return .upgradeable + } else { + return .isEndDateOld + } + } else { + guard let errorCode = course.courseware_access?.error_code else { return nil } + + switch errorCode { + case .startDateError: + return .startDateError + case .auditExpired: + return .auditExpired + + default: + return nil + } + } + } + + var shouldShowValueProp: Bool { + return type == .upgradeable || type == .auditExpired + } + + var errorTitle: String? { + switch type { + case .isEndDateOld: + return Strings.CourseDashboard.Error.courseEndedTitle + case .startDateError: + return Strings.CourseDashboard.Error.courseNotStartedTitle + case .auditExpired: + return Strings.CourseDashboard.Error.courseAccessExpiredTitle + default: + return Strings.CourseDashboard.Error.courseAccessExpiredTitle + } + } + + var errorInfo: String? { + switch type { + case .isEndDateOld: + return Strings.CourseDashboard.Error.courseAccessExpiredInfo + case .startDateError: + return formatedStartDate(displayInfo: course.start_display_info) + case .auditExpired: + return Strings.CourseDashboard.Error.auditExpiredUpgradeInfo + default: + return Strings.CourseDashboard.Error.courseAccessExpiredInfo + } + } + + private func formatedStartDate(displayInfo: OEXCourseStartDisplayInfo) -> String { + if let displayDate = displayInfo.displayDate, displayInfo.type == .string && !displayDate.isEmpty { + return Strings.CourseDashboard.Error.courseNotStartedInfo(startDate: displayDate) + } + + if let displayDate = displayInfo.date as? NSDate, displayInfo.type == .timestamp { + let formattedDisplayDate = DateFormatting.format(asMonthDayYearString: displayDate) ?? "" + return Strings.CourseDashboard.Error.courseNotStartedInfo(startDate: formattedDisplayDate) + } + + return Strings.courseNotStarted + } +} diff --git a/Source/CourseAnnouncementsViewController.swift b/Source/CourseAnnouncementsViewController.swift index 780f83fb79..b5e2c33a66 100644 --- a/Source/CourseAnnouncementsViewController.swift +++ b/Source/CourseAnnouncementsViewController.swift @@ -18,7 +18,7 @@ private func announcementsDeserializer(response: HTTPURLResponse, json: JSON) -> } } -class CourseAnnouncementsViewController: OfflineSupportViewController, LoadStateViewReloadSupport, InterfaceOrientationOverriding { +class CourseAnnouncementsViewController: OfflineSupportViewController, LoadStateViewReloadSupport, InterfaceOrientationOverriding, ScrollableDelegateProvider { typealias Environment = OEXAnalyticsProvider & OEXConfigProvider & DataManagerProvider & NetworkManagerProvider & OEXRouterProvider & OEXInterfaceProvider & ReachabilityProvider & OEXSessionProvider & OEXStylesProvider @@ -32,6 +32,9 @@ class CourseAnnouncementsViewController: OfflineSupportViewController, LoadState private let fontStyle = OEXTextStyle(weight : .normal, size: .base, color: OEXStyles.shared().neutralBlack()) private let switchStyle = OEXStyles.shared().standardSwitchStyle() + weak var scrollableDelegate: ScrollableDelegate? + private var scrollByDragging = false + @objc init(environment: Environment, courseID: String) { self.courseID = courseID self.environment = environment @@ -54,6 +57,7 @@ class CourseAnnouncementsViewController: OfflineSupportViewController, LoadState webView.backgroundColor = OEXStyles.shared().standardBackgroundColor() webView.isOpaque = false webView.navigationDelegate = self + webView.scrollView.delegate = self loadController.setupInController(controller: self, contentView: webView) announcementsLoader.listen(self) {[weak self] in @@ -174,3 +178,19 @@ extension CourseAnnouncementsViewController: WKNavigationDelegate { loadController.state = LoadState.failed(error: error as NSError) } } + +extension CourseAnnouncementsViewController: UIScrollViewDelegate { + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollByDragging = true + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollByDragging { + scrollableDelegate?.scrollViewDidScroll(scrollView: scrollView) + } + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + scrollByDragging = false + } +} diff --git a/Source/CourseCertificateView.swift b/Source/CourseCertificateView.swift index 0c33896594..6248e91523 100644 --- a/Source/CourseCertificateView.swift +++ b/Source/CourseCertificateView.swift @@ -16,7 +16,7 @@ struct CourseCertificateIem { class CourseCertificateView: UIView { - static let height: CGFloat = 100.0 + static let height: CGFloat = 132.0 private let certificateImageView = UIImageView() private let titleLabel = UILabel() private let subtitleLabel = UILabel() @@ -61,31 +61,85 @@ class CourseCertificateView: UIView { titleLabel.setContentCompressionResistancePriority(UILayoutPriority.defaultLow, for: .horizontal) subtitleLabel.adjustsFontSizeToFitWidth = true - - certificateImageView.snp.makeConstraints { make in - make.top.equalTo(self).offset(StandardVerticalMargin) - make.bottom.equalTo(self).inset(StandardVerticalMargin) + + setAccessibilityIdentifiers() + } + + override func layoutSubviews() { + super.layoutSubviews() + setConstraints() + } + + private func setConstraints() { + if traitCollection.verticalSizeClass == .regular { + addPortraitConstraints() + } else { + addLandscapeConstraints() + } + } + + private func addPortraitConstraints() { + if OEXConfig.shared().isNewDashboardEnabled { + certificateImageView.snp.remakeConstraints { make in + make.top.equalTo(self).offset(StandardVerticalMargin * 2) + make.centerX.equalTo(self) + make.width.equalTo(138) + make.height.equalTo(100) + } + + titleLabel.snp.remakeConstraints { make in + make.top.equalTo(certificateImageView.snp.bottom).offset(StandardVerticalMargin * 2) + make.centerX.equalTo(certificateImageView) + } + + subtitleLabel.snp.remakeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(StandardVerticalMargin) + make.centerX.equalTo(titleLabel) + } + + viewCertificateButton.snp.remakeConstraints { make in + make.top.equalTo(subtitleLabel.snp.bottom).offset(StandardVerticalMargin * 2) + make.leading.equalTo(self).offset(StandardHorizontalMargin) + make.trailing.equalTo(self).inset(StandardHorizontalMargin) + make.centerX.equalTo(subtitleLabel) + make.height.equalTo(StandardVerticalMargin * 4.5) + make.bottom.equalTo(self).inset(StandardVerticalMargin * 2) + + } + } + else { + // old portrait style is somewhat close to landscape design so for view simplicity using that here as well + addLandscapeConstraints() + } + } + + private func addLandscapeConstraints() { + certificateImageView.snp.remakeConstraints { make in + make.top.equalTo(self).offset(StandardVerticalMargin * 2) + make.bottom.equalTo(self).inset(StandardVerticalMargin * 2) make.leading.equalTo(self).offset(StandardHorizontalMargin) + make.width.equalTo(138) + make.height.equalTo(100) } - titleLabel.snp.makeConstraints { make in + titleLabel.snp.remakeConstraints { make in make.top.equalTo(certificateImageView) + make.leading.equalTo(certificateImageView.snp.trailing).offset(StandardHorizontalMargin) make.trailing.equalTo(self).inset(StandardHorizontalMargin) } - subtitleLabel.snp.makeConstraints { make in + subtitleLabel.snp.remakeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(StandardVerticalMargin) make.leading.equalTo(titleLabel) make.trailing.equalTo(titleLabel) - make.top.equalTo(titleLabel.snp.bottom) } - viewCertificateButton.snp.makeConstraints { make in - make.leading.equalTo(titleLabel) - make.trailing.equalTo(titleLabel) + viewCertificateButton.snp.remakeConstraints { make in + make.leading.equalTo(subtitleLabel) + make.trailing.equalTo(self).inset(StandardHorizontalMargin) + make.height.equalTo(StandardVerticalMargin * 4.5) make.bottom.equalTo(certificateImageView) } - - setAccessibilityIdentifiers() } private func setAccessibilityIdentifiers() { @@ -100,10 +154,19 @@ class CourseCertificateView: UIView { guard let certificateItem = item else {return} certificateImageView.image = certificateItem.certificateImage - let titleStyle = OEXTextStyle(weight: .normal, size: .large, color: OEXStyles.shared().primaryBaseColor()) - let subtitleStyle = OEXTextStyle(weight: .normal, size: .base, color: OEXStyles.shared().primaryXLightColor()) + let titleStyle = OEXMutableTextStyle(weight: .normal, size: .large, color: OEXStyles.shared().primaryBaseColor()) + let subtitleStyle = OEXMutableTextStyle(weight: .normal, size: .small, color: OEXStyles.shared().primaryXLightColor()) + + var title = Strings.Certificates.courseCompletionTitle + if OEXConfig.shared().isNewDashboardEnabled { + titleStyle.weight = .bold + titleStyle.size = .xxLarge + titleStyle.color = OEXStyles.shared().neutralBlack() + subtitleStyle.color = OEXStyles.shared().neutralXDark() + title = title.capitalized + } - titleLabel.attributedText = titleStyle.attributedString(withText: Strings.Certificates.courseCompletionTitle) + titleLabel.attributedText = titleStyle.attributedString(withText: title) subtitleLabel.attributedText = subtitleStyle.attributedString(withText: Strings.Certificates.courseCompletionSubtitle) addActionIfNeccessary() @@ -119,7 +182,4 @@ class CourseCertificateView: UIView { } addGestureRecognizer(tapGesture) } - } - - diff --git a/Source/CourseContentHeaderBlockPickerCell.swift b/Source/CourseContentHeaderBlockPickerCell.swift new file mode 100644 index 0000000000..729c93635e --- /dev/null +++ b/Source/CourseContentHeaderBlockPickerCell.swift @@ -0,0 +1,176 @@ +// +// CourseContentHeaderBlockPickerCell.swift +// edX +// +// Created by MuhammadUmer on 02/06/2023. +// Copyright © 2023 edX. All rights reserved. +// + +import UIKit + +class CourseContentHeaderBlockPickerCell: UITableViewCell { + static let identifier = "CourseContentHeaderBlockPickerCell" + + private let imageSize: CGFloat = 10 + private let imageContainerSize: CGFloat = 16 + private let completedImagesize: CGFloat = 16 + + private lazy var titleStyle = OEXTextStyle(weight: .normal, size: .small, color: OEXStyles.shared().neutralXXDark()) + private lazy var subtitleStyle = OEXTextStyle(weight: .normal, size: .xSmall, color: OEXStyles.shared().neutralXDark()) + + private lazy var lockedImageView: UIImageView = { + let imageView = UIImageView() + imageView.accessibilityIdentifier = "CourseContentHeaderBlockPickerCell:locked-image-view" + imageView.image = Icon.Closed.imageWithFontSize(size: imageSize) + imageView.tintColor = OEXStyles.shared().neutralWhiteT() + return imageView + }() + + private lazy var completedImageView: UIImageView = { + let imageView = UIImageView() + imageView.accessibilityIdentifier = "CourseContentHeaderBlockPickerCell:completed-image-view" + imageView.image = Icon.CheckCircle.imageWithFontSize(size: completedImagesize) + imageView.tintColor = OEXStyles.shared().successBase() + return imageView + }() + + private lazy var imageViewContainer: UIView = { + let view = UIView() + view.accessibilityIdentifier = "CourseContentHeaderBlockPickerCell:image-view-container" + view.backgroundColor = OEXStyles.shared().secondaryBaseColor() + return view + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.accessibilityIdentifier = "CourseContentHeaderBlockPickerCell:title-label" + label.numberOfLines = 1 + return label + }() + + private lazy var subtitleLabel: UILabel = { + let label = UILabel() + label.accessibilityIdentifier = "CourseContentHeaderBlockPickerCell:subtitle-label" + label.numberOfLines = 0 + return label + }() + + private lazy var separator: UIView = { + let view = UIView() + view.accessibilityIdentifier = "CourseContentHeaderBlockPickerCell:separator-view" + view.backgroundColor = OEXStyles.shared().neutralXLight() + return view + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + selectionStyle = .none + accessibilityIdentifier = "CourseContentHeaderBlockPickerCell:tableview-cell" + + addSubviews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + contentView.backgroundColor = OEXStyles.shared().neutralWhiteT() + contentView.subviews.forEach { view in + view.removeFromSuperview() + } + } + + override func layoutSubviews() { + imageViewContainer.layer.cornerRadius = imageContainerSize / 2 + imageViewContainer.clipsToBounds = true + } + + func setup(block: CourseBlock) { + titleLabel.attributedText = titleStyle.attributedString(withText: block.displayName) + subtitleLabel.text = "" + + if block.isGated { + addGatedSubviews() + completedImageView.isHidden = true + lockedImageView.isHidden = false + subtitleLabel.attributedText = subtitleStyle.attributedString(withText: Strings.CourseOutlineHeader.gatedContentTitle) + } else { + addSubviews() + completedImageView.isHidden = !block.isCompleted + lockedImageView.isHidden = true + } + } +} + +extension CourseContentHeaderBlockPickerCell { + private func addSubviews() { + contentView.addSubview(completedImageView) + contentView.addSubview(titleLabel) + contentView.addSubview(separator) + + completedImageView.snp.remakeConstraints { make in + make.leading.equalTo(contentView).offset(StandardHorizontalMargin) + make.height.width.equalTo(completedImagesize) + make.centerY.equalTo(contentView) + } + + titleLabel.snp.remakeConstraints { make in + make.leading.equalTo(completedImageView.snp.trailing).offset(StandardHorizontalMargin) + make.trailing.equalTo(contentView).inset(StandardHorizontalMargin) + make.centerY.equalTo(contentView) + } + + separator.snp.remakeConstraints { make in + make.leading.equalTo(self) + make.trailing.equalTo(self) + make.bottom.equalTo(self) + make.height.equalTo(1) + } + } +} + +extension CourseContentHeaderBlockPickerCell { + private func addGatedSubviews() { + imageViewContainer.addSubview(lockedImageView) + contentView.addSubview(imageViewContainer) + contentView.addSubview(titleLabel) + contentView.addSubview(subtitleLabel) + contentView.addSubview(separator) + + titleLabel.snp.remakeConstraints { make in + make.leading.equalTo(lockedImageView.snp.trailing).offset(StandardHorizontalMargin) + make.trailing.equalTo(contentView).inset(StandardHorizontalMargin) + make.top.equalTo(contentView).offset(StandardVerticalMargin) + } + + lockedImageView.snp.remakeConstraints { make in + make.center.equalTo(imageViewContainer) + make.height.equalTo(imageSize) + make.width.equalTo(imageSize) + } + + imageViewContainer.snp.remakeConstraints { make in + make.centerY.equalTo(subtitleLabel) + make.leading.equalTo(contentView).offset(StandardHorizontalMargin) + make.height.width.equalTo(imageContainerSize) + } + + subtitleLabel.snp.remakeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(StandardVerticalMargin * 1.25) + make.leading.equalTo(lockedImageView.snp.trailing).offset(StandardHorizontalMargin) + make.trailing.equalTo(contentView).inset(StandardHorizontalMargin) + make.centerY.equalTo(lockedImageView) + } + + separator.snp.remakeConstraints { make in + make.leading.equalTo(self) + make.trailing.equalTo(self) + make.bottom.equalTo(self) + make.height.equalTo(1) + } + } +} diff --git a/Source/CourseContentHeaderView.swift b/Source/CourseContentHeaderView.swift new file mode 100644 index 0000000000..ce96859e0e --- /dev/null +++ b/Source/CourseContentHeaderView.swift @@ -0,0 +1,304 @@ +// +// CourseContentHeaderView.swift +// edX +// +// Created by MuhammadUmer on 11/04/2023. +// Copyright © 2023 edX. All rights reserved. +// + +import UIKit + +protocol CourseContentHeaderViewDelegate: AnyObject { + func didTapBackButton() + func didTapOnUnitBlock(block: CourseBlock) +} + +class CourseContentHeaderView: UIView { + typealias Environment = OEXStylesProvider + + weak var delegate: CourseContentHeaderViewDelegate? + + private let environment: Environment + + private let dropdownImageSize: CGFloat = 20 + private let backButtonImageSize: CGFloat = 44 + private let attributedUnicodeSpace = NSAttributedString(string: "\u{2002}") + private let cellHeight: CGFloat = 36 + private let gatedCellHeight: CGFloat = 76 + + private lazy var headerTextstyle: OEXMutableTextStyle = { + let style = OEXMutableTextStyle(weight: .bold, size: .base, color: environment.styles.neutralWhiteT()) + style.alignment = .center + return style + }() + + private lazy var titleTextStyle = OEXMutableTextStyle(weight: .normal, size: .base, color: environment.styles.neutralWhiteT()) + private lazy var subtitleTextStyle = OEXMutableTextStyle(weight: .bold, size: .large, color: environment.styles.neutralWhiteT()) + + private lazy var backButton: UIButton = { + let button = UIButton() + button.accessibilityIdentifier = "CourseContentHeaderView:back-button" + button.setImage(Icon.ArrowLeft.imageWithFontSize(size: 44), for: .normal) + button.tintColor = environment.styles.neutralWhiteT() + button.oex_addAction({ [weak self] _ in + self?.delegate?.didTapBackButton() + }, for: .touchUpInside) + return button + }() + + private lazy var headerLabel: UILabel = { + let label = UILabel() + label.accessibilityIdentifier = "CourseContentHeaderView:header-label" + label.backgroundColor = .clear + label.alpha = 0 + return label + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.accessibilityIdentifier = "CourseContentHeaderView:title-label" + label.backgroundColor = .clear + return label + }() + + private lazy var subtitleLabel: UILabel = { + let label = UILabel() + label.accessibilityIdentifier = "CourseContentHeaderView:subtitle-label" + return label + }() + + private lazy var bottomContainer: UIView = { + let view = UIView() + view.accessibilityIdentifier = "CourseContentHeaderView:bottom-container" + return view + }() + + private lazy var imageContainer: UIView = { + let view = UIView() + view.accessibilityIdentifier = "CourseContentHeaderView:image-container" + return view + }() + + private lazy var dropDownImageView: UIImageView = { + let imageView = UIImageView() + imageView.accessibilityIdentifier = "CourseContentHeaderView:image-view" + imageView.image = Icon.Dropdown.imageWithFontSize(size: dropdownImageSize) + imageView.tintColor = environment.styles.neutralWhiteT() + return imageView + }() + + private lazy var button: UIButton = { + let button = UIButton() + button.accessibilityIdentifier = "CourseContentHeaderView:button-view" + button.oex_addAction({ [weak self] _ in + self?.handleDropDown() + }, for: .touchUpInside) + return button + }() + + private var shouldShowDropDown: Bool = true { + didSet { + if !shouldShowDropDown { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + self?.shouldShowDropDown = true + } + } + } + } + + private var dropDown: DropDown? + private var tableView: UITableView? + + private var currentBlock: CourseBlock? + private var blocks: [CourseBlock] = [] + + init(environment: Environment) { + self.environment = environment + super.init(frame: .zero) + addSubViews() + addConstraints() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func addSubViews() { + backgroundColor = environment.styles.primaryLightColor() + + addSubview(backButton) + addSubview(headerLabel) + addSubview(titleLabel) + imageContainer.addSubview(dropDownImageView) + bottomContainer.addSubview(imageContainer) + bottomContainer.addSubview(subtitleLabel) + bottomContainer.addSubview(button) + addSubview(bottomContainer) + + button.isEnabled = false + dropDownImageView.isHidden = true + } + + private func addConstraints() { + dropDownImageView.snp.makeConstraints { make in + make.leading.equalTo(imageContainer) + make.top.equalTo(imageContainer) + make.bottom.equalTo(imageContainer) + make.width.equalTo(dropdownImageSize) + } + + backButton.snp.makeConstraints { make in + make.leading.equalTo(self).inset(StandardVerticalMargin / 2) + make.top.equalTo(self).offset(StandardVerticalMargin * 1.25) + make.height.equalTo(backButtonImageSize) + make.width.equalTo(backButtonImageSize) + } + + headerLabel.snp.makeConstraints { make in + make.centerY.equalTo(backButton) + make.top.equalTo(backButton) + make.leading.equalTo(backButton.snp.trailing).offset(StandardHorizontalMargin) + make.trailing.equalTo(self).inset(StandardHorizontalMargin * 2) + } + + titleLabel.snp.makeConstraints { make in + make.top.equalTo(backButton.snp.bottom).offset(StandardVerticalMargin * 2) + make.leading.equalTo(self).offset(StandardHorizontalMargin) + make.trailing.equalTo(self).inset(StandardHorizontalMargin) + } + + bottomContainer.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(StandardVerticalMargin / 2) + make.leading.equalTo(self).offset(StandardHorizontalMargin) + make.trailing.equalTo(self).inset(StandardHorizontalMargin) + } + + subtitleLabel.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview() + } + + imageContainer.snp.makeConstraints { make in + make.leading.equalTo(subtitleLabel.snp.trailing).offset(10) + make.trailing.top.bottom.equalToSuperview() + make.width.greaterThanOrEqualTo(dropdownImageSize) + } + + button.snp.makeConstraints { make in + make.leading.equalTo(bottomContainer) + make.top.equalTo(bottomContainer) + make.bottom.equalTo(bottomContainer) + make.trailing.equalTo(dropDownImageView) + } + } + + private func handleDropDown() { + if dropDown?.isVisible == true { + dropDown?.hide() + rotateImageView(clockWise: false) + dropDown = nil + } else { + if shouldShowDropDown { + showDropDown() + rotateImageView(clockWise: true) + } + } + } + + private func showDropDown() { + let safeAreaInset = UIApplication.shared.windows.first?.safeAreaInsets ?? .zero + + let dropDown = DropDown() + dropDown.accessibilityIdentifier = "CourseContentHeaderView:drop-down" + tableView = dropDown.setupCustom() + tableView?.accessibilityIdentifier = "CourseContentHeaderView:table-view" + + dropDown.bottomOffset = CGPoint(x: 0, y: StandardVerticalMargin * 5) + dropDown.direction = .bottom + dropDown.anchorView = bottomContainer + dropDown.dismissMode = .automatic + dropDown.cornerRadius = 10 + dropDown.offsetFromWindowBottom = safeAreaInset.bottom + dropDown.cancelAction = { [weak self] in + self?.shouldShowDropDown = false + self?.rotateImageView(clockWise: false) + } + self.dropDown = dropDown + + tableView?.dataSource = self + tableView?.delegate = self + tableView?.register(CourseContentHeaderBlockPickerCell.self, forCellReuseIdentifier: CourseContentHeaderBlockPickerCell.identifier) + tableView?.rowHeight = UITableView.automaticDimension + tableView?.estimatedRowHeight = cellHeight + tableView?.separatorStyle = .singleLine + tableView?.separatorColor = environment.styles.neutralXLight() + tableView?.separatorInset = UIEdgeInsets(top: 0, left: 5, bottom: 0, right: 5) + tableView?.layer.cornerRadius = 10 + + let height = blocks.reduce(0) { $0 + ($1.isGated ? gatedCellHeight : cellHeight) } + dropDown.updatedTableHeight = height + dropDown.updatedMinHeight = cellHeight + + tableView?.reloadData() + + dropDown.show() + } + + private func rotateImageView(clockWise: Bool) { + UIView.animate(withDuration: 0.3) { [weak self] in + guard let weakSelf = self else { return } + if clockWise { + weakSelf.dropDownImageView.transform = weakSelf.dropDownImageView.transform.rotated(by: -(.pi * 0.999)) + } else { + weakSelf.dropDownImageView.transform = .identity + } + } + } + + func setBlocks(currentBlock: CourseBlock, blocks: [CourseBlock]) { + self.currentBlock = currentBlock + self.blocks = blocks + + if blocks.count > 1 { + button.isEnabled = true + dropDownImageView.isHidden = false + } + } + + func showHeaderLabel(show: Bool) { + headerLabel.alpha = show ? 1 : 0 + } + + func update(title: String, subtitle: String?) { + headerLabel.attributedText = headerTextstyle.attributedString(withText: title) + titleLabel.attributedText = titleTextStyle.attributedString(withText: title) + subtitleLabel.attributedText = subtitleTextStyle.attributedString(withText: subtitle) + } +} + +extension CourseContentHeaderView: UITableViewDelegate, UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return blocks.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: CourseContentHeaderBlockPickerCell.identifier, for: indexPath) as! CourseContentHeaderBlockPickerCell + let block = blocks[indexPath.row] + cell.setup(block: block) + cell.contentView.backgroundColor = currentBlock?.blockID == block.blockID ? environment.styles.neutralXLight() : environment.styles.neutralWhiteT() + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + handleDropDown() + delegate?.didTapOnUnitBlock(block: blocks[indexPath.row]) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let block = blocks[indexPath.row] + return block.isGated ? gatedCellHeight : cellHeight + } +} diff --git a/Source/CourseContentPageViewController.swift b/Source/CourseContentPageViewController.swift index 264a540ff9..1da0c37233 100644 --- a/Source/CourseContentPageViewController.swift +++ b/Source/CourseContentPageViewController.swift @@ -116,7 +116,8 @@ public class CourseContentPageViewController : UIPageViewController, UIPageViewC // Filed http://www.openradar.appspot.com/radar?id=6188034965897216 against Apple to better expose // this API. // Verified on iOS9 and iOS 8 - if let scrollView = (view.subviews.compactMap { return $0 as? UIScrollView }).first { + if let scrollView = view.subviews.compactMap({ return $0 as? UIScrollView }).first { + scrollView.delegate = self scrollView.delaysContentTouches = false } addObservers() @@ -213,21 +214,35 @@ public class CourseContentPageViewController : UIPageViewController, UIPageViewC switch direction { case .Next: - titleText = isGroup ? Strings.nextUnit : Strings.next + if environment.config.isNewComponentNavigationEnabled { + titleText = Strings.next + } else { + titleText = isGroup ? Strings.nextUnit : Strings.next + } moveDirection = .forward case .Prev: - titleText = isGroup ? Strings.previousUnit : Strings.previous + if environment.config.isNewComponentNavigationEnabled { + titleText = Strings.previous + } else { + titleText = isGroup ? Strings.previousUnit : Strings.previous + } moveDirection = .reverse } - let destinationText = adjacentGroup?.displayName + let destinationText: String? + + if environment.config.isNewComponentNavigationEnabled { + destinationText = nil + } else { + destinationText = adjacentGroup?.displayName + } let view = DetailToolbarButton(direction: direction, titleText: titleText, destinationText: destinationText) {[weak self] in self?.moveInDirection(direction: moveDirection) } view.sizeToFit() - let barButtonItem = UIBarButtonItem(customView: view) + let barButtonItem = UIBarButtonItem(customView: view) barButtonItem.isEnabled = enabled view.button.isEnabled = enabled return barButtonItem @@ -264,6 +279,9 @@ public class CourseContentPageViewController : UIPageViewController, UIPageViewC UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), nextItem ], animated : true) + if environment.config.isNewComponentNavigationEnabled { + navigationController?.toolbar.barTintColor = OEXStyles.shared().neutralWhiteT() + } } else { toolbarItems = [] @@ -318,7 +336,16 @@ public class CourseContentPageViewController : UIPageViewController, UIPageViewC } private func showCelebratoryModal(direction: UIPageViewController.NavigationDirection, overController: UIViewController?) { - let celebratoryModalView = environment.router?.showCelebratoryModal(fromController: self, courseID: courseQuerier.courseID) + + var controller: UIViewController = self + + if environment.config.isNewComponentNavigationEnabled { + if let contentContainerController = parent?.parent { + controller = contentContainerController + } + } + + let celebratoryModalView = environment.router?.showCelebratoryModal(fromController: controller, courseID: courseQuerier.courseID) if let videoBlockViewController = overController as? VideoBlockViewController { celebratoryModalView?.delegate = videoBlockViewController } @@ -392,6 +419,30 @@ public class CourseContentPageViewController : UIPageViewController, UIPageViewC updateTransitionState(is: true) } + public func moveToBlock(block: CourseBlock) { + guard let cursor = contentLoader.value, cursor.current.block.blockID != block.blockID else { + return + } + + let currentIndex = cursor.currentIndex() + + cursor.updateCurrentToItemMatching { $0.block.blockID == block.blockID } + + let nextIndex = cursor.currentIndex() + + let direction: NavigationDirection = nextIndex > currentIndex ? .forward : .reverse + + guard let nextController = controllerForBlock(block: block) else { return } + + setPageControllers(with: [nextController], direction: direction, animated: false) { [weak self] finished in + guard let weakSelf = self else { return } + weakSelf.updateTransitionState(is: false) + if weakSelf.shouldCelebrationAppear { + weakSelf.showCelebratoryModal(direction: direction, overController: nextController) + } + } + } + func controllerForBlock(block : CourseBlock, shouldCelebrationAppear: Bool = false) -> UIViewController? { let blockViewController : UIViewController? @@ -469,6 +520,19 @@ public class CourseContentPageViewController : UIPageViewController, UIPageViewC } } +// There is a bug with UIPageViewController, if user tries to quickly scroll between controllers, +// the UIPageViewControllerDelegate is not being called appropriately, +// to handle this, listen to UISsrollViewDelegate and handle the user interaction. +extension CourseContentPageViewController: UIScrollViewDelegate { + public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + updateTransitionState(is: false) + } + + public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + updateTransitionState(is: false) + } +} + // MARK: Testing extension CourseContentPageViewController { public func t_blockIDForCurrentViewController() -> OEXStream { diff --git a/Source/CourseDashboardAccessErrorView.swift b/Source/CourseDashboardAccessErrorView.swift new file mode 100644 index 0000000000..aacded5b55 --- /dev/null +++ b/Source/CourseDashboardAccessErrorView.swift @@ -0,0 +1,245 @@ +// +// CourseDashboardAccessErrorView.swift +// edX +// +// Created by Saeed Bashir on 12/2/22. +// Copyright © 2022 edX. All rights reserved. +// + +import Foundation + +protocol CourseDashboardAccessErrorViewDelegate: AnyObject { + func findCourseAction() + func upgradeCourseAction(course: OEXCourse, coursePrice: String, price: NSDecimalNumber?, currencyCode: String?, completion: @escaping ((Bool)->())) + func coursePrice(cell: CourseDashboardAccessErrorView, price: String?, elapsedTime: Int) +} + +class CourseDashboardAccessErrorView: UIView { + + typealias Environment = OEXConfigProvider & ServerConfigProvider & OEXInterfaceProvider + weak var delegate: CourseDashboardAccessErrorViewDelegate? + + private lazy var infoMessagesView = ValuePropMessagesView() + private var environment: Environment? + + private lazy var contentView = UIView() + + private lazy var upgradeButton: CourseUpgradeButtonView = { + let upgradeButton = CourseUpgradeButtonView() + upgradeButton.tapAction = { [weak self] in + guard let course = self?.course, let coursePrice = self?.localizedCoursePrice else { return } + self?.delegate?.upgradeCourseAction(course: course, coursePrice: coursePrice, price: self?.price, currencyCode: self?.currencyCode) { _ in + self?.upgradeButton.stopAnimating() + } + } + return upgradeButton + }() + + private var titleLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.accessibilityIdentifier = "CourseDashboardAccessErrorView:title-label" + return label + }() + + private var infoLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.accessibilityIdentifier = "CourseDashboardAccessErrorView:info-label" + return label + }() + + private lazy var findCourseButton: UIButton = { + let button = UIButton(type: .system) + button.accessibilityIdentifier = "CourseDashboardAccessErrorView:findcourse-button" + button.oex_addAction({ [weak self] _ in + self?.delegate?.findCourseAction() + }, for: .touchUpInside) + return button + }() + + private lazy var hiddenView: UIView = { + let view = UIView() + view.accessibilityIdentifier = "CourseDashboardAccessErrorView:hidden-view" + view.backgroundColor = .clear + + return view + }() + + init() { + super.init(frame: .zero) + } + + private var course: OEXCourse? + private var error: CourseAccessHelper? + + private var localizedCoursePrice: String? + private var price: NSDecimalNumber? + private var currencyCode: String? + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func handleCourseAccessError(environment: Environment, course: OEXCourse?, error: CourseAccessHelper?) { + guard let course = course else { return } + + self.course = course + self.error = error + self.environment = environment + + let title = error?.errorTitle ?? Strings.CourseDashboard.Error.courseEndedTitle + let info = error?.errorInfo ?? Strings.CourseDashboard.Error.courseAccessExpiredInfo + let showValueProp = error?.shouldShowValueProp ?? false + + configureViews() + update(title: title, info: info) + fetchCoursePrice() + + var upgradeEnabled: Bool = false + + if let enrollment = environment.interface?.enrollmentForCourse(withID: course.course_id), enrollment.isUpgradeable && environment.serverConfig.iapConfig?.enabledforUser == true { + upgradeEnabled = true + } + + setConstraints(showValueProp: showValueProp, showUpgradeButton: upgradeEnabled) + } + + private func configureViews() { + accessibilityIdentifier = "CourseDashboardAccessErrorView:view" + addSubview(contentView) + contentView.addSubview(titleLabel) + contentView.addSubview(infoLabel) + contentView.addSubview(infoMessagesView) + contentView.addSubview(upgradeButton) + contentView.addSubview(findCourseButton) + contentView.addSubview(hiddenView) + } + + func hideUpgradeButton() { + setConstraints(showValueProp: error?.shouldShowValueProp ?? false, showUpgradeButton: false) + } + + private func setConstraints(showValueProp: Bool, showUpgradeButton: Bool) { + contentView.snp.remakeConstraints { make in + make.edges.equalTo(self) + } + + titleLabel.snp.remakeConstraints { make in + make.top.equalTo(contentView).offset(StandardVerticalMargin * 2) + make.leading.equalTo(contentView).offset(StandardHorizontalMargin) + make.trailing.equalTo(contentView).inset(StandardHorizontalMargin) + } + + infoLabel.snp.remakeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(2 * StandardVerticalMargin) + make.leading.equalTo(contentView).offset(StandardHorizontalMargin) + make.trailing.equalTo(contentView).inset(StandardHorizontalMargin) + } + + var lastView: UIView + lastView = infoLabel + + if showValueProp { + infoMessagesView.snp.remakeConstraints { make in + make.top.equalTo(lastView.snp.bottom).offset(StandardVerticalMargin * 2) + make.leading.equalTo(contentView).offset(StandardHorizontalMargin) + make.trailing.equalTo(contentView).inset(StandardHorizontalMargin) + make.height.equalTo(infoMessagesView.height()) + } + + lastView = infoMessagesView + } + + if showUpgradeButton { + applyStyle(to: findCourseButton, text: Strings.CourseDashboard.Error.findANewCourse, light: true) + + upgradeButton.isHidden = false + upgradeButton.snp.remakeConstraints { make in + make.top.equalTo(lastView.snp.bottom).offset(StandardVerticalMargin * 5) + make.leading.equalTo(contentView).offset(StandardHorizontalMargin) + make.trailing.equalTo(contentView).inset(StandardHorizontalMargin) + make.height.equalTo(StandardVerticalMargin * 5.5) + } + + lastView = upgradeButton + } else { + applyStyle(to: findCourseButton, text: Strings.CourseDashboard.Error.findANewCourse, light: false) + upgradeButton.isHidden = true + upgradeButton.snp.remakeConstraints { make in + make.height.equalTo(0) + } + } + + findCourseButton.snp.remakeConstraints { make in + make.top.equalTo(lastView.snp.bottom).offset(StandardVerticalMargin * 2) + make.leading.equalTo(contentView).offset(StandardHorizontalMargin) + make.trailing.equalTo(contentView).inset(StandardHorizontalMargin) + make.height.equalTo(StandardVerticalMargin * 5.5) + } + + hiddenView.snp.remakeConstraints { make in + make.top.equalTo(findCourseButton.snp.bottom) + make.bottom.equalTo(contentView).offset(1) + } + } + + private func update(title: String, info: String) { + let titleTextStyle = OEXTextStyle(weight: .bold, size: .xLarge, color: OEXStyles.shared().neutralBlackT()) + let infoTextStyle = OEXTextStyle(weight: .normal, size: .base, color: OEXStyles.shared().neutralXDark()) + titleLabel.attributedText = titleTextStyle.attributedString(withText: title) + infoLabel.attributedText = infoTextStyle.attributedString(withText: info) + } + + private func applyStyle(to button: UIButton, text: String, light: Bool) { + let style: OEXTextStyle + let backgroundColor: UIColor + let borderWidth: CGFloat + let borderColor: UIColor + + if light { + style = OEXTextStyle(weight: .normal, size: .xLarge, color: OEXStyles.shared().secondaryBaseColor()) + backgroundColor = .clear + borderWidth = 1 + borderColor = OEXStyles.shared().neutralXLight() + } else { + style = OEXTextStyle(weight: .normal, size: .xLarge, color: OEXStyles.shared().neutralWhiteT()) + backgroundColor = OEXStyles.shared().secondaryBaseColor() + borderWidth = 0 + borderColor = .clear + } + + button.setAttributedTitle(style.attributedString(withText: text), for: UIControl.State()) + button.backgroundColor = backgroundColor + button.layer.borderWidth = borderWidth + button.layer.borderColor = borderColor.cgColor + button.layer.cornerRadius = 0 + button.layer.masksToBounds = true + } + + func fetchCoursePrice() { + guard let courseSku = course?.sku, + let enrollment = environment?.interface?.enrollmentForCourse(withID: course?.course_id), enrollment.isUpgradeable && environment?.serverConfig.iapConfig?.enabledforUser == true else { return } + + let startTime = CFAbsoluteTimeGetCurrent() + DispatchQueue.main.async { [weak self] in + self?.upgradeButton.startShimeringEffect() + PaymentManager.shared.fetchPrroduct(courseSku) { [weak self] product in + guard let weakSelf = self else { return } + + if let product = product, let coursePrice = product.localizedPrice { + let elapsedTime = CFAbsoluteTimeGetCurrent() - startTime + weakSelf.localizedCoursePrice = coursePrice + weakSelf.price = product.price + weakSelf.currencyCode = product.priceLocale.currencyCode + weakSelf.delegate?.coursePrice(cell: weakSelf, price: coursePrice, elapsedTime: elapsedTime.millisecond) + weakSelf.upgradeButton.setPrice(coursePrice) + weakSelf.upgradeButton.stopShimmerEffect() + } + else { + weakSelf.delegate?.coursePrice(cell: weakSelf, price: nil, elapsedTime: 0) + } + } + } + } +} diff --git a/Source/CourseDashboardErrorView.swift b/Source/CourseDashboardErrorView.swift new file mode 100644 index 0000000000..482a50d739 --- /dev/null +++ b/Source/CourseDashboardErrorView.swift @@ -0,0 +1,171 @@ +// +// CourseDashboardErrorView.swift +// edX +// +// Created by Saeed Bashir on 11/29/22. +// Copyright © 2022 edX. All rights reserved. +// + +import Foundation + +class CourseDashboardErrorView: UIView { + var myCoursesAction: (() -> Void)? + + private let contentView = UIView() + private let containerView = UIView() + private let bottomContainer = UIView() + private let errorLabel: UILabel = { + let label = UILabel() + label.accessibilityIdentifier = "CourseDashboardErrorView:error-label" + label.numberOfLines = 0 + let style = OEXMutableTextStyle(weight: .bold, size: .xxLarge, color: OEXStyles.shared().neutralBlackT()) + style.alignment = .center + label.attributedText = style.attributedString(withText: Strings.CourseDashboard.Error.generalError) + + return label + }() + + private lazy var errorImageView: UIImageView = { + guard let image = UIImage(named: "dashboard_error_image") else { return UIImageView() } + let imageView = UIImageView(image: image) + imageView.accessibilityIdentifier = "CourseDashboardErrorView:error-imageView" + return imageView + }() + + private lazy var gotoMyCoursesButton: UIButton = { + let button = UIButton(type: .system) + button.accessibilityIdentifier = "CourseDashboardErrorView:gotocourses-button" + button.backgroundColor = OEXStyles.shared().secondaryBaseColor() + button.oex_addAction({ [weak self] _ in + self?.myCoursesAction?() + }, for: .touchUpInside) + + let style = OEXTextStyle(weight: .normal, size: .xLarge, color: OEXStyles.shared().neutralWhite()) + button.setAttributedTitle(style.attributedString(withText: Strings.CourseDashboard.Error.goToCourses), for: UIControl.State()) + + return button + }() + + init() { + super.init(frame: .zero) + addSubViews() + setAccessibilityIdentifiers() + setConstraints() + } + + override func layoutSubviews() { + super.layoutSubviews() + containerView.addShadow(offset: CGSize(width: 0, height: 2), color: OEXStyles.shared().primaryDarkColor(), radius: 2, opacity: 0.35, cornerRadius: 6) + setConstraints() + } + + private func setConstraints() { + if traitCollection.verticalSizeClass == .regular { + addPortraitConstraints() + } else { + addLandscapeConstraints() + } + } + + private func addSubViews() { + backgroundColor = OEXStyles.shared().neutralWhiteT() + addSubview(contentView) + contentView.addSubview(containerView) + containerView.addSubview(errorImageView) + containerView.addSubview(bottomContainer) + + bottomContainer.addSubview(errorLabel) + bottomContainer.addSubview(gotoMyCoursesButton) + + containerView.backgroundColor = OEXStyles.shared().neutralWhiteT() + } + + private func setAccessibilityIdentifiers() { + accessibilityIdentifier = "CourseDashboardErrorView:view" + containerView.accessibilityIdentifier = "CourseDashboardErrorView:container-view" + bottomContainer.accessibilityIdentifier = "CourseDashboardErrorView:bottom-container-view" + } + + private func addPortraitConstraints() { + contentView.snp.remakeConstraints { make in + make.edges.equalTo(self) + } + + containerView.snp.remakeConstraints { make in + make.top.equalTo(contentView).offset(StandardVerticalMargin * 2) + make.leading.equalTo(contentView).offset(StandardHorizontalMargin) + make.trailing.equalTo(contentView).inset(StandardHorizontalMargin) + make.bottom.equalTo(contentView).inset(StandardVerticalMargin * 2) + } + + errorImageView.snp.remakeConstraints { make in + make.top.equalTo(containerView) + make.leading.equalTo(containerView) + make.trailing.equalTo(containerView) + make.height.equalTo(StandardVerticalMargin * 33) + } + + bottomContainer.snp.remakeConstraints { make in + make.top.equalTo(errorImageView.snp.bottom) + make.leading.equalTo(containerView) + make.trailing.equalTo(containerView) + make.bottom.equalTo(containerView) + } + + errorLabel.snp.remakeConstraints { make in + make.top.equalTo(bottomContainer).offset(StandardVerticalMargin * 2) + make.leading.equalTo(bottomContainer).offset(StandardHorizontalMargin * 2.2) + make.trailing.equalTo(bottomContainer).inset(StandardHorizontalMargin * 2.2) + } + + gotoMyCoursesButton.snp.remakeConstraints { make in + make.top.equalTo(errorLabel.snp.bottom).offset(StandardVerticalMargin * 2) + make.height.equalTo(StandardVerticalMargin * 5.5) + make.leading.equalTo(bottomContainer).offset(StandardHorizontalMargin * 2) + make.trailing.equalTo(bottomContainer).inset(StandardHorizontalMargin * 2) + make.bottom.equalTo(bottomContainer).inset(StandardVerticalMargin * 2) + } + } + + private func addLandscapeConstraints() { + containerView.snp.remakeConstraints { make in + make.top.equalTo(contentView).offset(StandardVerticalMargin * 2) + make.bottom.equalTo(contentView).inset(StandardVerticalMargin * 2) + make.leading.equalTo(contentView).offset(StandardHorizontalMargin) + make.trailing.equalTo(contentView).inset(StandardHorizontalMargin) + } + + errorImageView.snp.remakeConstraints { make in + make.top.equalTo(containerView) + make.leading.equalTo(containerView) + make.height.equalTo(StandardVerticalMargin * 33) + make.width.equalTo(contentView.frame.width / 2) + make.bottom.equalTo(containerView) + } + + bottomContainer.snp.remakeConstraints { make in + make.top.equalTo(containerView).offset(-StandardVerticalMargin * 2) + make.leading.equalTo(errorImageView.snp.trailing) + make.trailing.equalTo(containerView) + make.bottom.equalTo(containerView) + } + + errorLabel.snp.remakeConstraints { make in + make.top.equalTo(bottomContainer).offset(StandardVerticalMargin * 2) + make.leading.equalTo(bottomContainer).offset(StandardHorizontalMargin * 2) + make.trailing.equalTo(bottomContainer).inset(StandardHorizontalMargin * 2) + make.bottom.equalTo(gotoMyCoursesButton.snp.top) + } + + gotoMyCoursesButton.snp.remakeConstraints { make in + make.bottom.equalTo(bottomContainer).inset(StandardVerticalMargin * 4) + make.leading.equalTo(bottomContainer).offset(StandardHorizontalMargin * 2) + make.trailing.equalTo(bottomContainer).inset(StandardHorizontalMargin * 2) + make.height.equalTo(StandardVerticalMargin * 5.5) + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Source/CourseDashboardHeaderView.swift b/Source/CourseDashboardHeaderView.swift new file mode 100644 index 0000000000..07f70c1c88 --- /dev/null +++ b/Source/CourseDashboardHeaderView.swift @@ -0,0 +1,397 @@ +// +// CourseDashboardHeaderView.swift +// edX +// +// Created by MuhammadUmer on 15/11/2022. +// Copyright © 2022 edX. All rights reserved. +// + +import UIKit + +protocol CourseDashboardHeaderViewDelegate: AnyObject { + func didTapOnValueProp() + func didTapOnClose() + func didTapOnShareCourse(shareView: UIView) + func didTapTabbarItem(at position: Int, tabbarItem: TabBarItem) +} + +enum HeaderViewState { + case initial + case animating + case expanded + case collapsed +} + +class CourseDashboardHeaderView: UIView { + + typealias Environment = OEXAnalyticsProvider & DataManagerProvider & OEXInterfaceProvider & NetworkManagerProvider & ReachabilityProvider & OEXRouterProvider & OEXConfigProvider & OEXStylesProvider & ServerConfigProvider & OEXSessionProvider & RemoteConfigProvider + + weak var delegate: CourseDashboardHeaderViewDelegate? + + private let imageSize: CGFloat = 20 + private let attributedIconOfset: CGFloat = -4 + private let attributedUnicodeSpace = NSAttributedString(string: "\u{2002}") + + private lazy var containerView = UIView() + private lazy var courseInfoContainerView = UIView() + private var bottomContainer = UIView() + private lazy var datesBannerView = NewCourseDateBannerView() + private var bannerInfo: DatesBannerInfo? = nil + + var state: HeaderViewState = .initial + + private lazy var orgLabel: UILabel = { + let label = UILabel() + label.accessibilityIdentifier = "CourseDashboardHeaderView:org-label" + return label + }() + + private lazy var courseTitleLabel: UILabel = { + let label = UILabel() + label.accessibilityIdentifier = "CourseDashboardHeaderView:course-label-header" + label.backgroundColor = .clear + return label + }() + + private lazy var courseTitle: UITextView = { + let textView = UITextView() + textView.accessibilityIdentifier = "CourseDashboardHeaderView:course-label" + textView.isEditable = false + textView.isSelectable = true + textView.isUserInteractionEnabled = true + textView.backgroundColor = .clear + textView.isScrollEnabled = false + let padding = textView.textContainer.lineFragmentPadding + textView.textContainerInset = UIEdgeInsets(top: 0, left: -padding, bottom: 0, right: -padding) + + let tapGesture = AttachmentTapGestureRecognizer { [weak self] _ in + self?.delegate?.didTapOnShareCourse(shareView: textView) + } + + textView.addGestureRecognizer(tapGesture) + + return textView + }() + + private lazy var accessLabel: UILabel = { + let label = UILabel() + label.accessibilityIdentifier = "CourseDashboardHeaderView:course-access-label" + return label + }() + + private lazy var closeButton: UIButton = { + let button = UIButton() + button.accessibilityIdentifier = "CourseDashboardHeaderView:close-button" + button.setImage(Icon.Close.imageWithFontSize(size: imageSize), for: .normal) + button.accessibilityLabel = Strings.Accessibility.closeLabel + button.accessibilityHint = Strings.Accessibility.closeHint + button.oex_addAction({ [weak self] _ in + self?.delegate?.didTapOnClose() + }, for: .touchUpInside) + return button + }() + + private lazy var certificateView: CourseCertificateView? = nil + + private func addCertificateView() { + guard let course = course, + let enrollment = environment.interface?.enrollmentForCourse(withID: course.course_id), + let certificateUrl = enrollment.certificateUrl, + let certificateImage = UIImage(named: "courseCertificate") else { return } + + let certificateItem = CourseCertificateIem(certificateImage: certificateImage, certificateUrl: certificateUrl, action: { [weak self] in + if let weakSelf = self, let url = NSURL(string: certificateUrl), let parent = weakSelf.firstAvailableUIViewController() { + weakSelf.environment.router?.showCertificate(url: url, title: enrollment.course.name, fromController: parent) + } + }) + certificateView = CourseCertificateView(certificateItem: certificateItem) + certificateView?.accessibilityIdentifier = "CourseDashboardHeaderView:certificate-view" + } + + private lazy var valuePropView: UIView = { + let valuePropView = UIView() + valuePropView.accessibilityIdentifier = "CourseDashboardHeaderView:value-prop-view" + valuePropView.backgroundColor = environment.styles.standardBackgroundColor() + + let lockedImage = Icon.Closed.imageWithFontSize(size: imageSize).image(with: environment.styles.neutralWhiteT()) + let imageAttachment = NSTextAttachment() + imageAttachment.image = lockedImage + + if let image = imageAttachment.image { + imageAttachment.bounds = CGRect(x: 0, y: attributedIconOfset, width: image.size.width, height: image.size.height) + } + + let attributedImageString = NSAttributedString(attachment: imageAttachment) + let style = OEXTextStyle(weight: .semiBold, size: .base, color: environment.styles.neutralWhiteT()) + + let attributedStrings = [ + attributedImageString, + attributedUnicodeSpace, + style.attributedString(withText: Strings.ValueProp.courseDashboardButtonTitle) + ] + + let attributedTitle = NSAttributedString.joinInNaturalLayout(attributedStrings: attributedStrings) + + let button = UIButton() + button.oex_addAction({ [weak self] _ in + self?.delegate?.didTapOnValueProp() + }, for: .touchUpInside) + + button.backgroundColor = environment.styles.secondaryDarkColor() + button.setAttributedTitle(attributedTitle, for: .normal) + valuePropView.addSubview(button) + + button.snp.remakeConstraints { make in + make.edges.equalTo(valuePropView) + } + + return valuePropView + }() + + private lazy var tabbarView: CourseDashboardTabbarView = { + let tabbarView = CourseDashboardTabbarView(environment: environment, course: course, tabbarItems: tabbarItems) + tabbarView.accessibilityIdentifier = "CourseDashboardHeaderView:tabbar-view" + tabbarView.delegate = self + return tabbarView + }() + + private lazy var orgTextStyle = OEXTextStyle(weight: .bold, size: .small, color: environment.styles.accentBColor()) + + private lazy var courseTextStyle: OEXMutableTextStyle = { + let style = OEXMutableTextStyle(textStyle: OEXTextStyle(weight: .bold, size: .xLarge, color: environment.styles.neutralWhiteT())) + style.lineBreakMode = .byWordWrapping + return style + }() + + private lazy var courseTextLabelStyle: OEXMutableTextStyle = { + let style = OEXMutableTextStyle(textStyle: OEXTextStyle(weight: .bold, size: .base, color: environment.styles.neutralWhiteT())) + style.alignment = .center + return style + }() + + private lazy var accessTextStyle = OEXTextStyle(weight: .normal, size: .xSmall, color: environment.styles.neutralXLight()) + + private var canShowValuePropView: Bool { + guard let enrollment = environment.interface?.enrollmentForCourse(withID: course?.course_id) else { return false } + return enrollment.isUpgradeable && environment.serverConfig.valuePropEnabled + } + + private var showTabbar = false + + private let environment: Environment + private let course: OEXCourse? + private let tabbarItems: [TabBarItem] + private let error: CourseAccessHelper? + // it will be used to hide value prop from header in favor of embeded value prop on course dashboard + private var hideValueProp: Bool = false + + init(environment: Environment, course: OEXCourse?, tabbarItems: [TabBarItem], error: CourseAccessHelper?) { + self.environment = environment + self.course = course + self.tabbarItems = tabbarItems + self.error = error + super.init(frame: .zero) + + addSubViews() + setOrUpdateConstraints() + configureView() + } + + private func configureView() { + courseTitleLabel.attributedText = courseTextLabelStyle.attributedString(withText: course?.name) + + let courseTitleText = [ + courseTextStyle.attributedString(withText: course?.name), + attributedUnicodeSpace, + Icon.ShareCourse.attributedText(style: courseTextStyle, yOffset: attributedIconOfset) + ] + + orgLabel.attributedText = orgTextStyle.attributedString(withText: course?.org) + courseTitle.attributedText = NSAttributedString.joinInNaturalLayout(attributedStrings: courseTitleText) + accessLabel.attributedText = accessTextStyle.attributedString(withText: course?.nextRelevantDate) + } + + private func addSubViews() { + containerView.backgroundColor = environment.styles.primaryLightColor() + closeButton.tintColor = environment.styles.neutralWhiteT() + + addSubview(containerView) + containerView.addSubview(courseTitleLabel) + containerView.addSubview(closeButton) + containerView.addSubview(courseInfoContainerView) + containerView.addSubview(tabbarView) + + courseInfoContainerView.addSubview(orgLabel) + courseInfoContainerView.addSubview(courseTitle) + courseInfoContainerView.addSubview(accessLabel) + + showCourseTitleHeaderLabel(show: false) + addCertificateView() + } + + private func setOrUpdateConstraints() { + containerView.snp.remakeConstraints { make in + make.edges.equalTo(self) + } + + closeButton.snp.remakeConstraints { make in + make.top.equalTo(containerView).offset(StandardVerticalMargin * 2) + make.trailing.equalTo(containerView).inset(StandardVerticalMargin * 2) + make.height.equalTo(imageSize) + make.width.equalTo(imageSize) + } + + courseTitleLabel.snp.remakeConstraints { make in + make.top.equalTo(closeButton) + make.centerY.equalTo(closeButton) + make.leading.equalTo(containerView).offset(StandardHorizontalMargin) + make.trailing.equalTo(closeButton.snp.leading).offset(-StandardHorizontalMargin) + } + + if state == .collapsed { return } + + courseInfoContainerView.snp.remakeConstraints { make in + make.top.equalTo(closeButton.snp.bottom) + make.leading.equalTo(containerView).offset(StandardHorizontalMargin) + make.trailing.equalTo(containerView).inset(StandardHorizontalMargin) + } + + orgLabel.snp.remakeConstraints { make in + make.top.equalTo(courseInfoContainerView).offset(StandardVerticalMargin) + make.leading.equalTo(courseInfoContainerView) + make.trailing.equalTo(courseInfoContainerView) + } + + courseTitle.snp.remakeConstraints { make in + make.top.equalTo(orgLabel.snp.bottom).offset(StandardVerticalMargin / 2) + make.leading.equalTo(courseInfoContainerView) + make.trailing.lessThanOrEqualTo(courseInfoContainerView) + } + + accessLabel.snp.remakeConstraints { make in + make.top.equalTo(courseTitle.snp.bottom).offset(StandardVerticalMargin / 2) + make.leading.equalTo(courseInfoContainerView) + make.trailing.equalTo(courseInfoContainerView) + make.bottom.equalTo(courseInfoContainerView).inset(StandardVerticalMargin) + } + + bottomContainer = courseInfoContainerView + + if let certificateView = certificateView { + containerView.addSubview(certificateView) + + certificateView.snp.remakeConstraints { make in + make.top.equalTo(bottomContainer.snp.bottom).offset(StandardVerticalMargin) + make.leading.equalTo(containerView) + make.trailing.equalTo(containerView) + } + + bottomContainer = certificateView + } + + if canShowValuePropView && !hideValueProp { + containerView.addSubview(valuePropView) + + valuePropView.snp.remakeConstraints { make in + make.top.equalTo(bottomContainer.snp.bottom).offset(StandardVerticalMargin) + make.leading.equalTo(containerView).offset(StandardHorizontalMargin) + make.trailing.equalTo(containerView).inset(StandardHorizontalMargin) + make.height.equalTo(StandardVerticalMargin * 4.5) + } + + bottomContainer = valuePropView + } + else { + valuePropView.snp.remakeConstraints { make in + make.height.equalTo(0) + } + valuePropView.removeFromSuperview() + } + + if bannerInfo != nil { + datesBannerView.removeFromSuperview() + containerView.addSubview(datesBannerView) + + datesBannerView.snp.remakeConstraints { make in + make.top.equalTo(bottomContainer.snp.bottom).offset(StandardVerticalMargin) + make.leading.equalTo(containerView) + make.trailing.equalTo(containerView) + } + bottomContainer = datesBannerView + } + + tabbarView.snp.remakeConstraints { make in + let offSet = bottomContainer == certificateView || bannerInfo != nil ? 0 : StandardVerticalMargin * 2 + make.top.equalTo(bottomContainer.snp.bottom).offset(offSet) + make.leading.equalTo(containerView) + make.trailing.equalTo(containerView) + make.bottom.equalTo(containerView) + make.height.equalTo(showTabbar ? StandardVerticalMargin * 4.8 : 0) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateTabbarView(item: TabBarItem) { + tabbarView.updateView(item: item) + } + + func showTabbarView(show: Bool) { + showTabbar = show + setOrUpdateConstraints() + } + + func updateHeader(collapse: Bool) { + courseInfoContainerView.alpha = collapse ? 0 : 1 + valuePropView.alpha = collapse ? 0 : (canShowValuePropView && !hideValueProp ? 1 : 0) + certificateView?.alpha = collapse ? 0 : 1 + datesBannerView.alpha = collapse ? 0 : 1 + updateTabbarConstraints(collapse: collapse) + } + + func showCourseTitleHeaderLabel(show: Bool) { + courseTitleLabel.alpha = show ? 1 : 0 + } + + func updateTabbarConstraints(collapse: Bool) { + tabbarView.snp.remakeConstraints { make in + let offSet = bottomContainer == certificateView || bannerInfo != nil && !collapse ? 0 : StandardVerticalMargin * 2 + make.top.equalTo(collapse ? closeButton.snp.bottom : bottomContainer.snp.bottom).offset(offSet) + make.leading.equalTo(containerView) + make.trailing.equalTo(containerView) + make.height.equalTo(collapse || showTabbar ? StandardVerticalMargin * 4.8 : 0) + if !collapse { + make.bottom.equalTo(containerView) + } + } + } + + func showDatesBanner(delegate: CourseOutlineTableController?, bannerInfo: DatesBannerInfo?) { + self.bannerInfo = bannerInfo + datesBannerView.bannerInfo = bannerInfo + datesBannerView.delegate = delegate + datesBannerView.setupView() + setOrUpdateConstraints() + } + + func removeDatesBanner() { + bannerInfo = nil + datesBannerView.bannerInfo = bannerInfo + datesBannerView.delegate = nil + datesBannerView.removeFromSuperview() + setOrUpdateConstraints() + } + + func hidevalueProp(hide: Bool = true) { + hideValueProp = hide + setOrUpdateConstraints() + } +} + +extension CourseDashboardHeaderView: CourseDashboardTabbarViewDelegate { + func didSelectItem(at position: Int, tabbarItem: TabBarItem) { + delegate?.didTapTabbarItem(at: position, tabbarItem: tabbarItem) + } +} diff --git a/Source/CourseDashboardTabbarView.swift b/Source/CourseDashboardTabbarView.swift new file mode 100644 index 0000000000..5730200b54 --- /dev/null +++ b/Source/CourseDashboardTabbarView.swift @@ -0,0 +1,233 @@ +// +// CourseDashboardTabbarView.swift +// edX +// +// Created by MuhammadUmer on 02/12/2022. +// Copyright © 2022 edX. All rights reserved. +// + +import UIKit + +protocol CourseDashboardTabbarViewDelegate: AnyObject { + func didSelectItem(at position: Int, tabbarItem: TabBarItem) +} + +class CourseDashboardTabbarView: UIView { + typealias Environment = OEXAnalyticsProvider & DataManagerProvider & OEXInterfaceProvider & NetworkManagerProvider & ReachabilityProvider & OEXRouterProvider & OEXConfigProvider & OEXStylesProvider & ServerConfigProvider & OEXSessionProvider & RemoteConfigProvider + + weak var delegate: CourseDashboardTabbarViewDelegate? + + lazy var textStyle: OEXMutableTextStyle = { + let style = OEXMutableTextStyle(textStyle: OEXTextStyle(weight: .normal, size: .base, color: OEXStyles.shared().neutralXXDark())) + style.alignment = .center + return style + }() + + private lazy var collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.accessibilityIdentifier = "CourseDashboardTabbarView:collection-view" + collectionView.showsHorizontalScrollIndicator = false + collectionView.backgroundColor = .clear + collectionView.delegate = self + collectionView.dataSource = self + collectionView.register(CourseDashboardTabbarViewCell.self, forCellWithReuseIdentifier: CourseDashboardTabbarViewCell.identifier) + collectionView.translatesAutoresizingMaskIntoConstraints = false + return collectionView + }() + + private lazy var bottomBar: UIView = { + let view = UIView() + view.accessibilityIdentifier = "CourseDashboardTabbarView:bottom-bar-view" + view.backgroundColor = environment.styles.neutralLight() + return view + }() + + private var shouldShowDiscussions: Bool { + guard let course = course else { return false } + return environment.config.discussionsEnabled && course.hasDiscussionsEnabled + } + + private var shouldShowHandouts: Bool { + guard let course = course else { return false } + return course.course_handouts?.isEmpty == false + } + + private var selectedItemIndex = 0 + private var tabBarItems: [TabBarItem] = [] + + private let environment: Environment + private let course: OEXCourse? + + init(environment: Environment, course: OEXCourse?, tabbarItems: [TabBarItem]) { + self.environment = environment + self.course = course + self.tabBarItems = tabbarItems + super.init(frame: .zero) + accessibilityIdentifier = "CourseDashboardTabbarView" + addSubViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func addSubViews() { + backgroundColor = environment.styles.neutralWhiteT() + + addSubview(bottomBar) + addSubview(collectionView) + + bottomBar.snp.makeConstraints { make in + make.leading.equalTo(self) + make.trailing.equalTo(self) + make.bottom.equalTo(self) + make.height.equalTo(2) + } + + collectionView.snp.makeConstraints { make in + make.edges.equalTo(self) + } + + if tabBarItems.isEmpty { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let weakSelf = self else { return } + let selectedItemIndex = weakSelf.selectedItemIndex + let tabBarItems = weakSelf.tabBarItems + let indexPath = IndexPath(item: selectedItemIndex, section: 0) + weakSelf.collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) + weakSelf.collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) + weakSelf.delegate?.didSelectItem(at: selectedItemIndex, tabbarItem: tabBarItems[selectedItemIndex]) + } + } + + func updateView(item: TabBarItem) { + UIView.animate(withDuration: 0.5) { [weak self] in + guard let weakSelf = self else { return } + if let index = weakSelf.tabBarItems.firstIndex(of: item) { + let indexPath = IndexPath(item: index, section: 0) + weakSelf.collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) + weakSelf.collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) + } + } + } +} + +extension CourseDashboardTabbarView: UICollectionViewDelegate, UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return tabBarItems.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CourseDashboardTabbarViewCell.identifier, for: indexPath) as! CourseDashboardTabbarViewCell + let item = tabBarItems[indexPath.row] + cell.setTitle(title: item.title, textStyle: textStyle) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + selectedItemIndex = indexPath.item + collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) + delegate?.didSelectItem(at: selectedItemIndex, tabbarItem: tabBarItems[selectedItemIndex]) + } +} + +extension CourseDashboardTabbarView: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let item = tabBarItems[indexPath.row] + let tabTitle = item.title + let padding: CGFloat = 20 + if let font = textStyle.attributes["NSFont"] as? UIFont { + let titleWidth = NSString(string: tabTitle).boundingRect(with: frame.size, options: .usesLineFragmentOrigin, attributes: [.font: font], context: nil).size.width + let tabWidth = titleWidth + padding + return CGSize(width: tabWidth, height: frame.height) + } + return .zero + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { + return 0 + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + return 0 + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { + return .zero + } +} + +class CourseDashboardTabbarViewCell: UICollectionViewCell { + static let identifier = "CourseDashboardTabbarViewCell" + + private let tabbarItemTitle: UILabel = { + let label = UILabel() + label.accessibilityIdentifier = "CourseDashboardTabbarViewCell:tabbar-item-title-label" + return label + }() + + private let indicatorView: UIView = { + let indicatorView = UIView() + indicatorView.accessibilityIdentifier = "CourseDashboardTabbarViewCell:indicator-view" + return indicatorView + }() + + private let indicatorColor: UIColor = OEXStyles.shared().primaryBaseColor() + + override var isSelected: Bool { + didSet { + DispatchQueue.main.async { + UIView.animate(withDuration: 0.3) { [weak self] in + guard let weakSelf = self else { return } + weakSelf.indicatorView.backgroundColor = weakSelf.isSelected ? weakSelf.indicatorColor : .clear + weakSelf.layoutIfNeeded() + } + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + accessibilityIdentifier = "CourseDashboardTabbarViewCell" + backgroundColor = .clear + addSubviews() + addConstrains() + } + + private func addSubviews() { + contentView.addSubview(tabbarItemTitle) + addSubview(indicatorView) + } + + private func addConstrains() { + tabbarItemTitle.translatesAutoresizingMaskIntoConstraints = false + tabbarItemTitle.snp.makeConstraints { make in + make.center.equalTo(self) + } + + indicatorView.translatesAutoresizingMaskIntoConstraints = false + indicatorView.snp.makeConstraints { make in + make.height.equalTo(2) + make.leading.equalTo(self) + make.trailing.equalTo(self) + make.bottom.equalTo(self) + } + } + + func setTitle(title: String, textStyle: OEXMutableTextStyle) { + tabbarItemTitle.attributedText = textStyle.attributedString(withText: title) + } + + override func prepareForReuse() { + super.prepareForReuse() + tabbarItemTitle.text = "" + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Source/CourseDashboardViewController.swift b/Source/CourseDashboardViewController.swift index 56ca6b904f..969eae1841 100644 --- a/Source/CourseDashboardViewController.swift +++ b/Source/CourseDashboardViewController.swift @@ -56,7 +56,8 @@ class CourseDashboardViewController: UITabBarController, InterfaceOrientationOve override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - + + navigationController?.setNavigationBarHidden(false, animated: true) environment.analytics.trackScreen(withName: OEXAnalyticsScreenCourseDashboard, courseID: courseID, value: nil) } diff --git a/Source/CourseDateBannerView.swift b/Source/CourseDateBannerView.swift index 9e9a7981d7..d3874d702b 100644 --- a/Source/CourseDateBannerView.swift +++ b/Source/CourseDateBannerView.swift @@ -10,6 +10,16 @@ import UIKit private let cornerRadius: CGFloat = 0 +protocol BannerView { + var delegate: CourseShiftDatesDelegate? { get set } + var bannerInfo: DatesBannerInfo? { get set } + func setupView() +} + +extension CourseDateBannerView: BannerView {} +extension NewCourseDateBannerView: BannerView {} + + protocol CourseShiftDatesDelegate: AnyObject { func courseShiftDateButtonAction() } diff --git a/Source/CourseDates.swift b/Source/CourseDates.swift index 45a5a35117..b0b4dde190 100644 --- a/Source/CourseDates.swift +++ b/Source/CourseDates.swift @@ -221,7 +221,7 @@ enum BannerInfoStatus { } } -class DatesBannerInfo { +public class DatesBannerInfo { private enum Keys: String, RawStringExtractable { case contentTypeGatingEnabled = "content_type_gating_enabled" case missedDeadline = "missed_deadlines" diff --git a/Source/CourseDatesHeaderView.swift b/Source/CourseDatesHeaderView.swift index cb5bbd92b6..9c4989e822 100644 --- a/Source/CourseDatesHeaderView.swift +++ b/Source/CourseDatesHeaderView.swift @@ -133,7 +133,7 @@ class CourseDatesHeaderView: UITableViewHeaderFooterView { setupBottomContainer() } - if !isSelfPaced && bannerInfo.status != .upgradeToCompleteGradedBanner { + if (!isSelfPaced && bannerInfo.status != .upgradeToCompleteGradedBanner) || bannerInfo.status == .resetDatesBanner { topContainer.subviews.forEach { $0.removeFromSuperview() } } diff --git a/Source/CourseDatesViewController.swift b/Source/CourseDatesViewController.swift index c3f9848641..151d5bd74c 100644 --- a/Source/CourseDatesViewController.swift +++ b/Source/CourseDatesViewController.swift @@ -9,7 +9,7 @@ import UIKit import WebKit -class CourseDatesViewController: UIViewController, InterfaceOrientationOverriding { +class CourseDatesViewController: UIViewController, InterfaceOrientationOverriding, ScrollableDelegateProvider { private enum Pacing: String { case user = "self" @@ -114,6 +114,9 @@ class CourseDatesViewController: UIViewController, InterfaceOrientationOverridin private var courseBanner: CourseDateBannerModel? + weak var scrollableDelegate: ScrollableDelegate? + private var scrollByDragging = false + init(environment: Environment, courseID: String) { self.courseID = courseID self.environment = environment @@ -643,6 +646,22 @@ extension CourseDatesViewController: CourseDatesHeaderViewDelegate { } } +extension CourseDatesViewController { + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollByDragging = true + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollByDragging { + scrollableDelegate?.scrollViewDidScroll(scrollView: scrollView) + } + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + scrollByDragging = false + } +} + // For use in testing only extension CourseDatesViewController { func t_loadData(data: CourseDateModel) { diff --git a/Source/CourseGenericBlockTableViewCell.swift b/Source/CourseGenericBlockTableViewCell.swift index 75372f7c70..88e1de0a9f 100644 --- a/Source/CourseGenericBlockTableViewCell.swift +++ b/Source/CourseGenericBlockTableViewCell.swift @@ -19,10 +19,13 @@ class CourseGenericBlockTableViewCell : UITableViewCell, CourseBlockContainerCel override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) + contentView.addSubview(content) + content.snp.makeConstraints { make in make.edges.equalTo(contentView) } + accessibilityIdentifier = "CourseGenericBlockTableViewCell:view" content.accessibilityIdentifier = "CourseGenericBlockTableViewCell:content-view" } diff --git a/Source/CourseHandoutsViewController.swift b/Source/CourseHandoutsViewController.swift index 5f3880d4d6..89d58b587c 100644 --- a/Source/CourseHandoutsViewController.swift +++ b/Source/CourseHandoutsViewController.swift @@ -9,7 +9,7 @@ import UIKit import WebKit -public class CourseHandoutsViewController: OfflineSupportViewController, LoadStateViewReloadSupport, InterfaceOrientationOverriding { +public class CourseHandoutsViewController: OfflineSupportViewController, LoadStateViewReloadSupport, InterfaceOrientationOverriding, ScrollableDelegateProvider { public typealias Environment = DataManagerProvider & NetworkManagerProvider & ReachabilityProvider & OEXAnalyticsProvider & OEXStylesProvider & OEXConfigProvider @@ -19,6 +19,9 @@ public class CourseHandoutsViewController: OfflineSupportViewController, LoadSta let loadController : LoadStateViewController let handouts : BackedStream = BackedStream() + public weak var scrollableDelegate: ScrollableDelegate? + private var scrollByDragging = false + init(environment : Environment, courseID : String) { self.environment = environment self.courseID = courseID @@ -42,6 +45,8 @@ public class CourseHandoutsViewController: OfflineSupportViewController, LoadSta setConstraints() setStyles() webView.navigationDelegate = self + webView.scrollView.delegate = self + view.backgroundColor = environment.styles.standardBackgroundColor() setAccessibilityIdentifiers() @@ -149,3 +154,19 @@ extension CourseHandoutsViewController: WKNavigationDelegate { } } + +extension CourseHandoutsViewController: UIScrollViewDelegate { + public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollByDragging = true + } + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollByDragging { + scrollableDelegate?.scrollViewDidScroll(scrollView: scrollView) + } + } + + public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + scrollByDragging = false + } +} diff --git a/Source/CourseOutlineHeaderCell.swift b/Source/CourseOutlineHeaderCell.swift index 7524011b2f..5e7ce7a0ea 100644 --- a/Source/CourseOutlineHeaderCell.swift +++ b/Source/CourseOutlineHeaderCell.swift @@ -9,12 +9,59 @@ import Foundation import UIKit -class CourseOutlineHeaderCell : UITableViewHeaderFooterView { +protocol CourseOutlineHeaderCellDelegate: AnyObject { + func toggleSection(section: Int) +} + +class CourseOutlineHeaderCell: UITableViewHeaderFooterView { + static let identifier = "CourseOutlineHeaderCellIdentifier" - let headerFontStyle = OEXTextStyle(weight: .semiBold, size: .xSmall, color: OEXStyles.shared().neutralXDark()) - let headerLabel = UILabel() - let horizontalTopLine = UIView() + weak var delegate: CourseOutlineHeaderCellDelegate? + + var section = 0 + + private var isExpanded = false + private var isCompleted = false + + private let horizontalTopLine = UIView() + private let containerView = UIView() + private let iconSize = CGSize(width: 25, height: 25) + private let headerLabel = UILabel() + + private lazy var headerFontStyle: OEXTextStyle = { + if OEXConfig.shared().isNewDashboardEnabled { + return OEXTextStyle(weight: .normal, size: .base, color : OEXStyles.shared().neutralBlackT()) + } + return OEXTextStyle(weight: .semiBold, size: .xSmall, color: OEXStyles.shared().neutralXDark()) + }() + + private lazy var leadingImageButton: UIButton = { + let button = UIButton(type: .system) + button.accessibilityIdentifier = "CourseOutlineHeaderCell:trailing-button-view" + let image = Icon.CheckCircle.imageWithFontSize(size: 17) + button.setImage(image, for: .normal) + button.tintColor = OEXStyles.shared().successBase() + button.isHidden = true + return button + }() + + private lazy var trailingImageView: UIImageView = { + let imageView = UIImageView() + imageView.accessibilityIdentifier = "CourseOutlineHeaderCell:trailing-image-view" + imageView.image = Icon.ExpandMore.imageWithFontSize(size: 24) + imageView.tintColor = OEXStyles.shared().neutralDark() + return imageView + }() + + private lazy var button: UIButton = { + let button = UIButton() + button.accessibilityIdentifier = "CourseOutlineHeaderCell:button-view" + button.oex_addAction({ [weak self] _ in + self?.handleTap() + }, for: .touchUpInside) + return button + }() var block: CourseBlock? { didSet { @@ -26,9 +73,21 @@ class CourseOutlineHeaderCell : UITableViewHeaderFooterView { override init(reuseIdentifier: String?) { super.init(reuseIdentifier: reuseIdentifier) - addSubviews() - setStyles() + } + + func setupViewsNewDesign(isExpanded: Bool, isCompleted: Bool) { + self.isExpanded = isExpanded + self.isCompleted = isCompleted + + addSubviewsForNewDesign() + setConstraintsForNewDesign() setAccessibilityIdentifiers() + backgroundView = UIView(frame: .zero) + containerView.applyBorderStyle(style: BorderStyle(cornerRadius: .Size(0), width: .Size(1), color: OEXStyles.shared().neutralDark())) + } + + func setupViewsForOldDesign() { + addSubviewsForOldDesign() } private func setAccessibilityIdentifiers() { @@ -40,17 +99,71 @@ class CourseOutlineHeaderCell : UITableViewHeaderFooterView { required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - //MARK: Helper Methods - private func addSubviews() { - addSubview(headerLabel) + + private func addSubviewsForNewDesign() { + addSubview(containerView) + containerView.addSubview(leadingImageButton) + containerView.addSubview(headerLabel) + containerView.addSubview(button) + containerView.addSubview(trailingImageView) + button.superview?.bringSubviewToFront(button) + + containerView.backgroundColor = OEXStyles.shared().neutralWhiteT() + } + + func setConstraintsForNewDesign() { + leadingImageButton.isHidden = !isCompleted + + containerView.snp.remakeConstraints { make in + make.top.equalTo(self) + make.bottom.equalTo(self) + make.leading.equalTo(self).offset(StandardHorizontalMargin) + make.trailing.equalTo(self).inset(StandardHorizontalMargin) + } + + leadingImageButton.snp.remakeConstraints { make in + make.centerY.equalTo(containerView) + make.leading.equalTo(containerView).offset(StandardHorizontalMargin / 2) + make.size.equalTo(iconSize) + } + + trailingImageView.snp.remakeConstraints { make in + make.centerY.equalTo(containerView) + make.trailing.equalTo(containerView).inset(StandardHorizontalMargin / 2) + make.size.equalTo(iconSize) + } + + headerLabel.snp.remakeConstraints { make in + make.leading.equalTo(leadingImageButton).offset(StandardHorizontalMargin * 2.15) + make.centerY.equalTo(containerView) + make.trailing.equalTo(trailingImageView.snp.leading).offset(-StandardHorizontalMargin * 2.15) + } + + button.snp.remakeConstraints { make in + make.edges.equalTo(containerView) + } + + rotateImageView(clockWise: isExpanded) + } + + private func addSubviewsForOldDesign() { addSubview(horizontalTopLine) + addSubview(headerLabel) + backgroundView = UIView(frame: .zero) + } + + override func layoutSubviews() { + super.layoutSubviews() + + if subviews.contains(headerLabel) && subviews.contains(horizontalTopLine) { + let margin = StandardHorizontalMargin - 5 + headerLabel.frame = bounds.inset(by: UIEdgeInsets(top: 0, left: margin, bottom: 0, right: margin)) + horizontalTopLine.frame = CGRect(x: 0, y: 0, width: bounds.size.width, height: OEXStyles.dividerSize()) + } } private func setStyles() { - //Using CGRectZero size because the backgroundView automatically resizes. backgroundView = UIView(frame: .zero) - horizontalTopLine.backgroundColor = OEXStyles.shared().neutralBase() } @@ -67,12 +180,19 @@ class CourseOutlineHeaderCell : UITableViewHeaderFooterView { private func updateAccessibilityLabel(completion: Bool) { headerLabel.accessibilityHint = completion ? Strings.Accessibility.completed : nil } - - // Skip autolayout for performance reasons - override func layoutSubviews() { - super.layoutSubviews() - let margin = StandardHorizontalMargin - 5 - headerLabel.frame = bounds.inset(by: UIEdgeInsets.init(top: 0, left: margin, bottom: 0, right: margin)) - horizontalTopLine.frame = CGRect(x: 0, y: 0, width: bounds.size.width, height: OEXStyles.dividerSize()) + + private func handleTap() { + delegate?.toggleSection(section: section) + } + + private func rotateImageView(clockWise: Bool) { + UIView.animate(withDuration: 0.2) { [weak self] in + guard let weakSelf = self else { return } + if clockWise { + weakSelf.trailingImageView.transform = weakSelf.trailingImageView.transform.rotated(by: -(.pi * 0.999)) + } else { + weakSelf.trailingImageView.transform = .identity + } + } } } diff --git a/Source/CourseOutlineItemView.swift b/Source/CourseOutlineItemView.swift index aa1aea6865..a08f497c1b 100644 --- a/Source/CourseOutlineItemView.swift +++ b/Source/CourseOutlineItemView.swift @@ -30,6 +30,13 @@ public class CourseOutlineItemView: UIView { private let fontStyle = OEXTextStyle(weight: .normal, size: .base, color : OEXStyles.shared().neutralBlackT()) private let boldFontStyle = OEXTextStyle(weight: .bold, size: .small, color : OEXStyles.shared().neutralBlack()) + + private lazy var contentView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + private let titleLabel = UILabel() private let subtitleLabel = UILabel() private let leadingImageButton = UIButton(type: UIButton.ButtonType.system) @@ -39,6 +46,7 @@ public class CourseOutlineItemView: UIView { private var shouldShowLeadingView: Bool = true + var courseOutlineMode: CourseOutlineMode = .full var isSectionOutline = false { didSet { refreshTrailingViewConstraints() @@ -123,6 +131,11 @@ public class CourseOutlineItemView: UIView { } func setTitleText(title: String, elipsis: Bool = true) { + + if OEXConfig.shared().isNewDashboardEnabled && courseOutlineMode == .full { + titleLabel.numberOfLines = 0 + } + if !elipsis { titleLabel.attributedText = fontStyle.attributedString(withText: title) } else { @@ -132,6 +145,7 @@ public class CourseOutlineItemView: UIView { attributedString.append(attributedTitle) attributedString.append(attributedUnicodeSpace) attributedString.append(attributedTrailingImage) + titleLabel.attributedText = attributedString setConstraints() } @@ -181,6 +195,10 @@ public class CourseOutlineItemView: UIView { subtitleLabel.minimumScaleFactor = 0.6 subtitleLabel.attributedText = NSAttributedString.joinInNaturalLayout(attributedStrings: attributedStrings) setConstraints(with: blockType) + + if blockType == .Section && OEXConfig.shared().isNewDashboardEnabled && courseOutlineMode == .full { + applyBorderStyle(style: BorderStyle(cornerRadius: .Size(0), width: .Size(1), color: OEXStyles.shared().neutralDark())) + } } func setContentIcon(icon: Icon?, color: UIColor) { @@ -213,29 +231,40 @@ public class CourseOutlineItemView: UIView { } private func setConstraints(with blockType: CourseBlockType? = nil) { + contentView.snp.remakeConstraints { make in + make.top.equalTo(self) + make.bottom.equalTo(self) + make.leading.equalTo(self) + make.trailing.equalTo(self) + make.height.greaterThanOrEqualTo(60) + } + leadingImageButton.isHidden = !shouldShowLeadingView leadingImageButton.snp.remakeConstraints { make in make.centerY.equalTo(titleLabel) let offsetMargin = shouldShowLeadingView ? StandardHorizontalMargin / 2 : 0 - make.leading.equalTo(self).offset(offsetMargin) + make.leading.equalTo(contentView).offset(offsetMargin) make.size.equalTo(IconSize) } let shouldOffsetTitle = !(subtitleLabel.text?.isEmpty ?? true) titleLabel.snp.remakeConstraints { make in + make.top.equalTo(contentView).offset(StandardVerticalMargin) + let titleOffset = shouldOffsetTitle ? TitleOffsetCenterY : 0 - make.centerY.equalTo(self).offset(titleOffset) + make.centerY.equalTo(contentView).offset(titleOffset) + if shouldShowLeadingView { make.leading.equalTo(leadingImageButton.snp.trailing).offset(StandardHorizontalMargin / 2) } else { - make.leading.equalTo(self).offset(StandardHorizontalMargin) + make.leading.equalTo(contentView).offset(StandardHorizontalMargin) } make.trailing.lessThanOrEqualTo(trailingContainer.snp.leading).offset(TitleOffsetTrailing) } subtitleLabel.snp.remakeConstraints { make in - make.centerY.equalTo(self).offset(SubtitleOffsetCenterY) + make.top.equalTo(titleLabel.snp.bottom) if let blockType = blockType { if case CourseBlockType.Section = blockType { @@ -248,7 +277,7 @@ public class CourseOutlineItemView: UIView { } else { make.leading.equalTo(subtitleLeadingImageView.snp.leading).offset(0) } - make.trailing.lessThanOrEqualTo(self).offset(-StandardHorizontalMargin) + make.trailing.lessThanOrEqualTo(contentView).offset(-StandardHorizontalMargin) } separator.snp.remakeConstraints { make in @@ -260,12 +289,13 @@ public class CourseOutlineItemView: UIView { } private func addSubviews() { - addSubview(leadingImageButton) - addSubview(trailingContainer) - addSubview(titleLabel) - addSubview(subtitleLabel) - addSubview(subtitleLeadingImageView) - addSubview(separator) + addSubview(contentView) + contentView.addSubview(leadingImageButton) + contentView.addSubview(trailingContainer) + contentView.addSubview(titleLabel) + contentView.addSubview(subtitleLabel) + contentView.addSubview(subtitleLeadingImageView) + contentView.addSubview(separator) // For performance only add the static constraints once @@ -277,7 +307,7 @@ public class CourseOutlineItemView: UIView { } subtitleLabel.snp.remakeConstraints { make in - make.centerY.equalTo(self).offset(SubtitleOffsetCenterY) + make.top.equalTo(titleLabel.snp.bottom) if subtitleLeadingImageView.isHidden { make.leading.equalTo(subtitleLeadingImageView.snp.leading).offset(SubtitleLeadingOffset) @@ -291,8 +321,8 @@ public class CourseOutlineItemView: UIView { private func refreshTrailingViewConstraints() { trailingContainer.snp.remakeConstraints { make in - make.trailing.equalTo(self.snp.trailing).inset(isSectionOutline ? 10 : CellOffsetTrailing) - make.centerY.equalTo(self) + make.trailing.equalTo(contentView.snp.trailing).inset(isSectionOutline ? 10 : CellOffsetTrailing) + make.centerY.equalTo(contentView) make.width.equalTo(SmallIconSize * 2) } } diff --git a/Source/CourseOutlineQuerier.swift b/Source/CourseOutlineQuerier.swift index fc5bbcb43c..fa8d0bfd7b 100644 --- a/Source/CourseOutlineQuerier.swift +++ b/Source/CourseOutlineQuerier.swift @@ -45,7 +45,7 @@ public class CourseOutlineQuerier : NSObject { public struct BlockGroup { public let block : CourseBlock - public let children : [CourseBlock] + public var children : [CourseBlock] } public typealias Environment = OEXConfigProvider @@ -63,17 +63,12 @@ public class CourseOutlineQuerier : NSObject { private var observers: [BlockCompletionObserver] = [] func add(observer: BlockCompletionObserver) { - if let index = observers.firstIndexMatching({ $0.controller === observer.controller && $0.blockID == observer.blockID }) { - observers.remove(at: index) - } - + observers.removeAll { $0.controller === observer.controller && $0.blockID == observer.blockID } observers.append(observer) } func remove(observer: UIViewController) { - let filtered = observers.filter { $0.controller !== observer } - observers = [] - observers.append(contentsOf: filtered) + observers.removeAll { $0.controller === observer } } private var blocks: [CourseBlockID : CourseBlock] = [:] { diff --git a/Source/CourseOutlineTableSource.swift b/Source/CourseOutlineTableSource.swift index 1217a2cd81..c960264e8d 100644 --- a/Source/CourseOutlineTableSource.swift +++ b/Source/CourseOutlineTableSource.swift @@ -22,34 +22,51 @@ protocol CourseOutlineTableControllerDelegate: AnyObject { func resetCourseDate(controller: CourseOutlineTableController) } -class CourseOutlineTableController : UITableViewController, CourseVideoTableViewCellDelegate, CourseSectionTableViewCellDelegate, CourseVideosHeaderViewDelegate, VideoDownloadQualityDelegate { +public class CourseOutlineTableController: UITableViewController, ScrollableDelegateProvider { typealias Environment = DataManagerProvider & OEXInterfaceProvider & NetworkManagerProvider & OEXConfigProvider & OEXRouterProvider & OEXAnalyticsProvider & OEXStylesProvider & ServerConfigProvider weak var delegate: CourseOutlineTableControllerDelegate? + weak var newDashboardDelegate: NewCourseDashboardViewControllerDelegate? + private let environment: Environment - let courseQuerier: CourseOutlineQuerier - let courseID: String + private let courseID: String private var courseOutlineMode: CourseOutlineMode + private var courseBlockID: CourseBlockID? + private let courseQuerier: CourseOutlineQuerier private let courseDateBannerView = CourseDateBannerView(frame: .zero) private let courseCard = CourseCardView(frame: .zero) private var courseCertificateView: CourseCertificateView? private let headerContainer = UIView() + + private lazy var resumeCourseHeaderView = ResumeCourseHeaderView() private lazy var resumeCourseView = CourseOutlineHeaderView(frame: .zero, styles: OEXStyles.shared(), titleText: Strings.resume, subtitleText: "Placeholder") private lazy var valuePropView = UIView() - + var courseVideosHeaderView: CourseVideosHeaderView? - private var isResumeCourse = false - private var shouldHideTableViewHeader:Bool = false let refreshController = PullRefreshController() - private var courseBlockID: CourseBlockID? + + private var isResumeCourse = false + private var shouldHideTableViewHeader: Bool = false + + weak public var scrollableDelegate: ScrollableDelegate? + private var scrollByDragging = false + + private var collapsedSections = Set() + private var hasAddedToCollapsedSections = false + + var highlightedBlockID : CourseBlockID? = nil + private var groups : [CourseOutlineQuerier.BlockGroup] = [] + private var videos: [OEXHelperVideoDownload]? + private var watchedVideoBlock: [CourseBlockID] = [] var isSectionOutline = false { didSet { - if isSectionOutline { + if isSectionOutline || environment.config.isNewDashboardEnabled { hideTableHeaderView() } + tableView.reloadData() } } @@ -67,21 +84,6 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView fatalError("init(coder:) has not been implemented") } - var groups : [CourseOutlineQuerier.BlockGroup] = [] { - didSet { - courseQuerier.remove(observer: self) - groups.forEach { group in - let observer = BlockCompletionObserver(controller: self, blockID: group.block.blockID, mode: courseOutlineMode, delegate: self) - courseQuerier.add(observer: observer) - } - } - } - - var highlightedBlockID : CourseBlockID? = nil - private var videos: [OEXHelperVideoDownload]? - - private var watchedVideoBlock: [CourseBlockID] = [] - func addCertificateView() { guard environment.config.certificatesEnabled, let enrollment = enrollment, let certificateUrl = enrollment.certificateUrl, let certificateImage = UIImage(named: "courseCertificate") else { return } @@ -105,52 +107,6 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView return environment.interface?.enrollmentForCourse(withID: courseID) } - private func addValuePropView() { - if !canShowValueProp { return } - - headerContainer.addSubview(valuePropView) - valuePropView.backgroundColor = environment.styles.standardBackgroundColor() - - valuePropView.snp.remakeConstraints { make in - make.height.equalTo(0) - } - let lockedImage = Icon.Closed.imageWithFontSize(size: 20).image(with: OEXStyles.shared().neutralWhiteT()) - let imageAttachment = NSTextAttachment() - imageAttachment.image = lockedImage - if let image = imageAttachment.image { - imageAttachment.bounds = CGRect(x: 0, y: -4, width: image.size.width, height: image.size.height) - } - let attributedImageString = NSAttributedString(attachment: imageAttachment) - let style = OEXTextStyle(weight: .semiBold, size: .base, color: environment.styles.neutralWhiteT()) - let attributedStrings = [ - attributedImageString, - NSAttributedString(string: "\u{200b}"), - style.attributedString(withText: Strings.ValueProp.courseDashboardButtonTitle) - ] - let attributedTitle = NSAttributedString.joinInNaturalLayout(attributedStrings: attributedStrings) - - let button = UIButton(type: .system) - button.oex_addAction({ [weak self] _ in - if let course = self?.enrollment?.course { - self?.environment.router?.showValuePropDetailView(from: self, screen: .courseDashboard, course: course) { - self?.environment.analytics.trackValuePropModal(with: .CourseDashboard, courseId: course.course_id ?? "") - } - self?.environment.analytics.trackValuePropLearnMore(courseID: course.course_id ?? "", screenName: .CourseDashboard) - } - }, for: .touchUpInside) - - button.backgroundColor = environment.styles.secondaryDarkColor() - button.setAttributedTitle(attributedTitle, for: .normal) - valuePropView.addSubview(button) - - button.snp.remakeConstraints { make in - make.height.equalTo(StandardVerticalMargin * 4.5) - make.leading.equalTo(valuePropView).offset(StandardHorizontalMargin) - make.trailing.equalTo(valuePropView).inset(StandardHorizontalMargin) - make.center.equalTo(valuePropView) - } - } - private func setAccessibilityIdentifiers() { tableView.accessibilityIdentifier = "CourseOutlineTableController:table-view" headerContainer.accessibilityIdentifier = "CourseOutlineTableController:header-container" @@ -161,7 +117,7 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView valuePropView.accessibilityIdentifier = "CourseOutlineTableController:value-prop-view" } - override func viewDidLoad() { + public override func viewDidLoad() { tableView.dataSource = self tableView.delegate = self tableView.separatorStyle = .none @@ -174,9 +130,12 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView tableView.register(CourseUnknownTableViewCell.self, forCellReuseIdentifier: CourseUnknownTableViewCell.identifier) tableView.register(CourseSectionTableViewCell.self, forCellReuseIdentifier: CourseSectionTableViewCell.identifier) tableView.register(DiscussionTableViewCell.self, forCellReuseIdentifier: DiscussionTableViewCell.identifier) - configureHeaderView() + + if !environment.config.isNewDashboardEnabled || courseOutlineMode != .full { + configureOldHeaderView() + } + refreshController.setupInScrollView(scrollView: tableView) - setAccessibilityIdentifiers() NotificationCenter.default.oex_addObserver(observer: self, name: NSNotification.Name.OEXDownloadDeleted.rawValue) { _, observer, _ in @@ -184,7 +143,26 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView } } - private func configureHeaderView() { + private func configureNewHeaderView() { + headerContainer.addSubview(resumeCourseHeaderView) + tableView.tableHeaderView = headerContainer + + resumeCourseHeaderView.snp.makeConstraints { make in + make.top.equalTo(headerContainer) + make.bottom.equalTo(headerContainer).inset(StandardVerticalMargin * 2) + make.leading.equalTo(headerContainer).offset(StandardHorizontalMargin) + make.trailing.equalTo(headerContainer).inset(StandardHorizontalMargin) + make.height.equalTo(StandardVerticalMargin * 5) + } + + tableView.setAndLayoutTableHeaderView(header: headerContainer) + + UIView.animate(withDuration: 0.1) { [weak self] in + self?.view.layoutIfNeeded() + } + } + + private func configureOldHeaderView() { if courseOutlineMode == .full { courseDateBannerView.delegate = self headerContainer.addSubview(courseDateBannerView) @@ -229,107 +207,95 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView if let headerView = courseVideosHeaderView { headerContainer.addSubview(headerView) } - - refreshTableHeaderView(isResumeCourse: false) - } - - func courseVideosHeaderViewTapped() { - delegate?.outlineTableControllerChoseShowDownloads(controller: self) - } - - func invalidOrNoNetworkFound() { - showOverlay(withMessage: environment.interface?.networkErrorMessage() ?? Strings.noWifiMessage) - } - - func didTapVideoQuality() { - environment.analytics.trackEvent(with: AnalyticsDisplayName.CourseVideosDownloadQualityClicked, name: AnalyticsEventName.CourseVideosDownloadQualityClicked) - environment.router?.showDownloadVideoQuality(from: self, delegate: self, modal: true) - } - - func didUpdateVideoQuality() { - if courseOutlineMode == .video { - courseVideosHeaderView?.refreshView() - } - } - - private func indexPathForBlockWithID(blockID : CourseBlockID) -> NSIndexPath? { - for (i, group) in groups.enumerated() { - for (j, block) in group.children.enumerated() { - if block.blockID == blockID { - return IndexPath(row: j, section: i) as NSIndexPath - } - } - } - return nil } - override func viewWillAppear(_ animated: Bool) { + public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - if let path = self.tableView.indexPathForSelectedRow { - self.tableView.deselectRow(at: path, animated: false) + if let path = tableView.indexPathForSelectedRow { + tableView.deselectRow(at: path, animated: false) } - if let highlightID = highlightedBlockID, let indexPath = indexPathForBlockWithID(blockID: highlightID) - { + + if let highlightID = highlightedBlockID, + let indexPath = indexPathForBlockWithID(blockID: highlightID) { tableView.scrollToRow(at: indexPath as IndexPath, at: UITableView.ScrollPosition.middle, animated: false) } - - if courseOutlineMode == .video { - courseVideosHeaderView?.refreshView() - } + + courseOutlineMode == .video ? courseVideosHeaderView?.refreshView() : nil } - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { tableView.reloadData() } - override func updateViewConstraints() { + public override func updateViewConstraints() { super.updateViewConstraints() refreshTableHeaderView(isResumeCourse: isResumeCourse) } - override func numberOfSections(in tableView: UITableView) -> Int { - return groups.count + private func shouldApplyNewStyle(_ group: CourseOutlineQuerier.BlockGroup) -> Bool { + return environment.config.isNewDashboardEnabled && group.block.type == .Chapter && courseOutlineMode == .full } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - let group = groups[section] - return group.children.count + // MARK: UITableView DataSource & Delegate + public override func numberOfSections(in tableView: UITableView) -> Int { + return groups.count } - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return 30 + public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return shouldApplyNewStyle(groups[section]) + ? collapsedSections.contains(section) ? 0 : groups[section].children.count + : groups[section].children.count } - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - // Will remove manual heights when dropping iOS7 support and move to automatic cell heights. - return 60.0 + public override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return shouldApplyNewStyle(groups[section]) ? StandardVerticalMargin * 7.5 : StandardVerticalMargin * 3.75 } - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let group = groups[section] + public override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: CourseOutlineHeaderCell.identifier) as! CourseOutlineHeaderCell + + let group = groups[section] + header.section = section header.block = group.block + header.delegate = self - if courseOutlineMode == .video { - var allCompleted: Bool - - if group.block.type == .Unit { - allCompleted = group.children.allSatisfy { $0.isCompleted } - } else { - allCompleted = group.children.map { $0.blockID }.allSatisfy(watchedVideoBlock.contains) - } - - allCompleted ? header.showCompletedBackground() : header.showNeutralBackground() + let allCompleted = allBlocksCompleted(for: group) + + if shouldApplyNewStyle(group) { + header.setupViewsNewDesign(isExpanded: !collapsedSections.contains(section), isCompleted: allCompleted) } else { - let allCompleted = group.children.allSatisfy { $0.isCompleted } - allCompleted ? header.showCompletedBackground() : header.showNeutralBackground() + header.setupViewsForOldDesign() } + allCompleted ? header.showCompletedBackground() : header.showNeutralBackground() + return header } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + public override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return shouldApplyNewStyle(groups[section]) ? StandardVerticalMargin * 2 : 0 + } + + public override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + guard shouldApplyNewStyle(groups[section]) else { + return nil + } + + let view = UIView() + view.backgroundColor = environment.styles.neutralWhiteT() + return view + } + + public override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return 60 + } + + public override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UITableView.automaticDimension + } + + public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let group = groups[indexPath.section] let nodes = group.children let block = nodes[indexPath.row] @@ -382,16 +348,17 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView cell.swipeCellViewDelegate = (courseOutlineMode == .video) ? cell : nil cell.delegate = self cell.courseID = courseID - + cell.selectionStyle = .none return cell case .Discussion: let cell = tableView.dequeueReusableCell(withIdentifier: DiscussionTableViewCell.identifier, for: indexPath) as! DiscussionTableViewCell cell.block = block + cell.isSectionOutline = isSectionOutline return cell } } - override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + public override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard let cell = cell as? CourseBlockContainerCell else { assertionFailure("All course outline cells should implement CourseBlockContainerCell") return @@ -401,14 +368,13 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView cell.applyStyle(style: highlighted ? .Highlighted : .Normal) } - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let group = groups[indexPath.section] let chosenBlock = group.children[indexPath.row] delegate?.outlineTableController(controller: self, choseBlock: chosenBlock, parent: group.block.blockID) } - override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { - + public override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { guard let cell = tableView.cellForRow(at: indexPath) as? SwipeableCell, cell.state != .initial else { return indexPath } @@ -416,24 +382,116 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView return nil } - func videoCellChoseDownload(cell: CourseVideoTableViewCell, block : CourseBlock) { - self.delegate?.outlineTableController(controller: self, choseDownloadVideoForBlock: block) + deinit { + courseQuerier.remove(observer: self) + NotificationCenter.default.removeObserver(self) } - - func videoCellChoseShowDownloads(cell: CourseVideoTableViewCell) { - self.delegate?.outlineTableControllerChoseShowDownloads(controller: self) +} + +extension CourseOutlineTableController { + func setGroups(_ groups: [CourseOutlineQuerier.BlockGroup]) { + self.groups = groups + let collapsedSectionsBeforeReload = collapsedSections + collapsedSections = Set(0.. firstIncompleteSection { + collapsedSections.insert(index) + } + } + else { + if allBlocksCompleted(for: group) && !collapsedSections.contains(index) { + collapsedSections.insert(index) + } + } + } + + hasAddedToCollapsedSections = true + UIView.performWithoutAnimation { [weak self] in + self?.tableView.reloadData() + } } - func reloadCell(cell: UITableViewCell) { - self.delegate?.outlineTableControllerReload(controller: self) + private func allBlocksCompleted(for group: CourseOutlineQuerier.BlockGroup) -> Bool { + if courseOutlineMode == .video { + return group.block.type == .Unit ? + group.children.allSatisfy { $0.isCompleted } : + group.children.map { $0.blockID }.allSatisfy(watchedVideoBlock.contains) + } else { + return group.children.allSatisfy { $0.isCompleted } + } } - func sectionCellChoseShowDownloads(cell: CourseSectionTableViewCell) { - self.delegate?.outlineTableControllerChoseShowDownloads(controller: self) + private func indexPathForBlockWithID(blockID: CourseBlockID) -> IndexPath? { + for (i, group) in groups.enumerated() { + if let j = group.children.firstIndex(where: { $0.blockID == blockID }) { + return IndexPath(row: j, section: i) + } + } + return nil } - func sectionCellChoseDownload(cell: CourseSectionTableViewCell, videos: [OEXHelperVideoDownload], forBlock block : CourseBlock) { - self.delegate?.outlineTableController(controller: self, choseDownloadVideos: videos, rootedAtBlock:block) + private func addValuePropView() { + if !canShowValueProp { return } + + headerContainer.addSubview(valuePropView) + valuePropView.backgroundColor = environment.styles.standardBackgroundColor() + + valuePropView.snp.remakeConstraints { make in + make.height.equalTo(0) + } + let lockedImage = Icon.Closed.imageWithFontSize(size: 20).image(with: OEXStyles.shared().neutralWhiteT()) + let imageAttachment = NSTextAttachment() + imageAttachment.image = lockedImage + if let image = imageAttachment.image { + imageAttachment.bounds = CGRect(x: 0, y: -4, width: image.size.width, height: image.size.height) + } + let attributedImageString = NSAttributedString(attachment: imageAttachment) + let style = OEXTextStyle(weight: .semiBold, size: .base, color: environment.styles.neutralWhiteT()) + let attributedStrings = [ + attributedImageString, + NSAttributedString(string: "\u{200b}"), + style.attributedString(withText: Strings.ValueProp.courseDashboardButtonTitle) + ] + let attributedTitle = NSAttributedString.joinInNaturalLayout(attributedStrings: attributedStrings) + + let button = UIButton(type: .system) + button.oex_addAction({ [weak self] _ in + if let course = self?.enrollment?.course { + self?.environment.router?.showValuePropDetailView(from: self, screen: .courseDashboard, course: course) { + self?.environment.analytics.trackValuePropModal(with: .CourseDashboard, courseId: course.course_id ?? "") + } + self?.environment.analytics.trackValuePropLearnMore(courseID: course.course_id ?? "", screenName: .CourseDashboard) + } + }, for: .touchUpInside) + + button.backgroundColor = environment.styles.secondaryDarkColor() + button.setAttributedTitle(attributedTitle, for: .normal) + valuePropView.addSubview(button) + + button.snp.remakeConstraints { make in + make.height.equalTo(StandardVerticalMargin * 4.5) + make.leading.equalTo(valuePropView).offset(StandardHorizontalMargin) + make.trailing.equalTo(valuePropView).inset(StandardHorizontalMargin) + make.center.equalTo(valuePropView) + } } private func resumeCourse(with item: ResumeCourseItem) { @@ -443,16 +501,37 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView /// Shows the last accessed Header from the item as argument. Also, sets the relevant action if the course block exists in the course outline. func showResumeCourse(item: ResumeCourseItem) { + if environment.config.isNewDashboardEnabled { + showResumeCourseNewDesign(item: item) + } else { + showResumeCourseOldDesign(item: item) + } + } + + func showResumeCourseNewDesign(item: ResumeCourseItem) { if !item.lastVisitedBlockID.isEmpty { - courseQuerier.blockWithID(id: item.lastVisitedBlockID).extendLifetimeUntilFirstResult (success: { [weak self] block in + courseQuerier.blockWithID(id: item.lastVisitedBlockID).extendLifetimeUntilFirstResult { [weak self] block in + self?.configureNewHeaderView() + self?.resumeCourseHeaderView.tapAction = { [weak self] in + self?.resumeCourse(with: item) + } + } failure: { [weak self] _ in + self?.tableView.tableHeaderView = nil + } + } + } + + func showResumeCourseOldDesign(item: ResumeCourseItem) { + if !item.lastVisitedBlockID.isEmpty { + courseQuerier.blockWithID(id: item.lastVisitedBlockID).extendLifetimeUntilFirstResult { [weak self] block in self?.resumeCourseView.subtitleText = block.displayName self?.resumeCourseView.setViewButtonAction { [weak self] _ in self?.resumeCourse(with: item) } self?.refreshTableHeaderView(isResumeCourse: true) - }, failure: { [weak self] _ in + } failure: { [weak self] _ in self?.refreshTableHeaderView(isResumeCourse: false) - }) + } } else { refreshTableHeaderView(isResumeCourse: false) } @@ -468,6 +547,13 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView } func showCourseDateBanner(bannerInfo: DatesBannerInfo) { + if environment.config.isNewDashboardEnabled { + if bannerInfo.status == .resetDatesBanner { + showCourseDates(bannerInfo: bannerInfo, delegate: self) + } + return + } + if canShowValueProp && bannerInfo.status == .resetDatesBanner { courseDateBannerView.bannerInfo = bannerInfo updateCourseDateBannerView(show: true) @@ -479,6 +565,10 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView } func hideCourseDateBanner() { + if environment.config.isNewDashboardEnabled { + hideCourseDates() + return + } courseDateBannerView.bannerInfo = nil updateCourseDateBannerView(show: false) } @@ -515,7 +605,21 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView tableView.tableHeaderView = nil return } + + if environment.config.isNewDashboardEnabled { + updateNewHeaderConstraints() + } else { + updateOldHeaderConstraints() + } + tableView.setAndLayoutTableHeaderView(header: headerContainer) + } + + private func updateNewHeaderConstraints() { + + } + + private func updateOldHeaderConstraints() { var constraintView: UIView = courseCard courseCard.snp.remakeConstraints { make in make.trailing.equalTo(headerContainer) @@ -562,10 +666,11 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView make.height.equalTo(height) make.bottom.equalTo(headerContainer) } - tableView.setAndLayoutTableHeaderView(header: headerContainer) } private func refreshTableHeaderView(isResumeCourse: Bool) { + if environment.config.isNewDashboardEnabled && courseOutlineMode == .full { return } + self.isResumeCourse = isResumeCourse resumeCourseView.isHidden = !isResumeCourse @@ -599,10 +704,33 @@ class CourseOutlineTableController : UITableViewController, CourseVideoTableView let courseMode = environment.dataManager.enrollmentManager.enrolledCourseWithID(courseID: courseID)?.mode else { return } environment.analytics.trackDatesBannerAppearence(screenName: AnalyticsScreenName.CourseDashboard, courseMode: courseMode, eventName: eventName, bannerType: bannerType) } +} + +extension CourseOutlineTableController: CourseVideoTableViewCellDelegate { + func videoCellChoseDownload(cell: CourseVideoTableViewCell, block : CourseBlock) { + delegate?.outlineTableController(controller: self, choseDownloadVideoForBlock: block) + } - deinit { - courseQuerier.remove(observer: self) - NotificationCenter.default.removeObserver(self) + func videoCellChoseShowDownloads(cell: CourseVideoTableViewCell) { + delegate?.outlineTableControllerChoseShowDownloads(controller: self) + } + + func reloadCell(cell: UITableViewCell) { + delegate?.outlineTableControllerReload(controller: self) + } +} + +extension CourseOutlineTableController: CourseSectionTableViewCellDelegate { + func sectionCellChoseShowDownloads(cell: CourseSectionTableViewCell) { + delegate?.outlineTableControllerChoseShowDownloads(controller: self) + } + + func sectionCellChoseDownload(cell: CourseSectionTableViewCell, videos: [OEXHelperVideoDownload], forBlock block : CourseBlock) { + delegate?.outlineTableController(controller: self, choseDownloadVideos: videos, rootedAtBlock:block) + } + + func reloadSectionCell(cell: UITableViewCell) { + delegate?.outlineTableControllerReload(controller: self) } } @@ -612,24 +740,95 @@ extension CourseOutlineTableController: CourseShiftDatesDelegate { } } +extension CourseOutlineTableController: CourseVideosHeaderViewDelegate { + func courseVideosHeaderViewTapped() { + delegate?.outlineTableControllerChoseShowDownloads(controller: self) + } + + func invalidOrNoNetworkFound() { + showOverlay(withMessage: environment.interface?.networkErrorMessage() ?? Strings.noWifiMessage) + } + + func didTapVideoQuality() { + environment.analytics.trackEvent(with: AnalyticsDisplayName.CourseVideosDownloadQualityClicked, name: AnalyticsEventName.CourseVideosDownloadQualityClicked) + environment.router?.showDownloadVideoQuality(from: self, delegate: self, modal: true) + } +} + +extension CourseOutlineTableController: VideoDownloadQualityDelegate { + func didUpdateVideoQuality() { + if courseOutlineMode == .video { + courseVideosHeaderView?.refreshView() + } + } +} + extension CourseOutlineTableController: BlockCompletionDelegate { func didCompletionChanged(in blockGroup: CourseOutlineQuerier.BlockGroup, mode: CourseOutlineMode) { + guard mode == courseOutlineMode, mode == .full else { return } - if mode != courseOutlineMode { return } - - guard let index = groups.firstIndex(where: { - return $0.block.blockID == blockGroup.block.blockID - }) else { return } - - if tableView.isValidSection(with: index) { - if mode == .full { - groups[index] = blockGroup + if let index = groups.firstIndex(where: { $0.block.blockID == blockGroup.block.blockID }) { + groups[index] = blockGroup + collapsedSections.removeAll() + hasAddedToCollapsedSections = false + setGroups(groups) + } else { + for (index, group) in groups.enumerated() { + guard var child = courseQuerier.childrenOfBlockWithID(blockID: group.block.blockID, forMode: .full).value else { continue } + if let indexOfBlock = child.children.firstIndex(where: { $0.blockID == blockGroup.block.blockID }) { + child.children[indexOfBlock] = blockGroup.block + groups[index] = child + collapsedSections.removeAll() + hasAddedToCollapsedSections = false + setGroups(groups) + break + } } - tableView.reloadSections([index], with: .none) } } } +extension CourseOutlineTableController { + public override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollByDragging = true + } + + public override func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollByDragging { + scrollableDelegate?.scrollViewDidScroll(scrollView: scrollView) + } + } + + public override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + scrollByDragging = false + } +} + +extension CourseOutlineTableController: CourseOutlineHeaderCellDelegate { + func toggleSection(section: Int) { + if environment.config.isNewDashboardEnabled { + collapsedSections = collapsedSections.symmetricDifference([section]) + tableView.reloadSections([section], with: .none) + } + } +} + +extension CourseOutlineTableController: NewCourseDashboardViewControllerDelegate { + public func showCourseDates(bannerInfo: DatesBannerInfo?, delegate: CourseOutlineTableController?) { + newDashboardDelegate?.showCourseDates(bannerInfo: bannerInfo, delegate: self) + } + + public func hideCourseDates() { + newDashboardDelegate?.hideCourseDates() + } +} + +extension CourseOutlineTableController { + var t_groupsCount: Int { + return groups.count + } +} + extension UITableView { //set the tableHeaderView so that the required height can be determined, update the header's frame and set it again func setAndLayoutTableHeaderView(header: UIView) { @@ -644,4 +843,3 @@ extension UITableView { return index < numberOfSections } } - diff --git a/Source/CourseOutlineViewController.swift b/Source/CourseOutlineViewController.swift index d455ad5b3d..74ab3e419a 100644 --- a/Source/CourseOutlineViewController.swift +++ b/Source/CourseOutlineViewController.swift @@ -20,7 +20,8 @@ public class CourseOutlineViewController : CourseContentPageViewControllerDelegate, PullRefreshControllerDelegate, LoadStateViewReloadSupport, - InterfaceOrientationOverriding + InterfaceOrientationOverriding, + ScrollableDelegateProvider { public typealias Environment = OEXAnalyticsProvider & DataManagerProvider & OEXInterfaceProvider & NetworkManagerProvider & ReachabilityProvider & OEXRouterProvider & OEXConfigProvider & OEXStylesProvider & ServerConfigProvider @@ -42,6 +43,7 @@ public class CourseOutlineViewController : private(set) var courseOutlineMode: CourseOutlineMode private var loadCachedResponse: Bool = true private lazy var courseUpgradeHelper = CourseUpgradeHelper.shared + weak var newDashboardDelegate: NewCourseDashboardViewControllerDelegate? /// Strictly a test variable used as a trigger flag. Not to be used out of the test scope fileprivate var t_hasTriggeredSetResumeCourse = false @@ -67,15 +69,23 @@ public class CourseOutlineViewController : return environment.dataManager.enrollmentManager.enrolledCourseWithID(courseID: courseID)?.course } - public init(environment: Environment, courseID : String, rootID : CourseBlockID?, forMode mode: CourseOutlineMode?) { + public weak var scrollableDelegate: ScrollableDelegate? { + didSet { + tableController.scrollableDelegate = scrollableDelegate + } + } + + public init(environment: Environment, courseID : String, rootID : CourseBlockID?, forMode mode: CourseOutlineMode?, newDashboardDelegate: NewCourseDashboardViewControllerDelegate? = nil) { self.rootID = rootID self.environment = environment + self.newDashboardDelegate = newDashboardDelegate courseQuerier = environment.dataManager.courseDataManager.querierForCourseWithID(courseID: courseID, environment: environment) loadController = LoadStateViewController() insetsController = ContentInsetsController() courseOutlineMode = mode ?? .full tableController = CourseOutlineTableController(environment: environment, courseID: courseID, forMode: courseOutlineMode, courseBlockID: rootID) + tableController.newDashboardDelegate = newDashboardDelegate resumeCourseController = ResumeCourseController(blockID: rootID , dataManager: environment.dataManager, networkManager: environment.networkManager, courseQuerier: courseQuerier, forMode: courseOutlineMode) super.init(env: environment, shouldShowOfflineSnackBar: false) @@ -121,12 +131,12 @@ public class CourseOutlineViewController : view.setNeedsUpdateConstraints() addListeners() setAccessibilityIdentifiers() + loadCourseStream() } public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) resumeCourseController.loadResumeCourse(forMode: courseOutlineMode) - loadCourseStream() if courseOutlineMode == .video { // We are doing calculations to show downloading progress on video tab, For this purpose we are observing notifications. @@ -154,7 +164,10 @@ public class CourseOutlineViewController : loadController.insets = UIEdgeInsets(top: view.safeAreaInsets.top, left: 0, bottom: view.safeAreaInsets.bottom, right : 0) tableController.view.snp.remakeConstraints { make in - make.edges.equalTo(safeEdges) + make.top.equalTo(safeTop).offset(StandardVerticalMargin * 2) + make.bottom.equalTo(safeBottom) + make.leading.equalTo(safeLeading) + make.trailing.equalTo(safeTrailing) } super.updateViewConstraints() } @@ -322,8 +335,7 @@ public class CourseOutlineViewController : private func loadRowsStream() { rowsLoader.listen(self, success : { [weak self] groups in if let owner = self { - owner.tableController.groups = groups - owner.tableController.tableView.reloadData() + owner.tableController.setGroups(groups) owner.loadController.state = groups.count == 0 ? owner.emptyState() : .Loaded } }, failure : {[weak self] error in @@ -337,24 +349,33 @@ public class CourseOutlineViewController : } private func handleNavigationIfNeeded() { - if let courseUpgradeModel = courseUpgradeHelper.courseUpgradeModel { - courseUpgradeHelper.resetUpgradeModel() - - if courseUpgradeModel.screen == .courseDashboard { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - self?.courseUpgradeHelper.removeLoader(success: true, removeView: true) - } - } else if courseUpgradeModel.screen == .courseComponent, let blockID = courseUpgradeModel.blockID { - environment.router?.navigateToComponentScreen(from: self, courseID: courseUpgradeModel.courseID, componentID: blockID) { _ in - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.courseUpgradeHelper.removeLoader(success: true, removeView: true) - } - } - } - } else { + guard let courseUpgradeModel = courseUpgradeHelper.courseUpgradeModel else { // navigation from deeplink navigateToComponentScreenIfNeeded() + return } + + courseUpgradeHelper.resetUpgradeModel() + + switch courseUpgradeModel.screen { + case .courseDashboard: + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.removeLoader() + } + case .courseComponent: + guard let blockID = courseUpgradeModel.blockID else { return } + environment.router?.navigateToComponentScreen(from: self, courseID: courseUpgradeModel.courseID, componentID: blockID) { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.removeLoader() + } + } + default: + break + } + } + + private func removeLoader() { + courseUpgradeHelper.removeLoader(success: true, removeView: true) } private func loadBackedStreams() { @@ -395,7 +416,13 @@ public class CourseOutlineViewController : } private func showSnackBar() { - showDateResetSnackBar(message: Strings.Coursedates.toastSuccessMessage, buttonText: Strings.Coursedates.viewAllDates, showButton: true) { [weak self] in + guard let topController = UIApplication.shared.topMostController() else { return } + var showViewDatesButton = true + if let selectedController = newDashboardDelegate?.selectedController() { + showViewDatesButton = !(selectedController is CourseDatesViewController) + } + + topController.showDateResetSnackBar(message: Strings.Coursedates.toastSuccessMessage, buttonText: Strings.Coursedates.viewAllDates, showButton: showViewDatesButton) { [weak self] in if let weakSelf = self { weakSelf.environment.router?.showDatesTabController(controller: weakSelf) weakSelf.hideSnackBar() @@ -521,7 +548,7 @@ extension CourseOutlineViewController { } public func t_currentChildCount() -> Int { - return tableController.groups.count + return tableController.t_groupsCount } public func t_populateResumeCourseItem(item : ResumeCourseItem) -> Bool { diff --git a/Source/CourseSectionTableViewCell.swift b/Source/CourseSectionTableViewCell.swift index 8115b5831f..dc530ad09e 100644 --- a/Source/CourseSectionTableViewCell.swift +++ b/Source/CourseSectionTableViewCell.swift @@ -11,7 +11,7 @@ import UIKit protocol CourseSectionTableViewCellDelegate : AnyObject { func sectionCellChoseDownload(cell : CourseSectionTableViewCell, videos : [OEXHelperVideoDownload], forBlock block : CourseBlock) func sectionCellChoseShowDownloads(cell : CourseSectionTableViewCell) - func reloadCell(cell: UITableViewCell) + func reloadSectionCell(cell: UITableViewCell) } class CourseSectionTableViewCell: SwipeableCell, CourseBlockContainerCell { @@ -24,7 +24,11 @@ class CourseSectionTableViewCell: SwipeableCell, CourseBlockContainerCell { weak var delegate : CourseSectionTableViewCellDelegate? fileprivate var spinnerTimer = Timer() var courseID: String? - var courseOutlineMode: CourseOutlineMode = .full + var courseOutlineMode: CourseOutlineMode = .full { + didSet { + content.courseOutlineMode = courseOutlineMode + } + } var videos : OEXStream<[OEXHelperVideoDownload]> = OEXStream() { didSet { @@ -37,8 +41,16 @@ class CourseSectionTableViewCell: SwipeableCell, CourseBlockContainerCell { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) contentView.addSubview(content) + content.snp.makeConstraints { make in - make.edges.equalTo(contentView) + if OEXConfig.shared().isNewDashboardEnabled && courseOutlineMode == .full { + make.top.equalTo(contentView) + make.bottom.equalTo(contentView) + make.leading.equalTo(contentView).offset(StandardHorizontalMargin) + make.trailing.equalTo(contentView).inset(StandardHorizontalMargin) + } else { + make.edges.equalTo(contentView) + } } for notification in [NSNotification.Name.OEXDownloadProgressChanged, NSNotification.Name.OEXDownloadEnded, NSNotification.Name.OEXVideoStateChanged] { @@ -61,6 +73,10 @@ class CourseSectionTableViewCell: SwipeableCell, CourseBlockContainerCell { } downloadView.addGestureRecognizer(tapGesture) setAccessibilityIdentifiers() + + if OEXConfig.shared().isNewDashboardEnabled { + content.backgroundColor = OEXStyles.shared().neutralWhiteT() + } } private func setAccessibilityIdentifiers() { @@ -153,26 +169,29 @@ class CourseSectionTableViewCell: SwipeableCell, CourseBlockContainerCell { var block: CourseBlock? = nil { didSet { guard let block = block else { return } - content.setTitleText(title: block.displayName, elipsis: false) content.isGraded = block.graded content.setDetailText(title: block.format ?? "", dueDate: block.dueDate, blockType: block.type) - - if courseOutlineMode == .video, - let sectionChild = courseQuerier?.childrenOfBlockWithID(blockID: block.blockID, forMode: .video).value, - sectionChild.block.type == .Section, - let unitChild = courseQuerier?.childrenOfBlockWithID(blockID: sectionChild.block.blockID, forMode: .video).value, - unitChild.children.allSatisfy ({ $0.isCompleted }) { - completionAction?() - showCompletionBackground() - } else { - handleBlockNormally(block) - } - + handleVideoBlockIfNeeded(block) setupDownloadView() } } + private func handleVideoBlockIfNeeded(_ block: CourseBlock) { + guard courseOutlineMode == .video, + let sectionChild = courseQuerier?.childrenOfBlockWithID(blockID: block.blockID, forMode: .video).value, + sectionChild.block.type == .Section, + let unitChild = courseQuerier?.childrenOfBlockWithID(blockID: sectionChild.block.blockID, forMode: .video).value, + unitChild.children.allSatisfy ({ $0.isCompleted }) + else { + handleBlockNormally(block) + return + } + + completionAction?() + showCompletionBackground() + } + private func handleBlockNormally(_ block: CourseBlock) { if block.isCompleted { let shouldShowIcon = courseOutlineMode == .full ? true : false @@ -228,7 +247,7 @@ extension CourseSectionTableViewCell: SwipeableCellDelegate { @objc private func invalidateTimer(){ spinnerTimer.invalidate() downloadView.state = .Done - delegate?.reloadCell(cell: self) + delegate?.reloadSectionCell(cell: self) } } diff --git a/Source/CourseVideoTableViewCell.swift b/Source/CourseVideoTableViewCell.swift index 009360cb3f..24fc092e3f 100644 --- a/Source/CourseVideoTableViewCell.swift +++ b/Source/CourseVideoTableViewCell.swift @@ -114,6 +114,7 @@ class CourseVideoTableViewCell: SwipeableCell, CourseBlockContainerCell { private func setupContentView() { contentView.addSubview(content) + content.snp.makeConstraints { make in make.edges.equalTo(contentView) } diff --git a/Source/CoursesContainerViewController.swift b/Source/CoursesContainerViewController.swift index 8636da4c63..596601ff07 100644 --- a/Source/CoursesContainerViewController.swift +++ b/Source/CoursesContainerViewController.swift @@ -105,13 +105,15 @@ class CourseCardCell : UICollectionViewCell { protocol CoursesContainerViewControllerDelegate : AnyObject { func coursesContainerChoseCourse(course : OEXCourse) func showValuePropDetailView(with course: OEXCourse) + func reload() } extension CoursesContainerViewControllerDelegate { func showValuePropDetailView(with course: OEXCourse) {} + func reload() {} } -class CoursesContainerViewController: UICollectionViewController { +class CoursesContainerViewController: UICollectionViewController, ScrollableDelegateProvider { enum Context { case courseCatalog @@ -120,6 +122,9 @@ class CoursesContainerViewController: UICollectionViewController { typealias Environment = NetworkManagerProvider & OEXRouterProvider & OEXConfigProvider & OEXInterfaceProvider & OEXAnalyticsProvider & ServerConfigProvider + weak var scrollableDelegate: ScrollableDelegate? + private var scrollByDragging = false + private let environment : Environment private let context: Context @@ -127,6 +132,11 @@ class CoursesContainerViewController: UICollectionViewController { private var isAuditModeCourseAvailable: Bool = false + private lazy var errorView: GeneralErrorView = { + let errorView = GeneralErrorView() + return errorView + }() + var courses: [OEXCourse] = [] { didSet { if isiPad() { @@ -143,9 +153,9 @@ class CoursesContainerViewController: UICollectionViewController { } private var shouldShowFooter: Bool { - return context == .enrollmentList && isDiscoveryEnabled + return context == .enrollmentList && isDiscoveryEnabled && courses.isEmpty } - + init(environment: Environment, context: Context) { self.environment = environment self.context = context @@ -188,18 +198,14 @@ class CoursesContainerViewController: UICollectionViewController { ) } - override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return courses.count - } - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { - return CGSize(width: collectionView.frame.size.width, height: shouldShowFooter ? EnrolledCoursesFooterViewHeight : 0) + return CGSize(width: collectionView.frame.size.width, height: shouldShowFooter ? collectionView.frame.size.height : 0) } override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { if kind == UICollectionView.elementKindSectionFooter { let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: EnrolledCoursesFooterView.identifier, for: indexPath) as! EnrolledCoursesFooterView - footerView.findCoursesAction = {[weak self] in + footerView.findCoursesAction = { [weak self] in self?.environment.router?.showCourseCatalog(fromController: self, bottomBar: nil) self?.environment.analytics.trackUserFindsCourses(self?.courses.count ?? 0) } @@ -209,6 +215,10 @@ class CoursesContainerViewController: UICollectionViewController { return UICollectionReusableView() } + override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return courses.count + } + override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let course = courses[indexPath.row] @@ -258,6 +268,43 @@ class CoursesContainerViewController: UICollectionViewController { super.viewDidLayoutSubviews() insetsController.updateInsets() } + + func showError(message: String? = nil) { + setupErrorView() + errorView.setErrorMessage(message: message) + + errorView.tapAction = { [weak self] in + self?.delegate?.reload() + } + } + + private func setupErrorView() { + collectionView.alpha = 0 + view.addSubview(errorView) + view.bringSubviewToFront(errorView) + + errorView.snp.makeConstraints { make in + make.edges.equalTo(view) + } + } + + func showOutdatedVersionError() { + setupErrorView() + errorView.showOutdatedVersionError() + + errorView.tapAction = { [weak self] in + if let URL = self?.environment.config.appUpgradeConfig.iOSAppStoreURL() as? URL, UIApplication.shared.canOpenURL(URL) { + UIApplication.shared.open(URL as URL, options: [:], completionHandler: nil) + } + } + } + + func removeErrorView() { + if view.subviews.contains(errorView) { + errorView.removeFromSuperview() + collectionView.alpha = 1 + } + } } extension CoursesContainerViewController: UICollectionViewDelegateFlowLayout { @@ -292,3 +339,19 @@ extension CoursesContainerViewController: UICollectionViewDelegateFlowLayout { return CGSize(width: widthPerItem, height: heightPerItem) } } + +extension CoursesContainerViewController { + override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollByDragging = true + } + + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollByDragging { + scrollableDelegate?.scrollViewDidScroll(scrollView: scrollView) + } + } + + override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + scrollByDragging = false + } +} diff --git a/Source/CutomePlayer/VideoPlayer.swift b/Source/CutomePlayer/VideoPlayer.swift index 0365ed2691..69f36a4f39 100644 --- a/Source/CutomePlayer/VideoPlayer.swift +++ b/Source/CutomePlayer/VideoPlayer.swift @@ -31,7 +31,7 @@ protocol VideoPlayerDelegate: AnyObject { private var playbackLikelyToKeepUpContext = 0 class VideoPlayer: UIViewController,VideoPlayerControlsDelegate,TranscriptManagerDelegate { - typealias Environment = OEXInterfaceProvider & OEXAnalyticsProvider & OEXStylesProvider + typealias Environment = OEXInterfaceProvider & OEXAnalyticsProvider & OEXStylesProvider & OEXConfigProvider public let environment : Environment fileprivate var controls: VideoPlayerControls? @@ -689,9 +689,13 @@ class VideoPlayer: UIViewController,VideoPlayerControlsDelegate,TranscriptManage func setFullscreen(fullscreen: Bool, animated: Bool, with deviceOrientation: UIInterfaceOrientation, forceRotate rotate: Bool) { if !isVisible { return } isFullScreen = fullscreen + if fullscreen { - - fullScreenContainerView = UIApplication.shared.window?.rootViewController?.view ?? UIApplication.shared.windows[0].rootViewController?.view + if environment.config.isNewComponentNavigationEnabled { + fullScreenContainerView = parent?.findParentViewController(type: NewCourseContentController.self)?.view ?? UIApplication.shared.windows.first?.rootViewController?.view + } else { + fullScreenContainerView = UIApplication.shared.window?.rootViewController?.view ?? UIApplication.shared.windows.first?.rootViewController?.view + } if movieBackgroundView.frame == .zero { movieBackgroundView.frame = movieBackgroundFrame diff --git a/Source/Deep Linking/DeepLinkManager.swift b/Source/Deep Linking/DeepLinkManager.swift index 11d1ac2998..4d002d4447 100644 --- a/Source/Deep Linking/DeepLinkManager.swift +++ b/Source/Deep Linking/DeepLinkManager.swift @@ -103,19 +103,10 @@ import UIKit return .none } - private func showCourseDashboardViewController(with link: DeepLink) { - guard let topViewController = topMostViewController else { return } - - if let courseDashboardView = topViewController.parent as? CourseDashboardViewController, courseDashboardView.courseID == link.courseId { - if !controllerAlreadyDisplayed(for: link.type) { - courseDashboardView.switchTab(with: link.type, componentID: link.componentID) - return - } - } - + private func showCourseDashboardViewController(with link: DeepLink, completion: (() -> Void)? = nil) { dismiss() { [weak self] _ in if let topController = self?.topMostViewController { - self?.environment?.router?.showCourse(with: link, courseID: link.courseId ?? "", from: topController) + self?.environment?.router?.showCourse(with: link, courseID: link.courseId ?? "", from: topController, completion: completion) } } } @@ -331,6 +322,10 @@ import UIKit return postController.topicID == link.topicID } + if isControllerAlreadyDisplayed { + return + } + func showDiscussionPosts() { if let topController = topMostViewController { environment?.router?.showDiscussionPosts(from: topController, courseID: courseId, topicID: topicID) @@ -349,10 +344,10 @@ import UIKit courseDashboardController.switchTab(with: link.type) } else { - self?.showCourseDashboardViewController(with: link) + self?.showCourseDashboardViewController(with: link) { + showDiscussionPosts() + } } - - showDiscussionPosts() } } } @@ -367,6 +362,10 @@ import UIKit return discussionResponseController.threadID == link.threadID } + if isControllerAlreadyDisplayed { + return + } + func showResponses() { if let topController = topMostViewController { environment?.router?.showDiscussionResponses(from: topController, courseID: courseId, threadID: threadID, isDiscussionBlackedOut: false, completion: completion) @@ -409,6 +408,10 @@ import UIKit return discussionResponseController.threadID == link.threadID } + if isControllerAlreadyDisplayed { + return + } + func showComment() { if let topController = topMostViewController, let discussionResponseController = topController as? DiscussionResponsesViewController { environment?.router?.showDiscussionComments(from: discussionResponseController, courseID: courseID, commentID: commentID, threadID:threadID) @@ -522,6 +525,8 @@ import UIKit return } + let isNewDashboardEnabled = environment?.config.isNewDashboardEnabled ?? false + switch type { case .courseDashboard, .courseVideos, .discussions, .courseDates, .courseComponent: showCourseDashboardViewController(with: link) @@ -544,15 +549,23 @@ import UIKit showDiscussionTopic(with: link) break case .discussionPost: - showDiscussionResponses(with: link) + showDiscussionResponses(with: link) break case .discussionComment: - showdiscussionComments(with: link) + showdiscussionComments(with: link) case .courseHandout: - showCourseHandout(with: link) + if isNewDashboardEnabled { + showCourseDashboardViewController(with: link) + } else { + showCourseHandout(with: link) + } break case .courseAnnouncement: - showCourseAnnouncement(with: link) + if isNewDashboardEnabled { + showCourseDashboardViewController(with: link) + } else { + showCourseAnnouncement(with: link) + } break default: break diff --git a/Source/DetailToolbarButton.swift b/Source/DetailToolbarButton.swift index 1f9fcdceb3..ec52c1d653 100644 --- a/Source/DetailToolbarButton.swift +++ b/Source/DetailToolbarButton.swift @@ -82,9 +82,17 @@ class DetailToolbarButton: UIView { } private var titleStyle : OEXTextStyle { - let style = OEXMutableTextStyle(weight: .semiBold, size: .small, color: OEXStyles.shared().primaryBaseColor()) - style.alignment = self.textAlignment + let style: OEXMutableTextStyle + + if OEXConfig.shared().isNewComponentNavigationEnabled { + style = OEXMutableTextStyle(weight: .normal, size: .xLarge, color: OEXStyles.shared().neutralXDark()) + } else { + style = OEXMutableTextStyle(weight: .semiBold, size: .small, color: OEXStyles.shared().primaryBaseColor()) + } + + style.alignment = textAlignment style.dynamicTypeSupported = false + return style } diff --git a/Source/DiscoveryViewController.swift b/Source/DiscoveryViewController.swift index 581a6c3703..5c9cea9798 100644 --- a/Source/DiscoveryViewController.swift +++ b/Source/DiscoveryViewController.swift @@ -47,7 +47,7 @@ class DiscoveryViewController: UIViewController, InterfaceOrientationOverriding private func configureDiscoveryController() { guard environment.config.discovery.isEnabled else { return } - let coursesController = self.environment.config.discovery.type == .webview ? OEXFindCoursesViewController(environment: environment, showBottomBar: false, bottomBar: bottomBar, searchQuery: self.searchQuery) : CourseCatalogViewController(environment: self.environment) + let coursesController = self.environment.config.discovery.type == .webview ? OEXFindCoursesViewController(environment: environment, showBottomBar: false, bottomBar: bottomBar, searchQuery: self.searchQuery, fromStartupScreen: false) : CourseCatalogViewController(environment: self.environment) addChild(coursesController) didMove(toParent: self) diff --git a/Source/DiscoveryWebViewHelper.swift b/Source/DiscoveryWebViewHelper.swift index 39b1141fac..c986098827 100644 --- a/Source/DiscoveryWebViewHelper.swift +++ b/Source/DiscoveryWebViewHelper.swift @@ -43,6 +43,8 @@ class DiscoveryWebViewHelper: NSObject { self.init(environment: environment, delegate: delegate, bottomBar: bottomBar, searchQuery: nil) } + private lazy var titleView = UIView() + @objc init(environment: Environment, delegate: WebViewNavigationDelegate?, bottomBar: UIView?, searchQuery: String?) { self.environment = environment self.webView = WKWebView(frame: .zero, configuration: environment.config.webViewConfiguration()) @@ -69,8 +71,57 @@ class DiscoveryWebViewHelper: NSObject { refreshView() } + private func addShowTitleView() { + guard let container = delegate?.webViewContainingController() else { return } + + if contentView.contains(titleView) { + if titleView.alpha != 1 { + UIView.animate(withDuration: 0.4) { [weak self] in + self?.titleView.alpha = 1 + } + contentView.bringSubviewToFront(titleView) + } + } + else { + titleView.backgroundColor = OEXStyles.shared().navigationBarColor() + contentView.addSubview(titleView) + titleView.alpha = 0 + let title = UILabel() + title.textColor = OEXStyles.shared().navigationItemTintColor() + title.text = Strings.exploreTheCatalog + titleView.addSubview(title) + + title.snp.remakeConstraints { make in + make.center.equalTo(titleView) + } + } + + let offSet = container.view.viewWithTag(statuBarViewTag)?.frame.size.height ?? 0 + titleView.snp.remakeConstraints { make in + make.top.equalTo(contentView).offset(offSet) + make.trailing.equalTo(contentView) + make.leading.equalTo(contentView) + make.height.equalTo(44) + } + } + + private func hideTitleView() { + UIView.animate(withDuration: 0.4) { [weak self] in + self?.titleView.alpha = 0 + } + } + + @objc func updateTitleViewVisibility() { + if webView.scrollView.contentOffset.y >= 0 { + addShowTitleView() + } + else { + hideTitleView() + } + } + @objc func refreshView() { - guard let _ = delegate?.webViewContainingController() else { return } + guard let container = delegate?.webViewContainingController() else { return } contentView.subviews.forEach { $0.removeFromSuperview() } let isUserLoggedIn = environment.session.currentUser != nil @@ -93,6 +144,14 @@ class DiscoveryWebViewHelper: NSObject { } addObserver() + + if let container = container as? OEXFindCoursesViewController { + if !container.fromStartupScreen { + webView.scrollView.delegate = self + container.setStatusBar(color: OEXStyles.shared().navigationBarColor()) + addShowTitleView() + } + } } private func addObserver() { @@ -342,3 +401,31 @@ extension WKWebView { configuration.userContentController.addUserScript(script) } } + +extension DiscoveryWebViewHelper: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollView.contentOffset.y >= 0 { + addShowTitleView() + } + else { + hideTitleView() + } + } +} + +extension OEXFindCoursesViewController { + open override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } + + open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + + coordinator.animate { [weak self] _ in + guard let weakSelf = self else { return } + DispatchQueue.main.async { + weakSelf.setStatusBar(color: OEXStyles.shared().navigationBarColor()) + weakSelf.updateTitleViewVisibility() + } + } + } +} diff --git a/Source/DiscussionBlockViewController.swift b/Source/DiscussionBlockViewController.swift index 33a07af147..7bf55d77c9 100644 --- a/Source/DiscussionBlockViewController.swift +++ b/Source/DiscussionBlockViewController.swift @@ -8,10 +8,16 @@ import Foundation -class DiscussionBlockViewController: UIViewController, CourseBlockViewController { +class DiscussionBlockViewController: UIViewController, CourseBlockViewController, ScrollableDelegateProvider { typealias Environment = NetworkManagerProvider & OEXRouterProvider & OEXAnalyticsProvider & OEXStylesProvider & DataManagerProvider & OEXConfigProvider + weak var scrollableDelegate: ScrollableDelegate? { + didSet { + postsController.scrollableDelegate = scrollableDelegate + } + } + let courseID: String let blockID : CourseBlockID? diff --git a/Source/DiscussionTopicsViewController.swift b/Source/DiscussionTopicsViewController.swift index 11cf1b5f36..e60e297021 100644 --- a/Source/DiscussionTopicsViewController.swift +++ b/Source/DiscussionTopicsViewController.swift @@ -9,7 +9,7 @@ import Foundation import UIKit -public class DiscussionTopicsViewController: OfflineSupportViewController, UITableViewDataSource, UITableViewDelegate, InterfaceOrientationOverriding, LoadStateViewReloadSupport { +public class DiscussionTopicsViewController: OfflineSupportViewController, UITableViewDataSource, UITableViewDelegate, InterfaceOrientationOverriding, LoadStateViewReloadSupport, ScrollableDelegateProvider { public typealias Environment = DataManagerProvider & OEXRouterProvider & OEXAnalyticsProvider & ReachabilityProvider & NetworkManagerProvider @@ -31,6 +31,9 @@ public class DiscussionTopicsViewController: OfflineSupportViewController, UITab private let tableView = UITableView() private let searchBarSeparator = UIView() + public weak var scrollableDelegate: ScrollableDelegate? + private var scrollByDragging = false + public init(environment: Environment, courseID: String) { self.environment = environment self.courseID = courseID @@ -74,7 +77,7 @@ public class DiscussionTopicsViewController: OfflineSupportViewController, UITab tableView.cellLayoutMarginsFollowReadableWidth = false searchBar.applyStandardStyles(withPlaceholder: Strings.searchAllPosts) - + searchBar.searchBarStyle = .minimal searchBarDelegate = DiscussionSearchBarDelegate() { [weak self] text in if let owner = self { owner.environment.router?.showPostsFromController(controller: owner, courseID: owner.courseID, queryString : text) @@ -155,7 +158,6 @@ public class DiscussionTopicsViewController: OfflineSupportViewController, UITab self.environment.analytics.trackScreen(withName: OEXAnalyticsScreenViewTopics, courseID: self.courseID, value: nil) refreshTopics() - self.navigationController?.setNavigationBarHidden(false, animated: animated) } override func reloadViewData() { @@ -238,6 +240,22 @@ public class DiscussionTopicsViewController: OfflineSupportViewController, UITab } } +extension DiscussionTopicsViewController: UIScrollViewDelegate { + public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollByDragging = true + } + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollByDragging { + scrollableDelegate?.scrollViewDidScroll(scrollView: scrollView) + } + } + + public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + scrollByDragging = false + } +} + extension DiscussionTopicsViewController { public func t_topicsLoaded() -> OEXStream<[DiscussionTopic]> { return topics diff --git a/Source/DropDown/helpers/DPDConstants.swift b/Source/DropDown/helpers/DPDConstants.swift index b5caa30aa6..52047beb4a 100644 --- a/Source/DropDown/helpers/DPDConstants.swift +++ b/Source/DropDown/helpers/DPDConstants.swift @@ -6,6 +6,8 @@ // Copyright (c) 2015 Kevin Hirsch. All rights reserved. // +#if os(iOS) + import UIKit internal struct DPDConstant { @@ -55,3 +57,5 @@ internal struct DPDConstant { } } + +#endif diff --git a/Source/DropDown/helpers/DPDKeyboardListener.swift b/Source/DropDown/helpers/DPDKeyboardListener.swift index 0d578abfa3..d89c41c8aa 100644 --- a/Source/DropDown/helpers/DPDKeyboardListener.swift +++ b/Source/DropDown/helpers/DPDKeyboardListener.swift @@ -6,6 +6,8 @@ // Copyright (c) 2015 Kevin Hirsch. All rights reserved. // +#if os(iOS) + import UIKit internal final class KeyboardListener { @@ -66,3 +68,5 @@ extension KeyboardListener { } } + +#endif diff --git a/Source/DropDown/helpers/DPDUIView+Extension.swift b/Source/DropDown/helpers/DPDUIView+Extension.swift index 8d6a4dbe78..4a343166d5 100644 --- a/Source/DropDown/helpers/DPDUIView+Extension.swift +++ b/Source/DropDown/helpers/DPDUIView+Extension.swift @@ -6,6 +6,8 @@ // Copyright (c) 2015 Kevin Hirsch. All rights reserved. // +#if os(iOS) + import UIKit //MARK: - Constraints @@ -60,3 +62,5 @@ internal extension UIWindow { } } + +#endif diff --git a/Source/DropDown/resources/DropDownCell.xib b/Source/DropDown/resources/DropDownCell.xib new file mode 100644 index 0000000000..5e53383f6d --- /dev/null +++ b/Source/DropDown/resources/DropDownCell.xib @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/DropDown/src/DropDown+Appearance.swift b/Source/DropDown/src/DropDown+Appearance.swift index c7de93ce1b..7ac057dad6 100644 --- a/Source/DropDown/src/DropDown+Appearance.swift +++ b/Source/DropDown/src/DropDown+Appearance.swift @@ -6,6 +6,8 @@ // Copyright © 2016 Kevin Hirsch. All rights reserved. // +#if os(iOS) + import UIKit extension DropDown { @@ -29,3 +31,5 @@ extension DropDown { } } + +#endif diff --git a/Source/DropDown/src/DropDown.swift b/Source/DropDown/src/DropDown.swift index 944ebcff94..b7bf95e112 100644 --- a/Source/DropDown/src/DropDown.swift +++ b/Source/DropDown/src/DropDown.swift @@ -6,6 +6,8 @@ // Copyright (c) 2015 Kevin Hirsch. All rights reserved. // +#if os(iOS) + import UIKit public typealias Index = Int @@ -44,8 +46,11 @@ extension UIBarButtonItem: AnchorView { public final class DropDown: UIView { //TODO: handle iOS 7 landscape mode - - private var isVisible = false + + var updatedTableHeight: CGFloat? + var updatedMinHeight: CGFloat? + + private(set) var isVisible = false private var isHitTest: Bool = false { didSet { @@ -56,7 +61,7 @@ public final class DropDown: UIView { } } } - + /// The dismiss mode for a drop down. public enum DismissMode { @@ -362,11 +367,35 @@ public final class DropDown: UIView { didSet { reloadAllComponents() } } + public var normalTextStyle: OEXTextStyle? public var selectedTextStyle: OEXTextStyle? public var selectedBackgroundColor: UIColor? public var normalBackgroundColor: UIColor? + + /** + The NIB to use for DropDownCells + + Changing the cell nib automatically reloads the drop down. + */ + public var cellNib = UINib(nibName: "DropDownCell", bundle: bundle) { + didSet { + tableView.register(cellNib, forCellReuseIdentifier: DPDConstant.ReusableIdentifier.DropDownCell) + templateCell = nil + reloadAllComponents() + } + } + + /// Correctly specify Bundle for Swift Packages + fileprivate static var bundle: Bundle { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: DropDownCell.self) + #endif + } + //MARK: Content /** @@ -380,7 +409,7 @@ public final class DropDown: UIView { reloadAllComponents() } } - + public var selectedRowIndex: Int = 0 /** @@ -394,7 +423,10 @@ public final class DropDown: UIView { dataSource = localizationKeysDataSource.map { NSLocalizedString($0, comment: "") } } } - + + /// The indicies that have been selected + fileprivate var selectedRowIndices = Set() + /** The format for the cells' text. @@ -416,6 +448,14 @@ public final class DropDown: UIView { /// The action to execute when the user selects a cell. public var selectionAction: SelectionClosure? + + /** + The action to execute when the user selects multiple cells. + + Providing an action will turn on multiselection mode. + The single selection action will still be called if provided. + */ + public var multiSelectionAction: MultiSelectionClosure? /// The action to execute when the drop down will show. public var willShowAction: Closure? @@ -438,7 +478,11 @@ public final class DropDown: UIView { } fileprivate var minHeight: CGFloat { - return tableView.rowHeight + if let height = updatedMinHeight { + return height + } else { + return tableView.rowHeight + } } fileprivate var didSetupConstraints = false @@ -486,22 +530,20 @@ public final class DropDown: UIView { override public init(frame: CGRect) { super.init(frame: frame) - setup() } public required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) - setup() } } //MARK: - Setup -private extension DropDown { +extension DropDown { func setup() { - tableView.register(DropDownCell.self, forCellReuseIdentifier: DPDConstant.ReusableIdentifier.DropDownCell) + tableView.register(cellNib, forCellReuseIdentifier: DPDConstant.ReusableIdentifier.DropDownCell) DispatchQueue.main.async { //HACK: If not done in dispatch_async on main queue `setupUI` will have no effect @@ -522,6 +564,24 @@ private extension DropDown { accessibilityIdentifier = "drop_down" } + + func setupCustom() -> UITableView { + DispatchQueue.main.async { + //HACK: If not done in dispatch_async on main queue `setupUI` will have no effect + self.updateConstraintsIfNeeded() + self.setupUI() + } + + setHiddentState() + isHidden = true + + dismissMode = .onTap + + startListeningToKeyboard() + + accessibilityIdentifier = "drop_down" + return tableView + } func setupUI() { super.backgroundColor = dimmedBackgroundColor @@ -749,7 +809,7 @@ extension DropDown { fileprivate func fittingWidth() -> CGFloat { if templateCell == nil { - templateCell = DropDownCell(frame: .zero) + templateCell = (cellNib.instantiate(withOwner: nil, options: nil)[0] as! DropDownCell) } var maxWidth: CGFloat = 0 @@ -821,7 +881,6 @@ extension DropDown { - returns: Wether it succeed and how much height is needed to display all cells at once. */ - @discardableResult public func show(onTopOf window: UIWindow? = nil, beforeTransform transform: CGAffineTransform? = nil, anchorPoint: CGPoint? = nil) -> (canBeDisplayed: Bool, offscreenHeight: CGFloat?) { if self == DropDown.VisibleDropDown && DropDown.VisibleDropDown?.isHidden == false { // added condition - DropDown.VisibleDropDown?.isHidden == false -> to resolve forever hiding dropdown issue when continuous taping on button - Kartik Patel - 2016-12-29 @@ -838,15 +897,15 @@ extension DropDown { } if let visibleDropDown = DropDown.VisibleDropDown { - DropDown.VisibleDropDown = nil - visibleDropDown.cancel(shouldCallCallback: true) - return (true, 0) + visibleDropDown.cancel() } willShowAction?() DropDown.VisibleDropDown = self + isVisible = true + setNeedsUpdateConstraints() let visibleWindow = window != nil ? window : UIWindow.visibleWindow() @@ -890,10 +949,25 @@ extension DropDown { UIAccessibility.post(notification: .screenChanged, argument: self) //deselectRows(at: selectedRowIndices) - selectRows(at: selectedRowIndex) + selectRows(at: selectedRowIndices) return (layout.canBeDisplayed, layout.offscreenHeight) } + + public func showCustomView(anchorPoint: CGPoint? = nil) { + let customView = UIView(frame: CGRect(x: 0, y: 0, width: 300, height: 60)) + customView.backgroundColor = .red + setNeedsUpdateConstraints() + + let visibleWindow = window != nil ? window : UIWindow.visibleWindow() + visibleWindow?.addSubview(customView) + visibleWindow?.bringSubviewToFront(customView) + + if anchorPoint != nil { + customView.layer.anchorPoint = anchorPoint! + } + customView.transform = downScaleTransform + } public override func accessibilityPerformEscape() -> Bool { switch dismissMode { @@ -904,11 +978,11 @@ extension DropDown { return false } } - + public func forceHide() { - cancel(shouldCallCallback: true) + cancel(shouldCallCallback: true) } - + /// Hides the drop down. public func hide() { if self == DropDown.VisibleDropDown { @@ -942,7 +1016,7 @@ extension DropDown { fileprivate func cancel(shouldCallCallback: Bool = false) { hide() - if shouldCallCallback { + if shouldCallCallback { isVisible = false DropDown.VisibleDropDown = nil cancelAction?() @@ -986,27 +1060,42 @@ extension DropDown { tableView.selectRow( at: IndexPath(row: index, section: 0), animated: true, scrollPosition: scrollPosition ) - selectedRowIndex = index + selectedRowIndices.insert(index) } else { - deselectRows(at: selectedRowIndex) + deselectRows(at: selectedRowIndices) + selectedRowIndices.removeAll() } } - public func selectRows(at index: Int) { - selectRow(at: index) + public func selectRows(at indices: Set?) { + indices?.forEach { + selectRow(at: $0) + } + + // if we are in multi selection mode then reload data so that all selections are shown + if multiSelectionAction != nil { + tableView.reloadData() + } } - public func deselectRow(at index: Int?) { + public func deselectRow(at index: Index?) { guard let index = index , index >= 0 else { return } + // remove from indices + if let selectedRowIndex = selectedRowIndices.firstIndex(where: { $0 == index }) { + selectedRowIndices.remove(at: selectedRowIndex) + } + tableView.deselectRow(at: IndexPath(row: index, section: 0), animated: true) } // de-selects the rows at the indices provided - public func deselectRows(at index: Int) { - deselectRow(at: index) + public func deselectRows(at indices: Set?) { + indices?.forEach { + deselectRow(at: $0) + } } /// Returns the index of the selected row. @@ -1023,7 +1112,11 @@ extension DropDown { /// Returns the height needed to display all cells. fileprivate var tableHeight: CGFloat { - return tableView.rowHeight * CGFloat(dataSource.count) + if let height = updatedTableHeight { + return height + } else { + return tableView.rowHeight * CGFloat(dataSource.count) + } } //MARK: Objective-C methods for converting the Swift type Index @@ -1054,7 +1147,7 @@ extension DropDown: UITableViewDataSource, UITableViewDelegate { public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: DPDConstant.ReusableIdentifier.DropDownCell, for: indexPath) as! DropDownCell - let index = indexPath.row + let index = (indexPath as NSIndexPath).row configureCell(cell, at: index) @@ -1066,18 +1159,11 @@ extension DropDown: UITableViewDataSource, UITableViewDelegate { cell.accessibilityIdentifier = localizationKeysDataSource[index] } - //cell.optionLabel.textColor = textColor - //cell.optionLabel.font = textFont - //cell.selectedBackgroundColor = selectionBackgroundColor - //cell.highlightTextColor = selectedTextColor - //cell.normalTextColor = textColor - - cell.optionText = dataSource[index] - - cell.selectedBackgroundColor = selectedBackgroundColor - cell.normalBackgroundColor = normalBackgroundColor - cell.selectedTextStyle = selectedTextStyle - cell.normalTextStyle = normalTextStyle + cell.optionLabel.textColor = textColor + cell.optionLabel.font = textFont + cell.selectedBackgroundColor = selectionBackgroundColor + cell.highlightTextColor = selectedTextColor + cell.normalTextColor = textColor if index == selectedRowIndex { cell.setSelected(true, animated: true) @@ -1086,20 +1172,51 @@ extension DropDown: UITableViewDataSource, UITableViewDelegate { cell.setSelected(false, animated: false) cell.optionLabel.attributedText = normalTextStyle?.attributedString(withText: dataSource[index]) } - - cell.optionLabel.textAlignment = .center + + if let cellConfiguration = cellConfiguration { + cell.optionLabel.text = cellConfiguration(index, dataSource[index]) + } else { + cell.optionLabel.text = dataSource[index] + } + customCellConfiguration?(index, dataSource[index], cell) } public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - cell.isSelected = selectedRowIndex == indexPath.row + cell.isSelected = selectedRowIndices.first{ $0 == (indexPath as NSIndexPath).row } != nil } public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let selectedRowIndex = indexPath.row + let selectedRowIndex = (indexPath as NSIndexPath).row - self.selectedRowIndex = selectedRowIndex + // are we in multi-selection mode? + if let multiSelectionCallback = multiSelectionAction { + // if already selected then deselect + if selectedRowIndices.first(where: { $0 == selectedRowIndex}) != nil { + deselectRow(at: selectedRowIndex) + + let selectedRowIndicesArray = Array(selectedRowIndices) + let selectedRows = selectedRowIndicesArray.map { dataSource[$0] } + multiSelectionCallback(selectedRowIndicesArray, selectedRows) + return + } + else { + selectedRowIndices.insert(selectedRowIndex) + + let selectedRowIndicesArray = Array(selectedRowIndices) + let selectedRows = selectedRowIndicesArray.map { dataSource[$0] } + + selectionAction?(selectedRowIndex, dataSource[selectedRowIndex]) + multiSelectionCallback(selectedRowIndicesArray, selectedRows) + tableView.reloadData() + return + } + } + + // Perform single selection logic + selectedRowIndices.removeAll() + selectedRowIndices.insert(selectedRowIndex) selectionAction?(selectedRowIndex, dataSource[selectedRowIndex]) if let _ = anchorView as? UIBarButtonItem { @@ -1108,7 +1225,8 @@ extension DropDown: UITableViewDataSource, UITableViewDelegate { } cancel(shouldCallCallback: true) - } + + } } @@ -1117,10 +1235,11 @@ extension DropDown: UITableViewDataSource, UITableViewDelegate { extension DropDown { public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - let view = super.hitTest(point, with: event) + let view = super.hitTest(point, with: event) isHitTest = true + if dismissMode == .automatic && view === dismissableView { - cancel(shouldCallCallback: true) + cancel(shouldCallCallback: true) return nil } else { return view @@ -1181,3 +1300,5 @@ private extension DispatchQueue { } } } + +#endif diff --git a/Source/DropDown/src/DropDownCell.swift b/Source/DropDown/src/DropDownCell.swift index c6670160eb..bf49947ecc 100644 --- a/Source/DropDown/src/DropDownCell.swift +++ b/Source/DropDown/src/DropDownCell.swift @@ -6,42 +6,30 @@ // Copyright (c) 2015 Kevin Hirsch. All rights reserved. // +#if os(iOS) + import UIKit open class DropDownCell: UITableViewCell { - + //UI - lazy var optionLabel = UILabel() - - var optionText: String? + @IBOutlet open weak var optionLabel: UILabel! var selectedBackgroundColor: UIColor? - var normalBackgroundColor: UIColor? - var highlightTextColor: UIColor? var normalTextColor: UIColor? - - var normalTextStyle: OEXTextStyle? - var selectedTextStyle: OEXTextStyle? - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - backgroundColor = .clear - - addSubview(optionLabel) - - optionLabel.snp.makeConstraints { make in - make.leading.equalTo(self).offset(StandardHorizontalMargin) - make.trailing.equalTo(self).inset(StandardHorizontalMargin) - make.top.equalTo(self).offset(StandardVerticalMargin) - make.bottom.equalTo(self).inset(StandardVerticalMargin) - } - } - - required public init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } +} + +//MARK: - UI + +extension DropDownCell { + + override open func awakeFromNib() { + super.awakeFromNib() + + backgroundColor = .clear + } override open var isSelected: Bool { willSet { @@ -63,18 +51,17 @@ open class DropDownCell: UITableViewCell { let executeSelection: () -> Void = { [weak self] in guard let `self` = self else { return } - if let selectedBackgroundColor = self.selectedBackgroundColor, - let normalBackgroundColor = self.normalBackgroundColor { + if let selectedBackgroundColor = self.selectedBackgroundColor { if selected { self.backgroundColor = selectedBackgroundColor - self.optionLabel.attributedText = self.selectedTextStyle?.attributedString(withText: self.optionText) + self.optionLabel.textColor = self.highlightTextColor } else { - self.backgroundColor = normalBackgroundColor - self.optionLabel.attributedText = self.normalTextStyle?.attributedString(withText: self.optionText) + self.backgroundColor = .clear + self.optionLabel.textColor = self.normalTextColor } } } - + if animated { UIView.animate(withDuration: 0.3, animations: { executeSelection() @@ -87,3 +74,5 @@ open class DropDownCell: UITableViewCell { } } + +#endif diff --git a/Source/EnrolledCoursesFooterView.swift b/Source/EnrolledCoursesFooterView.swift index 1660fe59c8..5de7d7978e 100644 --- a/Source/EnrolledCoursesFooterView.swift +++ b/Source/EnrolledCoursesFooterView.swift @@ -8,69 +8,174 @@ import Foundation -let EnrolledCoursesFooterViewHeight: CGFloat = 100 - -class EnrolledCoursesFooterView : UICollectionReusableView { +class EnrolledCoursesFooterView: UICollectionReusableView { static let identifier = "EnrolledCoursesFooterView" - private let promptLabel = UILabel() - private let findCoursesButton = UIButton(type:.system) + var findCoursesAction: (() -> Void)? private let container = UIView() + private let bottomContainer = UIView() + private let promptLabel = UILabel() + + private lazy var imageView: UIImageView = { + guard let image = UIImage(named: "empty_state_placeholder") else { return UIImageView() } + return UIImageView(image: image) + }() - var findCoursesAction : (() -> Void)? + private lazy var findCoursesButton: UIButton = { + let button = UIButton(type: .system) + button.oex_addAction({ [weak self] _ in + self?.findCoursesAction?() + }, for: .touchUpInside) + return button + }() + + private var findCoursesTextStyle: OEXTextStyle { + return OEXTextStyle(weight: .bold, size: .xxLarge, color: OEXStyles.shared().neutralBlackT()) + } - private var findCoursesTextStyle : OEXTextStyle { - return OEXTextStyle(weight: .normal, size: .base, color: OEXStyles.shared().neutralXDark()) + private var findCoursesButtonTextStyle: OEXTextStyle { + return OEXTextStyle(weight: .normal, size: .xLarge, color: OEXStyles.shared().neutralWhite()) + } + + private let attributedUnicodeSpace = NSAttributedString(string: "\u{3000}") + + private var attributedSearchImage: NSAttributedString { + let lockImage = Icon.Search.imageWithFontSize(size: 22).image(with: OEXStyles.shared().neutralWhite()) + let imageAttachment = NSTextAttachment() + imageAttachment.image = lockImage + + let imageOffsetY: CGFloat = -4.0 + if let image = imageAttachment.image { + imageAttachment.bounds = CGRect(x: 0, y: imageOffsetY, width: image.size.width, height: image.size.height) + } + + return NSAttributedString(attachment: imageAttachment) } - override init(frame: CGRect) { - super.init(frame: CGRect.zero) + override init(frame: CGRect) { + super.init(frame: frame) + + addSubViews() + setAccessibilityIdentifiers() + } + + override func layoutSubviews() { + if traitCollection.verticalSizeClass == .regular { + addPortraitConstraints() + } else { + addLandscapeConstraints() + } + + container.addShadow(offset: CGSize(width: 0, height: 2), color: OEXStyles.shared().primaryDarkColor(), radius: 2, opacity: 0.35, cornerRadius: 6) + } + + private func addSubViews() { + backgroundColor = OEXStyles.shared().neutralWhiteT() addSubview(container) - container.addSubview(promptLabel) - container.addSubview(findCoursesButton) + + container.addSubview(imageView) + container.addSubview(bottomContainer) - self.promptLabel.attributedText = findCoursesTextStyle.attributedString(withText: Strings.EnrollmentList.findCoursesPrompt) - self.promptLabel.textAlignment = .center + bottomContainer.addSubview(promptLabel) + bottomContainer.addSubview(findCoursesButton) - self.findCoursesButton.applyButtonStyle(style: OEXStyles.shared().filledPrimaryButtonStyle, withTitle: Strings.EnrollmentList.findCourses.oex_uppercaseStringInCurrentLocale()) + container.backgroundColor = OEXStyles.shared().neutralWhiteT() + + promptLabel.attributedText = findCoursesTextStyle.attributedString(withText: Strings.EnrollmentList.findCoursesPrompt) + promptLabel.textAlignment = .center + promptLabel.numberOfLines = 0 + + let attributedString = NSMutableAttributedString() + attributedString.append(attributedSearchImage) + attributedString.append(attributedUnicodeSpace) + attributedString.append(findCoursesButtonTextStyle.attributedString(withText: Strings.EnrollmentList.findCourses)) + findCoursesButton.setAttributedTitle(attributedString, for: UIControl.State()) + findCoursesButton.backgroundColor = OEXStyles.shared().secondaryBaseColor() + } + + private func setAccessibilityIdentifiers() { + accessibilityIdentifier = "EnrolledCoursesFooterView:view" + imageView.accessibilityIdentifier = "EnrolledCoursesFooterView:image-view" + promptLabel.accessibilityIdentifier = "EnrolledCoursesFooterView:prompt-label" + findCoursesButton.accessibilityIdentifier = "EnrolledCoursesFooterView:find-courses-button" + container.accessibilityIdentifier = "EnrolledCoursesFooterView:container-view" + bottomContainer.accessibilityIdentifier = "EnrolledCoursesFooterView:bottom-container-view" + } + + private func addPortraitConstraints() { + container.snp.remakeConstraints { make in + make.top.equalTo(self).offset(StandardVerticalMargin * 2) + make.bottom.equalTo(bottomContainer.snp.bottom).offset(StandardVerticalMargin * 2) + make.leading.equalTo(self).offset(StandardHorizontalMargin) + make.trailing.equalTo(self).inset(StandardHorizontalMargin) + } - container.backgroundColor = OEXStyles.shared().standardBackgroundColor() - container.applyBorderStyle(style: BorderStyle()) + imageView.snp.remakeConstraints { make in + make.top.equalTo(container) + make.height.equalTo(StandardVerticalMargin * 33) + make.leading.equalTo(container) + make.trailing.equalTo(container) + } - container.snp.makeConstraints { make in - make.top.equalTo(self).offset(CourseCardCell.margin) - make.bottom.equalTo(self) - make.leading.equalTo(self).offset(CourseCardCell.margin) - make.trailing.equalTo(self).offset(-CourseCardCell.margin) + bottomContainer.snp.remakeConstraints { make in + make.top.equalTo(imageView.snp.bottom) + make.bottom.equalTo(findCoursesButton.snp.bottom).offset(StandardVerticalMargin * 2) + make.leading.equalTo(container) + make.trailing.equalTo(container) } - self.promptLabel.snp.makeConstraints { make in - make.leading.equalTo(container).offset(StandardHorizontalMargin) - make.trailing.equalTo(container).offset(-StandardHorizontalMargin) - make.top.equalTo(container).offset(StandardVerticalMargin) + promptLabel.snp.remakeConstraints { make in + make.top.equalTo(bottomContainer).offset(StandardVerticalMargin * 2) + make.leading.equalTo(bottomContainer).offset(StandardHorizontalMargin * 2.2) + make.trailing.equalTo(bottomContainer).inset(StandardHorizontalMargin * 2.2) } - self.findCoursesButton.snp.makeConstraints { make in - make.leading.equalTo(promptLabel) - make.trailing.equalTo(promptLabel) - make.top.equalTo(promptLabel.snp.bottom).offset(StandardVerticalMargin) - make.bottom.equalTo(container).offset(-StandardVerticalMargin) + findCoursesButton.snp.remakeConstraints { make in + make.top.equalTo(promptLabel.snp.bottom).offset(StandardVerticalMargin * 3.2) + make.bottom.equalTo(bottomContainer).inset(StandardVerticalMargin * 2) + make.height.equalTo(StandardVerticalMargin * 5.5) + make.leading.equalTo(bottomContainer).offset(StandardHorizontalMargin * 2) + make.trailing.equalTo(bottomContainer).inset(StandardHorizontalMargin * 2) + } + } + + private func addLandscapeConstraints() { + container.snp.remakeConstraints { make in + make.top.equalTo(self).offset(StandardVerticalMargin * 2) + make.bottom.equalTo(self).inset(StandardVerticalMargin * 2) + make.leading.equalTo(self).offset(StandardHorizontalMargin) + make.trailing.equalTo(self).inset(StandardHorizontalMargin) } - findCoursesButton.oex_addAction({[weak self] _ in - self?.findCoursesAction?() - }, for: .touchUpInside) + imageView.snp.remakeConstraints { make in + make.top.equalTo(container) + make.leading.equalTo(container) + make.bottom.equalTo(container) + make.width.equalTo(frame.size.width / 2) + } - setAccessibilityIdentifiers() - } + bottomContainer.snp.remakeConstraints { make in + make.top.equalTo(container).offset(-StandardVerticalMargin * 2) + make.leading.equalTo(imageView.snp.trailing) + make.trailing.equalTo(container) + make.bottom.equalTo(container) + } - private func setAccessibilityIdentifiers() { - accessibilityIdentifier = "EnrolledCoursesFooterView:view" - promptLabel.accessibilityIdentifier = "EnrolledCoursesFooterView:prompt-label" - findCoursesButton.accessibilityIdentifier = "EnrolledCoursesFooterView:find-courses-button" - container.accessibilityIdentifier = "EnrolledCoursesFooterView:container-view" + promptLabel.snp.remakeConstraints { make in + make.top.equalTo(bottomContainer).offset(StandardVerticalMargin * 2) + make.leading.equalTo(bottomContainer).offset(StandardHorizontalMargin * 2) + make.trailing.equalTo(bottomContainer).inset(StandardHorizontalMargin * 2) + make.bottom.equalTo(findCoursesButton.snp.top) + } + + findCoursesButton.snp.remakeConstraints { make in + make.bottom.equalTo(bottomContainer).inset(StandardVerticalMargin * 4) + make.leading.equalTo(bottomContainer).offset(StandardHorizontalMargin * 2) + make.trailing.equalTo(bottomContainer).inset(StandardHorizontalMargin * 2) + make.height.equalTo(StandardVerticalMargin * 5.5) + } } required init?(coder aDecoder: NSCoder) { diff --git a/Source/EnrolledCoursesViewController+CourseUpgrade.swift b/Source/EnrolledCoursesViewController+CourseUpgrade.swift index d643ad1367..0729c374ac 100644 --- a/Source/EnrolledCoursesViewController+CourseUpgrade.swift +++ b/Source/EnrolledCoursesViewController+CourseUpgrade.swift @@ -18,17 +18,29 @@ extension EnrolledCoursesViewController { func navigateToScreenAterCourseUpgrade() { guard let courseUpgradeModel = courseUpgradeHelper.courseUpgradeModel - else { return } - + else { return } + if courseUpgradeModel.screen == .courseDashboard || courseUpgradeModel.screen == .courseComponent { - navigationController?.popToViewController(of: EnrolledCoursesViewController.self, animated: true) { [weak self] in - guard let weakSelf = self else { return } - weakSelf.environment.router?.showCourseWithID(courseID: courseUpgradeModel.courseID, fromController: weakSelf, animated: true) + dismiss() { [weak self] _ in + self?.navigationController?.popToViewController(of: LearnContainerViewController.self, animated: true) { [weak self] in + guard let weakSelf = self else { return } + weakSelf.environment.router?.showCourseWithID(courseID: courseUpgradeModel.courseID, fromController: weakSelf, animated: true) + } } } else { courseUpgradeHelper.removeLoader() } } + + private func dismiss(completion: @escaping (Bool) -> Void) { + if let rootController = UIApplication.shared.window?.rootViewController, rootController.presentedViewController != nil { + rootController.dismiss(animated: false) { + completion(true) + } + } else { + completion(true) + } + } func resolveUnfinishedPaymentIfRequired() { guard var skus = courseUpgradeHelper.savedUnfinishedIAPSKUsForCurrentUser(), diff --git a/Source/EnrolledCoursesViewController.swift b/Source/EnrolledCoursesViewController.swift index aa14fe8579..d1f7c95ffd 100644 --- a/Source/EnrolledCoursesViewController.swift +++ b/Source/EnrolledCoursesViewController.swift @@ -10,16 +10,22 @@ import Foundation var isActionTakenOnUpgradeSnackBar: Bool = false -class EnrolledCoursesViewController : OfflineSupportViewController, CoursesContainerViewControllerDelegate, InterfaceOrientationOverriding { +class EnrolledCoursesViewController : OfflineSupportViewController, InterfaceOrientationOverriding, ScrollableDelegateProvider { typealias Environment = OEXAnalyticsProvider & OEXConfigProvider & DataManagerProvider & NetworkManagerProvider & ReachabilityProvider & OEXRouterProvider & OEXStylesProvider & OEXInterfaceProvider & ServerConfigProvider & OEXSessionProvider - let environment : Environment - private let coursesContainer : CoursesContainerViewController + weak var scrollableDelegate: ScrollableDelegate? { + didSet { + coursesContainer.scrollableDelegate = scrollableDelegate + } + } + + let environment: Environment + private let coursesContainer: CoursesContainerViewController private let loadController = LoadStateViewController() private let refreshController = PullRefreshController() private let insetsController = ContentInsetsController() - fileprivate let enrollmentFeed: Feed<[UserCourseEnrollment]?> + private let enrollmentFeed: Feed<[UserCourseEnrollment]?> private let userPreferencesFeed: Feed var handleBannerOnStart: Bool = false // this will be used to send first call for the banners lazy var courseUpgradeHelper = CourseUpgradeHelper.shared @@ -72,7 +78,6 @@ class EnrolledCoursesViewController : OfflineSupportViewController, CoursesConta setupListener() setupObservers() - addMenuButton() } override func viewWillAppear(_ animated: Bool) { @@ -94,18 +99,6 @@ class EnrolledCoursesViewController : OfflineSupportViewController, CoursesConta } } - - private func addMenuButton() { - let menuButton = UIBarButtonItem(image: Icon.Menu.imageWithFontSize(size: 22), style: .plain, target: nil, action: nil) - menuButton.accessibilityLabel = Strings.accessibilityProfile - menuButton.accessibilityIdentifier = "EnrolledTabBarViewController:menu-button" - menuButton.accessibilityHint = Strings.Accessibility.profileMenuHint - navigationItem.rightBarButtonItem = menuButton - - menuButton.oex_setAction { [weak self] in - self?.environment.router?.showProfile(controller: self) - } - } override func reloadViewData() { refreshIfNecessary() @@ -151,15 +144,15 @@ class EnrolledCoursesViewController : OfflineSupportViewController, CoursesConta switch result { case let .success(enrollments): if let enrollments = enrollments { + self?.coursesContainer.removeErrorView() self?.coursesContainer.courses = enrollments.compactMap { $0.course } self?.coursesContainer.collectionView.reloadData() self?.loadController.state = .Loaded + self?.handleUpgradationLoader(success: true) if enrollments.isEmpty { self?.enrollmentsEmptyState() } - - self?.handleUpgradationLoader(success: true) } else { self?.loadController.state = .Initial @@ -173,9 +166,10 @@ class EnrolledCoursesViewController : OfflineSupportViewController, CoursesConta return } - self?.loadController.state = LoadState.failed(error: error) if error.errorIsThisType(NSError.oex_outdatedVersionError()) { - self?.hideSnackBar() + self?.showOutdatedVersionError() + } else { + self?.showGeneralError() } self?.handleUpgradationLoader(success: false) @@ -183,6 +177,18 @@ class EnrolledCoursesViewController : OfflineSupportViewController, CoursesConta } } + private func showGeneralError() { + loadController.state = .Loaded + coursesContainer.showError() + } + + private func showOutdatedVersionError() { + loadController.state = .Loaded + coursesContainer.showOutdatedVersionError() + hideSnackBar() + } + + // set empty state when course discovery is disabled private func enrollmentsEmptyState() { if !isDiscoveryEnabled { let error = NSError.oex_error(with: .unknown, message: Strings.EnrollmentList.noEnrollment) @@ -247,11 +253,28 @@ class EnrolledCoursesViewController : OfflineSupportViewController, CoursesConta } } + private func showWhatsNewIfNeeded() { + if WhatsNewViewController.canShowWhatsNew(environment: environment as? RouterEnvironment) { + environment.router?.showWhatsNew(fromController: self) + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + coursesContainer.collectionView.collectionViewLayout.invalidateLayout() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} + +extension EnrolledCoursesViewController: CoursesContainerViewControllerDelegate { func coursesContainerChoseCourse(course: OEXCourse) { if let course_id = course.course_id { environment.router?.showCourseWithID(courseID: course_id, fromController: self, animated: true) - } - else { + } else { preconditionFailure("course without a course id") } } @@ -263,21 +286,13 @@ class EnrolledCoursesViewController : OfflineSupportViewController, CoursesConta } } - private func showWhatsNewIfNeeded() { - if WhatsNewViewController.canShowWhatsNew(environment: environment as? RouterEnvironment) { - environment.router?.showWhatsNew(fromController: self) + func reload() { + if environment.reachability.isReachable() { + loadController.state = .Initial + enrollmentFeed.refresh() + userPreferencesFeed.refresh() } } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - coursesContainer.collectionView.collectionViewLayout.invalidateLayout() - } - - deinit { - NotificationCenter.default.removeObserver(self) - } } //MARK:- PullRefreshControllerDelegate method diff --git a/Source/EnrolledTabBarViewController.swift b/Source/EnrolledTabBarViewController.swift index 9a0fea82e7..f69ed16bc0 100644 --- a/Source/EnrolledTabBarViewController.swift +++ b/Source/EnrolledTabBarViewController.swift @@ -9,15 +9,15 @@ import UIKit private enum TabBarOptions: Int { - case Course, Program, CourseCatalog, Debug - static let options = [Course, Program, CourseCatalog, Debug] + case Course, Profile, CourseCatalog, Debug + static let options = [CourseCatalog, Course, Profile, Debug] func title(config: OEXConfig? = nil) -> String { switch self { case .Course: - return Strings.courses - case .Program: - return Strings.programs + return Strings.learn + case .Profile: + return Strings.UserAccount.profile case .CourseCatalog: return config?.discovery.type == .native ? Strings.findCourses : Strings.discover case .Debug: @@ -26,12 +26,12 @@ private enum TabBarOptions: Int { } } -class EnrolledTabBarViewController: UITabBarController, UITabBarControllerDelegate, InterfaceOrientationOverriding, ChromeCastConnectedButtonDelegate { +class EnrolledTabBarViewController: UITabBarController, InterfaceOrientationOverriding, ChromeCastConnectedButtonDelegate { typealias Environment = OEXAnalyticsProvider & OEXConfigProvider & DataManagerProvider & NetworkManagerProvider & OEXRouterProvider & OEXInterfaceProvider & ReachabilityProvider & OEXSessionProvider & OEXStylesProvider & ServerConfigProvider - fileprivate let environment: Environment - private var tabBarItems : [TabBarItem] = [] + private let environment: Environment + private var tabBarItems: [TabBarItem] = [] // add the additional resources options like 'debug'(special developer option) in additionalTabBarItems private var additionalTabBarItems : [TabBarItem] = [] @@ -40,7 +40,7 @@ class EnrolledTabBarViewController: UITabBarController, UITabBarControllerDelega static var courseCatalogIndex: Int = 0 private var screenTitle: String { - guard let option = TabBarOptions.options.first else {return Strings.courses} + guard let option = TabBarOptions.options.first else { return Strings.courses } return option.title(config: environment.config) } @@ -61,8 +61,10 @@ class EnrolledTabBarViewController: UITabBarController, UITabBarControllerDelega delegate = self view.accessibilityIdentifier = "EnrolledTabBarViewController:view" - selectedIndex = 0 + selectedIndex = 1 title = "" + + addTabbarIndicator() } override func didReceiveMemoryWarning() { @@ -84,23 +86,17 @@ class EnrolledTabBarViewController: UITabBarController, UITabBarControllerDelega for option in TabBarOptions.options { switch option { case .Course: - item = TabBarItem(title: option.title(), viewController: ForwardingNavigationController(rootViewController: EnrolledCoursesViewController(environment: environment)), icon: Icon.CoursewareEnrolled, detailText: Strings.Dashboard.courseCourseDetail) + item = TabBarItem(title: option.title(), viewController: ForwardingNavigationController(rootViewController: LearnContainerViewController(environment: environment)), icon: Icon.CoursewareEnrolled, detailText: Strings.Dashboard.courseCourseDetail) tabBarItems.append(item) - - case .Program: - guard environment.config.programConfig.enabled, - let programsURL = environment.config.programConfig.programURL else { break} - - item = TabBarItem(title: option.title(), viewController: ForwardingNavigationController(rootViewController: ProgramsViewController(environment: environment, programsURL: programsURL)), icon: Icon.CoursewareEnrolled, detailText: Strings.Dashboard.courseCourseDetail) + case .Profile: + item = TabBarItem(title: option.title(), viewController: ForwardingNavigationController(rootViewController: ProfileOptionsViewController.init(environment: environment)), icon: Icon.Person, detailText: Strings.Dashboard.courseCourseDetail) tabBarItems.append(item) - case .CourseCatalog: guard let router = environment.router, let discoveryController = router.discoveryViewController() else { break } item = TabBarItem(title: option.title(config: environment.config), viewController: ForwardingNavigationController(rootViewController: discoveryController), icon: Icon.Discovery, detailText: Strings.Dashboard.courseCourseDetail) tabBarItems.append(item) - EnrolledTabBarViewController.courseCatalogIndex = 2 - + EnrolledTabBarViewController.courseCatalogIndex = 0 case .Debug: if environment.config.shouldShowDebug() { item = TabBarItem(title: option.title(), viewController: ForwardingNavigationController(rootViewController: DebugMenuViewController(environment: environment)), icon: Icon.Discovery, detailText: Strings.Dashboard.courseCourseDetail) @@ -114,7 +110,7 @@ class EnrolledTabBarViewController: UITabBarController, UITabBarControllerDelega AdditionalTabBarViewController(environment: environment, cellItems: additionalTabBarItems), icon: Icon.MoreOptionsIcon, detailText: "") tabBarItems.append(item) } - + loadTabBarViewControllers(tabBarItems: tabBarItems) } @@ -141,10 +137,12 @@ class EnrolledTabBarViewController: UITabBarController, UITabBarControllerDelega controller = tabBarViewController(ProfileOptionsViewController.self) break case .program, .programDetail: - selectedIndex = tabBarViewControllerIndex(with: ProgramsViewController.self) + selectedIndex = tabBarViewControllerIndex(with: LearnContainerViewController.self) + controller = tabBarViewController(LearnContainerViewController.self) break - case .courseDashboard, .courseDates, .courseVideos, .courseHandout, .courseComponent: - selectedIndex = tabBarViewControllerIndex(with: EnrolledCoursesViewController.self) + case .courseDashboard, .courseDates, .courseVideos, .courseHandout, .courseComponent, .courseAnnouncement, .discussions, .discussionPost, .discussionTopic, .discussionComment: + selectedIndex = tabBarViewControllerIndex(with: LearnContainerViewController.self) + controller = tabBarViewController(LearnContainerViewController.self) break case .discovery, .discoveryCourseDetail, .discoveryProgramDetail: if environment.config.discovery.isEnabled { @@ -166,8 +164,27 @@ class EnrolledTabBarViewController: UITabBarController, UITabBarControllerDelega } } -extension EnrolledTabBarViewController { - func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController){ +extension EnrolledTabBarViewController: UITabBarControllerDelegate { + func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { navigationItem.title = viewController.navigationItem.title } } + +extension UITabBarController { + func addTabbarIndicator(color: UIColor = OEXStyles.shared().primaryDarkColor(), lineHeight: CGFloat = 2) { + guard let count = tabBar.items?.count else { return } + let tabBarItemSize = CGSize(width: tabBar.frame.width / CGFloat(count), height: tabBar.frame.height) + let indicator = createTabbarIndicator(color: color, size: tabBarItemSize, lineHeight: lineHeight) + tabBar.selectionIndicatorImage = indicator + } + + private func createTabbarIndicator(color: UIColor, size: CGSize, lineHeight: CGFloat) -> UIImage { + let rect = CGRect(x: 0, y: size.height - lineHeight, width: size.width, height: lineHeight ) + UIGraphicsBeginImageContextWithOptions(size, false, 0) + color.setFill() + UIRectFill(rect) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image ?? UIImage() + } +} diff --git a/Source/GeneralErrorView.swift b/Source/GeneralErrorView.swift new file mode 100644 index 0000000000..6b1c84589d --- /dev/null +++ b/Source/GeneralErrorView.swift @@ -0,0 +1,195 @@ +// +// GeneralErrorView.swift +// edX +// +// Created by MuhammadUmer on 05/01/2023. +// Copyright © 2023 edX. All rights reserved. +// + +import Foundation + +class GeneralErrorView: UIView { + + var tapAction: (() -> ())? + + init() { + super.init(frame: .zero) + + addSubViews() + setAccessibilityIdentifiers() + setConstraints() + } + + private let containerView = UIView() + private let bottomContainer = UIView() + + private lazy var errorLabelstyle: OEXMutableTextStyle = { + let style = OEXMutableTextStyle(weight: .bold, size: .xxLarge, color: OEXStyles.shared().neutralBlackT()) + style.alignment = .center + return style + }() + + private lazy var errorLabel: UILabel = { + let label = UILabel() + label.accessibilityIdentifier = "GeneralErrorView:error-label" + label.numberOfLines = 0 + label.attributedText = errorLabelstyle.attributedString(withText: Strings.Dashboard.generalErrorMessage) + return label + }() + + private lazy var errorImageView: UIImageView = { + guard let image = UIImage(named: "dashboard_error_image") else { return UIImageView() } + let imageView = UIImageView(image: image) + imageView.accessibilityIdentifier = "GeneralErrorView:error-imageView" + return imageView + }() + + private let buttonTitleStyle = OEXTextStyle(weight: .normal, size: .xLarge, color: OEXStyles.shared().neutralWhite()) + + private lazy var errorActionButton: UIButton = { + let button = UIButton(type: .system) + button.accessibilityIdentifier = "GeneralErrorView:error-action-button" + button.backgroundColor = OEXStyles.shared().secondaryBaseColor() + button.oex_addAction({ [weak self] _ in + self?.tapAction?() + }, for: .touchUpInside) + + button.setAttributedTitle(buttonTitleStyle.attributedString(withText: Strings.Dashboard.tryAgain), for: UIControl.State()) + + return button + }() + + override func layoutSubviews() { + super.layoutSubviews() + containerView.addShadow(offset: CGSize(width: 0, height: 2), color: OEXStyles.shared().primaryDarkColor(), radius: 2, opacity: 0.35, cornerRadius: 6) + setConstraints() + } + + private func setConstraints() { + if traitCollection.verticalSizeClass == .regular { + addPortraitConstraints() + } else { + addLandscapeConstraints() + } + } + + private func addSubViews() { + backgroundColor = OEXStyles.shared().neutralWhiteT() + + addSubview(containerView) + + containerView.addSubview(errorImageView) + containerView.addSubview(bottomContainer) + bottomContainer.addSubview(errorLabel) + bottomContainer.addSubview(errorActionButton) + + containerView.backgroundColor = OEXStyles.shared().neutralWhiteT() + } + + private func setAccessibilityIdentifiers() { + accessibilityIdentifier = "GeneralErrorView:view" + containerView.accessibilityIdentifier = "GeneralErrorView:container-view" + bottomContainer.accessibilityIdentifier = "GeneralErrorView:bottom-container-view" + } + + private func addPortraitConstraints() { + containerView.snp.remakeConstraints { make in + make.top.equalTo(self).offset(StandardVerticalMargin * 2) + make.leading.equalTo(self).offset(StandardHorizontalMargin) + make.trailing.equalTo(self).inset(StandardHorizontalMargin) + make.bottom.equalTo(self).inset(StandardVerticalMargin * 2) + } + + errorImageView.snp.remakeConstraints { make in + make.top.equalTo(containerView) + make.leading.equalTo(containerView) + make.trailing.equalTo(containerView) + make.height.equalTo(StandardVerticalMargin * 33) + } + + bottomContainer.snp.remakeConstraints { make in + make.top.equalTo(errorImageView.snp.bottom) + make.leading.equalTo(containerView) + make.trailing.equalTo(containerView) + make.bottom.equalTo(containerView) + } + + errorLabel.snp.remakeConstraints { make in + make.top.equalTo(bottomContainer).offset(StandardVerticalMargin * 2) + make.leading.equalTo(bottomContainer).offset(StandardHorizontalMargin * 2.2) + make.trailing.equalTo(bottomContainer).inset(StandardHorizontalMargin * 2.2) + } + + errorActionButton.snp.remakeConstraints { make in + make.top.equalTo(errorLabel.snp.bottom).offset(StandardVerticalMargin * 2) + make.height.equalTo(StandardVerticalMargin * 5.5) + make.leading.equalTo(bottomContainer).offset(StandardHorizontalMargin * 2) + make.trailing.equalTo(bottomContainer).inset(StandardHorizontalMargin * 2) + make.bottom.equalTo(bottomContainer).inset(StandardVerticalMargin * 2) + } + } + + private func addLandscapeConstraints() { + containerView.snp.remakeConstraints { make in + make.top.equalTo(self).offset(StandardVerticalMargin * 2) + make.bottom.equalTo(self).inset(StandardVerticalMargin * 2) + make.leading.equalTo(self).offset(StandardHorizontalMargin) + make.trailing.equalTo(self).inset(StandardHorizontalMargin) + } + + errorImageView.snp.remakeConstraints { make in + make.top.equalTo(containerView) + make.leading.equalTo(containerView) + make.height.equalTo(StandardVerticalMargin * 33) + make.width.equalTo(frame.width / 2) + make.bottom.equalTo(containerView) + } + + bottomContainer.snp.remakeConstraints { make in + make.top.equalTo(containerView).offset(-StandardVerticalMargin * 2) + make.leading.equalTo(errorImageView.snp.trailing) + make.trailing.equalTo(containerView) + make.bottom.equalTo(containerView) + } + + errorLabel.snp.remakeConstraints { make in + make.top.equalTo(bottomContainer).offset(StandardVerticalMargin * 2) + make.leading.equalTo(bottomContainer).offset(StandardHorizontalMargin * 2) + make.trailing.equalTo(bottomContainer).inset(StandardHorizontalMargin * 2) + make.bottom.equalTo(errorActionButton.snp.top) + } + + errorActionButton.snp.remakeConstraints { make in + make.bottom.equalTo(bottomContainer).inset(StandardVerticalMargin * 4) + make.leading.equalTo(bottomContainer).offset(StandardHorizontalMargin * 2) + make.trailing.equalTo(bottomContainer).inset(StandardHorizontalMargin * 2) + make.height.equalTo(StandardVerticalMargin * 5.5) + } + } + + func setErrorMessage(message: String? = nil, imageName: String? = nil, buttonTitle: String? = nil) { + errorLabel.attributedText = errorLabelstyle.attributedString(withText: message ?? Strings.Dashboard.generalErrorMessage) + + if let image = UIImage(named: imageName ?? "") { + errorImageView.image = image + } + + if let buttonTitle = buttonTitle { + errorActionButton.setAttributedTitle(buttonTitleStyle.attributedString(withText: buttonTitle), for: UIControl.State()) + } + } + + func showOutdatedVersionError() { + errorLabel.attributedText = errorLabelstyle.attributedString(withText: Strings.VersionUpgrade.outDatedMessage) + + if let image = UIImage(named: "app_update_image") { + errorImageView.image = image + } + + errorActionButton.setAttributedTitle(buttonTitleStyle.attributedString(withText: Strings.Coursedates.calendarShiftPromptUpdateNow), for: UIControl.State()) + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Source/HTMLBlockViewController.swift b/Source/HTMLBlockViewController.swift index 3d0c411dcc..d379416d53 100644 --- a/Source/HTMLBlockViewController.swift +++ b/Source/HTMLBlockViewController.swift @@ -8,7 +8,7 @@ import UIKit -class HTMLBlockViewController: UIViewController, CourseBlockViewController, PreloadableBlockController { +class HTMLBlockViewController: UIViewController, CourseBlockViewController, PreloadableBlockController, ScrollableDelegateProvider { public typealias Environment = OEXAnalyticsProvider & OEXConfigProvider & DataManagerProvider & OEXSessionProvider & ReachabilityProvider & NetworkManagerProvider & OEXRouterProvider & OEXInterfaceProvider @@ -22,7 +22,7 @@ class HTMLBlockViewController: UIViewController, CourseBlockViewController, Prel private let environment: Environment private let subkind: CourseHTMLBlockSubkind - private lazy var courseDateBannerView = CourseDateBannerView(frame: .zero) + private lazy var courseDateBannerViewContainer = UIView() private let webController: AuthenticatedWebViewController private let loader = BackedStream() @@ -31,6 +31,9 @@ class HTMLBlockViewController: UIViewController, CourseBlockViewController, Prel private lazy var openInBrowserView = OpenInExternalBrowserView(frame: .zero) + weak var scrollableDelegate: ScrollableDelegate? + private var scrollByDragging = false + public init(blockID: CourseBlockID?, courseID: String, environment: Environment, subkind: CourseHTMLBlockSubkind) { self.courseID = courseID self.blockID = blockID @@ -43,6 +46,7 @@ class HTMLBlockViewController: UIViewController, CourseBlockViewController, Prel webController.delegate = self webController.ajaxCallbackDelegate = self + webController.scrollView.delegate = self addObserver() setupViews() @@ -61,8 +65,8 @@ class HTMLBlockViewController: UIViewController, CourseBlockViewController, Prel } private func setupViews() { - view.addSubview(courseDateBannerView) - courseDateBannerView.snp.makeConstraints { make in + view.addSubview(courseDateBannerViewContainer) + courseDateBannerViewContainer.snp.makeConstraints { make in make.trailing.equalTo(view) make.leading.equalTo(view) make.top.equalTo(view) @@ -82,7 +86,7 @@ class HTMLBlockViewController: UIViewController, CourseBlockViewController, Prel webController.view.snp.remakeConstraints { make in make.trailing.equalTo(view) make.leading.equalTo(view) - make.top.equalTo(courseDateBannerView.snp.bottom) + make.top.equalTo(courseDateBannerViewContainer.snp.bottom) make.bottom.equalTo(view) } } @@ -97,7 +101,7 @@ class HTMLBlockViewController: UIViewController, CourseBlockViewController, Prel webController.view.snp.remakeConstraints { make in make.trailing.equalTo(view) make.leading.equalTo(view) - make.top.equalTo(courseDateBannerView.snp.bottom) + make.top.equalTo(courseDateBannerViewContainer.snp.bottom) } openInBrowserView.snp.remakeConstraints { make in @@ -154,16 +158,31 @@ class HTMLBlockViewController: UIViewController, CourseBlockViewController, Prel } else { guard let status = bannerModel.bannerInfo.status else { return } + var courseDateBannerView: BannerView + + if environment.config.isNewComponentNavigationEnabled { + courseDateBannerView = NewCourseDateBannerView() + } else { + courseDateBannerView = CourseDateBannerView() + } + + if let courseDateBannerView = courseDateBannerView as? UIView { + courseDateBannerViewContainer.addSubview(courseDateBannerView) + courseDateBannerView.snp.remakeConstraints { make in + make.edges.equalTo(courseDateBannerViewContainer) + } + } + if status == .resetDatesBanner { courseDateBannerView.delegate = self courseDateBannerView.bannerInfo = bannerModel.bannerInfo courseDateBannerView.setupView() + height = StandardVerticalMargin * 16 trackDateBannerAppearanceEvent(bannerModel: bannerModel) - height = courseDateBannerView.heightForView(width: view.frame.size.width) } } - courseDateBannerView.snp.remakeConstraints { make in + courseDateBannerViewContainer.snp.remakeConstraints { make in make.trailing.equalTo(view) make.leading.equalTo(view) make.top.equalTo(view) @@ -176,7 +195,7 @@ class HTMLBlockViewController: UIViewController, CourseBlockViewController, Prel } private func hideCourseBannerView() { - courseDateBannerView.snp.remakeConstraints { make in + courseDateBannerViewContainer.snp.remakeConstraints { make in make.trailing.equalTo(view) make.leading.equalTo(view) make.top.equalTo(view) @@ -308,3 +327,19 @@ extension HTMLBlockViewController: OpenInExternalBrowserViewDelegate, BrowserVie loadWebviewStream(true) } } + +extension HTMLBlockViewController: UIScrollViewDelegate { + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollByDragging = true + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollByDragging { + scrollableDelegate?.scrollViewDidScroll(scrollView: scrollView) + } + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + scrollByDragging = false + } +} diff --git a/Source/Icon.swift b/Source/Icon.swift index 17ac74ff06..450b920f05 100644 --- a/Source/Icon.swift +++ b/Source/Icon.swift @@ -91,6 +91,7 @@ public enum Icon { case Announcements case ArrowUp case ArrowDown + case ArrowForward case Camera case ChevronRight case Close @@ -133,6 +134,7 @@ public enum Icon { case Menu case NoTopics case NoSearchResults + case Search case OpenURL case Pinned case RotateDevice @@ -144,6 +146,7 @@ public enum Icon { case StarEmpty case StarFilled case ShareCourse + case Share case Discovery case Transcript case Trophy @@ -177,6 +180,8 @@ public enum Icon { return MaterialIconRenderer(icon: .arrowUpward) case .ArrowDown: return MaterialIconRenderer(icon: .arrowDownward) + case .ArrowForward: + return MaterialIconRenderer(icon: .arrowForward) case .Account: return MaterialIconRenderer(icon: .moreVert) case .Camera: @@ -250,7 +255,7 @@ public enum Icon { case .Courseware: return MaterialIconRenderer(icon: .classroom) case .CoursewareEnrolled: - return MaterialIconRenderer(icon: .bookmarkBorder) + return MaterialIconRenderer(icon: .menuBook) case .CourseUnknownContent: return MaterialIconRenderer(icon: .laptop) case .CourseVideoContent: @@ -285,6 +290,8 @@ public enum Icon { return MaterialIconRenderer(icon: .star) case .ShareCourse: return MaterialIconRenderer(icon: .shareiOS) + case .Share: + return MaterialIconRenderer(icon: .share) case .Discovery: return MaterialIconRenderer(icon: .search) case .UnknownError: @@ -293,6 +300,8 @@ public enum Icon { return MaterialIconRenderer(icon: .list) case .NoSearchResults: return MaterialIconRenderer(icon: .playCircleOutline) + case .Search: + return MaterialIconRenderer(icon: .search) case .Trophy: return MaterialIconRenderer(icon: .emojiEvents) case .VideoFullscreen: diff --git a/Source/LearnContainerHeaderView.swift b/Source/LearnContainerHeaderView.swift index 3c74a55e98..a5173db6d2 100644 --- a/Source/LearnContainerHeaderView.swift +++ b/Source/LearnContainerHeaderView.swift @@ -17,12 +17,12 @@ protocol LearnContainerHeaderItem { } class LearnContainerHeaderView: UIView { - static let height = StandardVerticalMargin * 6.5 + static let expandedHeight = StandardVerticalMargin * 10.6 + static let collapsedHeight = StandardVerticalMargin * 5.5 weak var delegate: LearnContainerHeaderViewDelegate? - private let container = UIView() - private let dropDownContainer = UIView() + var headerViewState: HeaderViewState = .expanded private lazy var button: UIButton = { let button = UIButton() @@ -53,16 +53,17 @@ class LearnContainerHeaderView: UIView { return imageView }() - private var originalFrame: CGRect = .zero - + private let container = UIView() + private let dropDownContainer = UIView() private let dropDown = DropDown() + private let dropDownBottomOffset: CGFloat = StandardVerticalMargin * 2 + + private var items: [LearnContainerHeaderItem] private var shouldShowDropDown: Bool { return items.count > 1 } - private var items: [LearnContainerHeaderItem] - required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -84,40 +85,37 @@ class LearnContainerHeaderView: UIView { addSubview(dropDownContainer) addSubview(container) - container.snp.makeConstraints { make in + container.snp.remakeConstraints { make in make.top.equalTo(self).offset(-6) make.bottom.equalTo(self) make.leading.equalTo(self) make.trailing.equalTo(self) + make.height.equalTo(LearnContainerHeaderView.expandedHeight) } - dropDownContainer.snp.makeConstraints { make in - make.top.equalTo(self) - make.bottom.equalTo(self) + dropDownContainer.snp.remakeConstraints { make in + make.centerY.equalTo(label) make.leading.equalTo(self).offset(StandardHorizontalMargin) make.trailing.equalTo(self).inset(StandardHorizontalMargin) } - button.snp.makeConstraints { make in - make.top.equalTo(container) - make.bottom.equalTo(container) + button.snp.remakeConstraints { make in + make.centerY.equalTo(label) make.leading.equalTo(label) make.trailing.equalTo(imageView) } - label.snp.makeConstraints { make in - make.top.equalTo(container) - make.bottom.equalTo(container) + label.snp.remakeConstraints { make in + make.bottom.equalTo(container).inset(StandardVerticalMargin) make.leading.equalTo(container).offset(StandardHorizontalMargin) } - imageView.snp.makeConstraints { make in + imageView.snp.remakeConstraints { make in make.leading.equalTo(label.snp.trailing).offset(StandardHorizontalMargin / 2) make.centerY.equalTo(label) } imageView.isHidden = !shouldShowDropDown - originalFrame = container.frame } private func setupDropDown() { @@ -126,16 +124,16 @@ class LearnContainerHeaderView: UIView { let selectedTextStyle = OEXMutableTextStyle(weight: .bold, size: .base, color: OEXStyles.shared().primaryBaseColor()) selectedTextStyle.alignment = .center - + dropDown.setup() dropDown.accessibilityIdentifier = "LearnContainerHeaderView:drop-down-view" - dropDown.bottomOffset = CGPoint(x: 0, y: LearnContainerHeaderView.height) + dropDown.bottomOffset = CGPoint(x: 0, y: dropDownBottomOffset) dropDown.direction = .bottom dropDown.anchorView = dropDownContainer dropDown.dismissMode = .automatic dropDown.normalTextStyle = normalTextStyle dropDown.selectedTextStyle = selectedTextStyle - dropDown.selectedBackgroundColor = OEXStyles.shared().neutralXLight() - dropDown.normalBackgroundColor = OEXStyles.shared().neutralWhiteT() + dropDown.selectionBackgroundColor = OEXStyles.shared().neutralXLight() + dropDown.backgroundColor = OEXStyles.shared().neutralWhiteT() dropDown.textColor = OEXStyles.shared().primaryBaseColor() dropDown.selectedTextColor = OEXStyles.shared().primaryBaseColor() dropDown.dataSource = items.map { $0.title } @@ -143,7 +141,7 @@ class LearnContainerHeaderView: UIView { dropDown.selectionAction = { [weak self] index, _ in guard let weakSelf = self else { return } weakSelf.dropDown.selectedRowIndex = index - weakSelf.label.attributedText = weakSelf.largeTextStyle.attributedString(withText: weakSelf.items[index].title) + weakSelf.updateHeaderLabel() weakSelf.delegate?.didTapOnDropDown(item: weakSelf.items[index]) } dropDown.willShowAction = { [weak self] in @@ -154,6 +152,15 @@ class LearnContainerHeaderView: UIView { } } + private func updateHeaderLabel() { + let index = dropDown.indexForSelectedRow ?? 0 + if headerViewState == .collapsed { + label.attributedText = smallTextStyle.attributedString(withText: items[index].title) + } else if headerViewState == .expanded { + label.attributedText = largeTextStyle.attributedString(withText: items[index].title) + } + } + func changeHeader(for index: Int) { dropDown.selectedRowIndex = index label.attributedText = smallTextStyle.attributedString(withText: items[index].title) @@ -163,27 +170,9 @@ class LearnContainerHeaderView: UIView { dropDown.forceHide() } - func moveToCenter() { - dropDown.bottomOffset = CGPoint(x: 0, y: 44) - container.frame = CGRect(x: 0, y: 0, width: 180, height: 44) - container.center.x = frame.size.width / 2 - - if let index = dropDown.indexForSelectedRow { - label.attributedText = smallTextStyle.attributedString(withText: items[index].title) - } else { - label.attributedText = smallTextStyle.attributedString(withText: items[0].title) - } - } - - func moveBackOriginalFrame() { - container.frame = originalFrame - dropDown.bottomOffset = CGPoint(x: 0, y: 80) - - if let index = dropDown.indexForSelectedRow { - label.attributedText = largeTextStyle.attributedString(withText: items[index].title) - } else { - label.attributedText = largeTextStyle.attributedString(withText: items[0].title) - } + func updateHeaderViewState(collapse: Bool) { + headerViewState = collapse ? .collapsed : .expanded + updateHeaderLabel() } private func rotateImageView(clockWise: Bool) { diff --git a/Source/LearnContainerViewController.swift b/Source/LearnContainerViewController.swift index 656941bc7e..b0283bee6b 100644 --- a/Source/LearnContainerViewController.swift +++ b/Source/LearnContainerViewController.swift @@ -31,6 +31,8 @@ class LearnContainerViewController: UIViewController { private let environment: Environment + private var headerViewState: HeaderViewState = .expanded + private lazy var components: [Component] = { var items: [Component] = [] items.append(.courses) @@ -40,11 +42,23 @@ class LearnContainerViewController: UIViewController { return items }() + private let container = UIView() private lazy var headerView = LearnContainerHeaderView(items: components) - private let container = UIView() - private let coursesViewController: EnrolledCoursesViewController - private var programsViewController: ProgramsViewController? + private lazy var coursesViewController: EnrolledCoursesViewController = { + let controller = EnrolledCoursesViewController(environment: environment) + controller.scrollableDelegate = self + return controller + }() + + private lazy var programsViewController: ProgramsViewController? = { + if programsEnabled, let programsURL = environment.config.programConfig.programURL { + let controller = ProgramsViewController(environment: environment, programsURL: programsURL) + controller.scrollableDelegate = self + return controller + } + return nil + }() private var selectedComponent: Component? @@ -56,11 +70,6 @@ class LearnContainerViewController: UIViewController { init(environment: Environment) { self.environment = environment - self.coursesViewController = EnrolledCoursesViewController(environment: environment) - if environment.config.programConfig.enabled, - let programsURL = environment.config.programConfig.programURL { - self.programsViewController = ProgramsViewController(environment: environment, programsURL: programsURL) - } super.init(nibName: nil, bundle: nil) setupViews() } @@ -78,9 +87,15 @@ class LearnContainerViewController: UIViewController { update(component: .courses) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + navigationItem.setHidesBackButton(true, animated: false) + navigationController?.setNavigationBarHidden(true, animated: true) + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - headerView.dimissDropDown() } @@ -93,18 +108,18 @@ class LearnContainerViewController: UIViewController { view.addSubview(headerView) view.addSubview(container) - headerView.snp.makeConstraints { make in - make.top.equalTo(view) + headerView.snp.remakeConstraints { make in + make.top.equalTo(safeTop) make.leading.equalTo(safeLeading) make.trailing.equalTo(safeTrailing) - make.height.equalTo(LearnContainerHeaderView.height) + make.height.lessThanOrEqualTo(LearnContainerHeaderView.expandedHeight) } container.snp.makeConstraints { make in make.top.equalTo(headerView.snp.bottom) - make.bottom.equalTo(view) - make.leading.equalTo(view) - make.trailing.equalTo(view) + make.bottom.equalTo(safeBottom) + make.leading.equalTo(safeLeading) + make.trailing.equalTo(safeTrailing) } } @@ -189,6 +204,52 @@ extension LearnContainerViewController: LearnContainerHeaderViewDelegate { } } +extension LearnContainerViewController: ScrollableDelegate { + func scrollViewDidScroll(scrollView: UIScrollView) { + if scrollView.contentOffset.y <= 0 { + if headerViewState == .collapsed { + headerViewState = .animating + expandHeaderView() + } + } else if headerViewState == .expanded { + headerViewState = .animating + collapseHeaderView() + } + } + + private func expandHeaderView() { + headerView.snp.remakeConstraints { make in + make.top.equalTo(safeTop) + make.leading.equalTo(safeLeading) + make.trailing.equalTo(safeTrailing) + make.height.lessThanOrEqualTo(LearnContainerHeaderView.expandedHeight) + } + + UIView.animate(withDuration: 0.2) { [weak self] in + self?.headerView.updateHeaderViewState(collapse: false) + self?.view.layoutIfNeeded() + } completion: { [weak self] _ in + self?.headerViewState = .expanded + } + } + + private func collapseHeaderView() { + headerView.snp.remakeConstraints { make in + make.top.equalTo(safeTop) + make.leading.equalTo(safeLeading) + make.trailing.equalTo(safeTrailing) + make.height.lessThanOrEqualTo(LearnContainerHeaderView.collapsedHeight) + } + + UIView.animate(withDuration: 0.2) { [weak self] in + self?.headerView.updateHeaderViewState(collapse: true) + self?.view.layoutIfNeeded() + } completion: { [weak self] _ in + self?.headerViewState = .collapsed + } + } +} + extension LearnContainerViewController { func t_switchTo(component: Component) { if component == .programs { diff --git a/Source/NSError+OEXKnownErrors.h b/Source/NSError+OEXKnownErrors.h index 436801fbd9..ff7f81a514 100644 --- a/Source/NSError+OEXKnownErrors.h +++ b/Source/NSError+OEXKnownErrors.h @@ -43,6 +43,7 @@ typedef NS_ENUM(NSUInteger, OEXErrorCode) { - (id)initWithCoursewareAccess:(OEXCoursewareAccess*)access displayInfo:(nullable OEXCourseStartDisplayInfo*)info; +@property (strong, nonatomic) OEXCoursewareAccess* access; @property (readonly, nonatomic) OEXCoursewareAccessError* error; @end diff --git a/Source/NSError+OEXKnownErrors.m b/Source/NSError+OEXKnownErrors.m index 022ec0d42c..f130e3f278 100644 --- a/Source/NSError+OEXKnownErrors.m +++ b/Source/NSError+OEXKnownErrors.m @@ -41,7 +41,6 @@ + (NSError*)oex_unknownError { @interface OEXCoursewareAccessError () -@property (strong, nonatomic) OEXCoursewareAccess* access; @property (strong, nonatomic) OEXCourseStartDisplayInfo* displayInfo; @end diff --git a/Source/NetworkManager+Authenticators.swift b/Source/NetworkManager+Authenticators.swift index 1a389633a8..30757ded26 100644 --- a/Source/NetworkManager+Authenticators.swift +++ b/Source/NetworkManager+Authenticators.swift @@ -100,7 +100,7 @@ private func refreshAccessToken(router: OEXRouter?, clientId: String, refreshTok } performQueuedTasks(router: router, success: success) - return completion(success) + return completion(result.response, success) } } } diff --git a/Source/NewCourseContentController.swift b/Source/NewCourseContentController.swift new file mode 100644 index 0000000000..c0f6e42308 --- /dev/null +++ b/Source/NewCourseContentController.swift @@ -0,0 +1,328 @@ +// +// NewCourseContentController.swift +// edX +// +// Created by MuhammadUmer on 10/04/2023. +// Copyright © 2023 edX. All rights reserved. +// + +import UIKit + +class NewCourseContentController: UIViewController, InterfaceOrientationOverriding { + + typealias Environment = OEXAnalyticsProvider & DataManagerProvider & OEXRouterProvider & OEXConfigProvider & OEXStylesProvider + + private lazy var containerView: UIView = { + let view = UIView() + view.accessibilityIdentifier = "NewCourseContentController:container-view" + return view + }() + + private lazy var contentView: UIView = { + let view = UIView() + view.accessibilityIdentifier = "NewCourseContentController:content-view" + return view + }() + + private lazy var headerView: CourseContentHeaderView = { + let headerView = CourseContentHeaderView(environment: environment) + headerView.accessibilityIdentifier = "NewCourseContentController:header-view" + headerView.delegate = self + return headerView + }() + + private lazy var progressStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 1 + stackView.distribution = .fillEqually + stackView.alignment = .fill + stackView.backgroundColor = .clear + stackView.accessibilityIdentifier = "NewCourseContentController:progress-stack-view" + return stackView + }() + + private var courseContentViewController: CourseContentPageViewController? + private var headerViewState: HeaderViewState = .expanded + + private var currentBlock: CourseBlock? { + willSet { + currentBlock?.completion.unsubscribe(observer: self) + } + + didSet { + updateView() + currentBlock?.completion.subscribe(observer: self) { [weak self] _,_ in + self?.updateView() + } + } + } + + private let environment: Environment + private let blockID: CourseBlockID? + private let parentID: CourseBlockID? + private let courseID: CourseBlockID + private let courseQuerier: CourseOutlineQuerier + private let courseOutlineMode: CourseOutlineMode + + init(environment: Environment, blockID: CourseBlockID?, resumeCourseBlockID: CourseBlockID? = nil, parentID: CourseBlockID? = nil, courseID: CourseBlockID, courseOutlineMode: CourseOutlineMode? = .full) { + self.environment = environment + self.blockID = blockID + self.parentID = parentID + self.courseID = courseID + self.courseOutlineMode = courseOutlineMode ?? .full + courseQuerier = environment.dataManager.courseDataManager.querierForCourseWithID(courseID: courseID, environment: environment) + super.init(nibName: nil, bundle: nil) + + if let resumeCourseBlockID = resumeCourseBlockID { + currentBlock = courseQuerier.blockWithID(id: resumeCourseBlockID).firstSuccess().value + } else { + findCourseBlockToShow() + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + override var shouldAutorotate: Bool { + return true + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .allButUpsideDown + } + + override func viewDidLoad() { + super.viewDidLoad() + + setStatusBar(color: environment.styles.primaryLightColor()) + addSubViews() + setupComponentView() + setupCompletedBlocksView() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: false) + } + + private func addSubViews() { + view.accessibilityIdentifier = "NewCourseContentController:view" + view.backgroundColor = .white + view.addSubview(contentView) + + contentView.addSubview(headerView) + contentView.addSubview(progressStackView) + contentView.addSubview(containerView) + + contentView.snp.remakeConstraints { make in + make.edges.equalTo(safeEdges) + } + + headerView.snp.remakeConstraints { make in + make.top.equalTo(contentView) + make.leading.equalTo(contentView) + make.trailing.equalTo(contentView) + make.height.equalTo(StandardVerticalMargin * 17).priority(.high) + make.height.lessThanOrEqualTo(StandardVerticalMargin * 17) + } + + progressStackView.snp.remakeConstraints { make in + make.top.equalTo(headerView.snp.bottom) + make.leading.equalTo(contentView) + make.trailing.equalTo(contentView) + make.height.equalTo(StandardVerticalMargin * 0.75) + } + + containerView.snp.makeConstraints { make in + make.leading.equalTo(contentView) + make.trailing.equalTo(contentView) + make.top.equalTo(progressStackView.snp.bottom) + make.bottom.equalTo(contentView) + } + } + + private func setupComponentView() { + guard let currentBlock = currentBlock, + let parent = courseQuerier.parentOfBlockWith(id: currentBlock.blockID).firstSuccess().value + else { return } + + let courseContentViewController = CourseContentPageViewController(environment: environment, courseID: courseID, rootID: parent.blockID, initialChildID: currentBlock.blockID, forMode: courseOutlineMode) + courseContentViewController.navigationDelegate = self + + let childViewController = ForwardingNavigationController(rootViewController: courseContentViewController) + courseContentViewController.navigationController?.setNavigationBarHidden(true, animated: false) + + containerView.addSubview(childViewController.view) + + childViewController.view.snp.makeConstraints { make in + make.edges.equalTo(containerView) + } + + addChild(childViewController) + childViewController.didMove(toParent: self) + + self.courseContentViewController = courseContentViewController + } + + private func setupCompletedBlocksView() { + guard let block = currentBlock, + let section = courseQuerier.parentOfBlockWith(id: block.blockID, type: .Section).firstSuccess().value, + let sectionChildren = courseQuerier.childrenOfBlockWithID(blockID: section.blockID, forMode: courseOutlineMode).value + else { return } + + let childBlocks: [CourseBlock] = sectionChildren.children.compactMap { item in + courseQuerier.childrenOfBlockWithID(blockID: item.blockID, forMode: courseOutlineMode) + .firstSuccess().value?.children ?? [] + }.flatMap { $0 } + + let childViews: [UIView] = childBlocks.map { block -> UIView in + let view = UIView() + view.backgroundColor = block.isCompleted ? environment.styles.accentBColor() : environment.styles.neutralDark() + return view + } + + headerView.setBlocks(currentBlock: block, blocks: childBlocks) + progressStackView.removeAllArrangedSubviews() + progressStackView.addArrangedSubviews(childViews) + } + + private func findCourseBlockToShow() { + guard let childBlocks = courseQuerier.childrenOfBlockWithID(blockID: blockID, forMode: .full) + .firstSuccess().value?.children.compactMap({ $0 }).filter({ $0.type == .Unit }) + else { return } + + let blocks: [CourseBlock] = childBlocks.flatMap { block in + courseQuerier.childrenOfBlockWithID(blockID: block.blockID, forMode: courseOutlineMode).value?.children.compactMap { child in + courseQuerier.blockWithID(id: child.blockID).value + } ?? [] + } + + currentBlock = blocks.first(where: { !$0.isCompleted }) ?? blocks.last + } + + private func updateView() { + guard let block = currentBlock else { return } + updateTitle(block: block) + setupCompletedBlocksView() + } + + private func updateTitle(block: CourseBlock) { + guard let parent = courseQuerier.parentOfBlockWith(id: block.blockID, type: .Section).firstSuccess().value + else { return } + headerView.update(title: parent.displayName, subtitle: block.displayName) + } + + override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) { + coordinator.animate { [weak self] _ in + guard let weakSelf = self else { return } + DispatchQueue.main.async { + weakSelf.setStatusBar(color: weakSelf.environment.styles.primaryLightColor()) + } + } + } +} + +extension NewCourseContentController: CourseContentPageViewControllerDelegate { + func courseContentPageViewController(controller: CourseContentPageViewController, enteredBlockWithID blockID: CourseBlockID, parentID: CourseBlockID) { + guard let block = courseQuerier.blockWithID(id: blockID).firstSuccess().value else { return } + currentBlock = block + if var controller = controller.viewControllers?.first as? ScrollableDelegateProvider { + controller.scrollableDelegate = self + } + + // header animation is overlapping with UIPageController animation which results in crash + // calling the header animation after a delay of 1 sec to overcome the issue + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.updateHeaderState(with: controller) + } + } + + private func updateHeaderState(with controller: CourseContentPageViewController) { + if let _ = controller.viewControllers?.first as? VideoBlockViewController { + if currentOrientation() != .portrait { + collapseHeaderView() + } else if headerViewState == .collapsed { + collapseHeaderView() + } else if headerViewState == .expanded { + expandHeaderView() + } + } + } +} + +extension NewCourseContentController: CourseContentHeaderViewDelegate { + func didTapBackButton() { + navigationController?.popViewController(animated: true) + } + + func didTapOnUnitBlock(block: CourseBlock) { + courseContentViewController?.moveToBlock(block: block) + } +} + +extension NewCourseContentController: ScrollableDelegate { + func scrollViewDidScroll(scrollView: UIScrollView) { + guard headerViewState != .animating else { return } + + if scrollView.contentOffset.y <= 0 { + if headerViewState == .collapsed { + headerViewState = .animating + expandHeaderView() + } + } else if headerViewState == .expanded { + headerViewState = .animating + collapseHeaderView() + } + } +} + +extension NewCourseContentController { + private func expandHeaderView() { + headerView.snp.remakeConstraints { make in + make.top.equalTo(contentView) + make.leading.equalTo(contentView) + make.trailing.equalTo(contentView) + make.height.equalTo(StandardVerticalMargin * 17).priority(.high) + make.height.lessThanOrEqualTo(StandardVerticalMargin * 17) + } + + UIView.animate(withDuration: 0.3) { [weak self] in + self?.headerView.showHeaderLabel(show: false) + self?.view.layoutIfNeeded() + } completion: { [weak self] _ in + self?.headerViewState = .expanded + } + } + + private func collapseHeaderView() { + headerView.snp.remakeConstraints { make in + make.top.equalTo(contentView) + make.leading.equalTo(contentView) + make.trailing.equalTo(contentView) + make.height.equalTo(StandardVerticalMargin * 8) + } + + UIView.animate(withDuration: 0.3) { [weak self] in + self?.headerView.showHeaderLabel(show: true) + self?.view.layoutIfNeeded() + } completion: { [weak self] _ in + self?.headerViewState = .collapsed + } + } +} + +fileprivate extension UIStackView { + func addArrangedSubviews(_ views: [UIView]) { + views.forEach { addArrangedSubview($0) } + } + + func removeAllArrangedSubviews() { + arrangedSubviews.forEach { $0.removeFromSuperview() } + } +} diff --git a/Source/NewCourseDashboardViewController.swift b/Source/NewCourseDashboardViewController.swift new file mode 100644 index 0000000000..e44259bf6b --- /dev/null +++ b/Source/NewCourseDashboardViewController.swift @@ -0,0 +1,627 @@ +// +// NewCourseDashboardViewController.swift +// edX +// +// Created by MuhammadUmer on 18/11/2022. +// Copyright © 2022 edX. All rights reserved. +// + +import UIKit + +// view used at the exact same location of status bar in case of hidden navbar +let statuBarViewTag: Int = 123454321 + +public protocol NewCourseDashboardViewControllerDelegate: AnyObject { + func showCourseDates(bannerInfo: DatesBannerInfo?, delegate: CourseOutlineTableController?) + func hideCourseDates() + func selectedController() -> UIViewController? +} + +extension NewCourseDashboardViewControllerDelegate { + public func selectedController() -> UIViewController? { + return nil + } +} + +class NewCourseDashboardViewController: UIViewController, InterfaceOrientationOverriding { + + typealias Environment = OEXAnalyticsProvider & OEXConfigProvider & DataManagerProvider & NetworkManagerProvider & OEXRouterProvider & OEXInterfaceProvider & ReachabilityProvider & OEXSessionProvider & OEXStylesProvider & RemoteConfigProvider & ServerConfigProvider + + private lazy var headerView: CourseDashboardHeaderView = { + let view = CourseDashboardHeaderView(environment: environment, course: course, tabbarItems: tabBarItems, error: courseAccessHelper) + view.accessibilityIdentifier = "NewCourseDashboardViewController:header-view" + view.delegate = self + return view + }() + + private lazy var contentView: UIView = { + let view = UIView() + view.accessibilityIdentifier = "NewCourseDashboardViewController:contentView-view" + return view + }() + + private lazy var container: UIView = { + let view = UIView() + view.accessibilityIdentifier = "NewCourseDashboardViewController:container-view" + return view + }() + + private lazy var courseUpgradeHelper = CourseUpgradeHelper.shared + + private var statusbarColor: UIColor { + return environment.styles.primaryLightColor() + } + + private var pacing: String { + guard let course = course else { return "" } + return course.isSelfPaced ? "self" : "instructor" + } + + private var shouldShowDiscussions: Bool { + guard let course = course else { return false } + return environment.config.discussionsEnabled && course.hasDiscussionsEnabled + } + + private var shouldShowHandouts: Bool { + guard let course = course else { return false } + return course.course_handouts?.isEmpty == false + } + + private var course: OEXCourse? + private var error: NSError? + private var courseAccessHelper: CourseAccessHelper? + private var selectedTabbarItem: TabBarItem? + + private var headerViewState: HeaderViewState = .expanded { + didSet { + headerView.state = headerViewState + } + } + private var tabBarItems: [TabBarItem] = [] + private var isModalDismissable = true + private let courseStream: BackedStream + private let loadStateController: LoadStateViewController + + private let environment: Environment + let courseID: String + private let screen: CourseUpgradeScreen = .courseDashboard + + init(environment: Environment, courseID: String) { + self.environment = environment + self.courseID = courseID + self.courseStream = BackedStream() + self.loadStateController = LoadStateViewController() + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) + addSubviews() + loadCourseStream() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + navigationItem.setHidesBackButton(true, animated: true) + navigationController?.setNavigationBarHidden(true, animated: true) + environment.analytics.trackScreen(withName: OEXAnalyticsScreenCourseDashboard, courseID: courseID, value: nil) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + navigationController?.setNavigationBarHidden(false, animated: true) + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + private func addSubviews() { + view.backgroundColor = environment.styles.neutralWhiteT() + view.addSubview(contentView) + contentView.snp.remakeConstraints { make in + make.top.equalTo(view) + make.bottom.equalTo(safeBottom) + make.leading.equalTo(safeLeading) + make.trailing.equalTo(safeTrailing) + } + setStatusBar(inside: contentView, color: statusbarColor) + loadStateController.setupInController(controller: self, contentView: contentView) + } + + private func setupConstraints() { + container.removeFromSuperview() + headerView.removeFromSuperview() + + contentView.addSubview(container) + contentView.addSubview(headerView) + + headerView.snp.remakeConstraints { make in + make.top.equalTo(safeTop) + make.leading.equalTo(contentView) + make.trailing.equalTo(contentView) + make.height.lessThanOrEqualTo(StandardVerticalMargin * 100) + } + + container.snp.remakeConstraints { make in + make.leading.equalTo(contentView) + make.trailing.equalTo(contentView) + make.top.equalTo(headerView.snp.bottom) + make.bottom.equalTo(contentView) + } + } + + private func loadCourseStream() { + courseStream.backWithStream(environment.dataManager.enrollmentManager.streamForCourseWithID(courseID: courseID)) + courseStream.listen(self) { [weak self] result in + self?.resultLoaded(result: result) + } + } + + private func resultLoaded(result: Result) { + switch result { + case .success(let enrollment): + let course = enrollment.course + self.course = course + prepareTabViewData() + + if let access = enrollment.course.courseware_access, !access.has_access { + let enrollment = environment.interface?.enrollmentForCourse(withID: courseID) + courseAccessHelper = CourseAccessHelper(course: course, enrollment: enrollment) + headerView.showTabbarView(show: false) + } else { + headerView.showTabbarView(show: true) + } + + loadStateController.state = .Loaded + setupConstraints() + setupContentView() + + case .failure(let error): + if !courseStream.active { + loadStateController.state = .Loaded + self.error = error + headerView.showTabbarView(show: false) + setupContentView() + } + } + } + + private func setupContentView() { + container.subviews.forEach { $0.removeFromSuperview() } + + if showCourseAccessError { + headerView.hidevalueProp() + let view = CourseDashboardAccessErrorView() + view.delegate = self + view.handleCourseAccessError(environment: environment, course: course, error: courseAccessHelper) + container.addSubview(view) + view.snp.remakeConstraints { make in + make.edges.equalTo(container) + } + } else if showContentNotLoadedError { + headerView.hidevalueProp() + let view = CourseDashboardErrorView() + view.myCoursesAction = { [weak self] in + self?.dismiss(animated: true) + } + container.addSubview(view) + view.snp.remakeConstraints { make in + make.edges.equalTo(container) + } + } else if let tabBarItem = selectedTabbarItem { + headerView.hidevalueProp(hide: false) + let contentController = tabBarItem.viewController + if var controller = contentController as? ScrollableDelegateProvider { + controller.scrollableDelegate = self + } + addChild(contentController) + container.addSubview(contentController.view) + contentController.view.snp.remakeConstraints { make in + make.edges.equalTo(container) + } + contentController.didMove(toParent: self) + contentController.view.layoutIfNeeded() + } + } + + override var shouldAutorotate: Bool { + return true + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .allButUpsideDown + } + + private func redirectToDiscovery() { + dismiss(animated: true) { + guard let rootController = UIApplication.shared.window?.rootViewController, + let enrolledTabbarViewController = rootController.children.first as? EnrolledTabBarViewController else { return } + + enrolledTabbarViewController.switchTab(with: .discovery) + } + } + + var showCourseAccessError: Bool { + return courseAccessHelper != nil + } + + var showContentNotLoadedError: Bool { + // add more logic here, like check for the content etc + return error != nil + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + setupContentView() + + coordinator.animate { [weak self] _ in + guard let weakSelf = self else { return } + DispatchQueue.main.async { + weakSelf.setStatusBar(inside: weakSelf.contentView, color: weakSelf.statusbarColor) + } + } + + if headerViewState == .collapsed { + collapseHeaderView() + } else if headerViewState == .expanded { + expandHeaderView() + } + } + + private func prepareTabViewData() { + tabBarItems = [] + let outlineController = CourseOutlineViewController(environment: environment, courseID: courseID, rootID: nil, forMode: .full, newDashboardDelegate: self) + var item = TabBarItem(title: Strings.Dashboard.courseHome, viewController: outlineController, icon: Icon.Courseware, detailText: Strings.Dashboard.courseCourseDetail) + tabBarItems.append(item) + + if environment.config.isCourseVideosEnabled { + item = TabBarItem(title: Strings.Dashboard.courseVideos, viewController: CourseOutlineViewController(environment: environment, courseID: courseID, rootID: nil, forMode: .video), icon: Icon.CourseVideos, detailText: Strings.Dashboard.courseVideosDetail) + tabBarItems.append(item) + } + + if shouldShowDiscussions { + item = TabBarItem(title: Strings.Dashboard.courseDiscussion, viewController: DiscussionTopicsViewController(environment: environment, courseID: courseID), icon: Icon.Discussions, detailText: Strings.Dashboard.courseDiscussionDetail) + tabBarItems.append(item) + } + + if environment.config.courseDatesEnabled { + item = TabBarItem(title: Strings.Dashboard.courseImportantDates, viewController: CourseDatesViewController(environment: environment , courseID: courseID), icon: Icon.Calendar, detailText: Strings.Dashboard.courseImportantDatesDetail) + tabBarItems.append(item) + } + + if shouldShowHandouts { + item = TabBarItem(title: Strings.Dashboard.courseHandouts, viewController: CourseHandoutsViewController(environment: environment, courseID: courseID), icon: Icon.Handouts, detailText: Strings.Dashboard.courseHandoutsDetail) + tabBarItems.append(item) + } + + if environment.config.isAnnouncementsEnabled { + item = TabBarItem(title: Strings.Dashboard.courseAnnouncements, viewController: CourseAnnouncementsViewController(environment: environment, courseID: courseID), icon:Icon.Announcements, detailText: Strings.Dashboard.courseAnnouncementsDetail) + tabBarItems.append(item) + } + } + + func switchTab(with type: DeepLinkType, deeplink: DeepLink? = nil) { + var selectedItem: TabBarItem? + + switch type { + case .courseDashboard: + selectedItem = tabbarViewItem(with: CourseOutlineViewController.self, courseOutlineMode: .full) + break + case .courseComponent: + selectedItem = tabbarViewItem(with: CourseOutlineViewController.self, courseOutlineMode: .full) + if let controller = selectedItem?.viewController as? CourseOutlineViewController { + controller.componentID = deeplink?.componentID + } + break + case .courseVideos: + selectedItem = tabbarViewItem(with: CourseOutlineViewController.self, courseOutlineMode: .video) + break + case .discussions, .discussionTopic, .discussionPost, .discussionComment: + selectedItem = tabbarViewItem(with: DiscussionTopicsViewController.self) + break + case .courseDates: + selectedItem = tabbarViewItem(with: CourseDatesViewController.self) + break + case .courseHandout: + let item = tabbarViewItem(with: CourseHandoutsViewController.self) + selectedItem = item == nil ? tabbarViewItem(with: AdditionalTabBarViewController.self) : item + break + case .courseAnnouncement: + let item = tabbarViewItem(with: CourseAnnouncementsViewController.self) + selectedItem = item == nil ? tabbarViewItem(with: AdditionalTabBarViewController.self) : item + break + default: + selectedItem = tabBarItems.first + break + } + + if let selectedItem = selectedItem { + selectedTabbarItem?.viewController.removeFromParent() + selectedTabbarItem = selectedItem + headerView.updateTabbarView(item: selectedItem) + setupContentView() + } + } + + func tabbarViewItem(with controller: AnyClass, courseOutlineMode: CourseOutlineMode? = .full) -> TabBarItem? { + for item in tabBarItems { + if item.viewController.isKind(of: controller) { + if item.viewController.isKind(of: CourseOutlineViewController.self) { + if let courseOutlineVC = item.viewController as? CourseOutlineViewController { + if let courseOutlineMode = courseOutlineMode { + if courseOutlineVC.courseOutlineMode == courseOutlineMode { + return item + } + } else { + return item + } + } + } else { + return item + } + } + } + return nil + } + + var currentVisibileController: UIViewController? { + return selectedTabbarItem?.viewController + } +} + +extension NewCourseDashboardViewController: CourseDashboardHeaderViewDelegate { + func didTapOnValueProp() { + guard let course = course else { return } + environment.router?.showValuePropDetailView(from: self, screen: .courseDashboard, course: course) { [weak self] in + self?.environment.analytics.trackValuePropModal(with: .CourseDashboard, courseId: course.course_id ?? "") + } + environment.analytics.trackValuePropLearnMore(courseID: course.course_id ?? "", screenName: .CourseDashboard) + } + + func didTapOnClose() { + dismiss(animated: true) + } + + func didTapOnShareCourse(shareView: UIView) { + guard let course = course, + let urlString = course.course_about, + let url = NSURL(string: urlString) else { return } + + let controller = shareHashtaggedTextAndALink(textBuilder: { hashtagOrPlatform in + Strings.shareACourse(platformName: hashtagOrPlatform) + }, url: url, utmParams: course.courseShareUtmParams) { [weak self] analyticsType in + self?.environment.analytics.trackCourseShared(courseID: self?.courseID ?? "", url: urlString, type: analyticsType) + } + + let location = CGRect(x: shareView.bounds.origin.x + shareView.bounds.size.width - 18, y: shareView.bounds.origin.y, width: 18, height: shareView.bounds.size.height) + + controller.configurePresentationController(withSourceView: shareView, location: location) + + present(controller, animated: true, completion: nil) + } + + func didTapTabbarItem(at position: Int, tabbarItem: TabBarItem) { + if courseAccessHelper == nil && selectedTabbarItem != tabbarItem { + selectedTabbarItem?.viewController.removeFromParent() + selectedTabbarItem = tabbarItem + setupContentView() + } + } +} + +extension NewCourseDashboardViewController: CourseDashboardAccessErrorViewDelegate { + func findCourseAction() { + redirectToDiscovery() + } + + func coursePrice(cell: CourseDashboardAccessErrorView, price: String?, elapsedTime: Int) { + if let price = price { + trackPriceLoadDuration(price: price, elapsedTime: elapsedTime) + } + else { + trackPriceLoadError(cell: cell) + } + } + + func upgradeCourseAction(course: OEXCourse, coursePrice: String, price: NSDecimalNumber?, currencyCode: String?, completion: @escaping ((Bool) -> ())) { + let upgradeHandler = CourseUpgradeHandler(for: course, environment: environment) + + guard let courseID = course.course_id else { + courseUpgradeHelper.handleCourseUpgrade(upgradeHadler: upgradeHandler, state: .error(.generalError, nil)) + completion(false) + return + } + + environment.analytics.trackUpgradeNow(with: courseID, pacing: pacing, screenName: .courseDashboard, coursePrice: coursePrice) + + courseUpgradeHelper.setupHelperData(environment: environment, pacing: pacing, courseID: courseID, localizedCoursePrice: coursePrice, screen: .courseDashboard) + + upgradeHandler.upgradeCourse(price: price, currencyCode: currencyCode) { [weak self] status in + guard let weakSelf = self else { return } + weakSelf.enableUserInteraction(enable: false) + + switch status { + case .payment: + weakSelf.courseUpgradeHelper.handleCourseUpgrade(upgradeHadler: upgradeHandler, state: .payment) + break + + case .verify: + weakSelf.courseUpgradeHelper.handleCourseUpgrade(upgradeHadler: upgradeHandler, state: .fulfillment(showLoader: true)) + break + + case .complete: + weakSelf.enableUserInteraction(enable: true) + weakSelf.dismiss(animated: true) { [weak self] in + self?.courseUpgradeHelper.handleCourseUpgrade(upgradeHadler: upgradeHandler, state: .success(course.course_id ?? "", nil)) + } + completion(true) + break + + case .error(let type, let error): + weakSelf.enableUserInteraction(enable: true) + weakSelf.courseUpgradeHelper.handleCourseUpgrade(upgradeHadler: upgradeHandler, state: .error(type, error), delegate: type == .verifyReceiptError ? self : nil) + completion(false) + break + + default: + break + } + } + } + + private func enableUserInteraction(enable: Bool) { + isModalDismissable = enable + DispatchQueue.main.async { [weak self] in + self?.navigationItem.rightBarButtonItem?.isEnabled = enable + self?.view.isUserInteractionEnabled = enable + } + } +} + +extension NewCourseDashboardViewController { + private func trackPriceLoadDuration(price: String, elapsedTime: Int) { + guard let course = course, + let courseID = course.course_id else { return } + + environment.analytics.trackCourseUpgradeTimeToLoadPrice(courseID: courseID, pacing: pacing, coursePrice: price, screen: screen, elapsedTime: elapsedTime) + } + + private func trackPriceLoadError(cell: CourseDashboardAccessErrorView) { + guard let course = course, let courseID = course.course_id else { return } + environment.analytics.trackCourseUpgradeLoadError(courseID: courseID, pacing: pacing, screen: screen) + showCoursePriceErrorAlert(cell: cell) + } + + private func showCoursePriceErrorAlert(cell: CourseDashboardAccessErrorView) { + guard let topController = UIApplication.shared.topMostController() else { return } + + let alertController = UIAlertController().showAlert(withTitle: Strings.CourseUpgrade.FailureAlert.alertTitle, message: Strings.CourseUpgrade.FailureAlert.priceFetchErrorMessage, cancelButtonTitle: nil, onViewController: topController) { _, _, _ in } + + + alertController.addButton(withTitle: Strings.CourseUpgrade.FailureAlert.priceFetchError) { [weak self] _ in + cell.fetchCoursePrice() + self?.environment.analytics.trackCourseUpgradeErrorAction(courseID: self?.course?.course_id ?? "" , blockID: "", pacing: self?.pacing ?? "", coursePrice: "", screen: self?.screen ?? .none, errorAction: CourseUpgradeHelper.ErrorAction.reloadPrice.rawValue, upgradeError: "price", flowType: CourseUpgradeHandler.CourseUpgradeMode.userInitiated.rawValue) + } + + alertController.addButton(withTitle: Strings.cancel, style: .default) { [weak self] _ in + cell.hideUpgradeButton() + self?.environment.analytics.trackCourseUpgradeErrorAction(courseID: self?.course?.course_id ?? "" , blockID: "", pacing: self?.pacing ?? "", coursePrice: "", screen: self?.screen ?? .none, errorAction: CourseUpgradeHelper.ErrorAction.close.rawValue, upgradeError: "price", flowType: CourseUpgradeHandler.CourseUpgradeMode.userInitiated.rawValue) + } + } +} + +extension NewCourseDashboardViewController: UIAdaptivePresentationControllerDelegate { + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + return isModalDismissable + } +} + +extension NewCourseDashboardViewController: CourseUpgradeHelperDelegate { + func hideAlertAction() { + dismiss(animated: true, completion: nil) + } +} + +extension NewCourseDashboardViewController: ScrollableDelegate { + func scrollViewDidScroll(scrollView: UIScrollView) { + guard headerViewState != .animating else { return } + + if scrollView.contentOffset.y <= 0 { + if headerViewState == .collapsed { + headerViewState = .animating + expandHeaderView() + } + } else if headerViewState == .expanded { + headerViewState = .animating + collapseHeaderView() + } + } +} + +extension NewCourseDashboardViewController { + private func expandHeaderView() { + headerView.snp.remakeConstraints { make in + make.top.equalTo(safeTop) + make.leading.equalTo(contentView) + make.trailing.equalTo(contentView) + make.height.lessThanOrEqualTo(StandardVerticalMargin * 60) + } + + UIView.animateKeyframes(withDuration: 0.4, delay: 0, options: .calculationModeLinear) { [weak self] in + UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) { + self?.headerView.updateTabbarConstraints(collapse: false) + self?.headerView.showCourseTitleHeaderLabel(show: false) + self?.view.layoutIfNeeded() + } + UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) { + self?.headerView.updateHeader(collapse: false) + } + } completion: { [weak self] _ in + self?.headerViewState = .expanded + } + } + + private func collapseHeaderView() { + headerView.updateHeader(collapse: true) + + headerView.snp.remakeConstraints { make in + make.top.equalTo(safeTop) + make.leading.equalTo(contentView) + make.trailing.equalTo(contentView) + make.height.equalTo(StandardVerticalMargin * 11) + } + + UIView.animate(withDuration: 0.3) { [weak self] in + self?.headerView.showCourseTitleHeaderLabel(show: true) + self?.view.layoutIfNeeded() + } completion: { [weak self] _ in + self?.headerViewState = .collapsed + } + } +} + +extension NewCourseDashboardViewController: NewCourseDashboardViewControllerDelegate { + func showCourseDates(bannerInfo: DatesBannerInfo?, delegate: CourseOutlineTableController?) { + headerView.showDatesBanner(delegate: delegate, bannerInfo: bannerInfo) + } + + func hideCourseDates() { + headerView.removeDatesBanner() + } + + func selectedController() -> UIViewController? { + return selectedTabbarItem?.viewController + } +} + +public extension UIViewController { + func setStatusBar(inside contentView: UIView? = nil, color: UIColor) { + let overView: UIView + + if let contentView = contentView, let taggedView = contentView.viewWithTag(statuBarViewTag) { + overView = taggedView + } else if contentView != nil { + overView = UIView() + overView.tag = statuBarViewTag + contentView?.addSubview(overView) + } else if let taggedView = view.viewWithTag(statuBarViewTag) { + overView = taggedView + } else { + overView = UIView() + overView.tag = statuBarViewTag + view.addSubview(overView) + } + + let height = UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0 + let frame = UIApplication.shared.windows.first?.windowScene?.statusBarManager?.statusBarFrame ?? .zero + overView.frame = CGRect(x: frame.origin.x, y: frame.origin.y, width: frame.size.width, height: height) + overView.backgroundColor = color + } +} diff --git a/Source/NewCourseDateBannerView.swift b/Source/NewCourseDateBannerView.swift new file mode 100644 index 0000000000..2aa13abe83 --- /dev/null +++ b/Source/NewCourseDateBannerView.swift @@ -0,0 +1,138 @@ +// +// NewCourseDateBannerView.swift +// edX +// +// Created by SaeedBashir on 4/12/23. +// Copyright © 2023 edX. All rights reserved. +// + +import Foundation +import UIKit + +class NewCourseDateBannerView: UIView { + private var buttonHeight: CGFloat = 32 + private lazy var container = UIView() + + private lazy var messageLabel: UILabel = { + let label = UILabel(frame: .zero) + label.numberOfLines = 0 + label.accessibilityIdentifier = "NewCourseDateBannerView:message-label" + return label + }() + + private lazy var bannerHeaderStyle: OEXMutableTextStyle = { + let style = OEXMutableTextStyle(weight: .bold, size: .small, color: OEXStyles.shared().neutralXXDark()) + style.lineBreakMode = .byWordWrapping + return style + }() + + private lazy var bannerBodyStyle: OEXMutableTextStyle = { + let style = OEXMutableTextStyle(weight: .normal, size: .small, color: OEXStyles.shared().neutralXXDark()) + style.lineBreakMode = .byWordWrapping + return style + }() + + private lazy var buttonStyle: OEXMutableTextStyle = { + return OEXMutableTextStyle(weight: .semiBold, size: .base, color: OEXStyles.shared().neutralWhiteT()) + }() + + private lazy var bannerButton: UIButton = { + let button = UIButton() + button.contentEdgeInsets = UIEdgeInsets(top: 10, left: 15, bottom: 10, right: 15) + button.layer.backgroundColor = OEXStyles.shared().primaryBaseColor().cgColor + button.layer.borderColor = OEXStyles.shared().primaryBaseColor().cgColor + button.layer.borderWidth = 1 + button.layer.cornerRadius = 0 + button.oex_removeAllActions() + button.oex_addAction({ [weak self] _ in + self?.bannerButtonAction() + }, for: .touchUpInside) + button.accessibilityIdentifier = "NewCourseDateBannerView:reset-date-button" + return button + }() + + private var isButtonTextAvailable: Bool { + guard let bannerInfo = bannerInfo, let status = bannerInfo.status else { return false } + return !status.button.isEmpty + } + + var bannerInfo: DatesBannerInfo? + weak var delegate: CourseShiftDatesDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + func setupView() { + configureViews() + setConstraints() + populate() + } + + private func configureViews() { + container.subviews.forEach { $0.removeFromSuperview() } + container.superview?.removeFromSuperview() + backgroundColor = OEXStyles.shared().warningXXLight() + addSubview(container) + container.addSubview(messageLabel) + container.accessibilityIdentifier = "CourseResetDateBannerView:container-view" + + if isButtonTextAvailable { + container.addSubview(bannerButton) + } + } + + private func setConstraints() { + container.snp.makeConstraints { make in + make.edges.equalTo(self) + } + + messageLabel.snp.makeConstraints { make in + make.top.equalTo(container).offset(StandardVerticalMargin) + make.leading.equalTo(container).offset(StandardHorizontalMargin) + make.trailing.equalTo(container).inset(StandardHorizontalMargin) + } + + if isButtonTextAvailable { + bannerButton.snp.makeConstraints { make in + make.top.equalTo(messageLabel.snp.bottom).offset(StandardVerticalMargin * 2) + make.bottom.equalTo(container).inset(StandardVerticalMargin * 2) + make.height.equalTo(buttonHeight) + make.leading.equalTo(container).offset(StandardHorizontalMargin) + make.trailing.equalTo(container).inset(StandardHorizontalMargin) + } + } + } + + private func populate() { + guard let bannerInfo = bannerInfo, let status = bannerInfo.status else { return } + + let headerText = bannerHeaderStyle.attributedString(withText: status.header) + let bodyText = bannerBodyStyle.attributedString(withText: status.body).setLineSpacing(3) + + let messageText = [headerText, bodyText] + let attributedString = NSAttributedString.joinInNaturalLayout(attributedStrings: messageText) + + messageLabel.attributedText = attributedString + messageLabel.sizeToFit() + messageLabel.layoutIfNeeded() + messageLabel.setNeedsLayout() + + if isButtonTextAvailable { + let buttonText = buttonStyle.attributedString(withText: status.button) + bannerButton.setAttributedTitle(buttonText, for: .normal) + } + } + + @objc private func bannerButtonAction() { + guard let bannerInfo = bannerInfo else { return } + + if bannerInfo.status == .resetDatesBanner { + delegate?.courseShiftDateButtonAction() + } + } +} diff --git a/Source/NewDashboardContentCell.swift b/Source/NewDashboardContentCell.swift new file mode 100644 index 0000000000..ebdafd6123 --- /dev/null +++ b/Source/NewDashboardContentCell.swift @@ -0,0 +1,29 @@ +// +// NewDashboardContentCell.swift +// edX +// +// Created by MuhammadUmer on 23/11/2022. +// Copyright © 2022 edX. All rights reserved. +// + +import UIKit + +class NewDashboardContentCell: UITableViewCell { + static let identifier = "NewDashboardContentCell" + + var viewController: UIViewController? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + viewController?.removeFromParent() + } +} diff --git a/Source/OEXConfig+AppFeatures.swift b/Source/OEXConfig+AppFeatures.swift index 4cad3b0264..10254bbc8f 100644 --- a/Source/OEXConfig+AppFeatures.swift +++ b/Source/OEXConfig+AppFeatures.swift @@ -82,6 +82,14 @@ extension OEXConfig { } return false } + + var isNewDashboardEnabled: Bool { + return bool(forKey: "NEW_DASHBOARD_ENABLED", defaultValue: false) + } + + var isNewComponentNavigationEnabled: Bool { + return bool(forKey: "NEW_COMPONENT_NAVIGATION_ENABLED", defaultValue: false) && isNewDashboardEnabled + } } diff --git a/Source/OEXFindCoursesViewController.h b/Source/OEXFindCoursesViewController.h index 28c9f53939..fc7fce35b4 100644 --- a/Source/OEXFindCoursesViewController.h +++ b/Source/OEXFindCoursesViewController.h @@ -17,8 +17,10 @@ typedef NS_ENUM(NSInteger, OEXFindCoursesBaseType) { @interface OEXFindCoursesViewController : UIViewController @property (readonly, strong, nonatomic) UIView* bottomBar; -- (instancetype) initWithEnvironment:(RouterEnvironment* _Nullable)environment showBottomBar:(BOOL) showBottomBar bottomBar:(UIView* _Nullable)bottomBar searchQuery:(nullable NSString*)searchQuery; +- (void) updateTitleViewVisibility; +- (instancetype) initWithEnvironment:(RouterEnvironment* _Nullable)environment showBottomBar:(BOOL) showBottomBar bottomBar:(UIView* _Nullable)bottomBar searchQuery:(nullable NSString*)searchQuery fromStartupScreen:(BOOL) fromStartupScreen; @property (nonatomic) OEXFindCoursesBaseType startURL; +@property (nonatomic) BOOL fromStartupScreen; @end NS_ASSUME_NONNULL_END diff --git a/Source/OEXFindCoursesViewController.m b/Source/OEXFindCoursesViewController.m index 5da3e99c78..8e64274bfc 100644 --- a/Source/OEXFindCoursesViewController.m +++ b/Source/OEXFindCoursesViewController.m @@ -33,13 +33,14 @@ @interface OEXFindCoursesViewController () Void)? = nil) { + if environment.config.isNewComponentNavigationEnabled { + navigateToComponentScreenNew(from: controller, courseID: courseID, componentID: componentID, completion: completion) + } else { + navigateToComponentScreenOld(from: controller, courseID: courseID, componentID: componentID, completion: completion) + } + } + + func navigateToComponentScreenNew(from controller: UIViewController, courseID: CourseBlockID, componentID: CourseBlockID, completion: ((UIViewController) -> Void)? = nil) { + + var parentViewController: UIViewController? + + if environment.config.isNewDashboardEnabled { + if let dashboardController = controller.navigationController?.viewControllers.first as? NewCourseDashboardViewController { + dashboardController.switchTab(with: .courseDashboard) + parentViewController = dashboardController + } + } else { + if let dashboardController = controller.navigationController?.viewControllers.first(where: { $0 is CourseDashboardViewController }) as? CourseDashboardViewController { + dashboardController.switchTab(with: .courseDashboard) + parentViewController = dashboardController + } + } + + let contentController = NewCourseContentController(environment: environment, blockID: componentID, resumeCourseBlockID: componentID, courseID: courseID) + parentViewController?.navigationController?.pushViewController(contentController, animated: true, completion: completion) + } + + func navigateToComponentScreenOld(from controller: UIViewController, courseID: CourseBlockID, componentID: CourseBlockID, completion: ((UIViewController) -> Void)? = nil) { let courseQuerier = environment.dataManager.courseDataManager.querierForCourseWithID(courseID: courseID, environment: environment) guard let childBlock = courseQuerier.blockWithID(id: componentID).firstSuccess().value, let unitBlock = courseQuerier.parentOfBlockWith(id: childBlock.blockID, type: .Unit).firstSuccess().value, @@ -85,10 +113,17 @@ extension OEXRouter { if controller is CourseOutlineViewController { outlineViewController = controller } else { - guard let dashboardController = controller.navigationController?.viewControllers.first(where: { $0 is CourseDashboardViewController}) as? CourseDashboardViewController else { return } - dashboardController.switchTab(with: .courseDashboard) - guard let outlineController = dashboardController.currentVisibleController as? CourseOutlineViewController else { return } - outlineViewController = outlineController + if environment.config.isNewDashboardEnabled { + guard let dashboardController = controller.navigationController?.viewControllers.first as? NewCourseDashboardViewController else { return } + dashboardController.switchTab(with: .courseDashboard) + guard let outlineController = dashboardController.currentVisibileController else { return } + outlineViewController = outlineController + } else { + guard let dashboardController = controller.navigationController?.viewControllers.first(where: { $0 is CourseDashboardViewController}) as? CourseDashboardViewController else { return } + dashboardController.switchTab(with: .courseDashboard) + guard let outlineController = dashboardController.currentVisibleController as? CourseOutlineViewController else { return } + outlineViewController = outlineController + } } showContainerForBlockWithID(blockID: sectionBlock.blockID, type: sectionBlock.displayType, parentID: chapterBlock.blockID, courseID: courseID, fromController: outlineViewController) { [weak self] visibleController in @@ -102,6 +137,15 @@ extension OEXRouter { } func showContainerForBlockWithID(blockID: CourseBlockID?, type: CourseBlockDisplayType, parentID: CourseBlockID?, courseID: CourseBlockID, fromController controller: UIViewController, forMode mode: CourseOutlineMode? = .full, completion: ((UIViewController) -> Void)? = nil) { + if environment.config.isNewComponentNavigationEnabled { + let contentController = NewCourseContentController(environment: environment, blockID: blockID, parentID: parentID, courseID: courseID, courseOutlineMode: mode) + controller.navigationController?.pushViewController(contentController, animated: true, completion: completion) + } else { + showContainerForBlockWithIDOld(blockID: blockID, type: type, parentID: parentID, courseID: courseID, fromController: controller, forMode: mode, completion: completion) + } + } + + func showContainerForBlockWithIDOld(blockID: CourseBlockID?, type: CourseBlockDisplayType, parentID: CourseBlockID?, courseID: CourseBlockID, fromController controller: UIViewController, forMode mode: CourseOutlineMode? = .full, completion: ((UIViewController) -> Void)? = nil) { switch type { case .Outline: fallthrough @@ -168,11 +212,10 @@ extension OEXRouter { @objc(showMyCoursesAnimated:pushingCourseWithID:) func showMyCourses(animated: Bool = true, pushingCourseWithID courseID: String? = nil) { let controller = EnrolledTabBarViewController(environment: environment) + let learnController = controller.children.flatMap { $0.children }.compactMap { $0 as? LearnContainerViewController } .first showContentStack(withRootController: controller, animated: animated) - if let courseID = courseID { - let navController = controller.viewControllers?.first as? ForwardingNavigationController - let coursesController = navController?.viewControllers.first as? EnrolledCoursesViewController - showCourseWithID(courseID: courseID, fromController: coursesController ?? controller, animated: false) + if let courseID = courseID, let learnController = learnController { + showCourseWithID(courseID: courseID, fromController: learnController, animated: false) } } @@ -191,23 +234,42 @@ extension OEXRouter { } func showDatesTabController(controller: UIViewController) { - if let dashboardController = controller as? CourseDashboardViewController { - dashboardController.switchTab(with: .courseDates) - } else if let dashboardController = controller.navigationController?.viewControllers.first(where: { $0 is CourseDashboardViewController}) as? CourseDashboardViewController { - controller.navigationController?.popToViewController(dashboardController, animated: false) - dashboardController.switchTab(with: .courseDates) + if environment.config.isNewDashboardEnabled { + if let dashboardController = controller.findParentViewController(type: NewCourseContentController.self)?.navigationController?.viewControllers.first as? NewCourseDashboardViewController { + popToRoot(controller: dashboardController) { + dashboardController.switchTab(with: .courseDates) + } + } else if let dashboardController = UIApplication.shared.topMostController() as? NewCourseDashboardViewController { + dashboardController.switchTab(with: .courseDates) + } + } else { + if let dashboardController = controller.findParentViewController(type: CourseDashboardViewController.self) { + dashboardController.switchTab(with: .courseDates) + } else if let dashboardController = controller.navigationController?.viewControllers.first(where: { $0 is CourseDashboardViewController}) as? CourseDashboardViewController { + controller.navigationController?.popToViewController(dashboardController, animated: false) + dashboardController.switchTab(with: .courseDates) + } } } // MARK: Deep Linking //Method can be use to navigate on particular tab of course dashboard with deep link type - func showCourse(with deeplink: DeepLink, courseID: String, from controller: UIViewController) { + func showCourse(with deeplink: DeepLink, courseID: String, from controller: UIViewController, completion: (() -> Void)? = nil) { + if environment.config.isNewDashboardEnabled { + showCourseNew(with: deeplink, courseID: courseID, from: controller, completion: completion) + } else { + showCourseOld(with: deeplink, courseID: courseID, from: controller, completion: completion) + } + } + + private func showCourseOld(with deeplink: DeepLink, courseID: String, from controller: UIViewController, completion: (() -> Void)? = nil) { let courseDashboardController = controller.navigationController?.viewControllers.first(where: { $0.isKind(of: CourseDashboardViewController.self) }) if let dashboardController = courseDashboardController as? CourseDashboardViewController, dashboardController.courseID == deeplink.courseId { controller.navigationController?.setToolbarHidden(true, animated: false) controller.navigationController?.popToViewController(dashboardController, animated: true) dashboardController.switchTab(with: deeplink.type, componentID: deeplink.componentID) + completion?() } else if let enrolledTabBarController = controller.find(viewController: EnrolledTabBarViewController.self) { if let courseDashboardController = courseDashboardController { courseDashboardController.navigationController?.popToRootViewController(animated: true) { [weak self] in @@ -215,32 +277,53 @@ extension OEXRouter { self?.showCourseWithID(courseID: courseID, fromController: switchedViewController, animated: true) { controller in guard let dashboardController = controller as? CourseDashboardViewController else { return } dashboardController.switchTab(with: deeplink.type, componentID: deeplink.componentID) + completion?() } } } else { let switchedViewController = enrolledTabBarController.switchTab(with: deeplink.type) - if let switchedViewController = switchedViewController as? ForwardingNavigationController, - let enrolledCoursesController = switchedViewController.viewControllers.first as? EnrolledCoursesViewController { - enrolledCoursesController.navigationController?.popToRootViewController(animated: true) { [weak self] in - self?.showCourseWithID(courseID: courseID, fromController: enrolledCoursesController, animated: true) { controller in - guard let dashboardController = controller as? CourseDashboardViewController else { return } - dashboardController.switchTab(with: deeplink.type, componentID: deeplink.componentID) - return - } + if let switchedViewController = switchedViewController as? LearnContainerViewController { + switchedViewController.navigationController?.popToRootViewController(animated: true) { + switchedViewController.switchTo(component: .courses) } } + showCourseWithID(courseID: courseID, fromController: switchedViewController, animated: true) { controller in + guard let dashboardController = controller as? CourseDashboardViewController else { return } + dashboardController.switchTab(with: deeplink.type, componentID: deeplink.componentID) + completion?() + } + } + } + } + + private func showCourseNew(with deeplink: DeepLink, courseID: String, from controller: UIViewController, completion: (() -> Void)? = nil) { + if let enrolledTabBarController = controller.find(viewController: EnrolledTabBarViewController.self) { + let switchedViewController = enrolledTabBarController.switchTab(with: deeplink.type) + if let switchedViewController = switchedViewController as? LearnContainerViewController { + switchedViewController.navigationController?.popToRootViewController(animated: true) { + switchedViewController.switchTo(component: .courses) + } + } + showCourseWithID(courseID: courseID, fromController: switchedViewController, animated: true) { controller in + var dashboardController: NewCourseDashboardViewController? + if let controller = controller as? ForwardingNavigationController { + dashboardController = controller.viewControllers.first(where: { $0 is NewCourseDashboardViewController }) as? NewCourseDashboardViewController + } else { + dashboardController = controller as? NewCourseDashboardViewController + } + dashboardController?.switchTab(with: deeplink.type, deeplink: deeplink) + completion?() } } } func showProgram(with type: DeepLinkType, url: URL? = nil, from controller: UIViewController) { let tabbarController = controller.find(viewController: EnrolledTabBarViewController.self) - if let programNavController = tabbarController?.switchTab(with: type) as? ForwardingNavigationController, - let programsViewController = programNavController.viewControllers.first as? ProgramsViewController { - programsViewController.navigationController?.popToRootViewController(animated: true) { [weak self] in - if let url = url { - self?.showProgramDetails(with: url, from: programsViewController) - } + if let learnController = tabbarController?.switchTab(with: type) as? LearnContainerViewController { + popToRoot(controller: learnController) + if let programsViewController = learnController.switchTo(component: .programs) as? ProgramsViewController, + let url = url { + showProgramDetails(with: url, from: programsViewController) } } } @@ -258,7 +341,6 @@ extension OEXRouter { func showDiscoveryController(from controller: UIViewController, type: DeepLinkType, isUserLoggedIn: Bool, pathID: String?) { let bottomBar = BottomBarView(environment: environment) var discoveryController = discoveryViewController(bottomBar: bottomBar, searchQuery: nil) - discoveryController?.hidesBottomBarWhenPushed = true if isUserLoggedIn { // Pop out all views and switches enrolledCourses tab on the bases of link type @@ -397,7 +479,6 @@ extension OEXRouter { func showProfile(controller: UIViewController? = nil, completion: ((_ success: Bool) -> ())? = nil) { let profileViewController = ProfileOptionsViewController(environment: environment) let navigationController = ForwardingNavigationController(rootViewController: profileViewController) - navigationController.navigationBar.prefersLargeTitles = true controller?.navigationController?.present(navigationController, animated: true) { completion?(true) } @@ -480,13 +561,21 @@ extension OEXRouter { } func showCourseWithID(courseID: String, fromController: UIViewController, animated: Bool = true, completion: ((UIViewController) -> Void)? = nil) { - let controller = CourseDashboardViewController(environment: environment, courseID: courseID) - controller.hidesBottomBarWhenPushed = true - fromController.navigationController?.pushViewController(controller, animated: animated, completion: completion) + if environment.config.isNewDashboardEnabled { + let courseDashboardViewController = NewCourseDashboardViewController(environment: environment, courseID: courseID) + let controller = ForwardingNavigationController(rootViewController: courseDashboardViewController) + controller.navigationController?.setNavigationBarHidden(true, animated: false) + controller.modalPresentationStyle = .fullScreen + fromController.navigationController?.presentViewControler(controller, animated: animated, completion: completion) + } else { + let controller = CourseDashboardViewController(environment: environment, courseID: courseID) + controller.hidesBottomBarWhenPushed = true + fromController.navigationController?.pushViewController(controller, animated: animated, completion: completion) + } } func showCourseCatalog(fromController: UIViewController? = nil, bottomBar: UIView? = nil, searchQuery: String? = nil) { - guard let controller = discoveryViewController(bottomBar: bottomBar, searchQuery: searchQuery) else { return } + guard let controller = discoveryViewController(bottomBar: bottomBar, searchQuery: searchQuery, fromStartupScreen: true) else { return } if let fromController = fromController { fromController.tabBarController?.selectedIndex = EnrolledTabBarViewController.courseCatalogIndex } else { @@ -494,15 +583,14 @@ extension OEXRouter { } } - func discoveryViewController(bottomBar: UIView? = nil, searchQuery: String? = nil) -> UIViewController? { + func discoveryViewController(bottomBar: UIView? = nil, searchQuery: String? = nil, fromStartupScreen: Bool = false) -> UIViewController? { guard environment.config.discovery.isEnabled else { return nil } - return environment.config.discovery.type == .webview ? OEXFindCoursesViewController(environment: environment, showBottomBar: true, bottomBar: bottomBar, searchQuery: searchQuery) : CourseCatalogViewController(environment: environment) + return environment.config.discovery.type == .webview ? OEXFindCoursesViewController(environment: environment, showBottomBar: true, bottomBar: bottomBar, searchQuery: searchQuery, fromStartupScreen: fromStartupScreen) : CourseCatalogViewController(environment: environment) } func showProgramDetail(from controller: UIViewController, with pathId: String, bottomBar: UIView?) { let programDetailViewController = ProgramsDiscoveryViewController(with: environment, pathId: pathId, bottomBar: bottomBar?.copy() as? UIView) - programDetailViewController.hidesBottomBarWhenPushed = true pushViewController(controller: programDetailViewController, fromController: controller) } @@ -593,11 +681,11 @@ extension OEXRouter { let programDetailsController = ProgramsViewController(environment: environment, programsURL: url, viewType: .detail) programDetailsController.hidesBottomBarWhenPushed = true controller.navigationController?.pushViewController(programDetailsController, animated: true) + controller.navigationController?.setNavigationBarHidden(false, animated: true) } @objc public func showCourseDetails(from controller: UIViewController, with coursePathID: String, bottomBar: UIView?) { let courseInfoViewController = OEXCourseInfoViewController(environment: environment, pathID: coursePathID, bottomBar: bottomBar?.copy() as? UIView) - courseInfoViewController.hidesBottomBarWhenPushed = true controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) controller.navigationController?.pushViewController(courseInfoViewController, animated: true) } diff --git a/Source/PostsViewController.swift b/Source/PostsViewController.swift index ce8e6555a8..4b81b9c5ea 100644 --- a/Source/PostsViewController.swift +++ b/Source/PostsViewController.swift @@ -8,10 +8,13 @@ import UIKit -class PostsViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, PullRefreshControllerDelegate, InterfaceOrientationOverriding, DiscussionNewPostViewControllerDelegate { +class PostsViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, PullRefreshControllerDelegate, InterfaceOrientationOverriding, DiscussionNewPostViewControllerDelegate, ScrollableDelegateProvider { typealias Environment = NetworkManagerProvider & OEXRouterProvider & OEXAnalyticsProvider & OEXStylesProvider + weak var scrollableDelegate: ScrollableDelegate? + private var scrollByDragging = false + enum Context { case Topic(DiscussionTopic) case Following @@ -733,3 +736,18 @@ extension PostsViewController { } } +extension PostsViewController { + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollByDragging = true + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollByDragging { + scrollableDelegate?.scrollViewDidScroll(scrollView: scrollView) + } + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + scrollByDragging = false + } +} diff --git a/Source/ProfileOptionsViewController.swift b/Source/ProfileOptionsViewController.swift index 2a6c5dc03d..d2c48c748f 100644 --- a/Source/ProfileOptionsViewController.swift +++ b/Source/ProfileOptionsViewController.swift @@ -78,13 +78,11 @@ class ProfileOptionsViewController: UIViewController { super.viewDidLoad() title = Strings.UserAccount.profile - + navigationController?.view.backgroundColor = environment.styles.standardBackgroundColor() + setupViews() - addCloseButton() configureOptions() setupProfileLoader() - - navigationController?.view.backgroundColor = environment.styles.standardBackgroundColor() } override func viewWillAppear(_ animated: Bool) { @@ -92,6 +90,7 @@ class ProfileOptionsViewController: UIViewController { environment.analytics.trackScreen(withName: AnalyticsScreenName.Profile.rawValue) setupProfileLoader() navigationController?.navigationBar.prefersLargeTitles = true + navigationController?.navigationBar.applyDefaultNavbarColorScheme() } override func viewDidDisappear(_ animated: Bool) { @@ -123,19 +122,7 @@ class ProfileOptionsViewController: UIViewController { profileFeed.refresh() } } - - private func addCloseButton() { - let closeButton = UIBarButtonItem(image: Icon.Close.imageWithFontSize(size: crossButtonSize), style: .plain, target: nil, action: nil) - closeButton.accessibilityLabel = Strings.Accessibility.closeLabel - closeButton.accessibilityHint = Strings.Accessibility.closeHint - closeButton.accessibilityIdentifier = "ProfileOptionsViewController:close-button" - navigationItem.rightBarButtonItem = closeButton - - closeButton.oex_setAction { [weak self] in - self?.dismiss(animated: true, completion: nil) - } - } - + private func configureOptions() { options.append(.videoSetting) @@ -309,8 +296,8 @@ extension ProfileOptionsViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch options[indexPath.row] { case .personalInformation: - guard environment.config.profilesEnabled, let username = environment.session.currentUser?.username else { return } - environment.router?.showProfileForUsername(controller: self, username: username, editable: true) + guard environment.config.profilesEnabled, let _ = environment.session.currentUser?.username else { return } + environment.router?.showProfileEditorFromController(controller: self) environment.analytics.trackProfileOptionClcikEvent(displayName: AnalyticsDisplayName.PersonalInformationClicked, name: AnalyticsEventName.PersonalInformationClicked) default: return diff --git a/Source/Resources/Images.xcassets/app_update_image.imageset/Contents.json b/Source/Resources/Images.xcassets/app_update_image.imageset/Contents.json new file mode 100644 index 0000000000..fdccfbda1e --- /dev/null +++ b/Source/Resources/Images.xcassets/app_update_image.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "app_update_image@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Source/Resources/Images.xcassets/app_update_image.imageset/app_update_image@2x.png b/Source/Resources/Images.xcassets/app_update_image.imageset/app_update_image@2x.png new file mode 100644 index 0000000000..9e8f1d5ec1 Binary files /dev/null and b/Source/Resources/Images.xcassets/app_update_image.imageset/app_update_image@2x.png differ diff --git a/Source/Resources/Images.xcassets/dashboard_error_image.imageset/Contents.json b/Source/Resources/Images.xcassets/dashboard_error_image.imageset/Contents.json new file mode 100644 index 0000000000..968c61be7d --- /dev/null +++ b/Source/Resources/Images.xcassets/dashboard_error_image.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "dashboard_error_image.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Source/Resources/Images.xcassets/dashboard_error_image.imageset/dashboard_error_image.png b/Source/Resources/Images.xcassets/dashboard_error_image.imageset/dashboard_error_image.png new file mode 100644 index 0000000000..d007cbee42 Binary files /dev/null and b/Source/Resources/Images.xcassets/dashboard_error_image.imageset/dashboard_error_image.png differ diff --git a/Source/Resources/Images.xcassets/empty_state_placeholder.imageset/Contents.json b/Source/Resources/Images.xcassets/empty_state_placeholder.imageset/Contents.json new file mode 100644 index 0000000000..df35c8ac87 --- /dev/null +++ b/Source/Resources/Images.xcassets/empty_state_placeholder.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "empty_state_placeholder.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Source/Resources/Images.xcassets/empty_state_placeholder.imageset/empty_state_placeholder.png b/Source/Resources/Images.xcassets/empty_state_placeholder.imageset/empty_state_placeholder.png new file mode 100644 index 0000000000..6a26c0ab65 Binary files /dev/null and b/Source/Resources/Images.xcassets/empty_state_placeholder.imageset/empty_state_placeholder.png differ diff --git a/Source/ResumeCourseHeaderView.swift b/Source/ResumeCourseHeaderView.swift new file mode 100644 index 0000000000..b1dd7eb897 --- /dev/null +++ b/Source/ResumeCourseHeaderView.swift @@ -0,0 +1,68 @@ +// +// ResumeCourseHeaderView.swift +// edX +// +// Created by MuhammadUmer on 12/03/2023. +// Copyright © 2023 edX. All rights reserved. +// + +import UIKit + +class ResumeCourseHeaderView: UIView { + + var tapAction: (() -> ())? + + private lazy var button: UIButton = { + let button = UIButton() + + let arrowImage = Icon.ArrowForward.imageWithFontSize(size: 18).image(with: OEXStyles.shared().primaryBaseColor()) + let imageAttachment = NSTextAttachment() + imageAttachment.image = arrowImage + + if let image = imageAttachment.image { + imageAttachment.bounds = CGRect(x: 0, y: -4, width: image.size.width, height: image.size.height) + } + + let attributedImageString = NSAttributedString(attachment: imageAttachment) + let style = OEXTextStyle(weight: .bold, size: .base, color: OEXStyles.shared().primaryBaseColor()) + + let attributedStrings = [ + style.attributedString(withText: Strings.Dashboard.resumeCourse), + NSAttributedString(string: " "), + attributedImageString, + ] + + let attributedTitle = NSAttributedString.joinInNaturalLayout(attributedStrings: attributedStrings) + + button.setAttributedTitle(attributedTitle, for: UIControl.State()) + button.backgroundColor = OEXStyles.shared().neutralWhiteT() + button.layer.borderWidth = 1 + button.layer.borderColor = OEXStyles.shared().neutralXLight().cgColor + button.layer.cornerRadius = 0 + button.layer.masksToBounds = true + button.oex_addAction({ [weak self] _ in + self?.tapAction?() + }, for: .touchUpInside) + return button + }() + + init() { + super.init(frame: .zero) + setupViews() + setConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + addSubview(button) + } + + private func setConstraints() { + button.snp.makeConstraints { make in + make.edges.equalTo(self) + } + } +} diff --git a/Source/ScrollableDelegate.swift b/Source/ScrollableDelegate.swift new file mode 100644 index 0000000000..00f74aa7d4 --- /dev/null +++ b/Source/ScrollableDelegate.swift @@ -0,0 +1,17 @@ +// +// ScrollableDelegate.swift +// edX +// +// Created by MuhammadUmer on 01/02/2023. +// Copyright © 2023 edX. All rights reserved. +// + +import Foundation + +public protocol ScrollableDelegateProvider { + var scrollableDelegate: ScrollableDelegate? { get set } +} + +@objc public protocol ScrollableDelegate: AnyObject { + func scrollViewDidScroll(scrollView: UIScrollView) +} diff --git a/Source/TabBarItem.swift b/Source/TabBarItem.swift index 64e20da23a..56bf0f5fb9 100644 --- a/Source/TabBarItem.swift +++ b/Source/TabBarItem.swift @@ -15,3 +15,9 @@ struct TabBarItem { let icon: Icon let detailText: String } + +extension TabBarItem: Equatable { + static func == (lhs: TabBarItem, rhs: TabBarItem) -> Bool { + lhs.title == rhs.title + } +} diff --git a/Source/UIGestureRecognizer+BlockActions.swift b/Source/UIGestureRecognizer+BlockActions.swift index 92b2dade91..94b22de482 100644 --- a/Source/UIGestureRecognizer+BlockActions.swift +++ b/Source/UIGestureRecognizer+BlockActions.swift @@ -64,3 +64,38 @@ extension GestureActionable where Self : UIGestureRecognizer { } } +class AttachmentTapGestureRecognizer: UITapGestureRecognizer { + + typealias TappedAttachment = (attachment: NSTextAttachment, characterIndex: Int) + private var action: ((AttachmentTapGestureRecognizer) -> Void)? + + init(action: @escaping (AttachmentTapGestureRecognizer) -> Void) { + super.init(target: nil, action: nil) + self.action = action + } + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + guard let textView = view as? UITextView else { + state = .failed + return + } + + if let touch = touches.first, let _ = evaluateTouch(touch, on: textView) { + super.touchesBegan(touches, with: event) + action?(self) + } else { + state = .failed + } + } + + private func evaluateTouch(_ touch: UITouch, on textView: UITextView) -> TappedAttachment? { + let touch = touch.location(in: textView) + let point = CGPoint(x: touch.x - textView.textContainerInset.left, y: touch.y - textView.textContainerInset.top) + let glyphIndex: Int = textView.layoutManager.glyphIndex(for: point, in: textView.textContainer, fractionOfDistanceThroughGlyph: nil) + let glyphRect = textView.layoutManager.boundingRect(forGlyphRange: NSRange(location: glyphIndex, length: 1), in: textView.textContainer) + guard glyphRect.contains(point) else { return nil } + let characterIndex = textView.layoutManager.characterIndexForGlyph(at: glyphIndex) + guard characterIndex < textView.textStorage.length, NSTextAttachment.character == (textView.textStorage.string as NSString).character(at: characterIndex), let attachment = textView.textStorage.attribute(.attachment, at: characterIndex, effectiveRange: nil) as? NSTextAttachment else { return nil } + return (attachment, characterIndex) + } +} diff --git a/Source/UIViewController+CommonAdditions.swift b/Source/UIViewController+CommonAdditions.swift index cfc57f9acc..7ad3ee397a 100644 --- a/Source/UIViewController+CommonAdditions.swift +++ b/Source/UIViewController+CommonAdditions.swift @@ -46,10 +46,15 @@ extension UIViewController { return (navigationController != nil && navigationController?.presentingViewController?.presentedViewController == navigationController) } - func configurePresentationController(withSourceView sourceView: UIView) { + func configurePresentationController(withSourceView sourceView: UIView, location: CGRect? = nil) { if UIDevice.current.userInterfaceIdiom == UIUserInterfaceIdiom.pad { popoverPresentationController?.sourceView = sourceView - popoverPresentationController?.sourceRect = sourceView.bounds + if let location = location { + popoverPresentationController?.sourceRect = location + } + else { + popoverPresentationController?.sourceRect = sourceView.bounds + } } } @@ -60,4 +65,14 @@ extension UIViewController { } navigationItem.leftBarButtonItem = backItem } + + func findParentViewController(type: T.Type) -> T? { + if let parentViewController = self.parent as? T { + return parentViewController + } else if let parentViewController = self.parent { + return parentViewController.findParentViewController(type: type) + } else { + return nil + } + } } diff --git a/Source/UserProfileEditViewController.swift b/Source/UserProfileEditViewController.swift index a538e31b19..cba0b11284 100644 --- a/Source/UserProfileEditViewController.swift +++ b/Source/UserProfileEditViewController.swift @@ -210,8 +210,10 @@ class UserProfileEditViewController: UIViewController, UITableViewDelegate, UITa override func viewDidLoad() { super.viewDidLoad() + + navigationItem.largeTitleDisplayMode = .never addSubViews() - title = Strings.Profile.editTitle + title = Strings.ProfileOptions.UserProfile.title navigationItem.backBarButtonItem = UIBarButtonItem(title: " ", style: .plain, target: nil, action: nil) view.backgroundColor = environment.styles.standardBackgroundColor() diff --git a/Source/UserProfileViewController.swift b/Source/UserProfileViewController.swift index 07e853e8ad..57f51ec452 100644 --- a/Source/UserProfileViewController.swift +++ b/Source/UserProfileViewController.swift @@ -70,10 +70,15 @@ class UserProfileViewController: OfflineSupportViewController, UserProfilePresen presenter.refresh() } + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + navigationController?.navigationBar.applyDefaultNavbarColorScheme() + } + private func addBackBarButtonItem() { let backItem = UIBarButtonItem(image: Icon.ArrowLeft.imageWithFontSize(size: 40), style: .plain, target: nil, action: nil) backItem.accessibilityIdentifier = "UserProfileViewController:back-item" - backItem.oex_setAction {[weak self] in + backItem.oex_setAction { [weak self] in // Profile has different navbar color scheme that's why we need to revert nav bar color to original color while poping the controller self?.navigationController?.navigationBar.applyDefaultNavbarColorScheme() self?.navigationController?.popViewController(animated: true) @@ -89,7 +94,6 @@ class UserProfileViewController: OfflineSupportViewController, UserProfilePresen if let owner = self { owner.environment.router?.showProfileEditorFromController(controller: owner) } - self?.navigationController?.navigationBar.applyDefaultNavbarColorScheme() } editButton.accessibilityLabel = Strings.Profile.editAccessibility navigationItem.rightBarButtonItem = editButton @@ -97,7 +101,7 @@ class UserProfileViewController: OfflineSupportViewController, UserProfilePresen } private func addCloseButton() { - if (isModal()) {//isModal check if the view is presented then add close button + if isModal() { //isModal check if the view is presented then add close button let closeButton = UIBarButtonItem(title: Strings.close, style: .plain, target: nil, action: nil) closeButton.accessibilityIdentifier = "UserProfileViewController:close-button" closeButton.accessibilityLabel = Strings.Accessibility.closeLabel diff --git a/Source/ValuePropMessagesView.swift b/Source/ValuePropMessagesView.swift index c339bc974e..7a1b513119 100644 --- a/Source/ValuePropMessagesView.swift +++ b/Source/ValuePropMessagesView.swift @@ -50,11 +50,15 @@ class ValuePropMessagesView: UIView { } } - public func height()-> CGFloat { + public func height() -> CGFloat { tableView.layoutIfNeeded() return tableView.contentSize.height } + override func layoutSubviews() { + super.layoutSubviews() + tableView.isScrollEnabled = tableView.contentSize.height > tableView.frame.size.height + } } extension ValuePropMessagesView: UITableViewDataSource, UITableViewDelegate { diff --git a/Source/ValuePropUnlockViewContainer.swift b/Source/ValuePropUnlockViewContainer.swift index 622ded2edd..e1f182dff5 100644 --- a/Source/ValuePropUnlockViewContainer.swift +++ b/Source/ValuePropUnlockViewContainer.swift @@ -54,9 +54,9 @@ class ValuePropUnlockViewContainer: NSObject { func removeView(completion: (()-> ())? = nil) { func dismiss() { + controller?.removeFromParent() container?.subviews.forEach { $0.removeFromSuperview() } container?.removeFromSuperview() - controller?.removeFromParent() container = nil controller = nil shouldDismiss.unsubscribe(observer: self) diff --git a/Source/VideoBlockViewController.swift b/Source/VideoBlockViewController.swift index b4e6d144dd..4a3d812eb2 100644 --- a/Source/VideoBlockViewController.swift +++ b/Source/VideoBlockViewController.swift @@ -31,7 +31,7 @@ class VideoBlockViewController : OfflineSupportViewController, CourseBlockViewCo private var playOverlayButton: UIButton? private var overlayLabel: UILabel? var shouldCelebrationAppear: Bool - + init(environment : Environment, blockID : CourseBlockID?, courseID: String, shouldCelebrationAppear: Bool = false) { self.blockID = blockID self.environment = environment @@ -442,16 +442,29 @@ class VideoBlockViewController : OfflineSupportViewController, CourseBlockViewCo } } - // willTransition only called in case of iPhone because iPhone has regular and compact vertical classes. - // This method is specially for iPad - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - guard UIDevice.current.userInterfaceIdiom == .pad else { return } - - if videoPlayer.isFullScreen { - videoPlayer.setFullscreen(fullscreen: !UIDevice.current.orientation.isPortrait, animated: true, with: currentOrientation(), forceRotate: false) - } - else if UIDevice.current.orientation.isLandscape { - videoPlayer.setFullscreen(fullscreen: true, animated: true, with: currentOrientation(), forceRotate: false) + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + if UIDevice.current.userInterfaceIdiom == .pad { + if videoPlayer.isFullScreen { + videoPlayer.setFullscreen(fullscreen: !UIDevice.current.orientation.isPortrait, animated: true, with: currentOrientation(), forceRotate: false) + } else if UIDevice.current.orientation.isLandscape { + videoPlayer.setFullscreen(fullscreen: true, animated: true, with: currentOrientation(), forceRotate: false) + } + } else { + DispatchQueue.main.async { [weak self] in + if let weakSelf = self { + if weakSelf.chromeCastManager.isMiniPlayerAdded { return } + + if weakSelf.videoPlayer.isFullScreen { + if UITraitCollection.current.verticalSizeClass == .regular { + weakSelf.videoPlayer.setFullscreen(fullscreen: false, animated: true, with: weakSelf.currentOrientation(), forceRotate: false) + } else { + weakSelf.videoPlayer.setFullscreen(fullscreen: true, animated: true, with: weakSelf.currentOrientation(), forceRotate: false) + } + } else if UITraitCollection.current.verticalSizeClass == .compact && !weakSelf.shouldCelebrationAppear { + weakSelf.videoPlayer.setFullscreen(fullscreen: true, animated: true, with: weakSelf.currentOrientation(), forceRotate: false) + } + } + } } } diff --git a/Source/WebView/ProgramsViewController.swift b/Source/WebView/ProgramsViewController.swift index 7f764fc185..ef271ba374 100644 --- a/Source/WebView/ProgramsViewController.swift +++ b/Source/WebView/ProgramsViewController.swift @@ -14,15 +14,20 @@ public enum ProgramScreen { case detail } -class ProgramsViewController: UIViewController, InterfaceOrientationOverriding, PullRefreshControllerDelegate { +class ProgramsViewController: UIViewController, InterfaceOrientationOverriding, PullRefreshControllerDelegate, ScrollableDelegateProvider { typealias Environment = OEXAnalyticsProvider & OEXConfigProvider & OEXSessionProvider & OEXRouterProvider & ReachabilityProvider & OEXStylesProvider & NetworkManagerProvider - fileprivate let environment: Environment - fileprivate let webController: AuthenticatedWebViewController + + private let environment: Environment private(set) var programsURL: URL - fileprivate let refreshController = PullRefreshController() private(set) var type: ProgramScreen + private let webController: AuthenticatedWebViewController + private let refreshController = PullRefreshController() + + weak var scrollableDelegate: ScrollableDelegate? + private var scrollByDragging = false + init(environment: Environment, programsURL: URL, viewType type: ProgramScreen? = .base) { webController = AuthenticatedWebViewController(environment: environment) self.environment = environment @@ -31,6 +36,7 @@ class ProgramsViewController: UIViewController, InterfaceOrientationOverriding, super.init(nibName: nil, bundle: nil) webController.webViewDelegate = self webController.delegate = self + webController.scrollView.delegate = self setupView() loadPrograms() } @@ -104,3 +110,19 @@ extension ProgramsViewController: WebViewNavigationDelegate { return self } } + +extension ProgramsViewController: UIScrollViewDelegate { + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollByDragging = true + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollByDragging { + scrollableDelegate?.scrollViewDidScroll(scrollView: scrollView) + } + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + scrollByDragging = false + } +} diff --git a/Source/ar.lproj/Localizable-2.strings b/Source/ar.lproj/Localizable-2.strings index 79379f405f..afccc5dfcd 100644 --- a/Source/ar.lproj/Localizable-2.strings +++ b/Source/ar.lproj/Localizable-2.strings @@ -13,6 +13,22 @@ /* Continue text for aleart button titles or for other places*/ "CONTINUE_TEXT"="Continue"; +/* Find a new course button title on the course dashbaord error screen */ +"COURSE_DASHBOARD.ERROR.FIND_A_NEW_COURSE"="Find a new course"; +/* Course dashbaord loading eerror */ +"COURSE_DASHBOARD.ERROR.GENERAL_ERROR"="An error occured while loading your course"; +/* Go to my courses button title on the course dashbaord general error screen */ +"COURSE_DASHBOARD.ERROR.GO_TO_COURSES"="Go to My Courses"; +/*Error message title on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_TITLE" = "This course hasn’t started yet."; +/*Error message info on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_INFO" = "Come back {start_date} to see all your course content here."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_TITLE" = "Your access to this course has expired."; +/*Error message info on course dashboard screen when audit access expired of course or course ended*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_INFO" = "No new sessions are available at this time."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ENDED_TITLE" = "This course session has ended."; /* Label for date when course starts */ "COURSE.STARTING" = "Starts {start_date}"; /* Label for date when course end */ @@ -29,6 +45,10 @@ "COURSE.AUDIT.EXPIRED_AGO" = "Access expired {time_duaration}"; /* Course audit expired on date*/ "COURSE.AUDIT.EXPIRED_ON" = "Expired on {expiry_date}"; +/* Course Reset Date Banner Header */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Missed some deadlines? "; +/* Course Reset Date Banner Body */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; /*Title for Discover*/ "DISCOVER"="Discover"; /*Title for Discovery*/ @@ -43,5 +63,19 @@ "MY_COURSES"="My courses"; /*Title My programs*/ "MY_PROGRAMS"="My programs"; +/* Title text for the Home section of the course dashboard */ +"DASHBOARD.COURSE_HOME"="Home"; +/* Title for audit exipred error */ +"COURSE_DASHBOARD.ERROR.AUDIT_EXPIRED_UPGRADE_INFO"="Upgrade to get full access to this course and pursue a certificate."; +/* Title for audit title message */ +"COURSE.AUDIT_ACCESS_EXPIRED"="Access expired {expiry_date}"; +/* Enrolled courses loading error */ +"DASHBOARD.GENERAL_ERROR_MESSAGE"="An error occured while loading your courses"; +/* Enrolled courses loading error try again*/ +"DASHBOARD.TRY_AGAIN"="Try again"; +/* Course Dashboard Resume Course button title*/ +"DASHBOARD.RESUME_COURSE"="Resume course"; +/* Course outline header gated content title */ +"COURSE_OUTLINE_HEADER.GATED_CONTENT_TITLE"="Some content in this part of the course is locked for upgraded users only."; /* None option in the caption list to de select caption language*/ "NONE"="None"; diff --git a/Source/ar.lproj/Localizable.strings b/Source/ar.lproj/Localizable.strings index 185881927c..24fa5732db 100644 --- a/Source/ar.lproj/Localizable.strings +++ b/Source/ar.lproj/Localizable.strings @@ -336,10 +336,6 @@ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY"="مما يعني انك غير قادر ان تشارك في الواجبات المقدرة. يبدو انك فاتتك بعض المواعيد المهمة بناء على جدولنا .لتكمل الواجبات المقدرة كجزء من هذه الدورة ونقل واجبات الماضي الي المستقبل، يمكنك الترقية اليوم"; /* Course Reset Date Upgrade Reset Banner Button */ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON"="Upgrade to shift due dates"; -/* Course Reset Date Banner Header */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"=" يبدو انك فاتتك بعض المراعيد المهمة بناء على جدولنا المقترح"; -/* Course Reset Date Banner Body */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="لتبقى نفسك على المسار الصحيح، يمكنك ترقية جدولك و نقل واجبات الماضي الي المستقبل. لا تقلق لن تخسر اي تقدم فعلته عندما تنقل التواريخ المحددة"; /* Course Reset Date Banner Button */ "COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BUTTON"="تغيير المواعيد المقررة"; /* Course Dates Title */ @@ -617,7 +613,7 @@ /* Prompt shown to users on the course list encouraging them to find new courses */ "ENROLLMENT_LIST.FIND_COURSES_PROMPT" = "هل تبحثُ عن تحدٍ جديد؟"; /* Button title opening course catalog */ -"ENROLLMENT_LIST.FIND_COURSES" = "ابحث عن مساق"; +"ENROLLMENT_LIST.FIND_COURSES" = "Explore courses"; /* Error message when user is not enrolled to any course and course discovery is also disabled.*/ "ENROLLMENT_LIST.NO_ENROLLMENT" = "يبدو أنك لم تسجل في أي مساق بعد"; /* Prompt indicating user needs to enter an email address */ @@ -780,8 +776,6 @@ "PROFILE.CURRENT_LANGUAGE_LABEL" = "اللغة الحاليّة:"; /* 'Current Location' label on top row of edit country in user's profile */ "PROFILE.CURRENT_LOCATION_LABEL" = "الموقع الحالي:"; -/* Profile edit view title */ -"PROFILE.EDIT_TITLE" = "تعديل الصفحة الشخصيّة"; /* Profile screen fields visibility off info message */ "PROFILE.VISIBILITY_OFF_MESSGAE"="معلومات ملفك الشخصي مرئية لك فقط. يظهر اسم المستخدم الخاص بك فقط للآخرين على {platform_name}."; /* Accessibility label for edit profile button */ @@ -1073,7 +1067,7 @@ /*Deprecated version detail message*/ "VERSION_UPGRADE.DEPRECATED_MESSAGE" = "نسخة جديدة من التطبيق متوفرة حالياً."; /*Outdated version detail message*/ -"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "النسخة التي لديك من التطبيق لم تعُد مدعومة."; +"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "النسخة التي لديك من التطبيق لم تعُد مدعومة"; /*Outdated version alert message for login screen*/ "VERSION_UPGRADE_OUT_DATED_LOGIN_MESSAGE" = "النسخة التي لديك من التطبيق لم تعُد مدعومة. أحصل على أحدث نسخة لتسجل الدخول."; /*New version available without deadline message*/ diff --git a/Source/de.lproj/Localizable-2.strings b/Source/de.lproj/Localizable-2.strings index 79379f405f..afccc5dfcd 100644 --- a/Source/de.lproj/Localizable-2.strings +++ b/Source/de.lproj/Localizable-2.strings @@ -13,6 +13,22 @@ /* Continue text for aleart button titles or for other places*/ "CONTINUE_TEXT"="Continue"; +/* Find a new course button title on the course dashbaord error screen */ +"COURSE_DASHBOARD.ERROR.FIND_A_NEW_COURSE"="Find a new course"; +/* Course dashbaord loading eerror */ +"COURSE_DASHBOARD.ERROR.GENERAL_ERROR"="An error occured while loading your course"; +/* Go to my courses button title on the course dashbaord general error screen */ +"COURSE_DASHBOARD.ERROR.GO_TO_COURSES"="Go to My Courses"; +/*Error message title on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_TITLE" = "This course hasn’t started yet."; +/*Error message info on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_INFO" = "Come back {start_date} to see all your course content here."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_TITLE" = "Your access to this course has expired."; +/*Error message info on course dashboard screen when audit access expired of course or course ended*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_INFO" = "No new sessions are available at this time."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ENDED_TITLE" = "This course session has ended."; /* Label for date when course starts */ "COURSE.STARTING" = "Starts {start_date}"; /* Label for date when course end */ @@ -29,6 +45,10 @@ "COURSE.AUDIT.EXPIRED_AGO" = "Access expired {time_duaration}"; /* Course audit expired on date*/ "COURSE.AUDIT.EXPIRED_ON" = "Expired on {expiry_date}"; +/* Course Reset Date Banner Header */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Missed some deadlines? "; +/* Course Reset Date Banner Body */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; /*Title for Discover*/ "DISCOVER"="Discover"; /*Title for Discovery*/ @@ -43,5 +63,19 @@ "MY_COURSES"="My courses"; /*Title My programs*/ "MY_PROGRAMS"="My programs"; +/* Title text for the Home section of the course dashboard */ +"DASHBOARD.COURSE_HOME"="Home"; +/* Title for audit exipred error */ +"COURSE_DASHBOARD.ERROR.AUDIT_EXPIRED_UPGRADE_INFO"="Upgrade to get full access to this course and pursue a certificate."; +/* Title for audit title message */ +"COURSE.AUDIT_ACCESS_EXPIRED"="Access expired {expiry_date}"; +/* Enrolled courses loading error */ +"DASHBOARD.GENERAL_ERROR_MESSAGE"="An error occured while loading your courses"; +/* Enrolled courses loading error try again*/ +"DASHBOARD.TRY_AGAIN"="Try again"; +/* Course Dashboard Resume Course button title*/ +"DASHBOARD.RESUME_COURSE"="Resume course"; +/* Course outline header gated content title */ +"COURSE_OUTLINE_HEADER.GATED_CONTENT_TITLE"="Some content in this part of the course is locked for upgraded users only."; /* None option in the caption list to de select caption language*/ "NONE"="None"; diff --git a/Source/de.lproj/Localizable.strings b/Source/de.lproj/Localizable.strings index eadd054e82..56eaf2765a 100644 --- a/Source/de.lproj/Localizable.strings +++ b/Source/de.lproj/Localizable.strings @@ -320,10 +320,6 @@ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY"="was bedeutet, dass Sie nicht an benoteten Aufgaben teilnehmen können. Es sieht so aus, als ob Sie einige wichtige Fristen verpasst haben, basierend auf unserem vorgeschlagenen Zeitplan. Um benotete Aufgaben im Rahmen dieses Kurses abzuschließen und überfällige Aufgaben in die Zukunft zu verschieben, können Sie noch heute ein Upgrade durchführen."; /* Course Reset Date Upgrade Reset Banner Button */ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON"="Upgrade to shift due dates"; -/* Course Reset Date Banner Header */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Es sieht so aus, als ob Sie einige wichtige, auf unserem vorgeschlagenen Zeitplan basierende Fristen verpasst haben. "; -/* Course Reset Date Banner Body */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Um den Zeitplan einzuhalten, können Sie ihn aktualisieren und die überfälligen Aufgaben in die Zukunft verschieben. Keine Sorge – Sie verlieren keinen Ihrer Fortschritte, wenn Sie Ihre Fälligkeitstermine verschieben."; /* Course Reset Date Banner Button */ "COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BUTTON"="Fälligkeitstermine verschieben"; /* Course Dates Title */ @@ -569,7 +565,7 @@ /* Prompt shown to users on the course list encouraging them to find new courses */ "ENROLLMENT_LIST.FIND_COURSES_PROMPT" = "Suchen Sie nach eine neuen Herausforderung?"; /* Button title opening course catalog */ -"ENROLLMENT_LIST.FIND_COURSES" = "Einen Kurs finden"; +"ENROLLMENT_LIST.FIND_COURSES" = "Explore courses"; /* Error message when user is not enrolled to any course and course discovery is also disabled.*/ "ENROLLMENT_LIST.NO_ENROLLMENT" = "Es scheint, als wären Sie noch in keinem Kurs eingeschrieben."; /* Prompt indicating user needs to enter an email address */ @@ -732,8 +728,6 @@ "PROFILE.CURRENT_LANGUAGE_LABEL" = "Aktuelle Sprache:"; /* 'Current Location' label on top row of edit country in user's profile */ "PROFILE.CURRENT_LOCATION_LABEL" = "Derzeitiger Standort:"; -/* Profile edit view title */ -"PROFILE.EDIT_TITLE" = "Profil bearbeiten"; /* Profile screen fields visibility off info message */ "PROFILE.VISIBILITY_OFF_MESSGAE"="Ihre Profilinformationen sind nur für Sie sichtbar. Nur Ihr Benutzername ist für andere auf {platform_name} sichtbar."; /* Accessibility label for edit profile button */ @@ -1009,7 +1003,7 @@ /*Deprecated version detail message*/ "VERSION_UPGRADE.DEPRECATED_MESSAGE" = "Es ist eine neue Version der App verfügbar."; /*Outdated version detail message*/ -"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "Ihre Version der App wird nicht weiter unterstützt."; +"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "Ihre Version der App wird nicht weiter unterstützt"; /*Outdated version alert message for login screen*/ "VERSION_UPGRADE_OUT_DATED_LOGIN_MESSAGE" = "Ihre Version der App wird nicht weiter unterstützt. Aktualisieren Sie auf die letzte Version, um sich anzumelden."; /*New version available without deadline message*/ diff --git a/Source/en.lproj/Localizable-2.strings b/Source/en.lproj/Localizable-2.strings index 79379f405f..7f3b9bf272 100644 --- a/Source/en.lproj/Localizable-2.strings +++ b/Source/en.lproj/Localizable-2.strings @@ -13,6 +13,22 @@ /* Continue text for aleart button titles or for other places*/ "CONTINUE_TEXT"="Continue"; +/* Find a new course button title on the course dashbaord error screen */ +"COURSE_DASHBOARD.ERROR.FIND_A_NEW_COURSE"="Find a new course"; +/* Course dashbaord loading eerror */ +"COURSE_DASHBOARD.ERROR.GENERAL_ERROR"="An error occured while loading your course"; +/* Go to my courses button title on the course dashbaord general error screen */ +"COURSE_DASHBOARD.ERROR.GO_TO_COURSES"="Go to My Courses"; +/*Error message title on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_TITLE" = "This course hasn’t started yet."; +/*Error message info on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_INFO" = "Come back {start_date} to see all your course content here."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_TITLE" = "Your access to this course has expired."; +/*Error message info on course dashboard screen when audit access expired of course or course ended*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_INFO" = "No new sessions are available at this time."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ENDED_TITLE" = "This course session has ended."; /* Label for date when course starts */ "COURSE.STARTING" = "Starts {start_date}"; /* Label for date when course end */ @@ -29,10 +45,6 @@ "COURSE.AUDIT.EXPIRED_AGO" = "Access expired {time_duaration}"; /* Course audit expired on date*/ "COURSE.AUDIT.EXPIRED_ON" = "Expired on {expiry_date}"; -/*Title for Discover*/ -"DISCOVER"="Discover"; -/*Title for Discovery*/ -"EXPLORE_THE_CATALOG"="Explore the catalog"; /*Title for dialog when user is leaving the app when tapped on link to be opened in external browser*/ "LEAVING_APP_TITLE"="Leaving the app"; /* Message for dialog when user is leaving the app when tapped on link to be opened in external browser */ @@ -43,5 +55,23 @@ "MY_COURSES"="My courses"; /*Title My programs*/ "MY_PROGRAMS"="My programs"; +/*Title for Discovery*/ +"EXPLORE_THE_CATALOG"="Explore the catalog"; +/*Title for Discover*/ +"DISCOVER"="Discover"; +/* Title text for the Home section of the course dashboard */ +"DASHBOARD.COURSE_HOME"="Home"; +/* Title for audit exipred error */ +"COURSE_DASHBOARD.ERROR.AUDIT_EXPIRED_UPGRADE_INFO"="Upgrade to get full access to this course and pursue a certificate."; +/* Title for audit title message */ +"COURSE.AUDIT_ACCESS_EXPIRED"="Access expired {expiry_date}"; +/* Enrolled courses loading error */ +"DASHBOARD.GENERAL_ERROR_MESSAGE"="An error occured while loading your courses"; +/* Enrolled courses loading error try again*/ +"DASHBOARD.TRY_AGAIN"="Try again"; +/* Course Dashboard Resume Course button title*/ +"DASHBOARD.RESUME_COURSE"="Resume course"; +/* Course outline header gated content title */ +"COURSE_OUTLINE_HEADER.GATED_CONTENT_TITLE"="Some content in this part of the course is locked for upgraded users only."; /* None option in the caption list to de select caption language*/ "NONE"="None"; diff --git a/Source/en.lproj/Localizable.strings b/Source/en.lproj/Localizable.strings index bdbfcd6f45..43f7e55896 100644 --- a/Source/en.lproj/Localizable.strings +++ b/Source/en.lproj/Localizable.strings @@ -321,9 +321,9 @@ /* Course Reset Date Upgrade Reset Banner Button */ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON"="Upgrade to shift due dates"; /* Course Reset Date Banner Header */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="It looks like you missed some important deadlines based on our suggested schedule. "; +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Missed some deadlines?"; /* Course Reset Date Banner Body */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates."; +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; /* Course Reset Date Banner Button */ "COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BUTTON"="Shift due dates"; /* Course Dates Title */ @@ -569,7 +569,7 @@ /* Prompt shown to users on the course list encouraging them to find new courses */ "ENROLLMENT_LIST.FIND_COURSES_PROMPT" = "Looking for a new challenge?"; /* Button title opening course catalog */ -"ENROLLMENT_LIST.FIND_COURSES" = "Find a course"; +"ENROLLMENT_LIST.FIND_COURSES" = "Explore courses"; /* Error message when user is not enrolled to any course and course discovery is also disabled.*/ "ENROLLMENT_LIST.NO_ENROLLMENT" = "It looks like you are not enrolled in any courses yet."; /* Prompt indicating user needs to enter an email address */ @@ -732,8 +732,6 @@ "PROFILE.CURRENT_LANGUAGE_LABEL" = "Current language:"; /* 'Current Location' label on top row of edit country in user's profile */ "PROFILE.CURRENT_LOCATION_LABEL" = "Current location:"; -/* Profile edit view title */ -"PROFILE.EDIT_TITLE" = "Edit profile"; /* Profile screen fields visibility off info message */ "PROFILE.VISIBILITY_OFF_MESSGAE"="Your profile information is only visible to you. Only your username is visible to others on {platform_name}."; /* Accessibility label for edit profile button */ @@ -1011,7 +1009,7 @@ /*Deprecated version detail message*/ "VERSION_UPGRADE.DEPRECATED_MESSAGE" = "A new version of the app is available."; /*Outdated version detail message*/ -"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "Your version of the app is no longer supported."; +"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "Your version of the app is no longer supported"; /*Outdated version alert message for login screen*/ "VERSION_UPGRADE_OUT_DATED_LOGIN_MESSAGE" = "Your version of the app is no longer supported. Update to the latest version to login."; /*New version available without deadline message*/ diff --git a/Source/es-419.lproj/Localizable-2.strings b/Source/es-419.lproj/Localizable-2.strings index 79379f405f..afccc5dfcd 100644 --- a/Source/es-419.lproj/Localizable-2.strings +++ b/Source/es-419.lproj/Localizable-2.strings @@ -13,6 +13,22 @@ /* Continue text for aleart button titles or for other places*/ "CONTINUE_TEXT"="Continue"; +/* Find a new course button title on the course dashbaord error screen */ +"COURSE_DASHBOARD.ERROR.FIND_A_NEW_COURSE"="Find a new course"; +/* Course dashbaord loading eerror */ +"COURSE_DASHBOARD.ERROR.GENERAL_ERROR"="An error occured while loading your course"; +/* Go to my courses button title on the course dashbaord general error screen */ +"COURSE_DASHBOARD.ERROR.GO_TO_COURSES"="Go to My Courses"; +/*Error message title on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_TITLE" = "This course hasn’t started yet."; +/*Error message info on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_INFO" = "Come back {start_date} to see all your course content here."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_TITLE" = "Your access to this course has expired."; +/*Error message info on course dashboard screen when audit access expired of course or course ended*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_INFO" = "No new sessions are available at this time."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ENDED_TITLE" = "This course session has ended."; /* Label for date when course starts */ "COURSE.STARTING" = "Starts {start_date}"; /* Label for date when course end */ @@ -29,6 +45,10 @@ "COURSE.AUDIT.EXPIRED_AGO" = "Access expired {time_duaration}"; /* Course audit expired on date*/ "COURSE.AUDIT.EXPIRED_ON" = "Expired on {expiry_date}"; +/* Course Reset Date Banner Header */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Missed some deadlines? "; +/* Course Reset Date Banner Body */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; /*Title for Discover*/ "DISCOVER"="Discover"; /*Title for Discovery*/ @@ -43,5 +63,19 @@ "MY_COURSES"="My courses"; /*Title My programs*/ "MY_PROGRAMS"="My programs"; +/* Title text for the Home section of the course dashboard */ +"DASHBOARD.COURSE_HOME"="Home"; +/* Title for audit exipred error */ +"COURSE_DASHBOARD.ERROR.AUDIT_EXPIRED_UPGRADE_INFO"="Upgrade to get full access to this course and pursue a certificate."; +/* Title for audit title message */ +"COURSE.AUDIT_ACCESS_EXPIRED"="Access expired {expiry_date}"; +/* Enrolled courses loading error */ +"DASHBOARD.GENERAL_ERROR_MESSAGE"="An error occured while loading your courses"; +/* Enrolled courses loading error try again*/ +"DASHBOARD.TRY_AGAIN"="Try again"; +/* Course Dashboard Resume Course button title*/ +"DASHBOARD.RESUME_COURSE"="Resume course"; +/* Course outline header gated content title */ +"COURSE_OUTLINE_HEADER.GATED_CONTENT_TITLE"="Some content in this part of the course is locked for upgraded users only."; /* None option in the caption list to de select caption language*/ "NONE"="None"; diff --git a/Source/es-419.lproj/Localizable.strings b/Source/es-419.lproj/Localizable.strings index c2bec81a9a..868ad6ba5d 100644 --- a/Source/es-419.lproj/Localizable.strings +++ b/Source/es-419.lproj/Localizable.strings @@ -320,10 +320,6 @@ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY"="lo que significa que no puedes participar en tareas calificadas. Parece que no cumpliste con algunos plazos importantes según nuestro calendario sugerido. Para completar las asignaciones calificadas como parte de este curso y cambiar las asignaciones vencidas al futuro, puedes cambiarte a la opción paga."; /* Course Reset Date Upgrade Reset Banner Button */ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON"="Cambiate a la opción paga para poder cambiar fechas de entrega"; -/* Course Reset Date Banner Header */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Parece que no has cumplido con algunos plazos importantes según nuestro calendario sugerido."; -/* Course Reset Date Banner Body */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Para mantenerte en el camino adecuado, puedes actualizar este calendario y mover las tareas vencidas hacia el futuro. No te preocupes, no perderás nada de lo que hayas avanzado cuando cambies las fechas de entrega."; /* Course Reset Date Banner Button */ "COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BUTTON"="Cambia las fechas de entrega"; /* Course Dates Title */ @@ -569,7 +565,7 @@ /* Prompt shown to users on the course list encouraging them to find new courses */ "ENROLLMENT_LIST.FIND_COURSES_PROMPT" = "¿Buscando un nuevo desafío?"; /* Button title opening course catalog */ -"ENROLLMENT_LIST.FIND_COURSES" = "Encuentre un curso"; +"ENROLLMENT_LIST.FIND_COURSES" = "Explore courses"; /* Error message when user is not enrolled to any course and course discovery is also disabled.*/ "ENROLLMENT_LIST.NO_ENROLLMENT" = "Parece que todavía no está inscrito en ningún curso."; /* Prompt indicating user needs to enter an email address */ @@ -732,8 +728,6 @@ "PROFILE.CURRENT_LANGUAGE_LABEL" = "Idioma actual:"; /* 'Current Location' label on top row of edit country in user's profile */ "PROFILE.CURRENT_LOCATION_LABEL" = "Ubicación actual:"; -/* Profile edit view title */ -"PROFILE.EDIT_TITLE" = "Editar perfil"; /* Profile screen fields visibility off info message */ "PROFILE.VISIBILITY_OFF_MESSGAE"="Your profile information is only visible to you. Only your username is visible to others on {platform_name}."; /* Accessibility label for edit profile button */ @@ -1015,7 +1009,7 @@ /*Deprecated version detail message*/ "VERSION_UPGRADE.DEPRECATED_MESSAGE" = "Una versión nueva de la app está disponible."; /*Outdated version detail message*/ -"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "Su versión de la app ya no está soportada."; +"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "Su versión de la app ya no está soportada"; /*Outdated version alert message for login screen*/ "VERSION_UPGRADE_OUT_DATED_LOGIN_MESSAGE" = "Su versión de la aplicación ya no es compatible. Actualice a la última versión para iniciar sesión."; /*New version available without deadline message*/ diff --git a/Source/fr.lproj/Localizable-2.strings b/Source/fr.lproj/Localizable-2.strings index 79379f405f..27d88652b3 100644 --- a/Source/fr.lproj/Localizable-2.strings +++ b/Source/fr.lproj/Localizable-2.strings @@ -13,6 +13,22 @@ /* Continue text for aleart button titles or for other places*/ "CONTINUE_TEXT"="Continue"; +/* Find a new course button title on the course dashbaord error screen */ +"COURSE_DASHBOARD.ERROR.FIND_A_NEW_COURSE"="Find a new course"; +/* Course dashbaord loading eerror */ +"COURSE_DASHBOARD.ERROR.GENERAL_ERROR"="An error occured while loading your course"; +/* Go to my courses button title on the course dashbaord general error screen */ +"COURSE_DASHBOARD.ERROR.GO_TO_COURSES"="Go to My Courses"; +/*Error message title on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_TITLE" = "This course hasn’t started yet."; +/*Error message info on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_INFO" = "Come back {start_date} to see all your course content here."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_TITLE" = "Your access to this course has expired."; +/*Error message info on course dashboard screen when audit access expired of course or course ended*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_INFO" = "No new sessions are available at this time."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ENDED_TITLE" = "This course session has ended."; /* Label for date when course starts */ "COURSE.STARTING" = "Starts {start_date}"; /* Label for date when course end */ @@ -29,10 +45,10 @@ "COURSE.AUDIT.EXPIRED_AGO" = "Access expired {time_duaration}"; /* Course audit expired on date*/ "COURSE.AUDIT.EXPIRED_ON" = "Expired on {expiry_date}"; -/*Title for Discover*/ -"DISCOVER"="Discover"; -/*Title for Discovery*/ -"EXPLORE_THE_CATALOG"="Explore the catalog"; +/* Course Reset Date Banner Header */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Missed some deadlines? "; +/* Course Reset Date Banner Body */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; /*Title for dialog when user is leaving the app when tapped on link to be opened in external browser*/ "LEAVING_APP_TITLE"="Leaving the app"; /* Message for dialog when user is leaving the app when tapped on link to be opened in external browser */ @@ -43,5 +59,23 @@ "MY_COURSES"="My courses"; /*Title My programs*/ "MY_PROGRAMS"="My programs"; +/*Title for Discovery*/ +"EXPLORE_THE_CATALOG"="Explore the catalog"; +/*Title for Discover*/ +"DISCOVER"="Discover"; +/* Title text for the Home section of the course dashboard */ +"DASHBOARD.COURSE_HOME"="Home"; +/* Title for audit exipred error */ +"COURSE_DASHBOARD.ERROR.AUDIT_EXPIRED_UPGRADE_INFO"="Upgrade to get full access to this course and pursue a certificate."; +/* Title for audit title message */ +"COURSE.AUDIT_ACCESS_EXPIRED"="Access expired {expiry_date}"; +/* Enrolled courses loading error */ +"DASHBOARD.GENERAL_ERROR_MESSAGE"="An error occured while loading your courses"; +/* Enrolled courses loading error try again*/ +"DASHBOARD.TRY_AGAIN"="Try again"; +/* Course Dashboard Resume Course button title*/ +"DASHBOARD.RESUME_COURSE"="Resume course"; +/* Course outline header gated content title */ +"COURSE_OUTLINE_HEADER.GATED_CONTENT_TITLE"="Some content in this part of the course is locked for upgraded users only."; /* None option in the caption list to de select caption language*/ "NONE"="None"; diff --git a/Source/fr.lproj/Localizable.strings b/Source/fr.lproj/Localizable.strings index 24287686c0..d3eb51f33c 100644 --- a/Source/fr.lproj/Localizable.strings +++ b/Source/fr.lproj/Localizable.strings @@ -320,10 +320,6 @@ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY"="ce qui signifie que vous ne pouvez pas participer aux devoirs notés. Il semblerait que vous ayez manqué des échéances importantes de notre programme suggéré. Pour faire les devoirs notés dans le cadre de ce cours et pouvoir rendre les devoirs passés un peu plus tard, vous pouvez mettre à niveau votre inscription dès aujourd'hui."; /* Course Reset Date Upgrade Reset Banner Button */ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON"="Upgrade to shift due dates"; -/* Course Reset Date Banner Header */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Il semblerait que vous ayez manqué des échéances importantes de notre programme suggéré. "; -/* Course Reset Date Banner Body */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Pour rester sur la bonne voie, vous pouvez mettre à jour ce programme et rendre les devoirs passés un peu plus tard. Ne vous inquiétez pas : en modifiant les échéances, vous ne perdrez pas les progrès que vous avez faits."; /* Course Reset Date Banner Button */ "COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BUTTON"="Déplacer les dates d'échéances"; /* Course Dates Title */ @@ -569,7 +565,7 @@ /* Prompt shown to users on the course list encouraging them to find new courses */ "ENROLLMENT_LIST.FIND_COURSES_PROMPT" = "À la recherche d'un nouveau challenge?"; /* Button title opening course catalog */ -"ENROLLMENT_LIST.FIND_COURSES" = "Trouver un cours"; +"ENROLLMENT_LIST.FIND_COURSES" = "Explore courses"; /* Error message when user is not enrolled to any course and course discovery is also disabled.*/ "ENROLLMENT_LIST.NO_ENROLLMENT" = "Vous n'êtes inscrit à aucun cours."; /* Prompt indicating user needs to enter an email address */ @@ -732,8 +728,6 @@ "PROFILE.CURRENT_LANGUAGE_LABEL" = "Langue actuelle:"; /* 'Current Location' label on top row of edit country in user's profile */ "PROFILE.CURRENT_LOCATION_LABEL" = "Lieu actuel:"; -/* Profile edit view title */ -"PROFILE.EDIT_TITLE" = "Modifier le profil"; /* Profile screen fields visibility off info message */ "PROFILE.VISIBILITY_OFF_MESSGAE"="Les informations sur votre profil ne sont visibles que par vous. Seul votre nom d'utilisateur est visible par les autres sur {platform_name}."; /* Accessibility label for edit profile button */ @@ -1009,7 +1003,7 @@ /*Deprecated version detail message*/ "VERSION_UPGRADE.DEPRECATED_MESSAGE" = "Une nouvelle version est disponible."; /*Outdated version detail message*/ -"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "Votre version n'est plus supportée."; +"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "Votre version n'est plus supportée"; /*Outdated version alert message for login screen*/ "VERSION_UPGRADE_OUT_DATED_LOGIN_MESSAGE" = "Votre version n'est plus supportée. Mettez à jour avec la dernière version pour vous connecter."; /*New version available without deadline message*/ diff --git a/Source/he.lproj/Localizable-2.strings b/Source/he.lproj/Localizable-2.strings index 79379f405f..27d88652b3 100644 --- a/Source/he.lproj/Localizable-2.strings +++ b/Source/he.lproj/Localizable-2.strings @@ -13,6 +13,22 @@ /* Continue text for aleart button titles or for other places*/ "CONTINUE_TEXT"="Continue"; +/* Find a new course button title on the course dashbaord error screen */ +"COURSE_DASHBOARD.ERROR.FIND_A_NEW_COURSE"="Find a new course"; +/* Course dashbaord loading eerror */ +"COURSE_DASHBOARD.ERROR.GENERAL_ERROR"="An error occured while loading your course"; +/* Go to my courses button title on the course dashbaord general error screen */ +"COURSE_DASHBOARD.ERROR.GO_TO_COURSES"="Go to My Courses"; +/*Error message title on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_TITLE" = "This course hasn’t started yet."; +/*Error message info on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_INFO" = "Come back {start_date} to see all your course content here."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_TITLE" = "Your access to this course has expired."; +/*Error message info on course dashboard screen when audit access expired of course or course ended*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_INFO" = "No new sessions are available at this time."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ENDED_TITLE" = "This course session has ended."; /* Label for date when course starts */ "COURSE.STARTING" = "Starts {start_date}"; /* Label for date when course end */ @@ -29,10 +45,10 @@ "COURSE.AUDIT.EXPIRED_AGO" = "Access expired {time_duaration}"; /* Course audit expired on date*/ "COURSE.AUDIT.EXPIRED_ON" = "Expired on {expiry_date}"; -/*Title for Discover*/ -"DISCOVER"="Discover"; -/*Title for Discovery*/ -"EXPLORE_THE_CATALOG"="Explore the catalog"; +/* Course Reset Date Banner Header */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Missed some deadlines? "; +/* Course Reset Date Banner Body */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; /*Title for dialog when user is leaving the app when tapped on link to be opened in external browser*/ "LEAVING_APP_TITLE"="Leaving the app"; /* Message for dialog when user is leaving the app when tapped on link to be opened in external browser */ @@ -43,5 +59,23 @@ "MY_COURSES"="My courses"; /*Title My programs*/ "MY_PROGRAMS"="My programs"; +/*Title for Discovery*/ +"EXPLORE_THE_CATALOG"="Explore the catalog"; +/*Title for Discover*/ +"DISCOVER"="Discover"; +/* Title text for the Home section of the course dashboard */ +"DASHBOARD.COURSE_HOME"="Home"; +/* Title for audit exipred error */ +"COURSE_DASHBOARD.ERROR.AUDIT_EXPIRED_UPGRADE_INFO"="Upgrade to get full access to this course and pursue a certificate."; +/* Title for audit title message */ +"COURSE.AUDIT_ACCESS_EXPIRED"="Access expired {expiry_date}"; +/* Enrolled courses loading error */ +"DASHBOARD.GENERAL_ERROR_MESSAGE"="An error occured while loading your courses"; +/* Enrolled courses loading error try again*/ +"DASHBOARD.TRY_AGAIN"="Try again"; +/* Course Dashboard Resume Course button title*/ +"DASHBOARD.RESUME_COURSE"="Resume course"; +/* Course outline header gated content title */ +"COURSE_OUTLINE_HEADER.GATED_CONTENT_TITLE"="Some content in this part of the course is locked for upgraded users only."; /* None option in the caption list to de select caption language*/ "NONE"="None"; diff --git a/Source/he.lproj/Localizable.strings b/Source/he.lproj/Localizable.strings index 7db3ebfcbb..ae23b1a2c2 100644 --- a/Source/he.lproj/Localizable.strings +++ b/Source/he.lproj/Localizable.strings @@ -328,10 +328,6 @@ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY"="מה שאומר שאינך יכול להשתתף במטלות מדורגות. נראה שהחמצת כמה מועדים חשובים בהתבסס על לוח הזמנים המוצע שלנו. כדי להשלים מטלות מדורגות כחלק מהקורס הזה ולהעביר את המטלות שנקבעו בעבר אל העתיד, אתה יכול לשדרג היום."; /* Course Reset Date Upgrade Reset Banner Button */ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON"="Upgrade to shift due dates"; -/* Course Reset Date Banner Header */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="נראה שהחמצת כמה מועדים חשובים בהתבסס על לוח הזמנים המוצע שלנו. "; -/* Course Reset Date Banner Body */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="כדי לשמור על עצמכם על המסלול, תוכלו לעדכן את לוח הזמנים הזה ולהעביר את ההקצאות שהוגשו לעתיד. אל דאגה - לא תאבד שום התקדמות שהשגת כאשר תשנה את תאריכי היעד שלך."; /* Course Reset Date Banner Button */ "COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BUTTON"="תאריכי יעד של משמרת"; /* Course Dates Title */ @@ -589,7 +585,7 @@ /* Prompt shown to users on the course list encouraging them to find new courses */ "ENROLLMENT_LIST.FIND_COURSES_PROMPT" = "מחפשים אתגר חדש?"; /* Button title opening course catalog */ -"ENROLLMENT_LIST.FIND_COURSES" = "מצא קורס"; +"ENROLLMENT_LIST.FIND_COURSES" = "Explore courses"; /* Error message when user is not enrolled to any course and course discovery is also disabled.*/ "ENROLLMENT_LIST.NO_ENROLLMENT" = "נראה שאתם עדיין לא רשומים בשום קורס."; /* Prompt indicating user needs to enter an email address */ @@ -750,8 +746,6 @@ "PROFILE.CURRENT_LANGUAGE_LABEL" = "שפה נוכחית:"; /* 'Current Location' label on top row of edit country in user's profile */ "PROFILE.CURRENT_LOCATION_LABEL" = "מיקום נוכחי:"; -/* Profile edit view title */ -"PROFILE.EDIT_TITLE" = "ערוך פרופיל"; /* Profile screen fields visibility off info message */ "PROFILE.VISIBILITY_OFF_MESSGAE"="פרטי הפרופיל שלך גלויים רק לך. רק שם המשתמש שלך גלוי לאחרים ב-{platform_name}."; /* Accessibility label for edit profile button */ @@ -1035,7 +1029,7 @@ /*Deprecated version detail message*/ "VERSION_UPGRADE.DEPRECATED_MESSAGE" = "גרסה חדשה של האפליקציה זמינה להורדה."; /*Outdated version detail message*/ -"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "גרסת האפליקציה שברשותך לא נתמכת עוד."; +"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "גרסת האפליקציה שברשותך לא נתמכת עוד"; /*Outdated version alert message for login screen*/ "VERSION_UPGRADE_OUT_DATED_LOGIN_MESSAGE" = "גרסת האפליקציה שברשותכם אינה נתמכת עוד. עדכנו לגרסה האחרונה כדי להיכנס."; /*New version available without deadline message*/ diff --git a/Source/ja.lproj/Localizable-2.strings b/Source/ja.lproj/Localizable-2.strings index 48721ee964..14bd0ab70b 100644 --- a/Source/ja.lproj/Localizable-2.strings +++ b/Source/ja.lproj/Localizable-2.strings @@ -13,6 +13,24 @@ /* Continue text for aleart button titles or for other places*/ "CONTINUE_TEXT"="Continue"; +/* Find a new course button title on the course dashbaord error screen */ +"COURSE_DASHBOARD.ERROR.FIND_A_NEW_COURSE"="Find a new course"; +/* Course dashbaord loading eerror */ +"COURSE_DASHBOARD.ERROR.GENERAL_ERROR"="An error occured while loading your course"; +/* Go to my courses button title on the course dashbaord general error screen */ +"COURSE_DASHBOARD.ERROR.GO_TO_COURSES"="Go to My Courses"; +/*Error message title on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_TITLE" = "This course hasn’t started yet."; +/*Error message info on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_INFO" = "Come back {start_date} to see all your course content here."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_TITLE" = "Your access to this course has expired."; +/*Error message info on course dashboard screen when audit access expired of course or course ended*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_INFO" = "No new sessions are available at this time."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ENDED_TITLE" = "This course session has ended."; +/*Title for Discover*/ +"DISCOVER"="Discover"; /* Label for date when course starts */ "COURSE.STARTING" = "Starts {start_date}"; /* Label for date when course end */ @@ -29,10 +47,10 @@ "COURSE.AUDIT.EXPIRED_AGO" = "Access expired {time_duaration}"; /* Course audit expired on date*/ "COURSE.AUDIT.EXPIRED_ON" = "Expired on {expiry_date}"; -/*Title for Discover*/ -"DISCOVER"="Discovery"; -/*Title for Discovery*/ -"EXPLORE_THE_CATALOG"="Explore the catalog"; +/* Course Reset Date Banner Header */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Missed some deadlines? "; +/* Course Reset Date Banner Body */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; /*Title for dialog when user is leaving the app when tapped on link to be opened in external browser*/ "LEAVING_APP_TITLE"="Leaving the app"; /* Message for dialog when user is leaving the app when tapped on link to be opened in external browser */ @@ -43,5 +61,23 @@ "MY_COURSES"="My courses"; /*Title My programs*/ "MY_PROGRAMS"="My programs"; +/*Title for Discovery*/ +"EXPLORE_THE_CATALOG"="Explore the catalog"; +/*Title for Discover*/ +"DISCOVER"="Discovery"; +/* Title text for the Home section of the course dashboard */ +"DASHBOARD.COURSE_HOME"="Home"; +/* Title for audit exipred error */ +"COURSE_DASHBOARD.ERROR.AUDIT_EXPIRED_UPGRADE_INFO"="Upgrade to get full access to this course and pursue a certificate."; +/* Title for audit title message */ +"COURSE.AUDIT_ACCESS_EXPIRED"="Access expired {expiry_date}"; +/* Enrolled courses loading error */ +"DASHBOARD.GENERAL_ERROR_MESSAGE"="An error occured while loading your courses"; +/* Enrolled courses loading error try again*/ +"DASHBOARD.TRY_AGAIN"="Try again"; +/* Course Dashboard Resume Course button title*/ +"DASHBOARD.RESUME_COURSE"="Resume course"; +/* Course outline header gated content title */ +"COURSE_OUTLINE_HEADER.GATED_CONTENT_TITLE"="Some content in this part of the course is locked for upgraded users only."; /* None option in the caption list to de select caption language*/ "NONE"="None"; diff --git a/Source/ja.lproj/Localizable.strings b/Source/ja.lproj/Localizable.strings index e79d007c88..40a07b01ef 100644 --- a/Source/ja.lproj/Localizable.strings +++ b/Source/ja.lproj/Localizable.strings @@ -316,10 +316,6 @@ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY"="採点対象の課題には参加できません。推奨スケジュールにある重要な期限が守られなかったようです。このコースで採点対象の課題を完了し、過去の期限付き課題を未来日付に移動させるには、アップグレードしてください。"; /* Course Reset Date Upgrade Reset Banner Button */ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON"="Upgrade to shift due dates"; -/* Course Reset Date Banner Header */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="推奨スケジュールにある重要な期限が守られなかったようです。"; -/* Course Reset Date Banner Body */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="このスケジュールを更新し、過去の課題を未来日付に移動させることで、学習を計画通りに進めることができます。期限をずらしてもこれまでの進捗が失われることはありませんので、ご安心ください。"; /* Course Reset Date Banner Button */ "COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BUTTON"="期限を移動"; /* Course Dates Title */ @@ -560,7 +556,7 @@ /* Prompt shown to users on the course list encouraging them to find new courses */ "ENROLLMENT_LIST.FIND_COURSES_PROMPT" = "新しいチャレンジを探していますか?"; /* Button title opening course catalog */ -"ENROLLMENT_LIST.FIND_COURSES" = "講座を探す"; +"ENROLLMENT_LIST.FIND_COURSES" = "Explore courses"; /* Error message when user is not enrolled to any course and course discovery is also disabled.*/ "ENROLLMENT_LIST.NO_ENROLLMENT" = "あなたはまだどの講座にも受講登録をしていないようです。"; /* Prompt indicating user needs to enter an email address */ diff --git a/Source/pt-BR.lproj/Localizable-2.strings b/Source/pt-BR.lproj/Localizable-2.strings index 79379f405f..27d88652b3 100644 --- a/Source/pt-BR.lproj/Localizable-2.strings +++ b/Source/pt-BR.lproj/Localizable-2.strings @@ -13,6 +13,22 @@ /* Continue text for aleart button titles or for other places*/ "CONTINUE_TEXT"="Continue"; +/* Find a new course button title on the course dashbaord error screen */ +"COURSE_DASHBOARD.ERROR.FIND_A_NEW_COURSE"="Find a new course"; +/* Course dashbaord loading eerror */ +"COURSE_DASHBOARD.ERROR.GENERAL_ERROR"="An error occured while loading your course"; +/* Go to my courses button title on the course dashbaord general error screen */ +"COURSE_DASHBOARD.ERROR.GO_TO_COURSES"="Go to My Courses"; +/*Error message title on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_TITLE" = "This course hasn’t started yet."; +/*Error message info on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_INFO" = "Come back {start_date} to see all your course content here."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_TITLE" = "Your access to this course has expired."; +/*Error message info on course dashboard screen when audit access expired of course or course ended*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_INFO" = "No new sessions are available at this time."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ENDED_TITLE" = "This course session has ended."; /* Label for date when course starts */ "COURSE.STARTING" = "Starts {start_date}"; /* Label for date when course end */ @@ -29,10 +45,10 @@ "COURSE.AUDIT.EXPIRED_AGO" = "Access expired {time_duaration}"; /* Course audit expired on date*/ "COURSE.AUDIT.EXPIRED_ON" = "Expired on {expiry_date}"; -/*Title for Discover*/ -"DISCOVER"="Discover"; -/*Title for Discovery*/ -"EXPLORE_THE_CATALOG"="Explore the catalog"; +/* Course Reset Date Banner Header */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Missed some deadlines? "; +/* Course Reset Date Banner Body */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; /*Title for dialog when user is leaving the app when tapped on link to be opened in external browser*/ "LEAVING_APP_TITLE"="Leaving the app"; /* Message for dialog when user is leaving the app when tapped on link to be opened in external browser */ @@ -43,5 +59,23 @@ "MY_COURSES"="My courses"; /*Title My programs*/ "MY_PROGRAMS"="My programs"; +/*Title for Discovery*/ +"EXPLORE_THE_CATALOG"="Explore the catalog"; +/*Title for Discover*/ +"DISCOVER"="Discover"; +/* Title text for the Home section of the course dashboard */ +"DASHBOARD.COURSE_HOME"="Home"; +/* Title for audit exipred error */ +"COURSE_DASHBOARD.ERROR.AUDIT_EXPIRED_UPGRADE_INFO"="Upgrade to get full access to this course and pursue a certificate."; +/* Title for audit title message */ +"COURSE.AUDIT_ACCESS_EXPIRED"="Access expired {expiry_date}"; +/* Enrolled courses loading error */ +"DASHBOARD.GENERAL_ERROR_MESSAGE"="An error occured while loading your courses"; +/* Enrolled courses loading error try again*/ +"DASHBOARD.TRY_AGAIN"="Try again"; +/* Course Dashboard Resume Course button title*/ +"DASHBOARD.RESUME_COURSE"="Resume course"; +/* Course outline header gated content title */ +"COURSE_OUTLINE_HEADER.GATED_CONTENT_TITLE"="Some content in this part of the course is locked for upgraded users only."; /* None option in the caption list to de select caption language*/ "NONE"="None"; diff --git a/Source/pt-BR.lproj/Localizable.strings b/Source/pt-BR.lproj/Localizable.strings index 2df9f9f8a5..9a16b39e84 100644 --- a/Source/pt-BR.lproj/Localizable.strings +++ b/Source/pt-BR.lproj/Localizable.strings @@ -321,10 +321,6 @@ Se você não conseguir acompanhar tudo nas datas sugeridas, você poderá ajust "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY"="o que significa que você não pode participar em avaliações. Para completar avaliações como uma parte deste curso, faça o upgrade hoje. Parece que você perdeu alguns prazos importantes baseados em nossa programação sugerida. Para completar avaliações como parte deste curso e adiar tarefas já atrasadas, faça o upgrade hoje."; /* Course Reset Date Upgrade Reset Banner Button */ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON"="Upgrade to shift due dates"; -/* Course Reset Date Banner Header */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Parece que você perdeu alguns prazos importantes baseados em nossa programação sugerida. "; -/* Course Reset Date Banner Body */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Para se ficar em dia, você pode atualizar esta programação e adiar as tarefas já atrasadas. Não se preocupe – você não perderá o progresso já feito."; /* Course Reset Date Banner Button */ "COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BUTTON"="Alterar datas de entrega"; /* Course Dates Title */ @@ -570,7 +566,7 @@ Se você não conseguir acompanhar tudo nas datas sugeridas, você poderá ajust /* Prompt shown to users on the course list encouraging them to find new courses */ "ENROLLMENT_LIST.FIND_COURSES_PROMPT" = "À procura de novos desafios?"; /* Button title opening course catalog */ -"ENROLLMENT_LIST.FIND_COURSES" = "Encontre um Curso"; +"ENROLLMENT_LIST.FIND_COURSES" = "Explore courses"; /* Error message when user is not enrolled to any course and course discovery is also disabled.*/ "ENROLLMENT_LIST.NO_ENROLLMENT" = "Parece que você não se inscreveu em nenhum curso ainda."; /* Prompt indicating user needs to enter an email address */ @@ -733,8 +729,6 @@ Se você não conseguir acompanhar tudo nas datas sugeridas, você poderá ajust "PROFILE.CURRENT_LANGUAGE_LABEL" = "Idioma atual:"; /* 'Current Location' label on top row of edit country in user's profile */ "PROFILE.CURRENT_LOCATION_LABEL" = "Local atual:"; -/* Profile edit view title */ -"PROFILE.EDIT_TITLE" = "Editar perfil"; /* Profile screen fields visibility off info message */ "PROFILE.VISIBILITY_OFF_MESSGAE"="As informações do seu perfil só ficam visíveis para você. Somente seu nome de usuário pode ser visto por outros usuários no {platform_name}."; /* Accessibility label for edit profile button */ @@ -1010,7 +1004,7 @@ Se você não conseguir acompanhar tudo nas datas sugeridas, você poderá ajust /*Deprecated version detail message*/ "VERSION_UPGRADE.DEPRECATED_MESSAGE" = "Uma nova versão do aplicativo está disponível."; /*Outdated version detail message*/ -"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "A versão do seu aplicativo não é mais compatível. "; +"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "A versão do seu aplicativo não é mais compatível"; /*Outdated version alert message for login screen*/ "VERSION_UPGRADE_OUT_DATED_LOGIN_MESSAGE" = "A versão do seu aplicativa não é mais compativel. Atualize para a última versão para entrar. "; /*New version available without deadline message*/ diff --git a/Source/tr.lproj/Localizable-2.strings b/Source/tr.lproj/Localizable-2.strings index 79379f405f..27d88652b3 100644 --- a/Source/tr.lproj/Localizable-2.strings +++ b/Source/tr.lproj/Localizable-2.strings @@ -13,6 +13,22 @@ /* Continue text for aleart button titles or for other places*/ "CONTINUE_TEXT"="Continue"; +/* Find a new course button title on the course dashbaord error screen */ +"COURSE_DASHBOARD.ERROR.FIND_A_NEW_COURSE"="Find a new course"; +/* Course dashbaord loading eerror */ +"COURSE_DASHBOARD.ERROR.GENERAL_ERROR"="An error occured while loading your course"; +/* Go to my courses button title on the course dashbaord general error screen */ +"COURSE_DASHBOARD.ERROR.GO_TO_COURSES"="Go to My Courses"; +/*Error message title on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_TITLE" = "This course hasn’t started yet."; +/*Error message info on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_INFO" = "Come back {start_date} to see all your course content here."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_TITLE" = "Your access to this course has expired."; +/*Error message info on course dashboard screen when audit access expired of course or course ended*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_INFO" = "No new sessions are available at this time."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ENDED_TITLE" = "This course session has ended."; /* Label for date when course starts */ "COURSE.STARTING" = "Starts {start_date}"; /* Label for date when course end */ @@ -29,10 +45,10 @@ "COURSE.AUDIT.EXPIRED_AGO" = "Access expired {time_duaration}"; /* Course audit expired on date*/ "COURSE.AUDIT.EXPIRED_ON" = "Expired on {expiry_date}"; -/*Title for Discover*/ -"DISCOVER"="Discover"; -/*Title for Discovery*/ -"EXPLORE_THE_CATALOG"="Explore the catalog"; +/* Course Reset Date Banner Header */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Missed some deadlines? "; +/* Course Reset Date Banner Body */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; /*Title for dialog when user is leaving the app when tapped on link to be opened in external browser*/ "LEAVING_APP_TITLE"="Leaving the app"; /* Message for dialog when user is leaving the app when tapped on link to be opened in external browser */ @@ -43,5 +59,23 @@ "MY_COURSES"="My courses"; /*Title My programs*/ "MY_PROGRAMS"="My programs"; +/*Title for Discovery*/ +"EXPLORE_THE_CATALOG"="Explore the catalog"; +/*Title for Discover*/ +"DISCOVER"="Discover"; +/* Title text for the Home section of the course dashboard */ +"DASHBOARD.COURSE_HOME"="Home"; +/* Title for audit exipred error */ +"COURSE_DASHBOARD.ERROR.AUDIT_EXPIRED_UPGRADE_INFO"="Upgrade to get full access to this course and pursue a certificate."; +/* Title for audit title message */ +"COURSE.AUDIT_ACCESS_EXPIRED"="Access expired {expiry_date}"; +/* Enrolled courses loading error */ +"DASHBOARD.GENERAL_ERROR_MESSAGE"="An error occured while loading your courses"; +/* Enrolled courses loading error try again*/ +"DASHBOARD.TRY_AGAIN"="Try again"; +/* Course Dashboard Resume Course button title*/ +"DASHBOARD.RESUME_COURSE"="Resume course"; +/* Course outline header gated content title */ +"COURSE_OUTLINE_HEADER.GATED_CONTENT_TITLE"="Some content in this part of the course is locked for upgraded users only."; /* None option in the caption list to de select caption language*/ "NONE"="None"; diff --git a/Source/tr.lproj/Localizable.strings b/Source/tr.lproj/Localizable.strings index c3756e8656..db0ca02095 100644 --- a/Source/tr.lproj/Localizable.strings +++ b/Source/tr.lproj/Localizable.strings @@ -320,10 +320,6 @@ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY"="dolayısıyla notlu ödevlere katılamazsınız. Önerilen programımıza göre bazı önemli son tarihleri kaçırmışsınız gibi görünüyor. Bu kursun parçası olarak notlu ödevler tamamlamak ve tarihi geçmiş ödevleri geleceğe taşımak için bugün yükseltme yapabilirsiniz."; /* Course Reset Date Upgrade Reset Banner Button */ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON"="Upgrade to shift due dates"; -/* Course Reset Date Banner Header */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Önerilen programımıza göre bazı önemli son tarihleri kaçırmışsınız gibi görünüyor. "; -/* Course Reset Date Banner Body */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Plana uygun kalmak için bu programı güncelleyebilir ve tarihi geçmiş ödevleri geleceğe taşıyabilirsiniz. Endişelenmeyin, bitiş tarihlerinizi değiştirdiğinizde ilerlemenizin hiçbir kısmını kaybetmeyeceksiniz."; /* Course Reset Date Banner Button */ "COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BUTTON"="Bitiş tarihlerini değiştir"; /* Course Dates Title */ @@ -569,7 +565,7 @@ /* Prompt shown to users on the course list encouraging them to find new courses */ "ENROLLMENT_LIST.FIND_COURSES_PROMPT" = "Yeni bir maceraya hazır mısın?"; /* Button title opening course catalog */ -"ENROLLMENT_LIST.FIND_COURSES" = "Bir ders bul"; +"ENROLLMENT_LIST.FIND_COURSES" = "Explore courses"; /* Error message when user is not enrolled to any course and course discovery is also disabled.*/ "ENROLLMENT_LIST.NO_ENROLLMENT" = "Henüz herhangi bir derse kayıt yaptırmamış gözüküyorsunuz."; /* Prompt indicating user needs to enter an email address */ @@ -732,8 +728,6 @@ "PROFILE.CURRENT_LANGUAGE_LABEL" = "Şu anki dil:"; /* 'Current Location' label on top row of edit country in user's profile */ "PROFILE.CURRENT_LOCATION_LABEL" = "Şu anki konum:"; -/* Profile edit view title */ -"PROFILE.EDIT_TITLE" = "Profili düzenle"; /* Profile screen fields visibility off info message */ "PROFILE.VISIBILITY_OFF_MESSGAE"="Profil bilgileriniz yalnızca size görünür. {platform_name} üzerinde yalnızca kullanıcı adınız başkalarına görünür olur."; /* Accessibility label for edit profile button */ @@ -1009,9 +1003,9 @@ /*Deprecated version detail message*/ "VERSION_UPGRADE.DEPRECATED_MESSAGE" = "Uygulamanın yeni bir sürümü mevcut."; /*Outdated version detail message*/ -"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "Uygulamanızın bu sürümü artık desteklenmiyor."; +"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "Uygulamanızın bu sürümü artık desteklenmiyor"; /*Outdated version alert message for login screen*/ -"VERSION_UPGRADE_OUT_DATED_LOGIN_MESSAGE" = "Uygulamanızın bu sürümü artık desteklenmiyor. Giriş yapmak için en son sürüme güncelleyin."; +"VERSION_UPGRADE_OUT_DATED_LOGIN_MESSAGE" = "Uygulamanızın bu sürümü artık desteklenmiyor. Giriş yapmak için en son sürüme güncelleyin"; /*New version available without deadline message*/ "VERSION_UPGRADE.NEW_VERSION_AVAILABLE" = "Uygulamanın yeni bir sürümü mevcut."; /*Video player remaining time default value*/ diff --git a/Source/vi.lproj/Localizable-2.strings b/Source/vi.lproj/Localizable-2.strings index 79379f405f..27d88652b3 100644 --- a/Source/vi.lproj/Localizable-2.strings +++ b/Source/vi.lproj/Localizable-2.strings @@ -13,6 +13,22 @@ /* Continue text for aleart button titles or for other places*/ "CONTINUE_TEXT"="Continue"; +/* Find a new course button title on the course dashbaord error screen */ +"COURSE_DASHBOARD.ERROR.FIND_A_NEW_COURSE"="Find a new course"; +/* Course dashbaord loading eerror */ +"COURSE_DASHBOARD.ERROR.GENERAL_ERROR"="An error occured while loading your course"; +/* Go to my courses button title on the course dashbaord general error screen */ +"COURSE_DASHBOARD.ERROR.GO_TO_COURSES"="Go to My Courses"; +/*Error message title on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_TITLE" = "This course hasn’t started yet."; +/*Error message info on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_INFO" = "Come back {start_date} to see all your course content here."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_TITLE" = "Your access to this course has expired."; +/*Error message info on course dashboard screen when audit access expired of course or course ended*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_INFO" = "No new sessions are available at this time."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ENDED_TITLE" = "This course session has ended."; /* Label for date when course starts */ "COURSE.STARTING" = "Starts {start_date}"; /* Label for date when course end */ @@ -29,10 +45,10 @@ "COURSE.AUDIT.EXPIRED_AGO" = "Access expired {time_duaration}"; /* Course audit expired on date*/ "COURSE.AUDIT.EXPIRED_ON" = "Expired on {expiry_date}"; -/*Title for Discover*/ -"DISCOVER"="Discover"; -/*Title for Discovery*/ -"EXPLORE_THE_CATALOG"="Explore the catalog"; +/* Course Reset Date Banner Header */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Missed some deadlines? "; +/* Course Reset Date Banner Body */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; /*Title for dialog when user is leaving the app when tapped on link to be opened in external browser*/ "LEAVING_APP_TITLE"="Leaving the app"; /* Message for dialog when user is leaving the app when tapped on link to be opened in external browser */ @@ -43,5 +59,23 @@ "MY_COURSES"="My courses"; /*Title My programs*/ "MY_PROGRAMS"="My programs"; +/*Title for Discovery*/ +"EXPLORE_THE_CATALOG"="Explore the catalog"; +/*Title for Discover*/ +"DISCOVER"="Discover"; +/* Title text for the Home section of the course dashboard */ +"DASHBOARD.COURSE_HOME"="Home"; +/* Title for audit exipred error */ +"COURSE_DASHBOARD.ERROR.AUDIT_EXPIRED_UPGRADE_INFO"="Upgrade to get full access to this course and pursue a certificate."; +/* Title for audit title message */ +"COURSE.AUDIT_ACCESS_EXPIRED"="Access expired {expiry_date}"; +/* Enrolled courses loading error */ +"DASHBOARD.GENERAL_ERROR_MESSAGE"="An error occured while loading your courses"; +/* Enrolled courses loading error try again*/ +"DASHBOARD.TRY_AGAIN"="Try again"; +/* Course Dashboard Resume Course button title*/ +"DASHBOARD.RESUME_COURSE"="Resume course"; +/* Course outline header gated content title */ +"COURSE_OUTLINE_HEADER.GATED_CONTENT_TITLE"="Some content in this part of the course is locked for upgraded users only."; /* None option in the caption list to de select caption language*/ "NONE"="None"; diff --git a/Source/vi.lproj/Localizable.strings b/Source/vi.lproj/Localizable.strings index 27cf6b5dea..09389327a8 100644 --- a/Source/vi.lproj/Localizable.strings +++ b/Source/vi.lproj/Localizable.strings @@ -316,10 +316,6 @@ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY"="điều này có nghĩa là bạn không thể tham gia vào các bài tập chấm điểm. Có vẻ như bạn đã bỏ lỡ một số hạn chót quan trọng dựa trên lịch biểu gợi ý của chúng tôi. Để hoàn thành các bài tập chấm điểm thuộc khóa học này và di chuyển các bài tập đã quá hạn đến tương lai, bạn có thể nâng cấp hôm nay."; /* Course Reset Date Upgrade Reset Banner Button */ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON"="Upgrade to shift due dates"; -/* Course Reset Date Banner Header */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Có vẻ như bạn đã bỏ lỡ một số hạn chót quan trọng dựa trên lịch biểu gợi ý của chúng tôi. "; -/* Course Reset Date Banner Body */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"=" Để duy trì đúng hướng đi, bạn có thể cập nhật lịch biểu này và di chuyển các bài tập đã quá hạn đến tương lai. Đừng lo lắng—bạn sẽ không mất bất kỳ tiến bộ nào mà bạn đã đạt được khi di chuyển ngày đến hạn."; /* Course Reset Date Banner Button */ "COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BUTTON"="Di chuyển ngày đến hạn"; /* Course Dates Title */ @@ -555,7 +551,7 @@ /* Prompt shown to users on the course list encouraging them to find new courses */ "ENROLLMENT_LIST.FIND_COURSES_PROMPT" = "Tìm kiếm thử thách mới?"; /* Button title opening course catalog */ -"ENROLLMENT_LIST.FIND_COURSES" = "Tìm khóa học"; +"ENROLLMENT_LIST.FIND_COURSES" = "Explore courses"; /* Error message when user is not enrolled to any course and course discovery is also disabled.*/ "ENROLLMENT_LIST.NO_ENROLLMENT" = "Có vẻ như bạn chưa tham gia khóa học nào."; /* Prompt indicating user needs to enter an email address */ @@ -718,8 +714,6 @@ "PROFILE.CURRENT_LANGUAGE_LABEL" = "Ngôn ngữ hiện tại: "; /* 'Current Location' label on top row of edit country in user's profile */ "PROFILE.CURRENT_LOCATION_LABEL" = "Địa điểm hiện tại: "; -/* Profile edit view title */ -"PROFILE.EDIT_TITLE" = "Chỉnh sửa hồ sơ"; /* Profile screen fields visibility off info message */ "PROFILE.VISIBILITY_OFF_MESSGAE"="Thông tin hồ sơ của bạn chỉ hiển thị trước bạn. Chỉ có tên người dùng của bạn mới hiển thị trước những người khác trên {platform_name}."; /* Accessibility label for edit profile button */ @@ -991,7 +985,7 @@ /*Deprecated version detail message*/ "VERSION_UPGRADE.DEPRECATED_MESSAGE" = "Ứng dụng đã có phiên bản mới hơn."; /*Outdated version detail message*/ -"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "Phiên bản hiện tại của ứng dụng không còn được hỗ trợ."; +"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "Phiên bản hiện tại của ứng dụng không còn được hỗ trợ"; /*Outdated version alert message for login screen*/ "VERSION_UPGRADE_OUT_DATED_LOGIN_MESSAGE" = "Phiên bản hiện tại của ứng dụng không còn được hỗ trợ. Cập nhật lên phiên bản mới nhất để đăng nhập."; /*New version available without deadline message*/ diff --git a/Source/zh-Hans.lproj/Localizable-2.strings b/Source/zh-Hans.lproj/Localizable-2.strings index 79379f405f..27d88652b3 100644 --- a/Source/zh-Hans.lproj/Localizable-2.strings +++ b/Source/zh-Hans.lproj/Localizable-2.strings @@ -13,6 +13,22 @@ /* Continue text for aleart button titles or for other places*/ "CONTINUE_TEXT"="Continue"; +/* Find a new course button title on the course dashbaord error screen */ +"COURSE_DASHBOARD.ERROR.FIND_A_NEW_COURSE"="Find a new course"; +/* Course dashbaord loading eerror */ +"COURSE_DASHBOARD.ERROR.GENERAL_ERROR"="An error occured while loading your course"; +/* Go to my courses button title on the course dashbaord general error screen */ +"COURSE_DASHBOARD.ERROR.GO_TO_COURSES"="Go to My Courses"; +/*Error message title on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_TITLE" = "This course hasn’t started yet."; +/*Error message info on course dashboard screen for course yet to start*/ +"COURSE_DASHBOARD.ERROR.COURSE_NOT_STARTED_INFO" = "Come back {start_date} to see all your course content here."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_TITLE" = "Your access to this course has expired."; +/*Error message info on course dashboard screen when audit access expired of course or course ended*/ +"COURSE_DASHBOARD.ERROR.COURSE_ACCESS_EXPIRED_INFO" = "No new sessions are available at this time."; +/*Error message title on course dashboard screen when audit access of course*/ +"COURSE_DASHBOARD.ERROR.COURSE_ENDED_TITLE" = "This course session has ended."; /* Label for date when course starts */ "COURSE.STARTING" = "Starts {start_date}"; /* Label for date when course end */ @@ -29,10 +45,10 @@ "COURSE.AUDIT.EXPIRED_AGO" = "Access expired {time_duaration}"; /* Course audit expired on date*/ "COURSE.AUDIT.EXPIRED_ON" = "Expired on {expiry_date}"; -/*Title for Discover*/ -"DISCOVER"="Discover"; -/*Title for Discovery*/ -"EXPLORE_THE_CATALOG"="Explore the catalog"; +/* Course Reset Date Banner Header */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="Missed some deadlines? "; +/* Course Reset Date Banner Body */ +"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; /*Title for dialog when user is leaving the app when tapped on link to be opened in external browser*/ "LEAVING_APP_TITLE"="Leaving the app"; /* Message for dialog when user is leaving the app when tapped on link to be opened in external browser */ @@ -43,5 +59,23 @@ "MY_COURSES"="My courses"; /*Title My programs*/ "MY_PROGRAMS"="My programs"; +/*Title for Discovery*/ +"EXPLORE_THE_CATALOG"="Explore the catalog"; +/*Title for Discover*/ +"DISCOVER"="Discover"; +/* Title text for the Home section of the course dashboard */ +"DASHBOARD.COURSE_HOME"="Home"; +/* Title for audit exipred error */ +"COURSE_DASHBOARD.ERROR.AUDIT_EXPIRED_UPGRADE_INFO"="Upgrade to get full access to this course and pursue a certificate."; +/* Title for audit title message */ +"COURSE.AUDIT_ACCESS_EXPIRED"="Access expired {expiry_date}"; +/* Enrolled courses loading error */ +"DASHBOARD.GENERAL_ERROR_MESSAGE"="An error occured while loading your courses"; +/* Enrolled courses loading error try again*/ +"DASHBOARD.TRY_AGAIN"="Try again"; +/* Course Dashboard Resume Course button title*/ +"DASHBOARD.RESUME_COURSE"="Resume course"; +/* Course outline header gated content title */ +"COURSE_OUTLINE_HEADER.GATED_CONTENT_TITLE"="Some content in this part of the course is locked for upgraded users only."; /* None option in the caption list to de select caption language*/ "NONE"="None"; diff --git a/Source/zh-Hans.lproj/Localizable.strings b/Source/zh-Hans.lproj/Localizable.strings index b10eef97d0..1139f82528 100644 --- a/Source/zh-Hans.lproj/Localizable.strings +++ b/Source/zh-Hans.lproj/Localizable.strings @@ -316,10 +316,6 @@ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY"="这意味着你不能参加分级作业。根据我们建议的日程安排,您似乎错过了一些重要的截止日期。要完成本课程一部分的分级作业,并将过去到期的作业转移到未来,您可以今天进行功能升级。"; /* Course Reset Date Upgrade Reset Banner Button */ "COURSEDATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON"="Upgrade to shift due dates"; -/* Course Reset Date Banner Header */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.HEADER"="根据我们建议的日程安排,您似乎错过了一些重要的截止日期。"; -/* Course Reset Date Banner Body */ -"COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BODY"="为了让自己赶上进程,您可以更新此计划并将过期的任务转移到未来。不要担心,当你改变你的截止日期时,你不会失去你已经取得的任何过程记录。"; /* Course Reset Date Banner Button */ "COURSEDATES.RESET_DATE.RESET_DATE_BANNER.BUTTON"="改变截止日期"; /* Course Dates Title */ @@ -553,7 +549,7 @@ /* Prompt shown to users on the course list encouraging them to find new courses */ "ENROLLMENT_LIST.FIND_COURSES_PROMPT" = "寻找新的挑战?"; /* Button title opening course catalog */ -"ENROLLMENT_LIST.FIND_COURSES" = "发现课程"; +"ENROLLMENT_LIST.FIND_COURSES" = "Explore courses"; /* Error message when user is not enrolled to any course and course discovery is also disabled.*/ "ENROLLMENT_LIST.NO_ENROLLMENT" = "您还没有加入任何课程。"; /* Prompt indicating user needs to enter an email address */ @@ -716,8 +712,6 @@ "PROFILE.CURRENT_LANGUAGE_LABEL" = "当前语言:"; /* 'Current Location' label on top row of edit country in user's profile */ "PROFILE.CURRENT_LOCATION_LABEL" = "当前位置:"; -/* Profile edit view title */ -"PROFILE.EDIT_TITLE" = "编辑资料"; /* Profile screen fields visibility off info message */ "PROFILE.VISIBILITY_OFF_MESSGAE"="你的属性信息只能由你浏览。对于{paltform_name}上的其他用户只显示你的用户名。"; /* Accessibility label for edit profile button */ @@ -989,7 +983,7 @@ /*Deprecated version detail message*/ "VERSION_UPGRADE.DEPRECATED_MESSAGE" = "应用程序有新版本可用。"; /*Outdated version detail message*/ -"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "不支持您的新版本。"; +"VERSION_UPGRADE.OUT_DATED_MESSAGE" = "不支持您的新版本"; /*Outdated version alert message for login screen*/ "VERSION_UPGRADE_OUT_DATED_LOGIN_MESSAGE" = "您的应用版本不再受支持。请更新到最新版本后再登录。"; /*New version available without deadline message*/ diff --git a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios14_375x667@2x.png b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios14_375x667@2x.png index ae709f371d..34e1847a3c 100644 Binary files a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios14_375x667@2x.png and b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios14_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios14_rtl_375x667@2x.png b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios14_rtl_375x667@2x.png index d32f2d081d..f0f0c3f090 100644 Binary files a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios14_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios14_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios15_375x667@2x.png b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios15_375x667@2x.png index 70bae284d1..ece7f8e1d8 100644 Binary files a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios15_375x667@2x.png and b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios15_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios15_rtl_375x667@2x.png b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios15_rtl_375x667@2x.png index e022f11eb5..2b77db9a2c 100644 Binary files a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios15_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testCertificate_ios15_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios14_375x667@2x.png b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios14_375x667@2x.png index 22db42ee99..7467beae11 100644 Binary files a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios14_375x667@2x.png and b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios14_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios14_rtl_375x667@2x.png b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios14_rtl_375x667@2x.png index 989ee12b35..3102fbcfe1 100644 Binary files a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios14_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios14_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios15_375x667@2x.png b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios15_375x667@2x.png index 92d793e668..49ac47e36b 100644 Binary files a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios15_375x667@2x.png and b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios15_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios15_rtl_375x667@2x.png b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios15_rtl_375x667@2x.png index 1a7e1a0364..b4435a2fc2 100644 Binary files a/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios15_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.CourseDashboardViewControllerTests/testSnapshot_ios15_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios14_375x667@2x.png b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios14_375x667@2x.png index e196258edf..017e1a74f9 100644 Binary files a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios14_375x667@2x.png and b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios14_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios14_rtl_375x667@2x.png b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios14_rtl_375x667@2x.png index 45f53742d9..2294b8c5c7 100644 Binary files a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios14_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios14_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios15_375x667@2x.png b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios15_375x667@2x.png index d9c0afff6a..d608128748 100644 Binary files a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios15_375x667@2x.png and b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios15_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios15_rtl_375x667@2x.png b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios15_rtl_375x667@2x.png index da8dec7877..b45ff45b72 100644 Binary files a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios15_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentChapter_ios15_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios14_375x667@2x.png b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios14_375x667@2x.png index efd7dac5be..be2af59820 100644 Binary files a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios14_375x667@2x.png and b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios14_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios14_rtl_375x667@2x.png b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios14_rtl_375x667@2x.png index c02b4fcf62..1a2c6d4ec8 100644 Binary files a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios14_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios14_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios15_375x667@2x.png b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios15_375x667@2x.png index da08f12fa7..cc5ce09234 100644 Binary files a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios15_375x667@2x.png and b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios15_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios15_rtl_375x667@2x.png b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios15_rtl_375x667@2x.png index 3ced32d3b9..e87e803d42 100644 Binary files a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios15_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotContentCourse_ios15_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios14_375x667@2x.png b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios14_375x667@2x.png index 52fc724f47..e5f980043c 100644 Binary files a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios14_375x667@2x.png and b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios14_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios14_rtl_375x667@2x.png b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios14_rtl_375x667@2x.png index 8fa7d24bc2..a3f5b8b6d6 100644 Binary files a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios14_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios14_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios15_375x667@2x.png b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios15_375x667@2x.png index 38abbca10d..9848bf4b43 100644 Binary files a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios15_375x667@2x.png and b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios15_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios15_rtl_375x667@2x.png b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios15_rtl_375x667@2x.png index 346c67e60e..5edfb6bb71 100644 Binary files a/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios15_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.CourseOutlineViewControllerTests/testSnapshotVideoContent_ios15_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios14_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios14_375x667@2x.png index 16531f0e27..9d0189658e 100644 Binary files a/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios14_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios14_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios14_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios14_rtl_375x667@2x.png index 2a4e229dba..05923f7727 100644 Binary files a/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios14_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios14_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios15_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios15_375x667@2x.png index 9452b185bd..67342a7477 100644 Binary files a/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios15_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios15_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios15_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios15_rtl_375x667@2x.png index 1a63fc2d4f..401fd61812 100644 Binary files a/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios15_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledCoursesViewControllerTests/testCourseListDiscoveryEnabled_ios15_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios14_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios14_375x667@2x.png index 79223aa112..d50bb920d2 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios14_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios14_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios14_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios14_rtl_375x667@2x.png index 8a4c95dad0..92dd7da22f 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios14_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios14_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios15_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios15_375x667@2x.png index 5e6da82ca6..3462d2562c 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios15_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios15_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios15_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios15_rtl_375x667@2x.png index 07992f0252..fc86088f1d 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios15_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarProgramsView_ios15_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios14_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios14_375x667@2x.png index 32c29742f0..15f809d7da 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios14_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios14_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios14_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios14_rtl_375x667@2x.png index 3cdbe62106..0e0b2671de 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios14_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios14_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios15_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios15_375x667@2x.png index 5d0bb8a622..e3c6904ef3 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios15_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios15_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios15_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios15_rtl_375x667@2x.png index 30fd05eb29..92108a3096 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios15_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewCoursesEnabled_ios15_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios14_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios14_375x667@2x.png index 32c29742f0..15f809d7da 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios14_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios14_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios14_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios14_rtl_375x667@2x.png index 3cdbe62106..0e0b2671de 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios14_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios14_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios15_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios15_375x667@2x.png index 5d0bb8a622..e3c6904ef3 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios15_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios15_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios15_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios15_rtl_375x667@2x.png index 30fd05eb29..92108a3096 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios15_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryAndProgramDisable_ios15_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios14_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios14_375x667@2x.png index 79223aa112..be7599ac65 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios14_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios14_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios14_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios14_rtl_375x667@2x.png index 8a4c95dad0..c25be6b93d 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios14_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios14_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios15_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios15_375x667@2x.png index 5e6da82ca6..f9ce69d908 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios15_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios15_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios15_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios15_rtl_375x667@2x.png index 07992f0252..be70df34d5 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios15_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscoveryDisable_ios15_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios14_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios14_375x667@2x.png index f29a8277f9..9c958e5e67 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios14_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios14_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios14_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios14_rtl_375x667@2x.png index 4a4dcb0025..2b10cc8415 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios14_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios14_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios15_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios15_375x667@2x.png index 53ce19dd41..51be9ac506 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios15_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios15_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios15_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios15_rtl_375x667@2x.png index dd7a527c52..6099397c17 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios15_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewDiscovery_ios15_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios14_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios14_375x667@2x.png index 3ae280db9b..1b9c1b85e8 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios14_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios14_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios14_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios14_rtl_375x667@2x.png index 829779b171..d9984c33b8 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios14_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios14_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios15_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios15_375x667@2x.png index 0d319d7c36..79dfd089be 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios15_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios15_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios15_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios15_rtl_375x667@2x.png index a8977c2d73..eb4c6bcc4a 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios15_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarViewProgramDisable_ios15_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios14_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios14_375x667@2x.png index f272ef1649..91864ca16d 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios14_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios14_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios14_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios14_rtl_375x667@2x.png index c5e2591f19..49645d8979 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios14_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios14_rtl_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios15_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios15_375x667@2x.png index 875e977cfe..b91e94bdf8 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios15_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios15_375x667@2x.png differ diff --git a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios15_rtl_375x667@2x.png b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios15_rtl_375x667@2x.png index 1c4a76f20e..41cfa8de12 100644 Binary files a/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios15_rtl_375x667@2x.png and b/Test/Snapshots/edXTests.EnrolledTabBarViewControllerTest/testsnapshotEnrolledTabBarView_ios15_rtl_375x667@2x.png differ diff --git a/edX.xcodeproj/project.pbxproj b/edX.xcodeproj/project.pbxproj index 15f3d9ef35..f589827ad5 100644 --- a/edX.xcodeproj/project.pbxproj +++ b/edX.xcodeproj/project.pbxproj @@ -174,17 +174,24 @@ 5F0248C624AC9ED8000AF1FF /* CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F0248C524AC9ED8000AF1FF /* CourseDates.swift */; }; 5F0248C824AC9F09000AF1FF /* CourseDatesAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F0248C724AC9F09000AF1FF /* CourseDatesAPI.swift */; }; 5F08321427018D810022971F /* BannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F08321327018D810022971F /* BannerViewController.swift */; }; + 5F11143A298A9C7F00964F02 /* ScrollableDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F111439298A9C7F00964F02 /* ScrollableDelegate.swift */; }; 5F1E03FC26CA64B9004F8139 /* VideoDownloadQuality.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F1E03FB26CA64B9004F8139 /* VideoDownloadQuality.swift */; }; 5F28980A25074D5A00BF76DF /* CourseDateBannerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F28980925074D5A00BF76DF /* CourseDateBannerModel.swift */; }; 5F28980C2507B19400BF76DF /* CourseDateBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F28980B2507B19400BF76DF /* CourseDateBannerView.swift */; }; 5F2C5A19242C99EE00FBF986 /* CollectionPaginationManipulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F2C5A18242C99EE00FBF986 /* CollectionPaginationManipulator.swift */; }; + 5F2DFCB029262D8E00BDA40A /* CourseDashboardHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F2DFCAF29262D8E00BDA40A /* CourseDashboardHeaderView.swift */; }; + 5F2DFCB229278F0200BDA40A /* NewCourseDashboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F2DFCB129278F0200BDA40A /* NewCourseDashboardViewController.swift */; }; 5F35022427901E7400F76CB4 /* ValuePropUnlockViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F35022327901E7400F76CB4 /* ValuePropUnlockViewContainer.swift */; }; 5F371470260B3BC9004937DA /* Observeable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F37146F260B3BC9004937DA /* Observeable.swift */; }; + 5F3807B02A2871EB00D87796 /* DropDownCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5F3807AF2A2871EB00D87796 /* DropDownCell.xib */; }; 5F3A619A265796A7005329A4 /* CalendarSyncConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F3A6199265796A7005329A4 /* CalendarSyncConfig.swift */; }; 5F45030E276B4B290054D266 /* CourseUpgradeHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F45030D276B4B290054D266 /* CourseUpgradeHelper.swift */; }; 5F46A7CC24ADFFA300347EFC /* CourseDateViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F46A7C724ADFFA200347EFC /* CourseDateViewCell.swift */; }; 5F46A7CE24ADFFA300347EFC /* TimelinePoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F46A7C924ADFFA200347EFC /* TimelinePoint.swift */; }; 5F46A7CF24ADFFA300347EFC /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F46A7CB24ADFFA300347EFC /* Timeline.swift */; }; + 5F48E79929E868F900F52C4B /* CourseContentHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F48E79829E868F900F52C4B /* CourseContentHeaderView.swift */; }; + 5F5058172A3088DA00399C55 /* CourseContentHeaderBlockPickerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F5058162A3088DA00399C55 /* CourseContentHeaderBlockPickerCell.swift */; }; + 5F5239BF293DB9560046FF07 /* CourseDashboardTabbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F5239BE293DB9560046FF07 /* CourseDashboardTabbarView.swift */; }; 5F54D99D26B9228500F1EA71 /* PaymentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F54D99C26B9228500F1EA71 /* PaymentManager.swift */; }; 5F58DCE12819879300787A75 /* CourseUpgrade.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5F58DCE32819879300787A75 /* CourseUpgrade.strings */; }; 5F592AD0257521F600D96214 /* ISOParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F592ACF257521F600D96214 /* ISOParser.swift */; }; @@ -193,6 +200,13 @@ 5F5FC00D269DA9F800F92B8F /* CourseUpgradeButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F5FC00C269DA9F800F92B8F /* CourseUpgradeButtonView.swift */; }; 5F68992B29090FBD0060661B /* ExternalAuthOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F68992A29090FBD0060661B /* ExternalAuthOptionsView.swift */; }; 5F6AD05B27467898007686C2 /* ProfileOptions.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5F6AD05927467898007686C2 /* ProfileOptions.strings */; }; + 5F6AF0C329F992BE00450B42 /* DPDUIView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6AF0C029F992BC00450B42 /* DPDUIView+Extension.swift */; }; + 5F6AF0C429F992BE00450B42 /* DPDKeyboardListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6AF0C129F992BD00450B42 /* DPDKeyboardListener.swift */; }; + 5F6AF0C529F992BE00450B42 /* DPDConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6AF0C229F992BE00450B42 /* DPDConstants.swift */; }; + 5F6AF0C729F992CE00450B42 /* DropDown+Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6AF0C629F992CD00450B42 /* DropDown+Appearance.swift */; }; + 5F6AF0CA29F992D600450B42 /* DropDownCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6AF0C829F992D500450B42 /* DropDownCell.swift */; }; + 5F6AF0CB29F992D600450B42 /* DropDown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6AF0C929F992D600450B42 /* DropDown.swift */; }; + 5F6C8A52292DFCBB00E5FA7F /* NewDashboardContentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6C8A51292DFCBB00E5FA7F /* NewDashboardContentCell.swift */; }; 5F6F2B60255AABA400AA3708 /* Inter-SemiBoldItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 5F6F2B56255AABA200AA3708 /* Inter-SemiBoldItalic.ttf */; }; 5F6F2B61255AABA400AA3708 /* Inter-ExtraBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 5F6F2B57255AABA300AA3708 /* Inter-ExtraBold.ttf */; }; 5F6F2B62255AABA400AA3708 /* Inter-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 5F6F2B58255AABA300AA3708 /* Inter-Bold.ttf */; }; @@ -210,6 +224,8 @@ 5F7C0C6A2534521300A344B9 /* FillBackgroundLayoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F7C0C692534521300A344B9 /* FillBackgroundLayoutManager.swift */; }; 5F903AF4255020D7006365DE /* UIDeviceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F903AF3255020D7006365DE /* UIDeviceExtension.swift */; }; 5F99F3BD26C68308000605B4 /* VideoDownloadQualityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F99F3BC26C68308000605B4 /* VideoDownloadQualityViewController.swift */; }; + 5F9EE5E929BE4BF300FF5A0A /* ResumeCourseHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F9EE5E829BE4BF300FF5A0A /* ResumeCourseHeaderView.swift */; }; + 5FA4266F2966B1110013BBA8 /* CourseAccessHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FA4266E2966B1110013BBA8 /* CourseAccessHelper.swift */; }; 5FA7FEB1266A2BE5006286B6 /* BrowserViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FA7FEB0266A2BE5006286B6 /* BrowserViewController.swift */; }; 5FA8F8D827C4B153006003DA /* ShimmerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FA8F8D327C4B153006003DA /* ShimmerView.swift */; }; 5FAE055324EA647D00A29B1D /* CourseDates.json in Resources */ = {isa = PBXBuildFile; fileRef = 5FAE055124EA618000A29B1D /* CourseDates.json */; }; @@ -220,15 +236,11 @@ 5FC5AE112847944E007E5917 /* LearnContainerHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FC5AE102847944E007E5917 /* LearnContainerHeaderView.swift */; }; 5FD868362637F05E0045A149 /* CourseDatesHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FD868352637F05E0045A149 /* CourseDatesHeaderView.swift */; }; 5FD868382637F0FC0045A149 /* CalendarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FD868372637F0FC0045A149 /* CalendarManager.swift */; }; + 5FE2B8EE297ACBD60071FEF7 /* GeneralErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FE2B8ED297ACBD50071FEF7 /* GeneralErrorView.swift */; }; 5FEA006B290BA06700D39B44 /* ExternalProviderButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FEA006A290BA06700D39B44 /* ExternalProviderButtonView.swift */; }; 5FF08645257F91FA0078877A /* ValuePropComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FF08644257F91FA0078877A /* ValuePropComponentView.swift */; }; - 5FFB73BC2849F10D000523BF /* DPDKeyboardListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FFB73B32849F10D000523BF /* DPDKeyboardListener.swift */; }; - 5FFB73BD2849F10D000523BF /* DPDConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FFB73B42849F10D000523BF /* DPDConstants.swift */; }; - 5FFB73BE2849F10D000523BF /* DPDUIView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FFB73B52849F10D000523BF /* DPDUIView+Extension.swift */; }; - 5FFB73C02849F10D000523BF /* DropDown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FFB73B82849F10D000523BF /* DropDown.swift */; }; - 5FFB73C12849F10D000523BF /* DropDownCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FFB73B92849F10D000523BF /* DropDownCell.swift */; }; - 5FFB73C22849F10D000523BF /* DropDown+Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FFB73BA2849F10D000523BF /* DropDown+Appearance.swift */; }; 5FFC1D69286EECB700DD29EB /* ServerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FFC1D68286EECB700DD29EB /* ServerConfiguration.swift */; }; + 5FFC5D0729E4911800467829 /* NewCourseContentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FFC5D0629E4911800467829 /* NewCourseContentController.swift */; }; 5FFCF2C7284CA12600D6456F /* LearnContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FFCF2C6284CA12600D6456F /* LearnContainerViewController.swift */; }; 6919F5FF1D65CD27006935C8 /* OEXColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6919F5FE1D65CD27006935C8 /* OEXColors.swift */; }; 6926CDAB1D59BE3600A16E22 /* ic_next_press.png in Resources */ = {isa = PBXBuildFile; fileRef = 6926CDA41D59BE3600A16E22 /* ic_next_press.png */; }; @@ -821,6 +833,7 @@ E0D159951EB87699005E2A76 /* WhatsNewDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D159941EB87699005E2A76 /* WhatsNewDataModel.swift */; }; E0D159971EB87700005E2A76 /* WhatsNewObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D159961EB87700005E2A76 /* WhatsNewObject.swift */; }; E0D232D4291374A900051A04 /* UsingExternalAuthInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D232D3291374A900051A04 /* UsingExternalAuthInfoView.swift */; }; + E0D31F0729E6A0A600044368 /* NewCourseDateBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D31F0629E6A0A600044368 /* NewCourseDateBannerView.swift */; }; E0E163EC21368A1D00DAE9F0 /* FirebaseConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E163EB21368A1D00DAE9F0 /* FirebaseConfig.swift */; }; E0E163EE21368A4F00DAE9F0 /* FirebaseConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E163ED21368A4F00DAE9F0 /* FirebaseConfigTests.swift */; }; E0E163F021368A6300DAE9F0 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = E0E163EF21368A6300DAE9F0 /* GoogleService-Info.plist */; }; @@ -830,6 +843,8 @@ E0EC115C221AE70900F0574A /* ListenableObject.m in Sources */ = {isa = PBXBuildFile; fileRef = E0EC115B221AE70900F0574A /* ListenableObject.m */; }; E0EC12AD2216A6910090EEF6 /* NSString+OEXFormatting.h in Headers */ = {isa = PBXBuildFile; fileRef = 77E647C51C90C70600B6740D /* NSString+OEXFormatting.h */; settings = {ATTRIBUTES = (Public, ); }; }; E0EEC6E71F1CD279006C8D62 /* whats_new.json in Resources */ = {isa = PBXBuildFile; fileRef = E0EEC6E91F1CD279006C8D62 /* whats_new.json */; }; + E0F9C02F29362806003D96DF /* CourseDashboardErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0F9C02E29362806003D96DF /* CourseDashboardErrorView.swift */; }; + E0F9C0312939C497003D96DF /* CourseDashboardAccessErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0F9C0302939C497003D96DF /* CourseDashboardAccessErrorView.swift */; }; E0FC64C31C85B492004E3E92 /* DiscussionDataParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0FC64C11C85B46C004E3E92 /* DiscussionDataParsingTests.swift */; }; E0FCFCC91EC59DB2000B969C /* WhatsNewObjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0FCFCC81EC59DB2000B969C /* WhatsNewObjectTests.swift */; }; E0FF457920FDD24400109662 /* BlockCompletionApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0FF457820FDD24400109662 /* BlockCompletionApi.swift */; }; @@ -1107,17 +1122,24 @@ 5F0248C524AC9ED8000AF1FF /* CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDates.swift; sourceTree = ""; }; 5F0248C724AC9F09000AF1FF /* CourseDatesAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDatesAPI.swift; sourceTree = ""; }; 5F08321327018D810022971F /* BannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerViewController.swift; sourceTree = ""; }; + 5F111439298A9C7F00964F02 /* ScrollableDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollableDelegate.swift; sourceTree = ""; }; 5F1E03FB26CA64B9004F8139 /* VideoDownloadQuality.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQuality.swift; sourceTree = ""; }; 5F28980925074D5A00BF76DF /* CourseDateBannerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDateBannerModel.swift; sourceTree = ""; }; 5F28980B2507B19400BF76DF /* CourseDateBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDateBannerView.swift; sourceTree = ""; }; 5F2C5A18242C99EE00FBF986 /* CollectionPaginationManipulator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionPaginationManipulator.swift; sourceTree = ""; }; + 5F2DFCAF29262D8E00BDA40A /* CourseDashboardHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDashboardHeaderView.swift; sourceTree = ""; }; + 5F2DFCB129278F0200BDA40A /* NewCourseDashboardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewCourseDashboardViewController.swift; sourceTree = ""; }; 5F35022327901E7400F76CB4 /* ValuePropUnlockViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValuePropUnlockViewContainer.swift; sourceTree = ""; }; 5F37146F260B3BC9004937DA /* Observeable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observeable.swift; sourceTree = ""; }; + 5F3807AF2A2871EB00D87796 /* DropDownCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = DropDownCell.xib; sourceTree = ""; }; 5F3A6199265796A7005329A4 /* CalendarSyncConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSyncConfig.swift; sourceTree = ""; }; 5F45030D276B4B290054D266 /* CourseUpgradeHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseUpgradeHelper.swift; sourceTree = ""; }; 5F46A7C724ADFFA200347EFC /* CourseDateViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseDateViewCell.swift; sourceTree = ""; }; 5F46A7C924ADFFA200347EFC /* TimelinePoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelinePoint.swift; sourceTree = ""; }; 5F46A7CB24ADFFA300347EFC /* Timeline.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Timeline.swift; sourceTree = ""; }; + 5F48E79829E868F900F52C4B /* CourseContentHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContentHeaderView.swift; sourceTree = ""; }; + 5F5058162A3088DA00399C55 /* CourseContentHeaderBlockPickerCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseContentHeaderBlockPickerCell.swift; sourceTree = ""; }; + 5F5239BE293DB9560046FF07 /* CourseDashboardTabbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDashboardTabbarView.swift; sourceTree = ""; }; 5F54D99C26B9228500F1EA71 /* PaymentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentManager.swift; sourceTree = ""; }; 5F58DCE22819879300787A75 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/CourseUpgrade.strings; sourceTree = ""; }; 5F58DCE52819879600787A75 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/CourseUpgrade.strings"; sourceTree = ""; }; @@ -1146,6 +1168,13 @@ 5F64839F274CEB6000DB38E0 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/ProfileOptions.strings; sourceTree = ""; }; 5F68992A29090FBD0060661B /* ExternalAuthOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalAuthOptionsView.swift; sourceTree = ""; }; 5F6AD05A27467898007686C2 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/ProfileOptions.strings; sourceTree = ""; }; + 5F6AF0C029F992BC00450B42 /* DPDUIView+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DPDUIView+Extension.swift"; sourceTree = ""; }; + 5F6AF0C129F992BD00450B42 /* DPDKeyboardListener.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DPDKeyboardListener.swift; sourceTree = ""; }; + 5F6AF0C229F992BE00450B42 /* DPDConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DPDConstants.swift; sourceTree = ""; }; + 5F6AF0C629F992CD00450B42 /* DropDown+Appearance.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DropDown+Appearance.swift"; sourceTree = ""; }; + 5F6AF0C829F992D500450B42 /* DropDownCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DropDownCell.swift; sourceTree = ""; }; + 5F6AF0C929F992D600450B42 /* DropDown.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DropDown.swift; sourceTree = ""; }; + 5F6C8A51292DFCBB00E5FA7F /* NewDashboardContentCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewDashboardContentCell.swift; sourceTree = ""; }; 5F6F2B56255AABA200AA3708 /* Inter-SemiBoldItalic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Inter-SemiBoldItalic.ttf"; sourceTree = ""; }; 5F6F2B57255AABA300AA3708 /* Inter-ExtraBold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Inter-ExtraBold.ttf"; sourceTree = ""; }; 5F6F2B58255AABA300AA3708 /* Inter-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Inter-Bold.ttf"; sourceTree = ""; }; @@ -1162,6 +1191,8 @@ 5F7C0C692534521300A344B9 /* FillBackgroundLayoutManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBackgroundLayoutManager.swift; sourceTree = ""; }; 5F903AF3255020D7006365DE /* UIDeviceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDeviceExtension.swift; sourceTree = ""; }; 5F99F3BC26C68308000605B4 /* VideoDownloadQualityViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityViewController.swift; sourceTree = ""; }; + 5F9EE5E829BE4BF300FF5A0A /* ResumeCourseHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumeCourseHeaderView.swift; sourceTree = ""; }; + 5FA4266E2966B1110013BBA8 /* CourseAccessHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseAccessHelper.swift; sourceTree = ""; }; 5FA7FEB0266A2BE5006286B6 /* BrowserViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserViewController.swift; sourceTree = ""; }; 5FA8F8D327C4B153006003DA /* ShimmerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShimmerView.swift; sourceTree = ""; }; 5FAE055124EA618000A29B1D /* CourseDates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = CourseDates.json; sourceTree = ""; }; @@ -1172,15 +1203,11 @@ 5FC5AE102847944E007E5917 /* LearnContainerHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnContainerHeaderView.swift; sourceTree = ""; }; 5FD868352637F05E0045A149 /* CourseDatesHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDatesHeaderView.swift; sourceTree = ""; }; 5FD868372637F0FC0045A149 /* CalendarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManager.swift; sourceTree = ""; }; + 5FE2B8ED297ACBD50071FEF7 /* GeneralErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneralErrorView.swift; sourceTree = ""; }; 5FEA006A290BA06700D39B44 /* ExternalProviderButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalProviderButtonView.swift; sourceTree = ""; }; 5FF08644257F91FA0078877A /* ValuePropComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValuePropComponentView.swift; sourceTree = ""; }; - 5FFB73B32849F10D000523BF /* DPDKeyboardListener.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DPDKeyboardListener.swift; sourceTree = ""; }; - 5FFB73B42849F10D000523BF /* DPDConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DPDConstants.swift; sourceTree = ""; }; - 5FFB73B52849F10D000523BF /* DPDUIView+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DPDUIView+Extension.swift"; sourceTree = ""; }; - 5FFB73B82849F10D000523BF /* DropDown.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DropDown.swift; sourceTree = ""; }; - 5FFB73B92849F10D000523BF /* DropDownCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DropDownCell.swift; sourceTree = ""; }; - 5FFB73BA2849F10D000523BF /* DropDown+Appearance.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DropDown+Appearance.swift"; sourceTree = ""; }; 5FFC1D68286EECB700DD29EB /* ServerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfiguration.swift; sourceTree = ""; }; + 5FFC5D0629E4911800467829 /* NewCourseContentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewCourseContentController.swift; sourceTree = ""; }; 5FFCF2C6284CA12600D6456F /* LearnContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnContainerViewController.swift; sourceTree = ""; }; 6919F5FE1D65CD27006935C8 /* OEXColors.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OEXColors.swift; sourceTree = ""; }; 6926CDA41D59BE3600A16E22 /* ic_next_press.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = ic_next_press.png; sourceTree = ""; }; @@ -1910,6 +1937,7 @@ E0D159941EB87699005E2A76 /* WhatsNewDataModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WhatsNewDataModel.swift; sourceTree = ""; }; E0D159961EB87700005E2A76 /* WhatsNewObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WhatsNewObject.swift; sourceTree = ""; }; E0D232D3291374A900051A04 /* UsingExternalAuthInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsingExternalAuthInfoView.swift; sourceTree = ""; }; + E0D31F0629E6A0A600044368 /* NewCourseDateBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewCourseDateBannerView.swift; sourceTree = ""; }; E0E163EB21368A1D00DAE9F0 /* FirebaseConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FirebaseConfig.swift; sourceTree = ""; }; E0E163ED21368A4F00DAE9F0 /* FirebaseConfigTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FirebaseConfigTests.swift; sourceTree = ""; }; E0E163EF21368A6300DAE9F0 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; @@ -1918,6 +1946,8 @@ E0EC115A221AE70900F0574A /* ListenableObject.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ListenableObject.h; sourceTree = ""; }; E0EC115B221AE70900F0574A /* ListenableObject.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ListenableObject.m; sourceTree = ""; }; E0EEC6E81F1CD279006C8D62 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = en; path = en.lproj/whats_new.json; sourceTree = ""; }; + E0F9C02E29362806003D96DF /* CourseDashboardErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDashboardErrorView.swift; sourceTree = ""; }; + E0F9C0302939C497003D96DF /* CourseDashboardAccessErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDashboardAccessErrorView.swift; sourceTree = ""; }; E0FC64C11C85B46C004E3E92 /* DiscussionDataParsingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiscussionDataParsingTests.swift; sourceTree = ""; }; E0FCFCC81EC59DB2000B969C /* WhatsNewObjectTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WhatsNewObjectTests.swift; sourceTree = ""; }; E0FF457820FDD24400109662 /* BlockCompletionApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockCompletionApi.swift; sourceTree = ""; }; @@ -2214,6 +2244,7 @@ 5F28980B2507B19400BF76DF /* CourseDateBannerView.swift */, 5FD868352637F05E0045A149 /* CourseDatesHeaderView.swift */, 5FD868372637F0FC0045A149 /* CalendarManager.swift */, + E0D31F0629E6A0A600044368 /* NewCourseDateBannerView.swift */, ); name = "Course Dates"; sourceTree = ""; @@ -2420,6 +2451,7 @@ 5FFB73B02849F10D000523BF /* resources */ = { isa = PBXGroup; children = ( + 5F3807AF2A2871EB00D87796 /* DropDownCell.xib */, ); path = resources; sourceTree = ""; @@ -2427,9 +2459,9 @@ 5FFB73B22849F10D000523BF /* helpers */ = { isa = PBXGroup; children = ( - 5FFB73B32849F10D000523BF /* DPDKeyboardListener.swift */, - 5FFB73B42849F10D000523BF /* DPDConstants.swift */, - 5FFB73B52849F10D000523BF /* DPDUIView+Extension.swift */, + 5F6AF0C229F992BE00450B42 /* DPDConstants.swift */, + 5F6AF0C129F992BD00450B42 /* DPDKeyboardListener.swift */, + 5F6AF0C029F992BC00450B42 /* DPDUIView+Extension.swift */, ); path = helpers; sourceTree = ""; @@ -2437,9 +2469,9 @@ 5FFB73B72849F10D000523BF /* src */ = { isa = PBXGroup; children = ( - 5FFB73B82849F10D000523BF /* DropDown.swift */, - 5FFB73B92849F10D000523BF /* DropDownCell.swift */, - 5FFB73BA2849F10D000523BF /* DropDown+Appearance.swift */, + 5F6AF0C929F992D600450B42 /* DropDown.swift */, + 5F6AF0C829F992D500450B42 /* DropDownCell.swift */, + 5F6AF0C629F992CD00450B42 /* DropDown+Appearance.swift */, ); path = src; sourceTree = ""; @@ -2800,6 +2832,7 @@ 7772BE781AF40A730081CA7A /* Controller Helpers */ = { isa = PBXGroup; children = ( + 5FE2B8ED297ACBD50071FEF7 /* GeneralErrorView.swift */, 7765025E1C7CEBC5007384E7 /* SingleChildContainingViewController.swift */, 779D1CE01B8E6FE000FCC847 /* PullRefreshController.swift */, 77E00B701B82A06F00573622 /* GrowingTextViewController.swift */, @@ -3446,6 +3479,7 @@ E0249F2A26305486007F9AE1 /* OpenInExternalBrowserView.swift */, 7772BE721AF2FECC0081CA7A /* CourseOutlineHeaderCell.swift */, 773A047F1AF2E6DA0076532C /* CourseOutlineTableSource.swift */, + 5F9EE5E829BE4BF300FF5A0A /* ResumeCourseHeaderView.swift */, 773A04771AF2D5DE0076532C /* CourseOutlineViewController.swift */, 46CECC3B1B041D270073C63A /* AdditionalTableViewCell.swift */, 1AE0A6C71BF4F68A00E14917 /* CourseCertificateCell.swift */, @@ -3469,6 +3503,16 @@ 225E9FDB25BAF79E000D6332 /* CelebratoryModalViewController.swift */, 5FA7FEB0266A2BE5006286B6 /* BrowserViewController.swift */, 5F08321327018D810022971F /* BannerViewController.swift */, + 5F2DFCAF29262D8E00BDA40A /* CourseDashboardHeaderView.swift */, + 5F2DFCB129278F0200BDA40A /* NewCourseDashboardViewController.swift */, + 5FFC5D0629E4911800467829 /* NewCourseContentController.swift */, + 5F48E79829E868F900F52C4B /* CourseContentHeaderView.swift */, + 5F5058162A3088DA00399C55 /* CourseContentHeaderBlockPickerCell.swift */, + E0F9C02E29362806003D96DF /* CourseDashboardErrorView.swift */, + E0F9C0302939C497003D96DF /* CourseDashboardAccessErrorView.swift */, + 5FA4266E2966B1110013BBA8 /* CourseAccessHelper.swift */, + 5F6C8A51292DFCBB00E5FA7F /* NewDashboardContentCell.swift */, + 5F5239BE293DB9560046FF07 /* CourseDashboardTabbarView.swift */, ); name = Course; sourceTree = ""; @@ -3846,6 +3890,7 @@ 77D6A9A51C28F98A00E67CCF /* EnrolledCoursesViewController.swift */, 223972E01FE92BB500B2BBEC /* EnrolledTabBarViewController.swift */, 2240FD551FF266E4001D6589 /* TabBarItem.swift */, + 5F111439298A9C7F00964F02 /* ScrollableDelegate.swift */, E0D029F527043CCF001F83B1 /* EnrolledCoursesViewController+Banner.swift */, E0B58CDB2818F7DD0047D78F /* EnrolledCoursesViewController+CourseUpgrade.swift */, 5FC5AE102847944E007E5917 /* LearnContainerHeaderView.swift */, @@ -4225,6 +4270,7 @@ 69ECC60A1D50D1170030CF87 /* Icon-29@3x.png in Resources */, 5F6F2B64255AABA400AA3708 /* Inter-SemiBold.ttf in Resources */, 69ECC6191D50D1170030CF87 /* bt_grey_deactive.png in Resources */, + 5F3807B02A2871EB00D87796 /* DropDownCell.xib in Resources */, 5F6F2B67255AABA400AA3708 /* Inter-LightItalic.ttf in Resources */, 6926CDAC1D59BE3600A16E22 /* ic_next.png in Resources */, 19BB62311A9C656B007DBF47 /* OEXMySettingsViewController.xib in Resources */, @@ -4594,6 +4640,7 @@ files = ( 5FD868382637F0FC0045A149 /* CalendarManager.swift in Sources */, 22A970E824EED00E008FCAF6 /* AppleAuthProvider.swift in Sources */, + E0D31F0729E6A0A600044368 /* NewCourseDateBannerView.swift in Sources */, 77E6486E1C912DA200B6740D /* NetworkManager+StandardInterceptors.swift in Sources */, 7742F8DD1C3C979D009E555A /* DiscoveryConfig.swift in Sources */, 9E71B73F1B1D9DBD009C81E2 /* OEXStyles+Swift.swift in Sources */, @@ -4614,6 +4661,7 @@ 7772BE801AF821540081CA7A /* UIBarButtonItem+OEXBlockActions.m in Sources */, 1904A1511A1386C2006A5524 /* OEXStorageFactory.m in Sources */, E01F68CE2626BF2F006B6716 /* BrazeConfig.swift in Sources */, + 5F6AF0CA29F992D600450B42 /* DropDownCell.swift in Sources */, 778F17851C10A1B50099BF93 /* CourseCatalogDetailViewController.swift in Sources */, 770A3D451AF0167A008F09D9 /* UIControl+OEXBlockActions.m in Sources */, 22E6A219207AA54600D50EDE /* PlayerView.swift in Sources */, @@ -4627,12 +4675,14 @@ E0C6EF971BFF4B9900B315E3 /* UIButton+TintColor.swift in Sources */, B4B6D62A1A949F1B000F44E8 /* OEXRegistrationFieldSelectController.m in Sources */, B4B6D5E51A9490E9000F44E8 /* OEXLoginSplashViewController.m in Sources */, + 5F5058172A3088DA00399C55 /* CourseContentHeaderBlockPickerCell.swift in Sources */, 778F177C1C0D12780099BF93 /* CourseCatalogAPI.swift in Sources */, B7CCC73B209B16B100A66923 /* LayoutConstraint.swift in Sources */, B4B285E21A9A497A00DD603A /* OEXSegmentConfig.m in Sources */, 773A04801AF2E6DA0076532C /* CourseOutlineTableSource.swift in Sources */, B70BD00920B57E8F005F0D19 /* OEXCourseDetailTableViewCell.m in Sources */, 191A002B19405E1B004F7902 /* OEXCourse.m in Sources */, + 5F11143A298A9C7F00964F02 /* ScrollableDelegate.swift in Sources */, 2240FD561FF266E4001D6589 /* TabBarItem.swift in Sources */, E0A2461F1D5DA12A0066C766 /* AppStoreConfig.swift in Sources */, 7778F0981ABB1A6C00B4CDA0 /* NSError+OEXKnownErrors.m in Sources */, @@ -4644,13 +4694,12 @@ BD7C6C3E1DCA32370004D135 /* OEXFonts.swift in Sources */, E00523431CF81D9800B7F5C3 /* DiscussionBlockViewController.swift in Sources */, B70BD00720B57E89005F0D19 /* OEXTabBarItemsCell.m in Sources */, + 5F6AF0C529F992BE00450B42 /* DPDConstants.swift in Sources */, E02F65681E8B94CC000D1C4E /* TZSpacerView.swift in Sources */, 223CE58F25BEEACC0081C30F /* UIImage+GIF.swift in Sources */, 5DD0FFCF1B1D225C00837121 /* DiscussionNewPostViewController.swift in Sources */, - 5FFB73BC2849F10D000523BF /* DPDKeyboardListener.swift in Sources */, B4B285DF1A9A493A00DD603A /* OEXNewRelicConfig.m in Sources */, 9E1D952E1B678E4700ABE764 /* AccessibilityCLButton.swift in Sources */, - 5FFB73C22849F10D000523BF /* DropDown+Appearance.swift in Sources */, 7765025F1C7CEBC5007384E7 /* SingleChildContainingViewController.swift in Sources */, 7C2DD54F1D4A7901006148E0 /* UserPreference.swift in Sources */, 1A3AFFE51BD56370002846F3 /* CropViewController.swift in Sources */, @@ -4672,6 +4721,7 @@ 9EAB5BE91B564C2F00CA9F3C /* ProgressController.swift in Sources */, 5FFC1D69286EECB700DD29EB /* ServerConfiguration.swift in Sources */, B7CCC71F209B16B100A66923 /* ConstraintDescription.swift in Sources */, + 5F5239BF293DB9560046FF07 /* CourseDashboardTabbarView.swift in Sources */, E03168EB270301C300AD20FA /* OEXConfig+SingleKeys.swift in Sources */, 771273271BA36B76008BA397 /* LoggingAnalyticsTracker.swift in Sources */, 9E4E6C411B4BDB5C0034F7EB /* MockResumeCourseProvider.swift in Sources */, @@ -4696,6 +4746,7 @@ E006FDDA24F904F9006E91C2 /* AppleSocial.swift in Sources */, 77092C751B42E4C1004AA1A1 /* UIStatusBarStyle+Styles.swift in Sources */, 191A002E19405E97004F7902 /* OEXLatestUpdates.m in Sources */, + 5FFC5D0729E4911800467829 /* NewCourseContentController.swift in Sources */, 1AFEB1B61BBD5B95004C471D /* ProfilePictureTaker.swift in Sources */, E0A948132786A6DE00BE79D9 /* WebviewCookiesManager.swift in Sources */, B7CCC723209B16B100A66923 /* ConstraintPriorityTarget.swift in Sources */, @@ -4703,11 +4754,13 @@ E09B9D6B1D06C9700080BAE0 /* VersionUpgradeInfoController.swift in Sources */, 9E882B6A1BBA9825007347A2 /* DiscussionTopicCell.swift in Sources */, B7CCC72C209B16B100A66923 /* ConstraintView.swift in Sources */, + B4B285ED1A9B429200DD603A /* OEXNetworkUtility.m in Sources */, 5FFB73C12849F10D000523BF /* DropDownCell.swift in Sources */, BE0D454A192DD4F800D720D6 /* OEXNetworkInterface.m in Sources */, B419295E1A8A3F1900448AD5 /* OEXNetworkManager.m in Sources */, E0A2461D1D5CA6950066C766 /* LogoutApi.swift in Sources */, E0D029F627043CCF001F83B1 /* EnrolledCoursesViewController+Banner.swift in Sources */, + 5F6AF0CB29F992D600450B42 /* DropDown.swift in Sources */, E02E59EF1F0E231000060AE0 /* VersionParser.swift in Sources */, 770A27961A6995DF00DFC6FF /* NSArray+OEXFunctional.m in Sources */, 9CADE2F71A91E91A00ACDAF7 /* NSURL+OEXPathExtensions.m in Sources */, @@ -4722,10 +4775,12 @@ 778F17781C0D123F0099BF93 /* CourseCatalogViewController.swift in Sources */, 1AB539E41BFA24DC0065501F /* CertificateViewController.swift in Sources */, 69E1CD011D7D7BA300531449 /* OEXRegistrationViewController+Swift.swift in Sources */, + E0F9C0312939C497003D96DF /* CourseDashboardAccessErrorView.swift in Sources */, 19BB622A1A9B28F1007DBF47 /* OEXRegistrationFieldWrapperView.m in Sources */, 9E1081081B8B7EEC00888746 /* PaginatedFeed.swift in Sources */, B7CCC720209B16B100A66923 /* ConstraintMakerPriortizable.swift in Sources */, B4B6D6061A949EFC000F44E8 /* OEXRegistrationErrorMessage.m in Sources */, + 5F6AF0C429F992BE00450B42 /* DPDKeyboardListener.swift in Sources */, 80056C9A1B0CDE1A0004D85C /* DiscussionResponsesViewController.swift in Sources */, 775716B91CB804E40091AB10 /* UserProfilePresenter.swift in Sources */, 22AB111F2375695C007D03D2 /* PushLink.swift in Sources */, @@ -4744,7 +4799,6 @@ 1A8172531C3C21DE007262AA /* TwitterConfig.swift in Sources */, B4D5C7ED1A6FBAA300427D1D /* OEXSession.m in Sources */, B4B6D6481A95CF33000F44E8 /* OEXUserLicenseAgreementViewController.m in Sources */, - 5FFB73C02849F10D000523BF /* DropDown.swift in Sources */, 69EFBB0D1E36270000FF66C4 /* RatingContainerView.swift in Sources */, 5F28980C2507B19400BF76DF /* CourseDateBannerView.swift in Sources */, B4B6D62D1A949F1B000F44E8 /* OEXRegistrationFieldCheckBoxController.m in Sources */, @@ -4791,6 +4845,7 @@ 8FE04B4D1A1E2637007F88B8 /* OEXFBSocial.m in Sources */, 9EDB10B41B0C732300C760C6 /* CourseGenericBlockTableViewCell.swift in Sources */, B78823A61FC6F59A00F9CD61 /* LogistrationTextField.swift in Sources */, + 5FE2B8EE297ACBD60071FEF7 /* GeneralErrorView.swift in Sources */, B4D5C7EC1A6FBAA300427D1D /* OEXPersistentCredentialStorage.m in Sources */, 7713DFBF1AC3C16C005A1756 /* OEXRegisteringUserDetails.m in Sources */, E0B58CE028193AE30047D78F /* OEXAnalytics+CourseUpgrade.swift in Sources */, @@ -4809,6 +4864,7 @@ 199B9B751935E35D00081A09 /* OEXHelperVideoDownload.m in Sources */, BECB7B1F1924C0C3009C77F1 /* OEXAppDelegate.m in Sources */, B7CCC722209B16B100A66923 /* ConstraintViewDSL.swift in Sources */, + 5F2DFCB029262D8E00BDA40A /* CourseDashboardHeaderView.swift in Sources */, 5FA7FEB1266A2BE5006286B6 /* BrowserViewController.swift in Sources */, 1904A14F1A1386C2006A5524 /* ResourceData.m in Sources */, B7CCC721209B16B100A66923 /* ConstraintAttributes.swift in Sources */, @@ -4835,7 +4891,6 @@ 193B50481945A0520038E11C /* OEXCustomLabel.m in Sources */, 77000A371A76EF8C007D306C /* NSString+OEXValidation.m in Sources */, 9EC0AC7F1BCE371D00291B64 /* UIButton+Animations.swift in Sources */, - 5FFB73BD2849F10D000523BF /* DPDConstants.swift in Sources */, E08A008B1CF7546200F92DE7 /* DiscussionModel.swift in Sources */, 9ED168B01B29A9EF00AA7B5B /* ResumeCourseItem.swift in Sources */, 227386AA2057C0AA007AA396 /* TranscriptParser.swift in Sources */, @@ -4905,6 +4960,7 @@ 5DD0FFC31B17ED2100837121 /* DiscussionCommentsViewController.swift in Sources */, 770C514D1B1685F5009B9696 /* AuthenticatedWebViewController.swift in Sources */, E0D159911EB87658005E2A76 /* WhatsNewViewController.swift in Sources */, + 5F2DFCB229278F0200BDA40A /* NewCourseDashboardViewController.swift in Sources */, 9C9B7F6D1A8CC68400A857B2 /* OEXCourseInfoViewController.m in Sources */, 199B9B6F1935C72900081A09 /* DACircularProgressView.m in Sources */, 1AA79ABA1BAC7F710011D381 /* ProfileAPI.swift in Sources */, @@ -4914,6 +4970,7 @@ 779D1CE11B8E6FE000FCC847 /* PullRefreshController.swift in Sources */, B4D31D351AB1904200C8D45C /* NSJSONSerialization+OEXSafeAccess.m in Sources */, 223895CF1F25CF76005B9C15 /* SwipeableCell.swift in Sources */, + E0F9C02F29362806003D96DF /* CourseDashboardErrorView.swift in Sources */, 5F2C5A19242C99EE00FBF986 /* CollectionPaginationManipulator.swift in Sources */, 777DE7141C1630110068E280 /* CourseMediaInfo.swift in Sources */, E01591F21D533F3B00201B15 /* UIViewController+CommonAdditions.swift in Sources */, @@ -4961,6 +5018,7 @@ 9E0CC1031BA9574E00A1CFDB /* SpinnerButton.swift in Sources */, 191A002419405B91004F7902 /* OEXUserDetails.m in Sources */, 021AD6251F150BA3009AF653 /* Dictionary+SafeAccess.swift in Sources */, + 5F6AF0C329F992BE00450B42 /* DPDUIView+Extension.swift in Sources */, 77BECB0D1B0A8BBD00894276 /* UIEdgeInsets+Geometry.swift in Sources */, B7CCC728209B16B100A66923 /* ConstraintPriority.swift in Sources */, E09B73722342034400D0EE45 /* FCMProvider.swift in Sources */, @@ -4976,6 +5034,7 @@ 1AFEB1B11BBD51EE004C471D /* JSONFormBuilderChooser.swift in Sources */, 19321F651961698B00B7D75C /* OEXDownloadTableCell.m in Sources */, E40AEA73218D074700049C39 /* YoutubeVideoConfig.swift in Sources */, + 5F6AF0C729F992CE00450B42 /* DropDown+Appearance.swift in Sources */, B7CCC730209B16B100A66923 /* ConstraintOffsetTarget.swift in Sources */, E076A5C21C7DB624008A99C6 /* DiscussionResponsesDataController.swift in Sources */, 5F99F3BD26C68308000605B4 /* VideoDownloadQualityViewController.swift in Sources */, @@ -4988,6 +5047,7 @@ 775434831AD7394D00635A40 /* OEXPushNotificationManager.m in Sources */, 7793764F1BED404C00900A8C /* OEXConfig+URLCredentialProvider.swift in Sources */, E0060349265650A400CEBB12 /* BrazeListener.swift in Sources */, + 5F9EE5E929BE4BF300FF5A0A /* ResumeCourseHeaderView.swift in Sources */, 77E648591C91235A00B6740D /* OEXConfig+AppFeatures.swift in Sources */, 2254EF03207610FF00BA183C /* CustomPlayerButton.swift in Sources */, 77E00B711B82A06F00573622 /* GrowingTextViewController.swift in Sources */, @@ -5017,7 +5077,6 @@ E03E6A151D38C91F00944AAA /* OfflineSupportViewController.swift in Sources */, 773B1D421B1F48F100B861DF /* CourseOutlineHeaderView.swift in Sources */, B7A09BD520F88FBC00A6C249 /* ProgramsViewController.swift in Sources */, - 5FFB73BE2849F10D000523BF /* DPDUIView+Extension.swift in Sources */, 22FDD0AC2552691600BA378D /* FirebaseRemoteConfiguration.swift in Sources */, 2255C94E2111BADC005F7C8C /* ProgramConfig.swift in Sources */, E0357C4726CA59120041947A /* CourseUpgradeHandler.swift in Sources */, @@ -5036,6 +5095,7 @@ 220B1D352119B8380048ACBA /* OEXMicrosoftAuthProvider.swift in Sources */, B4B6C80D1A9C7AEA004F0FAF /* OEXPlaceholderTextView.m in Sources */, 77E648781C91330D00B6740D /* Result+Conveniences.swift in Sources */, + 5F6C8A52292DFCBB00E5FA7F /* NewDashboardContentCell.swift in Sources */, E0241782254043BB00E397EA /* SEGFirebaseIntegrationFactory.m in Sources */, 9EF21A9E1B4697CA000048F8 /* ResumeCourseController.swift in Sources */, 698EE6E91E38A53600DF4FEA /* RatingView.swift in Sources */, @@ -5047,6 +5107,7 @@ 77691F981B38A09B003922F2 /* NSAttributedString+Combination.swift in Sources */, 77ADF4B31AC1FACC00AC8955 /* OEXTextStyle.m in Sources */, 221D31821EC08B69001D8D71 /* CourseDatesViewController.swift in Sources */, + 5F48E79929E868F900F52C4B /* CourseContentHeaderView.swift in Sources */, 77B463911A30F6310083453A /* OEXAnnouncement.m in Sources */, E055D53D1D25256500230CA4 /* NetworkManager+ResponseInterceptors.swift in Sources */, 1A1181E41BB462D700CFE52B /* RemoteImage.swift in Sources */, @@ -5059,6 +5120,7 @@ 1AB539F51BFDFBC10065501F /* DebugMenuViewController.swift in Sources */, 5D43B18A1B0C1F9200448B52 /* MenuOptionsViewController.swift in Sources */, E0B58CDC2818F7DF0047D78F /* EnrolledCoursesViewController+CourseUpgrade.swift in Sources */, + 5FA4266F2966B1110013BBA8 /* CourseAccessHelper.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };