Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit -m ' *)",
"Bash(git push *)",
"Bash(xcodebuild build *)",
"WebFetch(domain:api.anthropic.com)",
"Bash(./vendor/bin/sail test *)",
"Bash(swift test *)",
"Bash(tuist generate *)",
"Bash(xcodebuild test *)",
"Bash(xcodebuild -workspace Spark.xcworkspace -list)"
]
}
}
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Repository Guidelines

## Project Structure & Module Organization

Spark is a Tuist-generated native Swift iOS workspace. `Project.swift` defines the app, extension, watch, and package targets; run `tuist generate` after changing target membership or settings. Main app code lives in `SparkApp/Sources/`, grouped by feature (`Today/`, `Settings/`, `Map/`, `Onboarding/`), with assets and icons in `SparkApp/Resources/`. App extensions live under `Extensions/`, watch targets under `Watch/`, and reusable Swift packages under `Packages/` (`SparkKit`, `SparkUI`, `SparkHealth`, `SparkSync`, `SparkLocation`, `SparkIntelligence`). Cross-target app tests are in `Tests/SparkAppTests/`; package tests live in each package's `Tests/` directory.

## Build, Test, and Development Commands

- `tuist generate` regenerates `Spark.xcworkspace` from `Project.swift`.
- `open Spark.xcworkspace` opens the generated workspace for Xcode development.
- `xcodebuild build -workspace Spark.xcworkspace -scheme SparkApp -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4.1' -configuration Debug` builds the main app.
- `cd Packages/SparkKit && swift test` runs the fastest package-level unit tests.
- `xcodebuild -workspace Spark.xcworkspace -scheme SparkApp -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4.1' -skipPackagePluginValidation -skipMacroValidation test` runs the app test scheme.

## Coding Style & Naming Conventions

Use Swift 6.2 with strict concurrency; `Project.swift` treats Swift and GCC warnings as errors. Follow the existing Swift style: four-space indentation, `UpperCamelCase` types, `lowerCamelCase` properties and functions, and feature-focused filenames like `TodayViewModel.swift` or `EventDetailView.swift`. Keep UI in app or extension targets, reusable domain/networking/persistence code in `SparkKit`, and shared visual components in `SparkUI`.

## Testing Guidelines

Tests use Swift Testing (`import Testing`, `@Suite`, `@Test`, `#expect`). Name tests after observable behavior, for example `productionEnvironmentPointsAtProductionHost`. Add package tests beside the package they cover, such as `Packages/SparkKit/Tests/SparkKitTests/`, and use `Tests/SparkAppTests/` for workspace-level smoke or integration coverage.

## Commit & Pull Request Guidelines

History uses short gitmoji-style subjects, often with scope or phase context, such as `:sparkles: Phase 2 Week 3 D12: Notification preferences` or `:bug: Fix handling bugs`. Keep commits focused and imperative. Pull requests should describe the user-visible change, list testing performed, link the issue or phase task, and include screenshots or recordings for UI changes.

## Security & Configuration Tips

Do not commit personal provisioning changes or local backend URLs. Environment overrides belong in the shared App Group `UserDefaults` keys documented in `README.md`; erase those keys to return to production. Treat release credentials such as `SENTRY_AUTH_TOKEN` as environment variables, not source files.
29 changes: 12 additions & 17 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Tech Stack

- **Language**: Swift 6.2 with strict concurrency enforcement (`SWIFT_STRICT_CONCURRENCY=complete`)
- **Minimum OS**: iOS 26.0 (also watchOS 26.0 for Phase 5)
- **Minimum OS**: iOS 26.4 (also watchOS 26.0 for Phase 5)
- **Project generation**: Tuist 4.x (not native Xcode workspace)
- **Package management**: SPM (Swift Package Manager) with 6 local packages + Sentry remote dependency
- **Data persistence**: SwiftData with App Group shared container
Expand Down Expand Up @@ -96,18 +96,14 @@ tuist generate

This creates `Spark.xcworkspace`. Open in Xcode 26.

### Provisioning (Personal Team Setup)
### Provisioning

