Compare commits
20 Commits
main
...
3134c064e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3134c064e9 | ||
|
|
0a87769561 | ||
|
|
e8d74d4f59 | ||
|
|
152b0bf9b5 | ||
|
|
372da6df5a | ||
|
|
c5285a2e30 | ||
|
|
bd43eecfbe | ||
|
|
2ec3278632 | ||
|
|
6456d8f69a | ||
|
|
a37807aff0 | ||
|
|
cac8cba38a | ||
|
|
28b4039593 | ||
| cbf369b754 | |||
| 5e7d476b59 | |||
| ce97de37bd | |||
| d235cce510 | |||
| 9e93172199 | |||
|
|
b540bddbe9 | ||
|
|
9ae94babb6 | ||
|
|
8d37021f78 |
86
.gitea/workflows/ci.yml
Normal file
86
.gitea/workflows/ci.yml
Normal 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
|
||||
86
.gitea/workflows/release.yml
Normal file
86
.gitea/workflows/release.yml
Normal 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
61
CONTRIBUTING.md
Normal 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
45
ExportOptions.plist
Normal 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>
|
||||
@@ -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
|
||||
}()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
108
STRUCTURE.md
Normal 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
75
docs/CICD.md
Normal 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 3–4) 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
82
scripts/gitea-setup.sh
Executable 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."
|
||||
Reference in New Issue
Block a user