Compare commits

...

20 Commits

Author SHA1 Message Date
kutesir
3134c064e9 Calendar: fix week/3-day dead space (pin content to top)
Some checks failed
CI / build-and-test (push) Has been cancelled
The timeline views' content is a thin header + a greedy ScrollView (≈0 ideal
height), so the top-level VStack shrank and the bottomTrailing ZStack floated
it to the bottom — leaving a big empty gap above the day header. Month view
has a tall fixed grid so it never surfaced. Pin the VStack to fill height and
top-align.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 01:13:23 +03:00
kutesir
0a87769561 Revert "Calendar month view: TickTick-style bars + collapse-to-week"
This reverts commit 2ec3278632.
2026-07-07 01:12:14 +03:00
kutesir
e8d74d4f59 Revert "Calendar month: drag-to-collapse + full-column selection highlight"
This reverts commit bd43eecfbe.
2026-07-07 01:09:58 +03:00
kutesir
152b0bf9b5 Calendar week view: timeline ⇄ grid layout toggle
Some checks failed
CI / build-and-test (push) Has been cancelled
Completes the TickTick weekly reference — a toggle flips the week between the
timeline and a grid of day-cards (2 columns), each card listing that day's
schedule as colored bars. Tapping a card opens that day. Both layouts swipe
by week.

- weekGrid state + toggle button (timeline/grid icons)
- weekGridLayout + weekDayCard

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:39:29 +03:00
kutesir
372da6df5a Calendar timeline: drag a task block to reschedule its time
Some checks failed
CI / build-and-test (push) Has been cancelled
The headline of the TickTick weekly timeline — task blocks are now draggable
vertically to change their time, snapped to 15-minute steps. Applies on the
week, 3-day, and day timelines.

- CVTimeItem carries taskID + recurring; task items are tagged, events are not
- CVTimeBlock: a draggable block (GestureState offset while dragging, commits
  on release); events and recurring tasks render fixed (non-draggable)
- rescheduleTask: clamps to the visible hour range and writes the new time via
  taskVM.setDate on the same day

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:35:36 +03:00
kutesir
c5285a2e30 Calendar week view: 7-day timeline (TickTick-style)
Some checks failed
CI / build-and-test (push) Has been cancelled
Replaces the mini-month + agenda-list split with a proper weekly timeline —
hours down the left axis, one column per weekday, events as time-positioned
blocks — reusing the same cvTimeGrid/cvDayColumnHeader the day and 3-day
views already use. Horizontal swipe navigates by week (navigateWeek).

Removes the now-unused cvAgendaItems helper.

(Not build-verified — sim runtime removed to reclaim disk; reuses existing
timeline components.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:29:43 +03:00
kutesir
bd43eecfbe Calendar month: drag-to-collapse + full-column selection highlight
Some checks failed
CI / build-and-test (push) Has been cancelled
Two refinements to get closer to the TickTick reference:
- Drag up on the calendar collapses to the selected week; drag down expands
  to the full month (horizontal swipe still changes month). One direction-
  aware DragGesture handles both axes.
- The selected day now gets a rounded full-column highlight behind its
  number + bars when collapsed (as in the tapped-day screenshot), instead
  of only the day circle.

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:19:38 +03:00
kutesir
2ec3278632 Calendar month view: TickTick-style bars + collapse-to-week
Some checks failed
CI / build-and-test (push) Has been cancelled
Matches the referenced TickTick monthly view:
- Day cells now show stacked colored schedule BARS (workout/events/tasks,
  up to 4) instead of dots — a glanceable overview of the month.
- Selecting a day COLLAPSES the grid to just that week, giving the day's
  agenda room below (as in the tapped-day screenshot). A chevron grabber
  expands back to the full month.

- DayCell: bars param + stacked RoundedRectangles, top-aligned, height 54
- monthView: renders week rows (all when expanded, only the selected week
  when collapsed) via weekRows()/selectedWeekRow(); grabber toggles
- eventDots cap raised to 4 for the bar stack

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:17:11 +03:00
kutesir
6456d8f69a Calendar: header follows the scrolled-to year in continuous year view
Some checks failed
CI / build-and-test (push) Has been cancelled
The top title now tracks whichever year is at the top of the year scroll
(e.g. scroll into 2027 and the header reads '2027'), instead of staying on
the displayed month. In month/week/day modes it still shows month + year.

- visibleYear state driven by a YearTopKey preference reporting each year
  block's top offset in the 'yearScroll' coordinate space
- headerTitle switches on viewMode
- iOS 16-safe (GeometryReader + PreferenceKey, no scrollPosition API)

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:09:22 +03:00
kutesir
a37807aff0 Calendar year view: continuous TickTick-style multi-year scroll
Some checks failed
CI / build-and-test (push) Has been cancelled
Replaces the single paged year (< 2026 >, with dead space below December)
with an infinite vertical scroll where years flow one after another. Opens
anchored on the current year (accent-colored); scroll up for past years,
down for future. Bottom padding clears the floating tab bar / + button.

- yearView: ScrollViewReader + LazyVStack over a year range, scrollTo(current)
- yearBlock(_:): bold year label + 12 mini-months (reuses cvMiniMonth)
- Removed navigateYear + the ◀ year ▶ header (superseded by inline labels)

(Not build-verified — sim runtime removed to reclaim disk; standard SwiftUI
+ existing cvMiniMonth helper.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:03:06 +03:00
kutesir
cac8cba38a Workout streak banner: swipeable week / year / vs-last-week pages
Some checks failed
CI / build-and-test (push) Has been cancelled
Replaces the single lifetime-tally banner (which used a meaningless streak%7
tick bar) with a 3-page paged TabView:
  1. This week — count + 7 per-day ticks for the current week
  2. This year — count + 12 month ticks, with all-time total underneath
  3. vs last week — this week's count and the delta (▲ more / ▼ fewer / same)

Adds WorkoutViewModel breakdowns: workoutsThisWeek/LastWeek/ThisYear,
currentWeekDays (7 flags), currentYearMonths (12 flags).

(Not build-verified — sim runtime removed to reclaim disk; uses only APIs
already present in the original banner.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:54:25 +03:00
kutesir
28b4039593 Today list: neutral checkbox stroke (color already on accent bar + chip)
Some checks failed
CI / build-and-test (push) Has been cancelled
The Tomorrow/Later rows carry their quadrant color on the left accent bar
and the category chip, so tinting the checkbox too was redundant. Match the
neutral text3 stroke the timeline rows already use.

(Not build-verified — local sim runtime removed to reclaim disk; trivial
one-line swap to an API already used elsewhere in the same file.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:19:56 +03:00
cbf369b754 Merge pull request 'docs: add CICD.md (pipeline flow)' (#27) from docs/cicd-flow into develop
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-07-05 19:40:41 +00:00
5e7d476b59 Merge pull request 'docs: add STRUCTURE.md' (#26) from docs/project-structure into develop
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-07-05 19:40:38 +00:00
ce97de37bd Merge pull request 'ci: add Gitea Actions CI/CD, branch workflow, and contributor docs' (#25) from feature/cicd-setup into develop
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-07-05 19:40:22 +00:00
d235cce510 ci: lowercase runner label (runner registered as self-hosted,macos)
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-05 12:07:52 +00:00
9e93172199 ci: lowercase runner label (runner registered as self-hosted,macos) 2026-07-05 12:07:52 +00:00
kutesir
b540bddbe9 docs: add CICD.md with pipeline flow diagram and gate rules
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 02:01:11 +03:00
kutesir
9ae94babb6 docs: add STRUCTURE.md describing targets, layout, and architecture
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:50:30 +03:00
kutesir
8d37021f78 ci: add Gitea Actions CI/CD, branch workflow, and contributor docs
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- .gitea/workflows/ci.yml: build + test KisaniCal scheme on a self-hosted
  macOS runner for every PR into main/develop (and pushes to develop).
- .gitea/workflows/release.yml: archive + export IPA on push to main, with
  a commented TestFlight upload placeholder (App Store Connect API key).
- ExportOptions.plist: export template (Team ID K8BLMMR883, app-store method).
- scripts/gitea-setup.sh: idempotent Gitea API setup for develop default
  branch + main branch protection.
- CONTRIBUTING.md: feature/* -> develop -> main workflow and PR gate rules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:38:22 +03:00
11 changed files with 936 additions and 146 deletions

86
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,86 @@
name: CI
# Build + test on every PR into main or develop (and on direct pushes to develop).
on:
pull_request:
branches: [main, develop]
push:
branches: [develop]
# ⚠️ RUNNER LABELS — must match how your Mac runner was registered with act_runner.
# Check your runner's labels in Gitea: Settings → Actions → Runners (click the runner).
# If it registered as e.g. `macos` or `self-hosted`, change `runs-on` below to match.
# `[self-hosted, macOS]` means "a runner that has BOTH labels".
jobs:
build-and-test:
runs-on: [self-hosted, macos]
env:
SCHEME: KisaniCal
PROJECT: KisaniCal.xcodeproj
steps:
- name: Checkout
uses: actions/checkout@v4
# The .xcodeproj is generated from project.yml via XcodeGen.
# Regenerate it so CI never builds a stale project. No-op if xcodegen isn't installed.
- name: Regenerate Xcode project (XcodeGen)
run: |
if command -v xcodegen >/dev/null 2>&1; then
xcodegen generate
else
echo "xcodegen not found on runner — using committed ${PROJECT}."
echo "Install with: brew install xcodegen"
fi
- name: Show toolchain
run: |
xcodebuild -version
xcrun simctl list runtimes | grep -i ios || true
# Pick a concrete, bootable iOS Simulator (needed for `xcodebuild test`).
- name: Select iOS Simulator destination
id: sim
run: |
# First available iPhone simulator on any installed iOS runtime.
UDID=$(xcrun simctl list devices available -j | python3 -c "import json,sys,re; d=json.load(sys.stdin)['devices']; devs=[x for k,v in d.items() if re.search('iOS',k) for x in v if x.get('isAvailable') and 'iPhone' in x['name']]; print(devs[0]['udid'] if devs else '')")
if [ -z "$UDID" ]; then
echo "No available iPhone simulator found on the runner." >&2
echo "Install an iOS runtime via Xcode → Settings → Components." >&2
exit 1
fi
echo "udid=$UDID" >> "$GITHUB_OUTPUT"
echo "Using simulator UDID: $UDID"
- name: Build
run: |
set -o pipefail
xcodebuild build \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Debug \
-destination "id=${{ steps.sim.outputs.udid }}" \
CODE_SIGNING_ALLOWED=NO \
| xcbeautify || xcodebuild build \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Debug \
-destination "id=${{ steps.sim.outputs.udid }}" \
CODE_SIGNING_ALLOWED=NO
- name: Test
run: |
set -o pipefail
xcodebuild test \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Debug \
-destination "id=${{ steps.sim.outputs.udid }}" \
CODE_SIGNING_ALLOWED=NO \
| xcbeautify || xcodebuild test \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Debug \
-destination "id=${{ steps.sim.outputs.udid }}" \
CODE_SIGNING_ALLOWED=NO

View File

@@ -0,0 +1,86 @@
name: Release
# Runs only when code lands on main (i.e. after a PR is merged).
on:
push:
branches: [main]
# ⚠️ Same runner-label caveat as ci.yml — adjust `runs-on` to match your registered runner.
jobs:
archive-and-export:
runs-on: [self-hosted, macos]
env:
SCHEME: KisaniCal
PROJECT: KisaniCal.xcodeproj
# A real distribution build MUST be signed. This requires an Apple
# Distribution certificate + provisioning profile installed in the
# runner's login keychain (see notes at the bottom of this file).
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Regenerate Xcode project (XcodeGen)
run: |
if command -v xcodegen >/dev/null 2>&1; then xcodegen generate; fi
- name: Archive
run: |
set -o pipefail
xcodebuild archive \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
-allowProvisioningUpdates
- name: Export IPA
run: |
set -o pipefail
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
-exportOptionsPlist ExportOptions.plist \
-exportPath "$RUNNER_TEMP/export" \
-allowProvisioningUpdates
ls -la "$RUNNER_TEMP/export"
# ------------------------------------------------------------------
# PLACEHOLDER: Upload to TestFlight / App Store Connect.
#
# Add these as Gitea repo secrets: Settings → Actions → Secrets
# APP_STORE_CONNECT_API_KEY_ID (the "Key ID" from App Store Connect)
# APP_STORE_CONNECT_ISSUER_ID (the "Issuer ID")
# APP_STORE_CONNECT_API_KEY_P8 (contents of the AuthKey_XXXX.p8 file)
#
# Then uncomment ONE of the approaches below.
# ------------------------------------------------------------------
# --- Option A: modern notarytool / App Store Connect API key (recommended) ---
# - name: Write API key to file
# run: |
# mkdir -p ~/private_keys
# echo "${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}" > ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8
#
# - name: Upload to TestFlight (altool with API key)
# run: |
# xcrun altool --upload-app \
# --type ios \
# --file "$RUNNER_TEMP/export/KisaniCal.ipa" \
# --apiKey "${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}" \
# --apiIssuer "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}"
# --- Option B: notarytool (for notarizing a macOS build, not iOS TestFlight) ---
# - name: Notarize
# run: |
# xcrun notarytool submit "$RUNNER_TEMP/export/KisaniCal.ipa" \
# --key ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8 \
# --key-id "${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}" \
# --issuer "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" \
# --wait
- name: Notice
run: |
echo "IPA exported. TestFlight upload step is a commented-out placeholder."
echo "Add App Store Connect API-key secrets and uncomment Option A in release.yml."

61
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,61 @@
# Contributing to KisaniCal
## Branch workflow
```
feature/* ─PR→ develop ─PR→ main
```
- **`main`** — protected, release-ready. Never commit or push directly. Every
change arrives via a reviewed pull request. Pushes to `main` trigger the
release workflow (archive → IPA → TestFlight).
- **`develop`** — the default working branch and integration target. CI runs on
every PR into it.
- **`feature/*`** — short-lived branches for a single change, e.g.
`feature/voice-quick-add`, `fix/icloud-restore`. Branch off `develop`.
## Day-to-day
```bash
git checkout develop
git pull
git checkout -b feature/my-change
# ...work, commit...
git push -u origin feature/my-change
# then open a PR in Gitea: feature/my-change → develop
```
When `develop` is ready to ship, open a PR **`develop``main`**.
## Pull request requirements
A PR into `main` (and `develop`) cannot be merged until:
1. **CI passes** — the `build-and-test` job in `.gitea/workflows/ci.yml` builds
the app and runs `KisaniCalTests` on an iOS Simulator.
2. **At least 1 approval** — reviewed and approved.
3. **No unresolved change requests** — a rejected review blocks the merge.
4. Your branch is **up to date** with the base branch.
Direct pushes, force-pushes, and deletion of `main` are blocked server-side by
Gitea branch protection, and a local `pre-push` hook blocks accidental direct
pushes to `main` from this clone.
## CI / CD
- **CI** (`.gitea/workflows/ci.yml`) — runs on every PR into `main`/`develop`
and on pushes to `develop`. Builds + tests. A red check blocks the merge.
- **Release** (`.gitea/workflows/release.yml`) — runs only on push to `main`
(i.e. after a PR merges). Archives, exports an IPA via `ExportOptions.plist`,
and (once configured) uploads to TestFlight.
Both workflows run on a **self-hosted macOS runner** with Xcode — there is no
Gitea-hosted Mac runner.
## Versioning
`MARKETING_VERSION` (e.g. `2.0`) and `CURRENT_PROJECT_VERSION` (build number)
live in `project.yml`. The build number must **increase** for every TestFlight
upload — App Store Connect rejects a build number it has already seen.

45
ExportOptions.plist Normal file
View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
ExportOptions template for `xcodebuild -exportArchive`.
FILL IN:
• teamID — your Apple Developer Team ID. Detected from project.yml
(DEVELOPMENT_TEAM): K8BLMMR883 ← pre-filled below, verify it.
• method — choose ONE:
app-store → TestFlight / App Store submission
ad-hoc → distribute to registered UDIDs
development → internal dev devices
enterprise → in-house (Apple Enterprise Program only)
Notes:
• signingStyle "automatic" lets Xcode manage certs/profiles (pairs with
-allowProvisioningUpdates in release.yml). Switch to "manual" if you
install a specific distribution profile on the runner.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>K8BLMMR883</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadSymbols</key>
<true/>
<key>uploadBitcode</key>
<false/>
<!-- Optional: pin the app to a specific provisioning profile (manual signing).
<key>provisioningProfiles</key>
<dict>
<key>com.kutesir.KisaniCal</key>
<string>YOUR_PROFILE_NAME</string>
</dict>
-->
</dict>
</plist>

View File

@@ -262,6 +262,58 @@ class WorkoutViewModel: ObservableObject {
Set(workoutDates).count
}
// MARK: - Streak-banner breakdowns (this week / this year / comparison)
/// Workout days in the week `offset` weeks from now (0 = current, -1 = last).
private func workoutCount(inWeekOffset offset: Int) -> Int {
let cal = Calendar.current
guard let ref = cal.date(byAdding: .weekOfYear, value: offset, to: Date()),
let wk = cal.dateInterval(of: .weekOfYear, for: ref) else { return 0 }
let set = Set(workoutDates)
return (0..<7).reduce(0) { acc, i in
guard let d = cal.date(byAdding: .day, value: i, to: wk.start) else { return acc }
return acc + (set.contains(iso.string(from: d)) ? 1 : 0)
}
}
var workoutsThisWeek: Int { workoutCount(inWeekOffset: 0) }
var workoutsLastWeek: Int { workoutCount(inWeekOffset: -1) }
var workoutsThisYear: Int {
let cal = Calendar.current
let year = cal.component(.year, from: Date())
return Set(workoutDates).filter {
guard let d = iso.date(from: $0) else { return false }
return cal.component(.year, from: d) == year
}.count
}
/// 7 flags for the current week (calendar week order) true if worked out that day.
var currentWeekDays: [Bool] {
let cal = Calendar.current
guard let wk = cal.dateInterval(of: .weekOfYear, for: Date()) else {
return Array(repeating: false, count: 7)
}
let set = Set(workoutDates)
return (0..<7).map { i in
guard let d = cal.date(byAdding: .day, value: i, to: wk.start) else { return false }
return set.contains(iso.string(from: d))
}
}
/// 12 flags for the current year true if 1 workout that month.
var currentYearMonths: [Bool] {
let cal = Calendar.current
let year = cal.component(.year, from: Date())
var months = Array(repeating: false, count: 12)
for s in workoutDates {
guard let d = iso.date(from: s), cal.component(.year, from: d) == year else { continue }
let m = cal.component(.month, from: d) - 1
if (0..<12).contains(m) { months[m] = true }
}
return months
}
private let iso: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
}()

View File

@@ -294,6 +294,10 @@ struct CalendarView: View {
}()
@State private var viewMode: CalendarViewMode = .month
@State private var calTaskListMode: Bool = false
/// Year currently at the top of the continuous year scroll (drives the header in .year mode).
@State private var visibleYear: Int = Calendar.current.component(.year, from: Date())
/// Week view: timeline when false, grid of day-cards when true.
@State private var weekGrid: Bool = false
@State private var showAddTask = false
@State private var showCalendarConnect = false
@State private var showWorkoutSession = false
@@ -333,7 +337,7 @@ struct CalendarView: View {
// Center: month + year
Spacer()
Text(monthTitle)
Text(headerTitle)
.font(AppFonts.sans(17, weight: .semibold))
.foregroundColor(AppColors.text(cs))
Spacer()
@@ -388,6 +392,9 @@ struct CalendarView: View {
calDayTaskListView
}
}
// Pin to the top so views whose content doesn't fill the height
// (week / 3-day timelines) don't float to the bottom of the ZStack.
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
FAB { showAddTask = true }
.padding(.trailing, 20).padding(.bottom, 10)
@@ -434,6 +441,12 @@ struct CalendarView: View {
}
}
/// In the continuous year view the header follows the scrolled-to year;
/// otherwise it's the month + year of the displayed month.
private var headerTitle: String {
viewMode == .year ? String(visibleYear) : monthTitle
}
private var monthTitle: String {
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"
return f.string(from: displayMonth)
@@ -673,42 +686,77 @@ struct CalendarView: View {
// MARK: - Year
private var yearView: some View {
let year = cal.component(.year, from: displayMonth)
let months: [Date] = (1...12).compactMap { cal.date(from: DateComponents(year: year, month: $0, day: 1)) }
return VStack(spacing: 0) {
// Year header with navigation (unbounded go as far forward/back as you like).
HStack {
Button { navigateYear(-1) } label: {
Image(systemName: "chevron.left").font(.system(size: 14, weight: .semibold))
.foregroundColor(AppColors.text(cs)).frame(width: 40, height: 40)
}
Spacer()
Text(String(year))
.font(AppFonts.sans(20, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
Button { navigateYear(1) } label: {
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold))
.foregroundColor(AppColors.text(cs)).frame(width: 40, height: 40)
}
}
.padding(.horizontal, 16).padding(.top, 4)
// Reports each year block's top offset within the year scroll, so the
// header can follow whichever year is currently at the top.
private struct YearTopKey: PreferenceKey {
static var defaultValue: [Int: CGFloat] = [:]
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
value.merge(nextValue()) { _, new in new }
}
}
// Continuous, TickTick-style: years flow one after another in an infinite
// scroll. Opens anchored on the current year; scroll up for past years,
// down for future ones no paging, no dead space below December.
private var yearRange: [Int] {
let now = cal.component(.year, from: Date())
return Array((now - 8)...(now + 20))
}
private var yearView: some View {
let currentYear = cal.component(.year, from: Date())
return ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 24) {
ForEach(months, id: \.self) { m in
cvMiniMonth(m)
LazyVStack(alignment: .leading, spacing: 28) {
ForEach(yearRange, id: \.self) { yr in
yearBlock(yr).id(yr)
}
}
.padding(16)
.padding(.top, 8)
.padding(.bottom, 96) // clear the floating tab bar / + button
}
.coordinateSpace(name: "yearScroll")
.onPreferenceChange(YearTopKey.self) { tops in
// The header year is the topmost block that has reached the top edge:
// among blocks at/above a small threshold, the one nearest the top.
let threshold: CGFloat = 60
let reached = tops.filter { $0.value <= threshold }
if let y = reached.max(by: { $0.value < $1.value })?.key
?? tops.min(by: { $0.value < $1.value })?.key,
y != visibleYear {
visibleYear = y
}
}
.onAppear {
visibleYear = currentYear
proxy.scrollTo(currentYear, anchor: .top)
}
}
}
private func navigateYear(_ offset: Int) {
withAnimation(KisaniSpring.snappy) {
displayMonth = cal.date(byAdding: .year, value: offset, to: displayMonth) ?? displayMonth
private func yearBlock(_ year: Int) -> some View {
let months: [Date] = (1...12).compactMap { cal.date(from: DateComponents(year: year, month: $0, day: 1)) }
let isCurrent = year == cal.component(.year, from: Date())
return VStack(alignment: .leading, spacing: 14) {
Text(String(year))
.font(AppFonts.sans(26, weight: .bold))
.foregroundColor(isCurrent ? AppColors.accent : AppColors.text(cs))
.padding(.horizontal, 16)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 24) {
ForEach(months, id: \.self) { m in
cvMiniMonth(m)
}
}
.padding(.horizontal, 16)
}
.background(
GeometryReader { geo in
Color.clear.preference(
key: YearTopKey.self,
value: [year: geo.frame(in: .named("yearScroll")).minY]
)
}
)
}
private func cvMiniMonth(_ month: Date) -> some View {
@@ -745,84 +793,109 @@ struct CalendarView: View {
// MARK: - Week
// Week: a 7-day timeline (TickTick-style) hours down the left, one column
// per weekday, events as positioned blocks. Toggle to a grid of day-cards.
private var weekView: some View {
HStack(alignment: .top, spacing: 0) {
VStack(spacing: 0) {
HStack(spacing: 0) {
ForEach(Array(weekdays.enumerated()), id: \.offset) { _, d in
Text(d).font(AppFonts.mono(7, weight: .semibold))
.foregroundColor(AppColors.text3(cs)).frame(maxWidth: .infinity)
}
}
.padding(.horizontal, 6).padding(.top, 10)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 2) {
ForEach(Array(gridDays().enumerated()), id: \.offset) { _, date in
let isTod = cal.isDateInToday(date)
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
let inMon = cal.isDate(date, equalTo: displayMonth, toGranularity: .month)
ZStack {
if isTod { Circle().fill(AppColors.accent).frame(width: 20, height: 20) }
else if isSel { Circle().fill(AppColors.accentSoft).frame(width: 20, height: 20) }
Text("\(cal.component(.day, from: date))")
.font(AppFonts.sans(9, weight: isTod ? .bold : .regular))
.foregroundColor(isTod ? .white : inMon ? AppColors.text(cs) : AppColors.text3(cs))
}
.frame(height: 22)
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
}
}
.padding(.horizontal, 4)
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
if v.translation.width < -40 { navigateMonth(1) }
else if v.translation.width > 40 { navigateMonth(-1) }
})
let days = cvWeekDays()
return VStack(spacing: 0) {
// Layout toggle: timeline grid
HStack {
Spacer()
Button {
withAnimation(KisaniSpring.snappy) { weekGrid.toggle() }
} label: {
Image(systemName: weekGrid ? "calendar.day.timeline.left" : "square.grid.2x2")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(AppColors.text2(cs))
.padding(.horizontal, 10).padding(.vertical, 5)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 14).padding(.top, 6).padding(.bottom, 2)
Rectangle().fill(AppColors.border(cs)).frame(width: 1)
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
ForEach(cvWeekDays(), id: \.self) { date in
let isTod = cal.isDateInToday(date)
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
let items = cvAgendaItems(date)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 4) {
Text(cvDayFmt.string(from: date))
.font(AppFonts.mono(9, weight: .semibold))
.foregroundColor(isTod ? AppColors.accent : AppColors.text2(cs))
Text("\(cal.component(.day, from: date))")
.font(AppFonts.sans(12, weight: isTod ? .bold : .medium))
.foregroundColor(isTod ? AppColors.accent : AppColors.text(cs))
}
if items.isEmpty {
Text("Free").font(AppFonts.sans(9)).foregroundColor(AppColors.text3(cs))
} else {
ForEach(Array(items.prefix(4).enumerated()), id: \.offset) { _, item in
HStack(spacing: 5) {
RoundedRectangle(cornerRadius: 1.5).fill(item.color)
.frame(width: 2.5, height: 11)
Text(item.title).font(AppFonts.sans(9))
.foregroundColor(AppColors.text2(cs)).lineLimit(1)
}
}
if items.count > 4 {
Text("+\(items.count - 4) more").font(AppFonts.sans(8))
.foregroundColor(AppColors.text3(cs))
}
}
}
.padding(10)
.background(isSel ? AppColors.accentSoft : Color.clear)
.contentShape(Rectangle())
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
Divider().background(AppColors.border(cs))
}
if weekGrid {
weekGridLayout(days)
} else {
cvDayColumnHeader(days)
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
if v.translation.width < -40 { navigateWeek(1) }
else if v.translation.width > 40 { navigateWeek(-1) }
})
Divider().background(AppColors.border(cs))
ScrollView(showsIndicators: false) {
cvTimeGrid(days: days).padding(.bottom, 40)
}
}
.frame(maxWidth: .infinity)
}
}
// Grid layout: the 7 days as cards (2 columns), each listing its schedule.
private func weekGridLayout(_ days: [Date]) -> some View {
ScrollView(showsIndicators: false) {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 10) {
ForEach(days, id: \.self) { date in
weekDayCard(date)
}
}
.padding(16)
}
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
if v.translation.width < -40 { navigateWeek(1) }
else if v.translation.width > 40 { navigateWeek(-1) }
})
}
private func weekDayCard(_ date: Date) -> some View {
let items = (cvAllDayItems(date) + cvTimeItems(date)).sorted { $0.startMin < $1.startMin }
let isTod = cal.isDateInToday(date)
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
return VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 5) {
Text(cvDayFmt.string(from: date))
.font(AppFonts.mono(9, weight: .semibold))
.foregroundColor(isTod ? AppColors.accent : AppColors.text3(cs))
Text("\(cal.component(.day, from: date))")
.font(AppFonts.sans(14, weight: isTod ? .bold : .semibold))
.foregroundColor(isTod ? AppColors.accent : AppColors.text(cs))
Spacer(minLength: 0)
}
if items.isEmpty {
Text("Free").font(AppFonts.sans(9)).foregroundColor(AppColors.text3(cs))
} else {
ForEach(Array(items.prefix(5).enumerated()), id: \.offset) { _, item in
HStack(spacing: 4) {
RoundedRectangle(cornerRadius: 2).fill(item.color).frame(width: 3, height: 12)
Text(item.title).font(AppFonts.sans(9))
.foregroundColor(AppColors.text2(cs)).lineLimit(1)
Spacer(minLength: 0)
}
}
if items.count > 5 {
Text("+\(items.count - 5) more").font(AppFonts.sans(8)).foregroundColor(AppColors.text3(cs))
}
}
}
.padding(10)
.frame(maxWidth: .infinity, minHeight: 92, alignment: .topLeading)
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(
RoundedRectangle(cornerRadius: AppRadius.large)
.stroke(isSel ? AppColors.accent : AppColors.border(cs), lineWidth: isSel ? 1.5 : 1)
)
.contentShape(Rectangle())
.onTapGesture {
withAnimation(KisaniSpring.micro) { selectedDate = date; viewMode = .day }
}
}
private func navigateWeek(_ offset: Int) {
withAnimation(KisaniSpring.snappy) {
selectedDate = cal.date(byAdding: .day, value: offset * 7, to: selectedDate) ?? selectedDate
displayMonth = cal.date(from: cal.dateComponents([.year, .month], from: selectedDate)) ?? displayMonth
}
}
@@ -902,11 +975,6 @@ struct CalendarView: View {
return items
}
/// Everything happening on a date all-day items first, then timed for compact
/// agenda listings (e.g. the Week column). Recurrence-aware.
private func cvAgendaItems(_ date: Date) -> [CVTimeItem] {
(cvAllDayItems(date) + cvTimeItems(date)).sorted { $0.startMin < $1.startMin }
}
@ViewBuilder
private func cvAllDayBand(days: [Date]) -> some View {
@@ -970,27 +1038,27 @@ struct CalendarView: View {
ForEach(cvTimeItems(date)) { item in
let top = max(0, CGFloat(item.startMin - cvStartH * 60) / 60.0 * cvHourH)
let dur = max(CGFloat(item.endMin - item.startMin) / 60.0 * cvHourH, 28)
RoundedRectangle(cornerRadius: 4)
.fill(item.color.opacity(0.28))
.overlay(alignment: .leading) {
Rectangle().fill(item.color).frame(width: 3)
.clipShape(RoundedRectangle(cornerRadius: 2))
CVTimeBlock(
item: item, top: top, height: dur, hourH: cvHourH,
draggable: item.taskID != nil && !item.recurring,
onReschedule: { newStartMin in
rescheduleTask(id: item.taskID, on: date, toStartMin: newStartMin)
}
.overlay(alignment: .topLeading) {
Text(item.title)
.font(AppFonts.sans(9, weight: .medium))
.foregroundColor(item.color)
.padding(.horizontal, 6).padding(.top, 4)
.lineLimit(2)
}
.frame(maxWidth: .infinity).frame(height: dur)
.padding(.trailing, 2)
.offset(y: top)
)
}
}
.frame(maxWidth: .infinity)
}
/// Move a timed task to a new start minute on the same day (drag-to-reschedule).
private func rescheduleTask(id: UUID?, on date: Date, toStartMin newStartMin: Int) {
guard let id, let task = taskVM.tasks.first(where: { $0.id == id }) else { return }
let clamped = min(max(newStartMin, cvStartH * 60), cvEndH * 60 - 15)
let h = clamped / 60, m = clamped % 60
guard let newDate = cal.date(bySettingHour: h, minute: m, second: 0, of: date) else { return }
withAnimation(KisaniSpring.snappy) { taskVM.setDate(task, date: newDate) }
}
// MARK: - Shared helpers
private var cvWeekStrip: some View {
@@ -1050,7 +1118,9 @@ struct CalendarView: View {
for task in taskVM.occurrences(on: date) where !task.isComplete && task.hasTime {
guard let d = task.dueDate else { continue }
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d)
items.append(CVTimeItem(title: task.title, color: task.quadrant.color, startMin: h * 60 + m, endMin: h * 60 + m + 30))
items.append(CVTimeItem(title: task.title, color: task.quadrant.color,
startMin: h * 60 + m, endMin: h * 60 + m + 30,
taskID: task.id, recurring: taskVM.recurs(task)))
}
for ev in calStore.isAuthorized ? calStore.events(for: date) : [] {
guard let s = ev.startDate, let e = ev.endDate, !ev.isAllDay else { continue }
@@ -1073,13 +1143,65 @@ struct CVTimeItem: Identifiable {
let color: Color
let startMin: Int
let endMin: Int
let taskID: UUID? // non-nil for task-backed items (draggable)
let recurring: Bool // recurring tasks aren't drag-rescheduled
init(title: String, color: Color, startMin: Int, endMin: Int) {
self.id = "\(title)-\(startMin)"
self.title = title
self.color = color
self.startMin = startMin
self.endMin = endMin
init(title: String, color: Color, startMin: Int, endMin: Int,
taskID: UUID? = nil, recurring: Bool = false) {
self.id = taskID?.uuidString ?? "\(title)-\(startMin)"
self.title = title
self.color = color
self.startMin = startMin
self.endMin = endMin
self.taskID = taskID
self.recurring = recurring
}
}
// A single timed block on the week/day timeline. Task-backed blocks can be
// dragged vertically to reschedule (snapped to 15-minute steps); events and
// recurring tasks render fixed.
private struct CVTimeBlock: View {
let item: CVTimeItem
let top: CGFloat
let height: CGFloat
let hourH: CGFloat
let draggable: Bool
let onReschedule: (Int) -> Void
@GestureState private var dragY: CGFloat = 0
private var block: some View {
RoundedRectangle(cornerRadius: 4)
.fill(item.color.opacity(dragY == 0 ? 0.28 : 0.45))
.overlay(alignment: .leading) {
Rectangle().fill(item.color).frame(width: 3)
.clipShape(RoundedRectangle(cornerRadius: 2))
}
.overlay(alignment: .topLeading) {
Text(item.title)
.font(AppFonts.sans(9, weight: .medium))
.foregroundColor(item.color)
.padding(.horizontal, 6).padding(.top, 4)
.lineLimit(2)
}
.frame(maxWidth: .infinity).frame(height: height)
.padding(.trailing, 2)
.offset(y: top + dragY)
}
var body: some View {
if draggable {
block.gesture(
DragGesture(minimumDistance: 6)
.updating($dragY) { value, state, _ in state = value.translation.height }
.onEnded { value in
let deltaMin = Int((Double(value.translation.height) / Double(hourH) * 60).rounded() / 15) * 15
onReschedule(item.startMin + deltaMin)
}
)
} else {
block
}
}
}

View File

@@ -1316,7 +1316,10 @@ struct TaskRowView: View {
}
} label: {
RoundedRectangle(cornerRadius: 5, style: .continuous)
.strokeBorder(task.quadrant.color, lineWidth: 1.5)
// Neutral stroke the row already carries its quadrant color
// on the left accent bar and the category chip, so a tinted
// checkbox is redundant.
.strokeBorder(AppColors.text3(cs), lineWidth: 1.5)
.frame(width: 16, height: 16)
.frame(width: 36, height: 44)
.padding(.leading, 4)

View File

@@ -146,7 +146,7 @@ struct WorkoutView: View {
.moveDisabled(true)
// Streak
StreakBannerView(streak: vm.streakDays)
StreakBannerView(vm: vm)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 10, trailing: 14))
@@ -908,28 +908,28 @@ struct RestDayCard: View {
}
// MARK: - Streak Banner
// Swipeable: this week this year this-week-vs-last-week.
struct StreakBannerView: View {
@Environment(\.colorScheme) private var cs
let streak: Int
@ObservedObject var vm: WorkoutViewModel
@State private var page = 0
var body: some View {
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 2) {
Text("\(streak)")
.font(AppFonts.mono(28, weight: .bold))
.foregroundColor(AppColors.text(cs))
Text("day streak")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
VStack(spacing: 8) {
TabView(selection: $page) {
weekPage.tag(0)
yearPage.tag(1)
comparePage.tag(2)
}
Spacer()
// 7-day tick bar current week progress
HStack(spacing: 3) {
ForEach(0..<7, id: \.self) { i in
let filled = streak > 0 && i < (streak % 7 == 0 ? 7 : streak % 7)
RoundedRectangle(cornerRadius: 1.5)
.fill(filled ? AppColors.accent : AppColors.borderHi(cs))
.frame(width: 18, height: 3)
.tabViewStyle(.page(indexDisplayMode: .never))
.frame(height: 50)
// Page dots
HStack(spacing: 5) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(i == page ? AppColors.accent : AppColors.borderHi(cs))
.frame(width: 5, height: 5)
}
}
}
@@ -937,6 +937,76 @@ struct StreakBannerView: View {
.padding(.vertical, 13)
.cardStyle()
}
// Page 1 this week (per-day ticks)
private var weekPage: some View {
HStack(spacing: 0) {
statBlock(value: "\(vm.workoutsThisWeek)", label: "this week")
Spacer()
HStack(spacing: 3) {
ForEach(Array(vm.currentWeekDays.enumerated()), id: \.offset) { _, done in
RoundedRectangle(cornerRadius: 1.5)
.fill(done ? AppColors.accent : AppColors.borderHi(cs))
.frame(width: 18, height: 3)
}
}
}
}
// Page 2 this year (month ticks) + all-time total
private var yearPage: some View {
HStack(spacing: 0) {
statBlock(value: "\(vm.workoutsThisYear)", label: "this year")
Spacer()
VStack(alignment: .trailing, spacing: 5) {
HStack(spacing: 2) {
ForEach(Array(vm.currentYearMonths.enumerated()), id: \.offset) { _, active in
RoundedRectangle(cornerRadius: 1)
.fill(active ? AppColors.accent : AppColors.borderHi(cs))
.frame(width: 9, height: 3)
}
}
Text("\(vm.streakDays) all-time")
.font(AppFonts.mono(9))
.foregroundColor(AppColors.text3(cs))
}
}
}
// Page 3 this week vs last week
private var comparePage: some View {
let delta = vm.workoutsThisWeek - vm.workoutsLastWeek
let deltaColor = delta > 0 ? AppColors.green : (delta < 0 ? AppColors.accent : AppColors.text3(cs))
return HStack(spacing: 0) {
statBlock(value: "\(vm.workoutsThisWeek)", label: "this week")
Spacer()
VStack(alignment: .trailing, spacing: 3) {
HStack(spacing: 4) {
Image(systemName: delta > 0 ? "arrow.up.right" : (delta < 0 ? "arrow.down.right" : "equal"))
.font(.system(size: 10, weight: .bold))
Text(delta == 0
? "same as last week"
: "\(abs(delta)) \(delta > 0 ? "more" : "fewer") than last week")
.font(AppFonts.mono(10, weight: .semibold))
}
.foregroundColor(deltaColor)
Text("last week: \(vm.workoutsLastWeek)")
.font(AppFonts.mono(9))
.foregroundColor(AppColors.text3(cs))
}
}
}
private func statBlock(value: String, label: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(value)
.font(AppFonts.mono(28, weight: .bold))
.foregroundColor(AppColors.text(cs))
Text(label)
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
}
}
}
// MARK: - Exercise Card

108
STRUCTURE.md Normal file
View File

@@ -0,0 +1,108 @@
# Project Structure
KisaniCal (display name **Wenza**) is a personal productivity + fitness iOS app,
built with SwiftUI and generated from `project.yml` via [XcodeGen]. It ships an
iOS app, a WidgetKit extension, and an optional watchOS companion.
## Build targets
Defined in [`project.yml`](project.yml):
| Target | Type | Platform | Bundle ID |
|---|---|---|---|
| `KisaniCal` | application | iOS 16+ | `com.kutesir.KisaniCal` |
| `KisaniCalWidgets` | app-extension | iOS 18+ | `com.kutesir.KisaniCal.KisaniCalWidgets` |
| `WenzaWatch` | application | watchOS 10+ | `com.kutesir.KisaniCal.watchkitapp` |
| `KisaniCalTests` | unit-test | iOS 16+ | — |
`WenzaWatch` is built/embedded only when the watchOS platform is installed
(see the commented `embed` note in `project.yml`).
## Directory layout
```
KisaniCal/ repo root (Gitea: kutesir/KisaniCal)
├── project.yml XcodeGen spec — source of truth for the .xcodeproj
├── KisaniCal.xcodeproj generated (do not hand-edit)
├── KisaniCal/ 📱 main iOS app target
│ ├── KisaniCalApp.swift @main entry point
│ ├── ContentView.swift root view / tab shell
│ ├── KisaniCal.entitlements
│ ├── LaunchScreen.storyboard
│ ├── Views/ SwiftUI screens (13)
│ │ ├── TodayView, CalendarView, MatrixView tasks
│ │ ├── WorkoutView, AddExerciseSheet fitness
│ │ ├── AuthView, OnboardingView, ProfileSetupView, SplashView
│ │ ├── SettingsView, TutorialView
│ │ └── TaskContextMenu, TodayMenuFeatures
│ ├── Managers/ stateful service singletons (10)
│ │ ├── AuthManager, CloudSyncManager auth + iCloud KVS
│ │ ├── HealthKitManager, SpeechRecognizer system frameworks
│ │ ├── NotificationManager, LiveActivityManager, ShortcutHandler
│ │ ├── TutorialManager
│ │ └── AppGroup, TaskActivityAttributes shared with widgets
│ ├── Models/ TaskItem, ExerciseModels, CalendarGrid
│ ├── Components/ SharedComponents, FloatingTabState
│ └── Theme/ DesignTokens
├── KisaniCalWidgets/ 🧩 WidgetKit extension (iOS 18+)
│ ├── KisaniCalWidgets.swift, MyTasksWidget, EventCountdownWidget
│ ├── TaskLiveActivity, WidgetViews, WidgetData
│ └── Info.plist, .entitlements
├── WenzaWatch/ ⌚️ watchOS companion (build-optional)
│ └── WenzaWatchApp.swift, Info.plist, .entitlements
├── KisaniCalTests/ 🧪 unit tests (the CI test target)
│ └── CalendarGridTests, RecurrenceTests, NLRecurrenceParseTests
├── .gitea/workflows/ 🔧 CI/CD (Gitea Actions)
│ ├── ci.yml build + test on PRs into main/develop
│ └── release.yml archive + export IPA on push to main
├── scripts/gitea-setup.sh Gitea branch-protection / default-branch setup
├── ExportOptions.plist IPA export template
├── CONTRIBUTING.md branch workflow + PR gate rules
├── PRODUCT.md product brief, users, design principles
├── SERVICES.md backing services / infra notes
├── ISSUES.md running issue log (KC-## ids)
└── Sanctum-Auto-FailOver-Uptime-Kuma.md
```
## Architecture
A flat SwiftUI **MVVM-lite** layout, organized by role rather than by feature:
- **Views** — SwiftUI screens, one file per screen.
- **Managers** — `ObservableObject` service singletons holding cross-cutting
state (auth, iCloud sync, HealthKit, notifications, speech, Live Activities).
- **Models** — plain data types (`TaskItem`, exercise/workout models, calendar
grid math).
- **Components / Theme** — shared UI and design tokens.
Three product domains coexist in one app:
1. **Tasks** — Today list, Calendar, Eisenhower Matrix; natural-language quick
add (with voice via `SpeechRecognizer`).
2. **Fitness** — workout logging with weighted/bodyweight sets; reads/writes
Health via `HealthKitManager`.
3. **Calendar / events** — surfaced on the dashboard, in **widgets**, and as
**Live Activities**.
### Cross-target code sharing
`KisaniCal/Managers/AppGroup.swift` and `TaskActivityAttributes.swift` are
compiled into **both** the app and the widget extension (see the widget target's
`sources` in `project.yml`), so task/activity data is shared through the App
Group container.
### Persistence & sync
State is persisted locally and mirrored to **iCloud key-value store** via
`CloudSyncManager`. Restore-on-launch rehydrates data after reinstall/update
(see ISSUES.md KC-39 for the save/restore symmetry the sync layer must keep).
[XcodeGen]: https://github.com/yonaskolb/XcodeGen

75
docs/CICD.md Normal file
View File

@@ -0,0 +1,75 @@
# CI/CD Flow
How a change travels from a local branch to TestFlight, and where the gates are.
```mermaid
flowchart TD
A["feature/* branch · local<br/><i>pre-push hook blocks main</i>"]
B["Pull request → develop<br/><i>develop is the default branch</i>"]
C["CI · ci.yml (macOS runner)<br/><i>xcodebuild build + test</i>"]
D{"Merge gate<br/>CI green + 1 approval"}
E["Pull request develop → main<br/><i>same gate</i>"]
F["Release · release.yml<br/><i>archive → IPA → TestFlight</i>"]
A -->|git push · open PR| B
B -->|PR triggers CI| C
C -->|status check: build-and-test| D
D -->|promote when ready| E
E -->|on merge to main| F
```
## Steps
1. **`feature/*` branch (local).** Branch off `develop`. A local
`.git/hooks/pre-push` hook rejects any direct push to `main`, so nothing
reaches the protected branch outside a PR.
2. **PR → `develop`.** `develop` is the default branch and the integration
target for all feature work.
3. **CI runs** — [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml) on the
self-hosted **macOS runner**. It runs `xcodebuild build` then
`xcodebuild test` (the `KisaniCalTests` target on an iOS simulator). The
result is published as a commit status named **`build-and-test`**.
4. **Merge gate.** Gitea branch protection blocks the merge until:
- the `build-and-test` status check is **green**,
- there is **≥ 1 approval**,
- there are no rejected reviews, and
- the branch is up to date with its base.
The same gate guards both `develop` and `main`.
5. **Promote `develop` → `main`** via a second PR when a release is ready. It
passes through the identical gate.
6. **Release runs** — [`.gitea/workflows/release.yml`](../.gitea/workflows/release.yml)
fires **only** on push to `main` (i.e. after a PR merges). It archives,
exports an IPA via [`ExportOptions.plist`](../ExportOptions.plist), and
uploads to **TestFlight** (currently a commented-out placeholder awaiting the
App Store Connect API-key secrets).
## Two automation halves
- **CI** (steps 34) gates **every** PR into `main`/`develop`.
- **Release** (step 6) runs **only** on `main`.
Blue/manual steps are actions you take; CI and Release run unattended on the
runner.
## Requirements & gotchas
- **Runner labels.** Both workflows use `runs-on: [self-hosted, macOS]`. The
registered runner must carry **both** labels or jobs queue forever. Check at
Settings → Actions → Runners.
- **Status-check name.** Branch protection requires a check literally named
`build-and-test` (the CI job name). If the rendered context differs, update
the rule (`scripts/gitea-setup.sh`, `STATUS_CONTEXT`).
- **Simulator.** The runner needs an installed iOS runtime (Xcode → Settings →
Components) — CI auto-selects the first available iPhone simulator.
- **Signing.** CI builds with `CODE_SIGNING_ALLOWED=NO`. The Release job needs a
distribution cert + profile in the runner's login keychain.
- **Build number.** `CURRENT_PROJECT_VERSION` in `project.yml` must increase for
every TestFlight upload — App Store Connect rejects a repeated build number.
See also [CONTRIBUTING.md](../CONTRIBUTING.md) for the branch workflow.

82
scripts/gitea-setup.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
#
# One-shot Gitea setup for KisaniCal:
# 1. Creates the `develop` branch on the server (from main) if missing.
# 2. Sets `develop` as the repo's default branch.
# 3. Adds branch protection on `main` (require PR + 1 approval + status check,
# block direct pushes / force pushes / deletion).
#
# USAGE:
# export GITEA_TOKEN=xxxxxxxxxxxxxxxxxxxx # a Gitea access token with repo scope
# ./scripts/gitea-setup.sh
#
# Create the token in Gitea: click your avatar → Settings → Applications →
# "Generate New Token" (scopes: at least write:repository ).
#
set -euo pipefail
GITEA_URL="http://10.10.1.21:3002"
OWNER="kutesir"
REPO="KisaniCal"
# The commit-status context that must pass before merge into main.
# For Gitea Actions this is the JOB name from ci.yml ("build-and-test").
# After your first CI run, confirm the exact context string at:
# ${GITEA_URL}/${OWNER}/${REPO}/commits (hover the check) and adjust if needed.
STATUS_CONTEXT="build-and-test"
: "${GITEA_TOKEN:?Set GITEA_TOKEN env var first (see header).}"
API="${GITEA_URL}/api/v1"
AUTH=(-H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json")
echo "==> 1/3 Ensuring 'develop' branch exists on server..."
if curl -fsS "${AUTH[@]}" "${API}/repos/${OWNER}/${REPO}/branches/develop" >/dev/null 2>&1; then
echo " develop already exists."
else
curl -fsS -X POST "${AUTH[@]}" \
"${API}/repos/${OWNER}/${REPO}/branches" \
-d '{"new_branch_name":"develop","old_branch_name":"main"}' >/dev/null
echo " created develop from main."
fi
echo "==> 2/3 Setting default branch to 'develop'..."
curl -fsS -X PATCH "${AUTH[@]}" \
"${API}/repos/${OWNER}/${REPO}" \
-d '{"default_branch":"develop"}' >/dev/null
echo " default branch = develop."
echo "==> 3/3 Applying branch protection on 'main'..."
# If a rule already exists this POST returns 409; we then PUT-update it.
PROTECT_PAYLOAD=$(cat <<JSON
{
"branch_name": "main",
"rule_name": "main",
"enable_push": false,
"enable_push_whitelist": false,
"required_approvals": 1,
"enable_approvals_whitelist": false,
"block_on_rejected_reviews": true,
"dismiss_stale_approvals": true,
"enable_status_check": true,
"status_check_contexts": ["${STATUS_CONTEXT}"],
"block_on_outdated_branch": true
}
JSON
)
if curl -fsS -X POST "${AUTH[@]}" \
"${API}/repos/${OWNER}/${REPO}/branch_protections" \
-d "${PROTECT_PAYLOAD}" >/dev/null 2>&1; then
echo " created protection rule for main."
else
echo " rule may already exist — updating it..."
curl -fsS -X PATCH "${AUTH[@]}" \
"${API}/repos/${OWNER}/${REPO}/branch_protections/main" \
-d "${PROTECT_PAYLOAD}" >/dev/null
echo " updated protection rule for main."
fi
echo ""
echo "Done. Note: Gitea automatically blocks force-pushes and deletion of a"
echo "protected branch, so 'main' is now safe from both."