From 8bc4fcdc476003d57ab92e79cb778319c3f67691 Mon Sep 17 00:00:00 2001 From: pepaskopec-blip Date: Tue, 22 Sep 2026 08:01:33 +0200 Subject: [PATCH] Restore serpentine exercise paths on the Mac subject maps. Co-authored-by: Cursor --- ios/Maturita.xcodeproj/project.pbxproj | 4 + ios/Maturita/UI/PathMap.swift | 269 +++++++++++++++++++++++ ios/Maturita/UI/Screens.swift | 183 +++++++++++++-- macos/Maturita.xcodeproj/project.pbxproj | 4 + 4 files changed, 443 insertions(+), 17 deletions(-) create mode 100644 ios/Maturita/UI/PathMap.swift diff --git a/ios/Maturita.xcodeproj/project.pbxproj b/ios/Maturita.xcodeproj/project.pbxproj index 5744532..4542c79 100644 --- a/ios/Maturita.xcodeproj/project.pbxproj +++ b/ios/Maturita.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ A101000000000000000009 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000009 /* Updater.swift */; }; A101000000000000000010 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000010 /* Components.swift */; }; A101000000000000000011 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000011 /* Icons.swift */; }; + A101000000000000000012 /* PathMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000012 /* PathMap.swift */; }; A101000000000000000013 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000013 /* Screens.swift */; }; A101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000014 /* Exercises.swift */; }; A101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000015 /* ExercisesMore.swift */; }; @@ -36,6 +37,7 @@ A201000000000000000009 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = ""; }; A201000000000000000010 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; A201000000000000000011 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = ""; }; + A201000000000000000012 /* PathMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathMap.swift; sourceTree = ""; }; A201000000000000000013 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = ""; }; A201000000000000000014 /* Exercises.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exercises.swift; sourceTree = ""; }; A201000000000000000015 /* ExercisesMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExercisesMore.swift; sourceTree = ""; }; @@ -103,6 +105,7 @@ children = ( A201000000000000000010 /* Components.swift */, A201000000000000000011 /* Icons.swift */, + A201000000000000000012 /* PathMap.swift */, A201000000000000000013 /* Screens.swift */, A201000000000000000014 /* Exercises.swift */, A201000000000000000015 /* ExercisesMore.swift */, @@ -224,6 +227,7 @@ A101000000000000000009 /* Updater.swift in Sources */, A101000000000000000010 /* Components.swift in Sources */, A101000000000000000011 /* Icons.swift in Sources */, + A101000000000000000012 /* PathMap.swift in Sources */, A101000000000000000013 /* Screens.swift in Sources */, A101000000000000000014 /* Exercises.swift in Sources */, A101000000000000000015 /* ExercisesMore.swift in Sources */, diff --git a/ios/Maturita/UI/PathMap.swift b/ios/Maturita/UI/PathMap.swift new file mode 100644 index 0000000..0065f6b --- /dev/null +++ b/ios/Maturita/UI/PathMap.swift @@ -0,0 +1,269 @@ +import SwiftUI + +struct MapNode: Identifiable { + let id: String + let label: String + let locked: Bool + let done: Bool + let current: Bool + var finish: Bool = false + var destination: Route? +} + +private struct NodePos { + var x: CGFloat + var y: CGFloat +} + +private struct Serpentine { + let points: [NodePos] + let width: CGFloat + let height: CGFloat +} + +private func serpentine( + count n: Int, + avail: CGFloat, + mx: CGFloat, + my: CGFloat, + spac: CGFloat, + gap: CGFloat, + wave: CGFloat, + lift: CGFloat = 0 +) -> Serpentine { + guard n > 0 else { return Serpentine(points: [], width: avail, height: my * 2) } + let scale = min(1, max(0.55, avail / (2 * mx + CGFloat(max(n - 1, 0)) * spac))) + let smx = mx * scale + let smy = my * scale + let sspac = spac * scale + let sgap = gap * scale + let swave = wave * scale + let slift = lift * scale + + var rows = 1 + while rows <= n { + let cols = (n + rows - 1) / rows + if 2 * smx + CGFloat(cols - 1) * sspac <= avail + 1 { break } + rows += 1 + } + rows = min(rows, n) + let cols = max(1, (n + rows - 1) / rows) + let cw = 2 * smx + CGFloat(cols - 1) * sspac + let ch = 2 * smy + CGFloat(rows - 1) * sgap + slift + let phase = CGFloat(2 * Double.pi * 1.7 / Double(max(n - 1, 1))) + + var pts = Array(repeating: NodePos(x: 0, y: 0), count: n) + for r in 0.. NodePos { + let n = pts.count + guard n > 1 else { return pts.first ?? NodePos(x: 0, y: 0) } + var k = Int(t) + var u = t - CGFloat(k) + if k < 0 { k = 0; u = 0 } + if k >= n - 1 { k = n - 2; u = 1 } + let p1 = pts[k] + let p2 = pts[k + 1] + let p0 = k - 1 >= 0 + ? pts[k - 1] + : NodePos(x: p1.x - (p2.x - p1.x), y: p1.y - (p2.y - p1.y)) + let p3 = k + 2 < n + ? pts[k + 2] + : NodePos(x: p2.x + (p2.x - p1.x), y: p2.y + (p2.y - p1.y)) + let u2 = u * u + let u3 = u2 * u + let x = 0.5 * (2 * p1.x + (-p0.x + p2.x) * u + + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * u2 + + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * u3) + let y = 0.5 * (2 * p1.y + (-p0.y + p2.y) * u + + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * u2 + + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * u3) + return NodePos(x: x, y: y) +} + +struct PathMap: View { + let nodes: [MapNode] + let palette: Palette + var litUntil: CGFloat = 0 + var nodeSize: CGFloat = 72 + var mx: CGFloat = 90 + var my: CGFloat = 110 + var spac: CGFloat = 170 + var gap: CGFloat = 170 + var wave: CGFloat = 28 + var branch: (index: Int, node: MapNode)? = nil + + var body: some View { + GeometryReader { geo in + let layout = serpentine( + count: nodes.count, + avail: max(geo.size.width, 320), + mx: mx, + my: my, + spac: spac, + gap: gap, + wave: wave, + lift: branch == nil ? 0 : 40 + ) + ScrollView([.horizontal, .vertical]) { + ZStack(alignment: .topLeading) { + railCanvas(points: layout.points) + .frame(width: layout.width, height: layout.height + 96) + + ForEach(Array(nodes.enumerated()), id: \.element.id) { i, node in + let pt = layout.points[i] + nodeView(node, index: i) + .position(x: pt.x, y: pt.y) + Text(node.label) + .font(.caption.weight(.semibold)) + .foregroundStyle(node.locked ? palette.overlay : palette.text) + .multilineTextAlignment(.center) + .frame(width: 140) + .position(x: pt.x, y: pt.y + nodeSize / 2 + 18) + } + + if let branch, branch.index < layout.points.count { + let base = layout.points[branch.index] + let tip = NodePos(x: base.x + 48, y: base.y - 96) + Path { p in + p.move(to: CGPoint(x: base.x, y: base.y - nodeSize / 2)) + p.addQuadCurve( + to: CGPoint(x: tip.x, y: tip.y + nodeSize / 2), + control: CGPoint(x: base.x + 10, y: tip.y + 20) + ) + } + .stroke(palette.rail, style: StrokeStyle(lineWidth: 8, lineCap: .round)) + nodeView(branch.node, index: nil) + .position(x: tip.x, y: tip.y) + Text(branch.node.label) + .font(.caption.weight(.semibold)) + .foregroundStyle(palette.text) + .multilineTextAlignment(.center) + .frame(width: 140) + .position(x: tip.x, y: tip.y + nodeSize / 2 + 18) + } + } + .frame(width: max(layout.width, geo.size.width), height: layout.height + 96, alignment: .topLeading) + } + } + .frame(minHeight: 360) + } + + @ViewBuilder + private func railCanvas(points: [NodePos]) -> some View { + Canvas { ctx, _ in + guard points.count >= 2 else { return } + func stroke(_ t0: CGFloat, _ t1: CGFloat, color: Color, width: CGFloat, dashed: Bool = false) { + var path = Path() + let steps = max(1, Int((t1 - t0) * 48)) + let start = catmull(points, t0) + path.move(to: CGPoint(x: start.x, y: start.y)) + for s in 1...steps { + let t = t0 + (t1 - t0) * CGFloat(s) / CGFloat(steps) + let pt = catmull(points, t) + path.addLine(to: CGPoint(x: pt.x, y: pt.y)) + } + var style = StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round) + if dashed { style.dash = [1, 18] } + ctx.stroke(path, with: .color(color), style: style) + } + let end = CGFloat(points.count - 1) + stroke(0, end, color: palette.rail, width: 12) + if litUntil > 0 { + stroke(0, min(litUntil, end), color: palette.accent, width: 12) + } + let mutedStart = litUntil > 0 ? litUntil : 0 + if mutedStart < end { + stroke(mutedStart, end, color: palette.subtext.opacity(0.22), width: 12, dashed: true) + } + } + } + + @ViewBuilder + private func nodeView(_ node: MapNode, index: Int?) -> some View { + let content = ZStack { + Circle() + .fill(nodeFill(node)) + .overlay { + if node.locked { + Circle().strokeBorder(palette.lockedBorder, lineWidth: 1) + } + } + if node.finish { + VStack(spacing: 2) { + if let index { Text("\(index + 1)").font(.title3.weight(.black)) } + FinishFlagBadge(palette: palette) + } + .foregroundStyle(palette.overlay) + } else if node.locked { + VStack(spacing: 2) { + if let index { Text("\(index + 1)").font(.title3.weight(.black)) } + Image(systemName: "lock.fill").font(.caption.weight(.bold)) + } + .foregroundStyle(palette.overlay) + } else if node.done { + VStack(spacing: 2) { + if let index { Text("\(index + 1)").font(.title3.weight(.black)) } + Image(systemName: "checkmark").font(.caption.weight(.bold)) + } + .foregroundStyle(palette.onAccent) + } else { + if let index { + Text("\(index + 1)") + .font(.title3.weight(.black)) + .foregroundStyle(node.current ? palette.onAccent : palette.text) + } + } + } + .frame(width: nodeSize, height: nodeSize) + .shadow(color: .black.opacity(node.locked ? 0 : 0.12), radius: 6, y: 2) + + if let dest = node.destination, !node.locked { + NavigationLink(value: dest) { content } + .buttonStyle(.plain) + } else { + content.opacity(node.locked ? 0.55 : 1) + } + } + + private func nodeFill(_ node: MapNode) -> Color { + if node.locked { return palette.lockedBg } + if node.current || node.done { return palette.accent } + return palette.node + } +} + +private struct FinishFlagBadge: View { + let palette: Palette + + var body: some View { + let cell: CGFloat = 4 + Canvas { ctx, _ in + for iy in 0..<2 { + for ix in 0..<3 { + let dark = ((ix + iy) & 1) == 1 + let rect = CGRect(x: CGFloat(ix) * cell, y: CGFloat(iy) * cell, width: cell, height: cell) + ctx.fill( + Path(rect), + with: .color(dark ? palette.finishLight : palette.finishStroke) + ) + } + } + } + .frame(width: cell * 3, height: cell * 2) + } +} diff --git a/ios/Maturita/UI/Screens.swift b/ios/Maturita/UI/Screens.swift index 6042f82..1ded07c 100644 --- a/ios/Maturita/UI/Screens.swift +++ b/ios/Maturita/UI/Screens.swift @@ -507,6 +507,54 @@ struct RoadmapScreen: View { @ObservedObject var vm: AppModel var body: some View { + #if os(macOS) + roadmapPath + #else + roadmapList + #endif + } + + #if os(macOS) + private var roadmapPath: some View { + let current = vm.content.german.firstIndex { u in + u.bool("unlocked") && u.strs("names").indices.contains { !vm.progress.germanDone(u.int("id"), $0 + 1) } + } + let lit = CGFloat(max((current ?? vm.content.german.count) - 1, 0)) + return PathMap( + nodes: vm.content.german.enumerated().map { i, u in + let unlocked = u.bool("unlocked") + let names = u.strs("names") + let done = unlocked && !names.isEmpty && names.indices.allSatisfy { + vm.progress.germanDone(u.int("id"), $0 + 1) + } + return MapNode( + id: u.str("page"), + label: u.str("title"), + locked: !unlocked, + done: done, + current: i == current, + finish: i == vm.content.german.count - 1, + destination: unlocked ? .unitMap(u.int("id")) : nil + ) + }, + palette: vm.palette, + litUntil: lit, + nodeSize: 78, + mx: 100, + my: 120, + spac: 190, + gap: 180, + wave: 32 + ) + .padding(20) + .backgroundExtensionEffect() + .navigationTitle(vm.tr("roadmap_title")) + .navigationSubtitle(vm.tr("roadmap_sub")) + } + #endif + + #if os(iOS) + private var roadmapList: some View { List { ForEach(Array(vm.content.german.enumerated()), id: \.offset) { _, u in let unlocked = u.bool("unlocked") @@ -530,6 +578,7 @@ struct RoadmapScreen: View { .navigationSubtitle(vm.tr("roadmap_sub")) .appListStyle() } + #endif } struct UnitMapScreen: View { @@ -538,28 +587,84 @@ struct UnitMapScreen: View { var body: some View { if let u = vm.content.germanUnit(unitId) { - let names = u.strs("names") - List { - ForEach(names.indices, id: \.self) { i in - NavigationLink(value: Route.germanEx(unitId, i + 1)) { - Label { - Text(names[i]) - } icon: { - Image(systemName: vm.progress.germanDone(unitId, i + 1) ? "checkmark.circle.fill" : "circle") - } - } - } - if u.has("branch") { - NavigationLink(value: Route.vocab(unitId)) { - Label(vm.tr("Vokabeltraining"), systemImage: "character.book.closed") + #if os(macOS) + unitPath(u) + #else + unitList(u) + #endif + } + } + + #if os(macOS) + private func unitPath(_ u: J) -> some View { + let names = u.strs("names") + let next = names.indices.first { !vm.progress.germanDone(unitId, $0 + 1) } + let lit = CGFloat(next ?? names.count) + let branch: (index: Int, node: MapNode)? = u.has("branch") + ? ( + 1, + MapNode( + id: "vocab-\(unitId)", + label: vm.tr("Vokabeltraining"), + locked: false, + done: false, + current: false, + destination: .vocab(unitId) + ) + ) + : nil + return PathMap( + nodes: names.enumerated().map { i, name in + MapNode( + id: "e\(i + 1)", + label: name, + locked: false, + done: vm.progress.germanDone(unitId, i + 1), + current: i == next, + destination: .germanEx(unitId, i + 1) + ) + }, + palette: vm.palette, + litUntil: lit, + nodeSize: 72, + mx: 96, + my: 118, + spac: 180, + gap: 170, + wave: 28, + branch: branch + ) + .padding(20) + .backgroundExtensionEffect() + .navigationTitle(u.str("title")) + .navigationSubtitle(vm.tr(u.str("sub"))) + } + #endif + + #if os(iOS) + private func unitList(_ u: J) -> some View { + let names = u.strs("names") + return List { + ForEach(names.indices, id: \.self) { i in + NavigationLink(value: Route.germanEx(unitId, i + 1)) { + Label { + Text(names[i]) + } icon: { + Image(systemName: vm.progress.germanDone(unitId, i + 1) ? "checkmark.circle.fill" : "circle") } } } - .navigationTitle(u.str("title")) - .navigationSubtitle(vm.tr(u.str("sub"))) - .appListStyle() + if u.has("branch") { + NavigationLink(value: Route.vocab(unitId)) { + Label(vm.tr("Vokabeltraining"), systemImage: "character.book.closed") + } + } } + .navigationTitle(u.str("title")) + .navigationSubtitle(vm.tr(u.str("sub"))) + .appListStyle() } + #endif } struct NetYearsScreen: View { @@ -599,11 +704,51 @@ struct LessonRow: Identifiable { } struct LessonListScreen: View { + @ObservedObject var vm: AppModel let title: String let subtitle: String let rows: [LessonRow] var body: some View { + #if os(macOS) + lessonPath + #else + lessonList + #endif + } + + #if os(macOS) + private var lessonPath: some View { + let next = rows.firstIndex { !$0.done } + return PathMap( + nodes: rows.enumerated().map { i, row in + MapNode( + id: row.id, + label: row.title, + locked: false, + done: row.done, + current: i == next, + destination: row.destination + ) + }, + palette: vm.palette, + litUntil: CGFloat(next ?? rows.count), + nodeSize: 70, + mx: 92, + my: 112, + spac: 176, + gap: 168, + wave: 26 + ) + .padding(20) + .backgroundExtensionEffect() + .navigationTitle(title) + .navigationSubtitle(subtitle) + } + #endif + + #if os(iOS) + private var lessonList: some View { List(rows) { row in NavigationLink(value: row.destination) { Label { @@ -617,6 +762,7 @@ struct LessonListScreen: View { .navigationSubtitle(subtitle) .appListStyle() } + #endif } struct SlidesScreen: View { @@ -815,6 +961,7 @@ struct RouteDestination: View { NetYearsScreen(vm: vm) case .netMap: LessonListScreen( + vm: vm, title: vm.tr("net_year1"), subtitle: vm.tr("net_sub"), rows: vm.content.netLessons.map { l in @@ -835,6 +982,7 @@ struct RouteDestination: View { NetQuizScreen(vm: vm, id: id) case .hwMap: LessonListScreen( + vm: vm, title: vm.tr("Technické vybavení"), subtitle: vm.tr("hw_sub"), rows: vm.content.hw.map { l in @@ -857,6 +1005,7 @@ struct RouteDestination: View { CzechMapScreen(vm: vm) case .mluvnice: LessonListScreen( + vm: vm, title: vm.tr("Mluvnice"), subtitle: vm.tr("mluv_sub"), rows: vm.content.mluvnice.map { m in diff --git a/macos/Maturita.xcodeproj/project.pbxproj b/macos/Maturita.xcodeproj/project.pbxproj index ec37b15..963a0e4 100644 --- a/macos/Maturita.xcodeproj/project.pbxproj +++ b/macos/Maturita.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ B101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000014 /* Exercises.swift */; }; B101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000015 /* ExercisesMore.swift */; }; B101000000000000000016 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B201000000000000000016 /* Assets.xcassets */; }; + B101000000000000000017 /* PathMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000019 /* PathMap.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -44,6 +45,7 @@ B201000000000000000016 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B201000000000000000017 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B201000000000000000018 /* Maturita.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Maturita.entitlements; sourceTree = ""; }; + B201000000000000000019 /* PathMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PathMap.swift; path = ../ios/Maturita/UI/PathMap.swift; sourceTree = SOURCE_ROOT; }; B301000000000000000001 /* Maturita.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Maturita.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -108,6 +110,7 @@ children = ( B201000000000000000011 /* Components.swift */, B201000000000000000012 /* Icons.swift */, + B201000000000000000019 /* PathMap.swift */, B201000000000000000013 /* Screens.swift */, B201000000000000000014 /* Exercises.swift */, B201000000000000000015 /* ExercisesMore.swift */, @@ -230,6 +233,7 @@ B101000000000000000010 /* ProgressStore.swift in Sources */, B101000000000000000011 /* Components.swift in Sources */, B101000000000000000012 /* Icons.swift in Sources */, + B101000000000000000017 /* PathMap.swift in Sources */, B101000000000000000013 /* Screens.swift in Sources */, B101000000000000000014 /* Exercises.swift in Sources */, B101000000000000000015 /* ExercisesMore.swift in Sources */,