mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
Restore serpentine exercise paths on the Mac subject maps.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7761b52ed0
commit
8bc4fcdc47
4 changed files with 442 additions and 16 deletions
|
|
@ -18,6 +18,7 @@
|
||||||
A101000000000000000009 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000009 /* Updater.swift */; };
|
A101000000000000000009 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000009 /* Updater.swift */; };
|
||||||
A101000000000000000010 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000010 /* Components.swift */; };
|
A101000000000000000010 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000010 /* Components.swift */; };
|
||||||
A101000000000000000011 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000011 /* Icons.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 */; };
|
A101000000000000000013 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000013 /* Screens.swift */; };
|
||||||
A101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000014 /* Exercises.swift */; };
|
A101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000014 /* Exercises.swift */; };
|
||||||
A101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000015 /* ExercisesMore.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 = "<group>"; };
|
A201000000000000000009 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = "<group>"; };
|
||||||
A201000000000000000010 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
|
A201000000000000000010 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
|
||||||
A201000000000000000011 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = "<group>"; };
|
A201000000000000000011 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = "<group>"; };
|
||||||
|
A201000000000000000012 /* PathMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathMap.swift; sourceTree = "<group>"; };
|
||||||
A201000000000000000013 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = "<group>"; };
|
A201000000000000000013 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = "<group>"; };
|
||||||
A201000000000000000014 /* Exercises.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exercises.swift; sourceTree = "<group>"; };
|
A201000000000000000014 /* Exercises.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exercises.swift; sourceTree = "<group>"; };
|
||||||
A201000000000000000015 /* ExercisesMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExercisesMore.swift; sourceTree = "<group>"; };
|
A201000000000000000015 /* ExercisesMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExercisesMore.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -103,6 +105,7 @@
|
||||||
children = (
|
children = (
|
||||||
A201000000000000000010 /* Components.swift */,
|
A201000000000000000010 /* Components.swift */,
|
||||||
A201000000000000000011 /* Icons.swift */,
|
A201000000000000000011 /* Icons.swift */,
|
||||||
|
A201000000000000000012 /* PathMap.swift */,
|
||||||
A201000000000000000013 /* Screens.swift */,
|
A201000000000000000013 /* Screens.swift */,
|
||||||
A201000000000000000014 /* Exercises.swift */,
|
A201000000000000000014 /* Exercises.swift */,
|
||||||
A201000000000000000015 /* ExercisesMore.swift */,
|
A201000000000000000015 /* ExercisesMore.swift */,
|
||||||
|
|
@ -224,6 +227,7 @@
|
||||||
A101000000000000000009 /* Updater.swift in Sources */,
|
A101000000000000000009 /* Updater.swift in Sources */,
|
||||||
A101000000000000000010 /* Components.swift in Sources */,
|
A101000000000000000010 /* Components.swift in Sources */,
|
||||||
A101000000000000000011 /* Icons.swift in Sources */,
|
A101000000000000000011 /* Icons.swift in Sources */,
|
||||||
|
A101000000000000000012 /* PathMap.swift in Sources */,
|
||||||
A101000000000000000013 /* Screens.swift in Sources */,
|
A101000000000000000013 /* Screens.swift in Sources */,
|
||||||
A101000000000000000014 /* Exercises.swift in Sources */,
|
A101000000000000000014 /* Exercises.swift in Sources */,
|
||||||
A101000000000000000015 /* ExercisesMore.swift in Sources */,
|
A101000000000000000015 /* ExercisesMore.swift in Sources */,
|
||||||
|
|
|
||||||
269
ios/Maturita/UI/PathMap.swift
Normal file
269
ios/Maturita/UI/PathMap.swift
Normal file
|
|
@ -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..<rows {
|
||||||
|
let base = r * cols
|
||||||
|
let len = min(cols, n - base)
|
||||||
|
let fwd = r % 2 == 0
|
||||||
|
for c in 0..<len {
|
||||||
|
let i = base + c
|
||||||
|
let cc = fwd ? c : cols - 1 - c
|
||||||
|
pts[i] = NodePos(
|
||||||
|
x: smx + CGFloat(cc) * sspac,
|
||||||
|
y: smy + slift + CGFloat(r) * sgap + swave * sin(CGFloat(i) * phase)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Serpentine(points: pts, width: cw, height: ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func catmull(_ pts: [NodePos], _ t: CGFloat) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -507,6 +507,54 @@ struct RoadmapScreen: View {
|
||||||
@ObservedObject var vm: AppModel
|
@ObservedObject var vm: AppModel
|
||||||
|
|
||||||
var body: some View {
|
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 {
|
List {
|
||||||
ForEach(Array(vm.content.german.enumerated()), id: \.offset) { _, u in
|
ForEach(Array(vm.content.german.enumerated()), id: \.offset) { _, u in
|
||||||
let unlocked = u.bool("unlocked")
|
let unlocked = u.bool("unlocked")
|
||||||
|
|
@ -530,6 +578,7 @@ struct RoadmapScreen: View {
|
||||||
.navigationSubtitle(vm.tr("roadmap_sub"))
|
.navigationSubtitle(vm.tr("roadmap_sub"))
|
||||||
.appListStyle()
|
.appListStyle()
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UnitMapScreen: View {
|
struct UnitMapScreen: View {
|
||||||
|
|
@ -538,8 +587,64 @@ struct UnitMapScreen: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let u = vm.content.germanUnit(unitId) {
|
if let u = vm.content.germanUnit(unitId) {
|
||||||
|
#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 names = u.strs("names")
|
||||||
List {
|
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
|
ForEach(names.indices, id: \.self) { i in
|
||||||
NavigationLink(value: Route.germanEx(unitId, i + 1)) {
|
NavigationLink(value: Route.germanEx(unitId, i + 1)) {
|
||||||
Label {
|
Label {
|
||||||
|
|
@ -559,7 +664,7 @@ struct UnitMapScreen: View {
|
||||||
.navigationSubtitle(vm.tr(u.str("sub")))
|
.navigationSubtitle(vm.tr(u.str("sub")))
|
||||||
.appListStyle()
|
.appListStyle()
|
||||||
}
|
}
|
||||||
}
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NetYearsScreen: View {
|
struct NetYearsScreen: View {
|
||||||
|
|
@ -599,11 +704,51 @@ struct LessonRow: Identifiable {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LessonListScreen: View {
|
struct LessonListScreen: View {
|
||||||
|
@ObservedObject var vm: AppModel
|
||||||
let title: String
|
let title: String
|
||||||
let subtitle: String
|
let subtitle: String
|
||||||
let rows: [LessonRow]
|
let rows: [LessonRow]
|
||||||
|
|
||||||
var body: some View {
|
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
|
List(rows) { row in
|
||||||
NavigationLink(value: row.destination) {
|
NavigationLink(value: row.destination) {
|
||||||
Label {
|
Label {
|
||||||
|
|
@ -617,6 +762,7 @@ struct LessonListScreen: View {
|
||||||
.navigationSubtitle(subtitle)
|
.navigationSubtitle(subtitle)
|
||||||
.appListStyle()
|
.appListStyle()
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SlidesScreen: View {
|
struct SlidesScreen: View {
|
||||||
|
|
@ -815,6 +961,7 @@ struct RouteDestination: View {
|
||||||
NetYearsScreen(vm: vm)
|
NetYearsScreen(vm: vm)
|
||||||
case .netMap:
|
case .netMap:
|
||||||
LessonListScreen(
|
LessonListScreen(
|
||||||
|
vm: vm,
|
||||||
title: vm.tr("net_year1"),
|
title: vm.tr("net_year1"),
|
||||||
subtitle: vm.tr("net_sub"),
|
subtitle: vm.tr("net_sub"),
|
||||||
rows: vm.content.netLessons.map { l in
|
rows: vm.content.netLessons.map { l in
|
||||||
|
|
@ -835,6 +982,7 @@ struct RouteDestination: View {
|
||||||
NetQuizScreen(vm: vm, id: id)
|
NetQuizScreen(vm: vm, id: id)
|
||||||
case .hwMap:
|
case .hwMap:
|
||||||
LessonListScreen(
|
LessonListScreen(
|
||||||
|
vm: vm,
|
||||||
title: vm.tr("Technické vybavení"),
|
title: vm.tr("Technické vybavení"),
|
||||||
subtitle: vm.tr("hw_sub"),
|
subtitle: vm.tr("hw_sub"),
|
||||||
rows: vm.content.hw.map { l in
|
rows: vm.content.hw.map { l in
|
||||||
|
|
@ -857,6 +1005,7 @@ struct RouteDestination: View {
|
||||||
CzechMapScreen(vm: vm)
|
CzechMapScreen(vm: vm)
|
||||||
case .mluvnice:
|
case .mluvnice:
|
||||||
LessonListScreen(
|
LessonListScreen(
|
||||||
|
vm: vm,
|
||||||
title: vm.tr("Mluvnice"),
|
title: vm.tr("Mluvnice"),
|
||||||
subtitle: vm.tr("mluv_sub"),
|
subtitle: vm.tr("mluv_sub"),
|
||||||
rows: vm.content.mluvnice.map { m in
|
rows: vm.content.mluvnice.map { m in
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
B101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000014 /* Exercises.swift */; };
|
B101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000014 /* Exercises.swift */; };
|
||||||
B101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000015 /* ExercisesMore.swift */; };
|
B101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000015 /* ExercisesMore.swift */; };
|
||||||
B101000000000000000016 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B201000000000000000016 /* Assets.xcassets */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
|
@ -44,6 +45,7 @@
|
||||||
B201000000000000000016 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
B201000000000000000016 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
B201000000000000000017 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
B201000000000000000017 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
B201000000000000000018 /* Maturita.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Maturita.entitlements; sourceTree = "<group>"; };
|
B201000000000000000018 /* Maturita.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Maturita.entitlements; sourceTree = "<group>"; };
|
||||||
|
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; };
|
B301000000000000000001 /* Maturita.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Maturita.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
|
@ -108,6 +110,7 @@
|
||||||
children = (
|
children = (
|
||||||
B201000000000000000011 /* Components.swift */,
|
B201000000000000000011 /* Components.swift */,
|
||||||
B201000000000000000012 /* Icons.swift */,
|
B201000000000000000012 /* Icons.swift */,
|
||||||
|
B201000000000000000019 /* PathMap.swift */,
|
||||||
B201000000000000000013 /* Screens.swift */,
|
B201000000000000000013 /* Screens.swift */,
|
||||||
B201000000000000000014 /* Exercises.swift */,
|
B201000000000000000014 /* Exercises.swift */,
|
||||||
B201000000000000000015 /* ExercisesMore.swift */,
|
B201000000000000000015 /* ExercisesMore.swift */,
|
||||||
|
|
@ -230,6 +233,7 @@
|
||||||
B101000000000000000010 /* ProgressStore.swift in Sources */,
|
B101000000000000000010 /* ProgressStore.swift in Sources */,
|
||||||
B101000000000000000011 /* Components.swift in Sources */,
|
B101000000000000000011 /* Components.swift in Sources */,
|
||||||
B101000000000000000012 /* Icons.swift in Sources */,
|
B101000000000000000012 /* Icons.swift in Sources */,
|
||||||
|
B101000000000000000017 /* PathMap.swift in Sources */,
|
||||||
B101000000000000000013 /* Screens.swift in Sources */,
|
B101000000000000000013 /* Screens.swift in Sources */,
|
||||||
B101000000000000000014 /* Exercises.swift in Sources */,
|
B101000000000000000014 /* Exercises.swift in Sources */,
|
||||||
B101000000000000000015 /* ExercisesMore.swift in Sources */,
|
B101000000000000000015 /* ExercisesMore.swift in Sources */,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue