Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
d75edfe
Create UI
petkybenedek Mar 3, 2026
7323f78
Merge branch 'master' into feature/MBL-19529-Dashboard-simple-edit-mode
petkybenedek Mar 3, 2026
aeb8276
Merge master
petkybenedek Mar 3, 2026
3713440
Correct animation
petkybenedek Mar 3, 2026
35fdfc9
Correct logic
petkybenedek Mar 3, 2026
bffad74
Correct tests and claude findings
petkybenedek Mar 3, 2026
eb5320a
Requested changes
petkybenedek Mar 4, 2026
eeb566e
Color selector corrections
petkybenedek Mar 5, 2026
52aac49
Merge branch 'master' into feature/MBL-19529-Dashboard-simple-edit-mode
petkybenedek Mar 5, 2026
2336355
Remove no longer set variable tests.
vargaat Mar 6, 2026
4742afb
Remove unnecessary test
petkybenedek Mar 6, 2026
3e2bca1
Merge branch 'feature/MBL-19529-Dashboard-simple-edit-mode' of https:…
petkybenedek Mar 6, 2026
19bd6ab
Increase popver size
petkybenedek Mar 6, 2026
198391f
- Split DashboardWidgetIdentifier into SystemWidgetIdentifier (fixe…
vargaat Mar 6, 2026
7c4c007
Add color saving.
vargaat Mar 9, 2026
2afe479
Add course settings switches. Move converences to system widgets.
vargaat Mar 9, 2026
70ad1d1
Add unit tests.
vargaat Mar 9, 2026
2ed5649
Use ID based color saving.
vargaat Mar 10, 2026
a7ebd90
Requested changes, change settings presentation logic
petkybenedek Mar 10, 2026
09d93cb
Update unit tests.
vargaat Mar 10, 2026
40bac3e
Merge branch 'feature/MBL-19529-Dashboard-simple-edit-mode' into feat…
vargaat Mar 10, 2026
763f10a
Rename dashboard settings view to screen.
vargaat Mar 11, 2026
704c4d6
Move factory methods to widget enums.
vargaat Mar 11, 2026
3dbcdcd
Also move custom settings view factory to widget id file.
vargaat Mar 11, 2026
afeca50
Update colors.
vargaat Mar 11, 2026
3bad66c
Update hidden widget update logic.
vargaat Mar 11, 2026
614361b
Fix unit test. Update done button color.
vargaat Mar 11, 2026
82f547c
Merge branch 'master' into feature/MBL-19529-Dashboard-simple-edit-mode
vargaat Mar 11, 2026
47f125f
Merge branch 'feature/MBL-19529-Dashboard-simple-edit-mode' into feat…
vargaat Mar 11, 2026
08f384c
Implement code review suggestions.
vargaat Mar 12, 2026
3359454
Merge branch 'master' into feature/MBL-19529-Learner-Dashboard-settin…
vargaat Mar 12, 2026
df062d5
Shorten test file name.
vargaat Mar 12, 2026
617c604
Create FlexibleGrid and place the dashboard colors in it.
petkybenedek Mar 13, 2026
9c8dd1e
Extract default course colors from interactor.
vargaat Mar 13, 2026
a83bbaa
Remove unnecessary helper.
vargaat Mar 13, 2026
fc45bfb
Implement multiple UI related code review suggestions.
vargaat Mar 13, 2026
c0f9aa0
Add back bottom padding.
vargaat Mar 13, 2026
882f35a
Fix lint violation.
vargaat Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Core/Core/Common/CommonUI/InstUI/Styles/Elevation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,24 +43,24 @@ extension View {
public func elevation(
_ shape: InstUI.Styles.Elevation.Shape,
background: some ShapeStyle,
shadowColor: Color = .black
isShadowVisible: Bool = true
) -> some View {
self.elevation(
cornerRadius: shape.cornerRadius,
background: background,
shadowColor: shadowColor
isShadowVisible: isShadowVisible
)
}

public func elevation(
cornerRadius: CGFloat,
background: some ShapeStyle,
shadowColor: Color = .black
isShadowVisible: Bool = true
) -> some View {
self
.background(background)
.cornerRadius(cornerRadius)
.shadow(color: shadowColor.opacity(0.08), radius: 2, y: 2)
.shadow(color: shadowColor.opacity(0.16), radius: 2, y: 1)
.shadow(color: .black.opacity(isShadowVisible ? 0.08 : 0), radius: 2, y: 2)
.shadow(color: .black.opacity(isShadowVisible ? 0.16 : 0), radius: 2, y: 1)
}
}
10 changes: 8 additions & 2 deletions Core/Core/Common/CommonUI/InstUI/Views/Cells/ToggleCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,16 @@ extension InstUI {

private let label: Label
@Binding private var value: Bool
private let dividerStyle: InstUI.Divider.Style

public init(label: Label, value: Binding<Bool>) {
public init(
label: Label,
value: Binding<Bool>,
dividerStyle: InstUI.Divider.Style = .full
) {
self.label = label
self._value = value
self.dividerStyle = dividerStyle
}

public var body: some View {
Expand All @@ -40,7 +46,7 @@ extension InstUI {
// best effort estimations to match the height of other cells, correcting for Toggle
.padding(.top, 10)
.padding(.bottom, 8)
InstUI.Divider()
InstUI.Divider(dividerStyle)
}
}
}
Expand Down
139 changes: 139 additions & 0 deletions Core/Core/Common/CommonUI/Layout/FlexibleGrid.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//
// This file is part of Canvas.
// Copyright (C) 2026-present Instructure, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//

import SwiftUI

/// FlexibleGrid places as many subviews in a row as possible. The remaining space is distributed evenly between the row's subviews.
/// If the last row is not complete the subviews are placed from the leading edge.
public struct FlexibleGrid: Layout {
let minimumSpacing: CGFloat
let lineSpacing: CGFloat

public init(minimumSpacing: CGFloat = 8, lineSpacing: CGFloat = 8) {
self.minimumSpacing = minimumSpacing
self.lineSpacing = lineSpacing
}

public func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
guard !subviews.isEmpty, let maxWidth = proposal.width else { return .zero }

let itemWidth = measureItemWidth(subviews: subviews, maxWidth: maxWidth)
let columns = columnCount(itemWidth: itemWidth, maxWidth: maxWidth)
let rowCount = (subviews.count + columns - 1) / columns
let rowHeight = measureRowHeight(subviews: subviews, maxWidth: maxWidth)

let totalHeight = CGFloat(rowCount) * rowHeight + CGFloat(max(rowCount - 1, 0)) * lineSpacing

let contentWidth = {
if let proposedWidth = proposal.width {
return proposedWidth
} else {
let gaps = max(columns - 1, 0)
return CGFloat(columns) * itemWidth + CGFloat(gaps) * minimumSpacing
}
}()

return CGSize(width: contentWidth, height: totalHeight)
}

public func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
guard !subviews.isEmpty else { return }

let itemWidth = measureItemWidth(subviews: subviews, maxWidth: bounds.width)
let columns = columnCount(itemWidth: itemWidth, maxWidth: bounds.width)
let rowHeight = measureRowHeight(subviews: subviews, maxWidth: bounds.width)

let totalItemWidth = CGFloat(columns) * itemWidth
let gaps = max(columns - 1, 0)
let uniformSpacing = gaps > 0 ? (bounds.width - totalItemWidth) / CGFloat(gaps) : 0

for (index, subview) in subviews.enumerated() {
let col = index % columns
let row = index / columns

let x = bounds.minX + CGFloat(col) * (itemWidth + uniformSpacing)

let y = bounds.minY + CGFloat(row) * (rowHeight + lineSpacing)
let size = subview.sizeThatFits(ProposedViewSize(width: itemWidth, height: nil))
let yOffset = (rowHeight - size.height) / 2

subview.place(
at: CGPoint(x: x, y: y + yOffset),
anchor: .topLeading,
proposal: ProposedViewSize(width: size.width, height: size.height)
)
}
}

private func measureItemWidth(subviews: Subviews, maxWidth: CGFloat) -> CGFloat {
subviews.reduce(CGFloat(0)) { maxSoFar, subview in
let size = subview.sizeThatFits(ProposedViewSize(width: maxWidth, height: nil))
return max(maxSoFar, size.width)
}
}

private func measureRowHeight(subviews: Subviews, maxWidth: CGFloat) -> CGFloat {
subviews.reduce(CGFloat(0)) { maxSoFar, subview in
let size = subview.sizeThatFits(ProposedViewSize(width: maxWidth, height: nil))
return max(maxSoFar, size.height)
}
}

private func columnCount(itemWidth: CGFloat, maxWidth: CGFloat) -> Int {
guard itemWidth > 0 else { return 1 }
var count = 1
while CGFloat(count + 1) * itemWidth + CGFloat(count) * minimumSpacing <= maxWidth {
count += 1
}
return count
}
}