Each target shares:
- **App Group**: `group.co.cronx.spark`
- **App Group**: `group.co.cronx.sparkapp`
- **Keychain access group**: `$(AppIdentifierPrefix)co.cronx.spark`
- **Associated domain**: `applinks:spark.cronx.co`

In Xcode, for each target:
1. Select target → Signing & Capabilities → pick your Team
2. Xcode auto-registers App Group, Keychain Sharing, Associated Domains, Push Notifications, HealthKit

No Project.swift changes needed; `DEVELOPMENT_TEAM` is auto-read from Xcode user settings.
The William Scott development team is declared in `Project.swift`, so `tuist generate` preserves signing automatically. Xcode can then auto-register App Group, Keychain Sharing, Associated Domains, Push Notifications, and HealthKit as needed.

## Build & Test Commands

Expand All @@ -121,11 +117,11 @@ tuist generate
xcodebuild build \
-workspace Spark.xcworkspace \
-scheme SparkApp \
-destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=26.0' \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4.1' \
-configuration Debug

# Build from Xcode
# Select SparkApp scheme → iPhone 16 Pro simulator → ⌘B
# Select SparkApp scheme → iPhone 17 Pro simulator → ⌘B
```

### Test
Expand All @@ -138,13 +134,13 @@ cd Packages/SparkKit && swift test
xcodebuild \
-workspace Spark.xcworkspace \
-scheme SparkApp \
-destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=26.0' \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4.1' \
-skipPackagePluginValidation \
-skipMacroValidation \
test

# From Xcode
# Select SparkApp scheme → iPhone 16 Pro simulator → ⌘U
# Select SparkApp scheme → iPhone 17 Pro simulator → ⌘U
```

