Rework the mobile UIs on native components and fix layout bugs everywhere.

Android moves to Material 3 (navigation bar, app bars, list items,
bottom sheets, pager) with a real back stack. iOS hides the tab bar in
exercises so the bottom action is reachable, aligns content, and uses
system colours so themes and dark mode work. Both pick a readable
accent tint. Desktop keeps the VLSM table inside the window and gives
statistics the same header rhythm as other pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-09-18 15:37:15 +02:00
commit 0621311b4c
25 changed files with 1965 additions and 1836 deletions

View file

@ -1,166 +1,6 @@
import SwiftUI
struct BackIcon: View {
var color: Color
var size: CGFloat = 16
var body: some View {
Canvas { ctx, sz in
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.18))
p.addLine(to: CGPoint(x: sz.width * 0.28, y: sz.height * 0.5))
p.addLine(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.82))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round))
}
.frame(width: size, height: size)
}
}
struct SearchIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.28
let c = CGPoint(x: sz.width * 0.42, y: sz.height * 0.42)
ctx.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.12)
var handle = Path()
handle.move(to: CGPoint(x: sz.width * 0.62, y: sz.height * 0.62))
handle.addLine(to: CGPoint(x: sz.width * 0.82, y: sz.height * 0.82))
ctx.stroke(handle, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.12, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct StatsIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let w = min(sz.width, sz.height) * 0.16
let bars: [(CGFloat, CGFloat, CGFloat)] = [
(0.18, 0.48, 0.36), (0.42, 0.22, 0.62), (0.66, 0.36, 0.48),
]
for b in bars {
let rect = CGRect(x: sz.width * b.0, y: sz.height * b.1, width: w, height: sz.height * b.2)
ctx.fill(Path(roundedRect: rect, cornerRadius: w), with: .color(color))
}
}
.frame(width: size, height: size)
}
}
struct SettingsIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let cx = sz.width / 2
let cy = sz.height / 2
let s = min(sz.width, sz.height)
let rOuter = s * 0.48
let rInner = s * 0.34
let rHole = s * 0.16
let teeth = 8
let toothHalf = CGFloat.pi / CGFloat(teeth)
var gear = Path()
for i in 0..<teeth {
let a = -CGFloat.pi / 2 + 2 * CGFloat.pi * CGFloat(i) / CGFloat(teeth)
let a0 = a - toothHalf * 0.55
let a1 = a - toothHalf * 0.28
let a2 = a + toothHalf * 0.28
let a3 = a + toothHalf * 0.55
let p0 = CGPoint(x: cx + cos(a0) * rInner, y: cy + sin(a0) * rInner)
if i == 0 { gear.move(to: p0) } else { gear.addLine(to: p0) }
gear.addLine(to: CGPoint(x: cx + cos(a1) * rOuter, y: cy + sin(a1) * rOuter))
gear.addLine(to: CGPoint(x: cx + cos(a2) * rOuter, y: cy + sin(a2) * rOuter))
gear.addLine(to: CGPoint(x: cx + cos(a3) * rInner, y: cy + sin(a3) * rInner))
}
gear.closeSubpath()
gear.addEllipse(in: CGRect(x: cx - rHole, y: cy - rHole, width: rHole * 2, height: rHole * 2))
ctx.fill(gear, with: .color(color), style: FillStyle(eoFill: true))
}
.frame(width: size, height: size)
}
}
struct LockIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let body = CGRect(x: sz.width * 0.28, y: sz.height * 0.46, width: sz.width * 0.44, height: sz.height * 0.36)
ctx.fill(Path(roundedRect: body, cornerRadius: min(sz.width, sz.height) * 0.08), with: .color(color))
var arc = Path()
arc.addArc(
center: CGPoint(x: sz.width * 0.5, y: sz.height * 0.38),
radius: sz.width * 0.18,
startAngle: .degrees(200),
endAngle: .degrees(340),
clockwise: false
)
ctx.stroke(arc, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.1, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct CheckIcon: View {
var color: Color
var size: CGFloat = 16
var body: some View {
Canvas { ctx, sz in
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.18, y: sz.height * 0.52))
p.addLine(to: CGPoint(x: sz.width * 0.4, y: sz.height * 0.74))
p.addLine(to: CGPoint(x: sz.width * 0.84, y: sz.height * 0.26))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round))
}
.frame(width: size, height: size)
}
}
struct WifiIcon: View {
var color: Color
var size: CGFloat = 28
var body: some View {
Canvas { ctx, sz in
let c = CGPoint(x: sz.width / 2, y: sz.height * 0.72)
ctx.fill(Path(ellipseIn: CGRect(x: c.x - sz.width * 0.06, y: c.y - sz.width * 0.06, width: sz.width * 0.12, height: sz.width * 0.12)), with: .color(color))
for i in 1...3 {
let r = min(sz.width, sz.height) * (0.16 + CGFloat(i) * 0.16)
var p = Path()
p.addArc(center: c, radius: r, startAngle: .degrees(210), endAngle: .degrees(330), clockwise: false)
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
}
.frame(width: size, height: size)
}
}
struct ChipIcon: View {
var color: Color
var size: CGFloat = 28
var body: some View {
Canvas { ctx, sz in
let m = min(sz.width, sz.height) * 0.22
ctx.stroke(
Path(roundedRect: CGRect(x: m, y: m, width: sz.width - 2 * m, height: sz.height - 2 * m), cornerRadius: 6),
with: .color(color),
lineWidth: min(sz.width, sz.height) * 0.08
)
let pin = min(sz.width, sz.height) * 0.08
for i in 0..<3 {
let t = m + (sz.height - 2 * m) * CGFloat(i + 1) / 4
var l = Path(); l.move(to: CGPoint(x: m - pin, y: t)); l.addLine(to: CGPoint(x: m, y: t))
var r = Path(); r.move(to: CGPoint(x: sz.width - m, y: t)); r.addLine(to: CGPoint(x: sz.width - m + pin, y: t))
ctx.stroke(l, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
ctx.stroke(r, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
}
}
.frame(width: size, height: size)
}
}
// Circular flags used as subject glyphs; everything else is an SF Symbol.
struct GermanFlag: View {
var size: CGFloat = 48
@ -193,112 +33,3 @@ struct CzechFlag: View {
.frame(width: size, height: size)
}
}
struct BookIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
ctx.stroke(
Path(roundedRect: CGRect(x: sz.width * 0.22, y: sz.height * 0.18, width: sz.width * 0.56, height: sz.height * 0.64), cornerRadius: 4),
with: .color(color),
lineWidth: min(sz.width, sz.height) * 0.08
)
var spine = Path()
spine.move(to: CGPoint(x: sz.width * 0.38, y: sz.height * 0.18))
spine.addLine(to: CGPoint(x: sz.width * 0.38, y: sz.height * 0.82))
ctx.stroke(spine, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
}
.frame(width: size, height: size)
}
}
struct QuizIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.36
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height / 2 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
let d = min(sz.width, sz.height) * 0.12
ctx.fill(Path(ellipseIn: CGRect(x: sz.width / 2 - d / 2, y: sz.height * 0.68 - d / 2, width: d, height: d)), with: .color(color))
var stem = Path()
stem.move(to: CGPoint(x: sz.width / 2, y: sz.height * 0.32))
stem.addLine(to: CGPoint(x: sz.width / 2, y: sz.height * 0.52))
ctx.stroke(stem, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct OrderIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
for i in 0..<3 {
let y = sz.height * (0.28 + CGFloat(i) * 0.22)
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.22, y: y))
p.addLine(to: CGPoint(x: sz.width * 0.78, y: y))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
}
.frame(width: size, height: size)
}
}
struct GlobeIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.34
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height / 2 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width * 0.32, y: sz.height * 0.18, width: sz.width * 0.36, height: sz.height * 0.64)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
}
.frame(width: size, height: size)
}
}
struct PeopleIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.12
ctx.fill(Path(ellipseIn: CGRect(x: sz.width * 0.35 - r, y: sz.height * 0.32 - r, width: r * 2, height: r * 2)), with: .color(color))
ctx.fill(Path(ellipseIn: CGRect(x: sz.width * 0.65 - r, y: sz.height * 0.32 - r, width: r * 2, height: r * 2)), with: .color(color))
var a = Path(); a.addArc(center: CGPoint(x: sz.width * 0.35, y: sz.height * 0.62), radius: sz.width * 0.17, startAngle: .degrees(200), endAngle: .degrees(340), clockwise: false)
var b = Path(); b.addArc(center: CGPoint(x: sz.width * 0.65, y: sz.height * 0.62), radius: sz.width * 0.17, startAngle: .degrees(200), endAngle: .degrees(340), clockwise: false)
ctx.stroke(a, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
ctx.stroke(b, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct BulbIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.22
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height * 0.4 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
ctx.fill(Path(roundedRect: CGRect(x: sz.width * 0.4, y: sz.height * 0.58, width: sz.width * 0.2, height: sz.height * 0.2), cornerRadius: 3), with: .color(color))
}
.frame(width: size, height: size)
}
}
@ViewBuilder
func noteIcon(_ name: String, _ color: Color) -> some View {
switch name {
case "globe": GlobeIcon(color: color)
case "people": PeopleIcon(color: color)
case "bulb": BulbIcon(color: color)
case "quiz": QuizIcon(color: color)
case "order": OrderIcon(color: color)
default: BookIcon(color: color)
}
}