#Preview("Left to Right") {
let colors: [Color] = [.red, .blue, .green, .yellow, .indigo, .teal, .purple]

FlexibleGrid {
ForEach(colors, id: \.self) { color in
Circle()
.fill(color)
.scaledFrame(size: 40)
}
}
}

#Preview("Right to Left") {
let colors: [Color] = [.red, .blue, .green, .yellow, .indigo, .teal, .purple]

FlexibleGrid {
ForEach(colors, id: \.self) { color in
Circle()
.fill(color)
.scaledFrame(size: 40)
}
}
.environment(\.layoutDirection, .rightToLeft)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// This file is part of Canvas.
// Copyright (C) 2026-present Instructure, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//

import UIKit

public extension CourseColorData {
static let all: [CourseColorData] = [
.init(persistentId: "plum", color: .course1, name: String(localized: "Plum", bundle: .core, comment: "This is a name of a color.")),
.init(persistentId: "fuchsia", color: .course2, name: String(localized: "Fuchsia", bundle: .core, comment: "This is a name of a color.")),
.init(persistentId: "violet", color: .course3, name: String(localized: "Violet", bundle: .core, comment: "This is a name of a color.")),
.init(persistentId: "ocean", color: .course4, name: String(localized: "Ocean", bundle: .core, comment: "This is a name of a color.")),
.init(persistentId: "sky", color: .course5, name: String(localized: "Sky", bundle: .core, comment: "This is a name of a color.")),
.init(persistentId: "sea", color: .course6, name: String(localized: "Sea", bundle: .core, comment: "This is a name of a color.")),
.init(persistentId: "aurora", color: .course7, name: String(localized: "Aurora", bundle: .core, comment: "This is a name of a color.")),
.init(persistentId: "forest", color: .course8, name: String(localized: "Forest", bundle: .core, comment: "This is a name of a color.")),
.init(persistentId: "honey", color: .course9, name: String(localized: "Honey", bundle: .core, comment: "This is a name of a color.")),
.init(persistentId: "copper", color: .course10, name: String(localized: "Copper", bundle: .core, comment: "This is a name of a color.")),
.init(persistentId: "rose", color: .course11, name: String(localized: "Rose", bundle: .core, comment: "This is a name of a color.")),
.init(persistentId: "stone", color: .course12, name: String(localized: "Stone", bundle: .core, comment: "This is a name of a color."))
]
}