### Lint & Code Quality
Expand All @@ -157,7 +153,7 @@ swiftformat --lint .
xcodebuild \
-workspace Spark.xcworkspace \
-scheme SparkApp \
-destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=26.0' \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4.1' \
build \
-skipPackagePluginValidation
```
Expand All @@ -167,7 +163,7 @@ xcodebuild \
To point at a local backend instead of `spark.cronx.co`, write to the shared App Group `UserDefaults`:

```swift
let defaults = UserDefaults(suiteName: "group.co.cronx.spark")!
let defaults = UserDefaults(suiteName: "group.co.cronx.sparkapp")!
defaults.set("http://192.168.1.42:8000/api/v1/mobile", forKey: "spark.env.baseURL")
defaults.set("http://192.168.1.42:8000/oauth/authorize", forKey: "spark.env.oauthURL")
defaults.set("lan", forKey: "spark.env.name")
Expand Down Expand Up @@ -295,7 +291,7 @@ Tests cover:
xcodebuild \
-workspace Spark.xcworkspace \
-scheme SparkApp \
-destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=26.0' \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4.1' \
test
```

Expand All @@ -312,7 +308,7 @@ Test every family × size class × light/dark × extreme Dynamic Type before eac
- Runs on every push to `main` / `dev` and every PR
- Caches DerivedData + SPM packages
- Runs `swift test` on SparkKit
- Runs `xcodebuild test` on SparkApp (iPhone 16 Pro, iOS 26 simulator)
- Runs `xcodebuild test` on SparkApp (iPhone 17 Pro, iOS 26.4.1 simulator)
- Uploads xcresult on failure

## Version & Release
Expand Down Expand Up @@ -403,4 +399,3 @@ If backend changes compact resource format:
- **SwiftData docs**: https://developer.apple.com/swiftdata/
- **ActivityKit docs**: https://developer.apple.com/activitykit/
- **Liquid Glass**: iOS 26 Glass Effect (`GlassEffectContainer`, `.glassEffect()`)

6 changes: 3 additions & 3 deletions Extensions/SparkControls/Sources/SparkControlsBundle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ struct SparkControlsBundle: WidgetBundle {

struct QuickCheckInControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "co.cronx.spark.controls.checkin") {
StaticControlConfiguration(kind: "co.cronx.sparkapp.controls.checkin") {
ControlWidgetButton(action: QuickCheckInAction()) {
Label("Check In", systemImage: "plus.circle.fill")
}
Expand All @@ -39,7 +39,7 @@ struct QuickCheckInAction: AppIntent {

struct OpenTodayControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "co.cronx.spark.controls.open-today") {
StaticControlConfiguration(kind: "co.cronx.sparkapp.controls.open-today") {
ControlWidgetButton(action: OpenTodayAction()) {
Label("Open Spark", systemImage: "sparkles")
}
Expand All @@ -60,7 +60,7 @@ struct OpenTodayAction: AppIntent {

struct FocusDomainControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "co.cronx.spark.controls.focus-domain") {
StaticControlConfiguration(kind: "co.cronx.sparkapp.controls.focus-domain") {
ControlWidgetButton(action: FocusDomainAction()) {
Label("Focus", systemImage: "scope")
}
Expand Down
4 changes: 2 additions & 2 deletions Extensions/SparkControls/SparkControls.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.co.cronx.spark</string>
<string>group.co.cronx.sparkapp</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)co.cronx.spark</string>
<string>$(AppIdentifierPrefix)co.cronx.sparkapp</string>
</array>
</dict>
</plist>
4 changes: 2 additions & 2 deletions Extensions/SparkIntents/SparkIntents.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.co.cronx.spark</string>
<string>group.co.cronx.sparkapp</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)co.cronx.spark</string>
<string>$(AppIdentifierPrefix)co.cronx.sparkapp</string>
</array>
</dict>
</plist>
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.co.cronx.spark</string>
<string>group.co.cronx.sparkapp</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)co.cronx.spark</string>
<string>$(AppIdentifierPrefix)co.cronx.sparkapp</string>
</array>
</dict>
</plist>
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ final class NotificationService: UNNotificationServiceExtension, @unchecked Send

private func downloadAttachment(from url: URL, notificationID: String) async -> UNNotificationAttachment? {
let cacheDir = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.co.cronx.spark")?
.containerURL(forSecurityApplicationGroupIdentifier: "group.co.cronx.sparkapp")?
.appendingPathComponent("NotificationMedia", isDirectory: true)
?? FileManager.default.temporaryDirectory

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.co.cronx.spark</string>
<string>group.co.cronx.sparkapp</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)co.cronx.spark</string>
<string>$(AppIdentifierPrefix)co.cronx.sparkapp</string>
</array>
</dict>
</plist>
26 changes: 21 additions & 5 deletions Extensions/SparkShare/Sources/ShareViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ final class ShareViewController: UIViewController {

private func shareImageData(_ data: Data) {
let dir = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.co.cronx.spark")?
.containerURL(forSecurityApplicationGroupIdentifier: "group.co.cronx.sparkapp")?
.appendingPathComponent("ShareUploads", isDirectory: true)
?? FileManager.default.temporaryDirectory
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
Expand All @@ -110,8 +110,24 @@ final class ShareViewController: UIViewController {
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type")
let config = URLSessionConfiguration.background(withIdentifier: "co.cronx.spark.share.upload")
config.sharedContainerIdentifier = "group.co.cronx.spark"
let config = URLSessionConfiguration.background(withIdentifier: "co.cronx.sparkapp.share.upload")
config.sharedContainerIdentifier = "group.co.cronx.sparkapp"
let fileSize = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
Task {
await APITelemetry.shared.capture(
APITelemetryEvent(
operation: "http.client.background_upload.schedule",
method: request.httpMethod ?? "POST",
url: APITelemetryRedactor.url(uploadURL),
endpointPath: "/check-ins/media",
requiresAuth: true,
requestHeaders: APITelemetryRedactor.headers(request.allHTTPHeaderFields ?? [:]),
responseSizeBytes: fileSize,
durationMillis: 0,
outcome: .success
)
)
}
URLSession(configuration: config).uploadTask(with: request, fromFile: fileURL).resume()
}

Expand Down Expand Up @@ -140,8 +156,8 @@ final class ShareViewController: UIViewController {
private func syncAccessToken() -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: "co.cronx.spark.accessToken",
kSecAttrAccessGroup: "$(AppIdentifierPrefix)co.cronx.spark",
kSecAttrService: "co.cronx.sparkapp.accessToken",
kSecAttrAccessGroup: "$(AppIdentifierPrefix)co.cronx.sparkapp",
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
Expand Down
4 changes: 2 additions & 2 deletions Extensions/SparkShare/SparkShare.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.co.cronx.spark</string>
<string>group.co.cronx.sparkapp</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)co.cronx.spark</string>
<string>$(AppIdentifierPrefix)co.cronx.sparkapp</string>
</array>
</dict>
</plist>
21 changes: 12 additions & 9 deletions Extensions/SparkWidgets/Sources/LockScreenWidgets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import WidgetKit
// MARK: - Circular (sleep ring + steps ring)

struct SleepCircularWidget: Widget {
let kind = "co.cronx.spark.widgets.sleep-circular"
let kind = "co.cronx.sparkapp.widgets.sleep-circular"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in
Expand Down Expand Up @@ -45,7 +45,7 @@ struct SleepCircularView: View {
}

struct StepsCircularWidget: Widget {
let kind = "co.cronx.spark.widgets.steps-circular"
let kind = "co.cronx.sparkapp.widgets.steps-circular"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in
Expand Down Expand Up @@ -84,7 +84,7 @@ struct StepsCircularView: View {
// MARK: - Rectangular (top metric)

struct TopMetricRectangularWidget: Widget {
let kind = "co.cronx.spark.widgets.top-metric-rect"
let kind = "co.cronx.sparkapp.widgets.top-metric-rect"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in
Expand Down Expand Up @@ -130,7 +130,7 @@ struct TopMetricRectangularView: View {
// MARK: - Inline (next event)

struct NextEventInlineWidget: Widget {
let kind = "co.cronx.spark.widgets.next-event-inline"
let kind = "co.cronx.sparkapp.widgets.next-event-inline"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in
Expand All @@ -147,11 +147,14 @@ struct NextEventInlineView: View {

var body: some View {
let snap = entry.snapshot
if let title = snap.nextEventTitle {
let time = snap.nextEventStart.map { " · \($0)" } ?? ""
Label("\(title)\(time)", systemImage: "calendar")
} else {
Label("No upcoming events", systemImage: "calendar")
Group {
if let title = snap.nextEventTitle {
let time = snap.nextEventStart.map { " · \($0)" } ?? ""
Label("\(title)\(time)", systemImage: "calendar")
} else {
Label("No upcoming events", systemImage: "calendar")
}
}
.widgetURL(URL(string: "https://spark.cronx.co/today"))
}
}
2 changes: 1 addition & 1 deletion Extensions/SparkWidgets/Sources/NextEventWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import SwiftUI
import WidgetKit

struct NextEventWidget: Widget {
let kind = "co.cronx.spark.widgets.nextevent"
let kind = "co.cronx.sparkapp.widgets.nextevent"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import WidgetKit
/// Keychain are reachable from an extension before we start building real
/// widget content in Phase 3.
struct PlumbingSmokeTestWidget: Widget {
let kind: String = "co.cronx.spark.widgets.plumbing"
let kind: String = "co.cronx.sparkapp.widgets.plumbing"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: PlumbingProvider()) { entry in
Expand Down
2 changes: 1 addition & 1 deletion Extensions/SparkWidgets/Sources/SleepScoreWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import SwiftUI
import WidgetKit

struct SleepScoreWidget: Widget {
let kind = "co.cronx.spark.widgets.sleep"
let kind = "co.cronx.sparkapp.widgets.sleep"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in
Expand Down
2 changes: 1 addition & 1 deletion Extensions/SparkWidgets/Sources/SpendTodayWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import SwiftUI
import WidgetKit

struct SpendTodayWidget: Widget {
let kind = "co.cronx.spark.widgets.spend"
let kind = "co.cronx.sparkapp.widgets.spend"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: SparkTimelineProvider()) { entry in
Expand Down
Loading
Loading