public struct CourseColorData: Identifiable {
public let persistentId: String
public let color: UIColor
public let name: String

public var id: String { persistentId }

public init(persistentId: String, color: UIColor, name: String) {
self.persistentId = persistentId
self.color = color
self.name = name
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,42 +19,25 @@
import UIKit

public protocol CourseColorsInteractor {
/// These are the pre-defined course colors the user can choose from. The values are the color names for accessibility.
var colors: KeyValuePairs<UIColor, String> { get }
/// These are the pre-defined course colors the user can choose from.
var colors: [CourseColorData] { get }

/// - parameters:
/// - colorHex: The hex color of the color in light theme.
func courseColorFromAPIColor(_ colorHex: String) -> UIColor
}

public class CourseColorsInteractorLive: CourseColorsInteractor {
// Used elsewhere without the live interactor's logic
public static let colors: KeyValuePairs<UIColor, String> = [
.course1: String(localized: "Plum", bundle: .core, comment: "This is a name of a color."),
.course2: String(localized: "Fuchsia", bundle: .core, comment: "This is a name of a color."),
.course3: String(localized: "Violet", bundle: .core, comment: "This is a name of a color."),
.course4: String(localized: "Ocean", bundle: .core, comment: "This is a name of a color."),
.course5: String(localized: "Sky", bundle: .core, comment: "This is a name of a color."),
.course6: String(localized: "Sea", bundle: .core, comment: "This is a name of a color."),
.course7: String(localized: "Aurora", bundle: .core, comment: "This is a name of a color."),
.course8: String(localized: "Forest", bundle: .core, comment: "This is a name of a color."),
.course9: String(localized: "Honey", bundle: .core, comment: "This is a name of a color."),
.course10: String(localized: "Copper", bundle: .core, comment: "This is a name of a color."),
.course11: String(localized: "Rose", bundle: .core, comment: "This is a name of a color."),
.course12: String(localized: "Stone", bundle: .core, comment: "This is a name of a color.")
]
public let colors: [CourseColorData]

public var colors: KeyValuePairs<UIColor, String> {
Self.colors
}

public init() {
public init(colors: [CourseColorData] = CourseColorData.all) {
self.colors = colors
}

public func courseColorFromAPIColor(_ colorHex: String) -> UIColor {
let predefinedColor = colors.first { (color, _) in
color.variantForLightMode.hexString == colorHex
}?.key
let predefinedColor = colors.first {
$0.color.variantForLightMode.hexString == colorHex
}?.color

if let predefinedColor {
return predefinedColor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,15 @@ public struct CustomizeCourseView: View {
width: width - 32 // account for padding coming from EditorRow
) { itemIndex in
let item = viewModel.colors[itemIndex]
let uiColor = item.key
let isSelected = viewModel.shouldShowCheckmark(for: uiColor)
Button(action: { viewModel.color = uiColor }, label: {
let isSelected = viewModel.shouldShowCheckmark(for: item.color)
Button(action: { viewModel.color = item.color }, label: {
Circle()
.fill(Color(uiColor))
.fill(Color(item.color))
.overlay(isSelected ? Image.checkSolid.foregroundColor(.textLightest) : nil)
.animation(.default, value: viewModel.color)
})
.accessibility(addTraits: isSelected ? .isSelected : [])
.accessibility(label: Text(item.value))
.accessibility(label: Text(item.name))
}
.padding(.vertical, 12)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class CustomizeCourseViewModel: ObservableObject {

// MARK: - Outputs
@Published public private(set) var isLoading: Bool = false
public let colors: KeyValuePairs<UIColor, String>
public let colors: [CourseColorData]
public let courseImage: URL?
public let hideColorOverlay: Bool
public let dismissView = PassthroughSubject<Void, Never>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ class CourseColorsInteractorLiveTests: XCTestCase {
super.tearDown()
}

func testDefaultColorsAreCourseColorDataAll() {
XCTAssertEqual(testee.colors.map(\.persistentId), CourseColorData.all.map(\.persistentId))
}

func testColorsSupportDarkMode() {
for (color, _) in testee.colors {
XCTAssertNotEqual(color.variantForLightMode, color.variantForDarkMode)
for colorData in testee.colors {
XCTAssertNotEqual(colorData.color.variantForLightMode, colorData.color.variantForDarkMode)
}

XCTAssertEqual(testee.colors.count, 12)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class CustomizeCourseViewModelTests: CoreTestCase {
func testInitialPropertiesMapped() {
XCTAssertEqual(testee.isLoading, false)
XCTAssertTrue(testee.colors.elementsEqual(colorsInteractor.colors) { color1, color2 in
color1.key == color2.key && color1.value == color2.value
color1.persistentId == color2.persistentId
})
XCTAssertEqual(testee.courseImage, .make())
XCTAssertEqual(testee.hideColorOverlay, true)
Expand Down
Loading
Loading