Procházet zdrojové kódy

Rework UI; add logic for Watch State setup and for connection between iPhone and Watch; several fixes

polscm32 aka Marvout před 1 rokem
rodič
revize
da363da062

+ 156 - 8
Trio Watch App Extension/ContentView.swift

@@ -1,17 +1,165 @@
+import Charts
 import SwiftUI
 
 struct ContentView: View {
+    @State private var state = WatchState()
+    @State private var showingCarbsSheet = false
+    @State private var showingBolusSheet = false
+    @State private var currentPage: Double = 0
+    @State private var rotationDegrees: Double = 0.0
+
     var body: some View {
-        VStack {
-            Image(systemName: "globe")
-                .imageScale(.large)
-                .foregroundStyle(.tint)
-            Text("Hello, world!")
+        TabView(selection: $currentPage) {
+            // Page 1: Current glucose and trend
+            ZStack {
+                TrendShape(rotationDegrees: rotationDegrees)
+                    .animation(.spring(response: 0.5, dampingFraction: 0.6), value: rotationDegrees)
+
+                VStack(alignment: .center) {
+                    Text(state.currentGlucose)
+                        .fontWeight(.bold)
+                        .font(.system(size: 40))
+
+                    if let delta = state.delta {
+                        Text(delta)
+                            .font(.caption2)
+                            .foregroundStyle(.secondary)
+                    }
+                }
+            }
+            .tag(0.0)
+            .onChange(of: state.trend) { newTrend in
+                withAnimation {
+                    updateRotation(for: newTrend)
+                }
+            }
+            .toolbar {
+                ToolbarItem(placement: .bottomBar) {
+                    Button {
+                        showingCarbsSheet = true
+                    } label: {
+                        Image(systemName: "fork.knife")
+                    }
+                }
+
+                ToolbarItem(placement: .bottomBar) {
+                    Button {
+                        showingBolusSheet = true
+                    } label: {
+                        Image(systemName: "drop.fill")
+                    }
+                }
+            }
+
+            // Page 2: Glucose chart
+            GlucoseChartView(glucoseValues: state.glucoseValues)
+                .tag(1.0)
+        }
+        .tabViewStyle(.verticalPage)
+        .navigationBarHidden(true)
+        .digitalCrownRotation($currentPage, from: 0, through: 1, by: 1)
+        .sheet(isPresented: $showingCarbsSheet) {
+            CarbsInputView(state: state)
+        }
+        .sheet(isPresented: $showingBolusSheet) {
+            BolusInputView(state: state)
+        }
+    }
+
+    private func updateRotation(for trend: String?) {
+        switch trend {
+        case "↑",
+             "↑↑": // DoubleUp, SingleUp
+            rotationDegrees = -90
+        case "↗": // FortyFiveUp
+            rotationDegrees = -45
+        case "→": // Flat
+            rotationDegrees = 0
+        case "↘": // FortyFiveDown
+            rotationDegrees = 45
+        case "↓",
+             "↓↓": // SingleDown, DoubleDown
+            rotationDegrees = 90
+        default:
+            rotationDegrees = 0
         }
-        .padding()
     }
 }
 
-#Preview {
-    ContentView()
+struct GlucoseChartView: View {
+    let glucoseValues: [(date: Date, glucose: Double)]
+    @State private var timeWindow: TimeWindow = .threeHours
+
+    enum TimeWindow: Int {
+        case threeHours = 3
+        case sixHours = 6
+        case twelveHours = 12
+        case twentyFourHours = 24
+
+        var next: TimeWindow {
+            switch self {
+            case .threeHours: return .sixHours
+            case .sixHours: return .twelveHours
+            case .twelveHours: return .twentyFourHours
+            case .twentyFourHours: return .threeHours
+            }
+        }
+    }
+
+    private var filteredValues: [(date: Date, glucose: Double)] {
+        let cutoffDate = Date().addingTimeInterval(-Double(timeWindow.rawValue) * 3600)
+        return glucoseValues.filter { $0.date > cutoffDate }
+    }
+
+    private func glucoseColor(_ value: Double) -> Color {
+        if value > 180 {
+            return .orange
+        } else if value < 70 {
+            return .red
+        } else {
+            return .green
+        }
+    }
+
+    var body: some View {
+        Chart {
+            ForEach(filteredValues, id: \.date) { reading in
+                LineMark(
+                    x: .value("Time", reading.date),
+                    y: .value("Glucose", reading.glucose)
+                )
+                .foregroundStyle(Color.accentColor.gradient)
+                .lineStyle(StrokeStyle(lineWidth: 2))
+
+                PointMark(
+                    x: .value("Time", reading.date),
+                    y: .value("Glucose", reading.glucose)
+                )
+                .foregroundStyle(glucoseColor(reading.glucose))
+                .symbolSize(40) // Kleinere Punkte
+            }
+        }
+        .chartXAxis {
+            AxisMarks(values: .automatic(desiredCount: 4)) { _ in
+                AxisValueLabel(format: .dateTime.hour())
+            }
+        }
+        .chartYAxis {
+            AxisMarks(position: .leading)
+        }
+        .padding()
+        .onTapGesture {
+            withAnimation {
+                timeWindow = timeWindow.next
+            }
+        }
+        .overlay(alignment: .topLeading) {
+            Text("\(timeWindow.rawValue)h")
+                .font(.caption2)
+                .foregroundStyle(.secondary)
+                .padding(.leading)
+        }
+    }
 }
+
+// Rest der View-Komponenten bleiben unverändert...

+ 1 - 1
Trio Watch App Extension/TrioWatchApp.swift

@@ -3,7 +3,7 @@ import SwiftUI
 @main struct TrioWatch_Watch_AppApp: App {
     var body: some Scene {
         WindowGroup {
-            ContentView()
+            TrioMainWatchView()
         }
     }
 }

+ 36 - 0
Trio Watch App Extension/Views/BolusInputView.swift

@@ -0,0 +1,36 @@
+import Foundation
+import SwiftUI
+
+// MARK: - Bolus Input View
+
+struct BolusInputView: View {
+    @Environment(\.dismiss) var dismiss
+    @State private var bolusAmount = 0.0
+    @State private var isExternalInsulin = false
+    let state: WatchState
+
+    var body: some View {
+        NavigationView {
+            VStack {
+                Picker("Bolus", selection: $bolusAmount) {
+                    ForEach(0 ... 100, id: \.self) { number in
+                        Text(String(format: "%.1f U", Double(number) / 10))
+                            .tag(Double(number) / 10)
+                    }
+                }
+
+                Toggle("External Insulin", isOn: $isExternalInsulin)
+                    .toggleStyle(.switch)
+                    .padding(.horizontal)
+
+                Button(isExternalInsulin ? "Add External Insulin" : "Add Bolus") {
+                    state.sendBolusRequest(Decimal(bolusAmount), isExternal: isExternalInsulin)
+                    dismiss()
+                }
+                .buttonStyle(.bordered)
+                .tint(.blue)
+            }
+            .navigationTitle("Add Insulin")
+        }
+    }
+}

+ 30 - 0
Trio Watch App Extension/Views/CarbsInputView.swift

@@ -0,0 +1,30 @@
+import Foundation
+import SwiftUI
+
+// MARK: - Carbs Input View
+
+struct CarbsInputView: View {
+    @Environment(\.dismiss) var dismiss
+    @State private var carbsAmount = 0
+    let state: WatchState
+
+    var body: some View {
+        NavigationView {
+            VStack {
+                Picker("Carbs", selection: $carbsAmount) {
+                    ForEach(0 ... 100, id: \.self) { amount in
+                        Text("\(amount)g").tag(amount)
+                    }
+                }
+
+                Button("Add Carbs") {
+                    state.sendCarbsRequest(carbsAmount)
+                    dismiss()
+                }
+                .buttonStyle(.bordered)
+                .tint(.orange)
+            }
+            .navigationTitle("Add Carbs")
+        }
+    }
+}

+ 76 - 0
Trio Watch App Extension/Views/GlucoseChartView.swift

@@ -0,0 +1,76 @@
+import Charts
+import Foundation
+import SwiftUI
+
+// MARK: - Current Glucose View
+
+struct GlucoseChartView: View {
+    let glucoseValues: [(date: Date, glucose: Double)]
+    @State private var timeWindow: TimeWindow = .threeHours
+
+    enum TimeWindow: Int {
+        case threeHours = 3
+        case sixHours = 6
+        case twelveHours = 12
+        case twentyFourHours = 24
+
+        var next: TimeWindow {
+            switch self {
+            case .threeHours: return .sixHours
+            case .sixHours: return .twelveHours
+            case .twelveHours: return .twentyFourHours
+            case .twentyFourHours: return .threeHours
+            }
+        }
+    }
+
+    // TODO: should we only change the x axis here like we do in the main chart instead of filtering the values?
+    private var filteredValues: [(date: Date, glucose: Double)] {
+        let cutoffDate = Date().addingTimeInterval(-Double(timeWindow.rawValue) * 3600)
+        return glucoseValues.filter { $0.date > cutoffDate }
+    }
+
+    // TODO: replace hard coded values with actual settings and add dynamic color
+    private func glucoseColor(_ value: Double) -> Color {
+        if value > 180 {
+            return .orange
+        } else if value < 70 {
+            return .red
+        } else {
+            return .green
+        }
+    }
+
+    var body: some View {
+        Chart {
+            ForEach(filteredValues, id: \.date) { reading in
+                PointMark(
+                    x: .value("Time", reading.date),
+                    y: .value("Glucose", reading.glucose)
+                )
+                .foregroundStyle(glucoseColor(reading.glucose))
+                .symbolSize(15)
+            }
+        }
+        .chartXAxis {
+            AxisMarks(values: .automatic(desiredCount: 4)) { _ in
+                AxisValueLabel(format: .dateTime.hour())
+            }
+        }
+        .chartYAxis {
+            AxisMarks(position: .leading)
+        }
+        .padding()
+        .onTapGesture {
+            withAnimation {
+                timeWindow = timeWindow.next
+            }
+        }
+        .overlay(alignment: .topTrailing) {
+            Text("\(timeWindow.rawValue)h")
+                .font(.caption2)
+                .foregroundStyle(.secondary)
+                .padding(.trailing)
+        }
+    }
+}

+ 61 - 0
Trio Watch App Extension/Views/TrendShape.swift

@@ -0,0 +1,61 @@
+import SwiftUI
+
+struct Triangle: Shape {
+    /// Creates a triangle shape pointing to the right
+    func path(in rect: CGRect) -> Path {
+        var path = Path()
+
+        // Draw the triangle pointing to the right
+        path.move(to: CGPoint(x: rect.maxX - 10, y: rect.midY))
+        path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
+        path.addQuadCurve(
+            to: CGPoint(x: rect.minX, y: rect.maxY),
+            control: CGPoint(x: rect.midX - 15, y: rect.midY)
+        )
+        path.closeSubpath()
+
+        return path
+    }
+}
+
+/// A view that displays a circular trend indicator with a directional triangle
+struct TrendShape: View {
+    /// Rotation angle in degrees for the trend direction
+    let rotationDegrees: Double
+
+    // Angular gradient for the outer circle, transitioning through various blues and purples
+    private let angularGradient = AngularGradient(
+        colors: [
+            Color(red: 0.7215686275, green: 0.3411764706, blue: 1), // #B857FF
+            Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569), // #9F6CFA
+            Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765), // #7C8BF3
+            Color(red: 0.3411764706, green: 0.6666666667, blue: 0.9254901961), // #57AAEC
+            Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902), // #43BBE9
+            Color(red: 0.7215686275, green: 0.3411764706, blue: 1) // #B857FF (repeated for seamless transition)
+        ],
+        center: .center,
+        startAngle: .degrees(270),
+        endAngle: .degrees(-90)
+    )
+
+    // Color for the direction indicator triangle
+    private let triangleColor = Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902) // #43BBE9
+
+    var body: some View {
+        ZStack {
+            // Outer circle with gradient
+            Circle()
+                .stroke(angularGradient, lineWidth: 6)
+                .frame(width: 90, height: 90)
+                .background(Circle().fill(Color.black))
+
+            // Triangle with the color of the last gradient color
+            Triangle()
+                .fill(triangleColor)
+                .frame(width: 20, height: 20)
+                .offset(x: 55)
+        }
+        .rotationEffect(.degrees(rotationDegrees))
+        .shadow(color: Color.black.opacity(0.33), radius: 3)
+    }
+}

+ 96 - 0
Trio Watch App Extension/Views/TrioMainWatchView.swift

@@ -0,0 +1,96 @@
+import Charts
+import SwiftUI
+
+struct TrioMainWatchView: View {
+    @State private var state = WatchState()
+    @State private var showingCarbsSheet = false
+    @State private var showingBolusSheet = false
+    @State private var currentPage: Double = 0
+    @State private var rotationDegrees: Double = 0.0
+
+    var body: some View {
+        TabView(selection: $currentPage) {
+            // Page 1: Current glucose and action buttons
+            VStack(spacing: 20) {
+                ZStack {
+                    TrendShape(rotationDegrees: rotationDegrees)
+                        .animation(.spring(response: 0.5, dampingFraction: 0.6), value: rotationDegrees)
+
+                    VStack(alignment: .center) {
+                        Text(state.currentGlucose)
+                            .fontWeight(.bold)
+                            .font(.system(size: 40))
+
+                        if let delta = state.delta {
+                            Text(delta)
+                                .font(.caption2)
+                                .foregroundStyle(.secondary)
+                        }
+                    }
+                }
+
+                HStack(spacing: 20) {
+                    Button {
+                        showingCarbsSheet = true
+                    } label: {
+                        Image(systemName: "fork.knife")
+                            .font(.system(size: 30))
+                    }
+                    .buttonStyle(.bordered)
+                    .tint(.orange)
+
+                    Button {
+                        showingBolusSheet = true
+                    } label: {
+                        Image(systemName: "drop.fill")
+                            .font(.system(size: 30))
+                    }
+                    .buttonStyle(.bordered)
+                    .tint(.blue)
+                }
+            }
+            .tag(0.0)
+            .onChange(of: state.trend) { _, newTrend in
+                withAnimation {
+                    updateRotation(for: newTrend)
+                }
+            }
+
+            // Page 2: Glucose chart
+            GlucoseChartView(glucoseValues: state.glucoseValues)
+                .tag(1.0)
+        }
+        .tabViewStyle(.verticalPage)
+        .digitalCrownRotation($currentPage, from: 0, through: 1, by: 1)
+        .sheet(isPresented: $showingCarbsSheet) {
+            CarbsInputView(state: state)
+        }
+        .sheet(isPresented: $showingBolusSheet) {
+            BolusInputView(state: state)
+        }
+    }
+
+    // TODO: - Refactor this like in CurrentGlucoseView
+    private func updateRotation(for trend: String?) {
+        switch trend {
+        case "DoubleUp",
+             "SingleUp":
+            rotationDegrees = -90
+        case "FortyFiveUp":
+            rotationDegrees = -45
+        case "Flat":
+            rotationDegrees = 0
+        case "FortyFiveDown":
+            rotationDegrees = 45
+        case "DoubleDown",
+             "SingleDown":
+            rotationDegrees = 90
+        default:
+            rotationDegrees = 0
+        }
+    }
+}
+
+#Preview {
+    TrioMainWatchView()
+}

+ 130 - 0
Trio Watch App Extension/WatchState.swift

@@ -0,0 +1,130 @@
+import Foundation
+import WatchConnectivity
+
+/// WatchState manages the communication between the Watch app and the iPhone app using WatchConnectivity.
+/// It handles glucose data synchronization and sending treatment requests (bolus, carbs) to the phone.
+@Observable final class WatchState: NSObject, WCSessionDelegate {
+    // MARK: - Properties
+
+    /// The WatchConnectivity session instance used for communication
+    private var session: WCSession?
+    /// Indicates if the paired iPhone is currently reachable
+    var isReachable = false
+
+    var currentGlucose: String = "--"
+    var trend: String? = ""
+    var delta: String? = ""
+    var glucoseValues: [(date: Date, glucose: Double)] = []
+
+    override init() {
+        super.init()
+        setupSession()
+    }
+
+    /// Configures the WatchConnectivity session if supported on the device
+    private func setupSession() {
+        if WCSession.isSupported() {
+            let session = WCSession.default
+            session.delegate = self
+            session.activate()
+            self.session = session
+        } else {
+            print("⌚️ WCSession is not supported on this device")
+        }
+    }
+
+    // MARK: - Send Data to Phone
+
+    /// Sends a bolus insulin request to the paired iPhone
+    /// - Parameters:
+    ///   - amount: The insulin amount to be delivered
+    ///   - isExternal: Indicates if the bolus is from an external source
+    func sendBolusRequest(_ amount: Decimal, isExternal: Bool) {
+        guard let session = session, session.isReachable else { return }
+
+        let message: [String: Any] = [
+            "bolus": amount,
+            "isExternal": isExternal
+        ]
+
+        session.sendMessage(message, replyHandler: nil) { error in
+            print("Error sending bolus request: \(error.localizedDescription)")
+        }
+    }
+
+    /// Sends a carbohydrate entry request to the paired iPhone
+    /// - Parameters:
+    ///   - amount: The amount of carbs in grams
+    ///   - date: The timestamp for the carb entry (defaults to current time)
+    func sendCarbsRequest(_ amount: Int, _ date: Date = Date()) {
+        guard let session = session, session.isReachable else { return }
+
+        let message: [String: Any] = [
+            "carbs": amount,
+            "date": date.timeIntervalSince1970
+        ]
+
+        session.sendMessage(message, replyHandler: nil) { error in
+            print("Error sending carbs request: \(error.localizedDescription)")
+        }
+    }
+
+    // MARK: - WCSessionDelegate
+
+    /// Called when the session has completed activation
+    /// Updates the reachability status and logs the activation state
+    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
+        DispatchQueue.main.async {
+            if let error = error {
+                print("⌚️ Watch session activation failed: \(error.localizedDescription)")
+                return
+            }
+
+            print("⌚️ Watch session activated with state: \(activationState.rawValue)")
+            self.isReachable = session.isReachable
+            print("⌚️ Watch isReachable after activation: \(session.isReachable)")
+        }
+    }
+
+    /// Handles incoming messages from the paired iPhone
+    /// Updates local glucose data, trend, and delta information
+    func session(_: WCSession, didReceiveMessage message: [String: Any]) {
+        print("⌚️ Watch received message: \(message)")
+
+        DispatchQueue.main.async { [weak self] in
+            guard let self = self else { return }
+
+            if let currentGlucose = message["currentGlucose"] as? String {
+                self.currentGlucose = currentGlucose
+            }
+
+            if let trend = message["trend"] as? String {
+                self.trend = trend
+            }
+
+            if let delta = message["delta"] as? String {
+                self.delta = delta
+            }
+
+            if let glucoseData = message["glucoseValues"] as? [[String: Any]] {
+                self.glucoseValues = glucoseData.compactMap { data in
+                    guard let glucose = data["glucose"] as? Double,
+                          let timestamp = data["date"] as? TimeInterval
+                    else { return nil }
+
+                    return (Date(timeIntervalSince1970: timestamp), glucose)
+                }
+                .sorted { $0.date < $1.date }
+            }
+        }
+    }
+
+    /// Called when the reachability status of the paired iPhone changes
+    /// Updates the local reachability status
+    func sessionReachabilityDidChange(_ session: WCSession) {
+        DispatchQueue.main.async {
+            self.isReachable = session.isReachable
+            print("⌚️ Watch reachability changed: \(session.isReachable)")
+        }
+    }
+}

+ 40 - 4
Trio.xcodeproj/project.pbxproj

@@ -305,6 +305,13 @@
 		BD7DA9A72AE06E2B00601B20 /* BolusCalculatorConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7DA9A62AE06E2B00601B20 /* BolusCalculatorConfigProvider.swift */; };
 		BD7DA9A92AE06E9200601B20 /* BolusCalculatorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7DA9A82AE06E9200601B20 /* BolusCalculatorStateModel.swift */; };
 		BD7DA9AC2AE06EB900601B20 /* BolusCalculatorConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7DA9AB2AE06EB900601B20 /* BolusCalculatorConfigRootView.swift */; };
+		BDA25EE42D260CD500035F34 /* AppleWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */; };
+		BDA25EE62D260D5E00035F34 /* WatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EE52D260D5800035F34 /* WatchState.swift */; };
+		BDA25EFD2D261C0000035F34 /* WatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EFC2D261BF200035F34 /* WatchState.swift */; };
+		BDA25F1C2D26BD0700035F34 /* TrendShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25F1B2D26BD0300035F34 /* TrendShape.swift */; };
+		BDA25F1E2D26D5DD00035F34 /* GlucoseChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25F1D2D26D5D800035F34 /* GlucoseChartView.swift */; };
+		BDA25F202D26D5FE00035F34 /* CarbsInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25F1F2D26D5FB00035F34 /* CarbsInputView.swift */; };
+		BDA25F222D26D62800035F34 /* BolusInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25F212D26D62200035F34 /* BolusInputView.swift */; };
 		BDA6CC882CAF219B00F942F9 /* TempTargetSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA6CC872CAF219800F942F9 /* TempTargetSetup.swift */; };
 		BDB3C1192C03DD1000CEEAA1 /* UserDefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB3C1182C03DD1000CEEAA1 /* UserDefaultsExtension.swift */; };
 		BDB899882C564509006F3298 /* ForecastChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB899872C564509006F3298 /* ForecastChart.swift */; };
@@ -330,7 +337,7 @@
 		BDFD165A2AE40438007F0DDA /* TreatmentsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFD16592AE40438007F0DDA /* TreatmentsRootView.swift */; };
 		BDFF799F2D25AA890016C40C /* TrioWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = BDFF797C2D25AA870016C40C /* TrioWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
 		BDFF7A872D25F97D0016C40C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BDFF7A832D25F97D0016C40C /* Assets.xcassets */; };
-		BDFF7A882D25F97D0016C40C /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFF7A842D25F97D0016C40C /* ContentView.swift */; };
+		BDFF7A882D25F97D0016C40C /* TrioMainWatchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFF7A842D25F97D0016C40C /* TrioMainWatchView.swift */; };
 		BDFF7A892D25F97D0016C40C /* TrioWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFF7A852D25F97D0016C40C /* TrioWatchApp.swift */; };
 		BDFF7A8B2D25F97D0016C40C /* TrioWatch_Watch_AppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFF7A8A2D25F97D0016C40C /* TrioWatch_Watch_AppTests.swift */; };
 		BDFF7A8E2D25F97D0016C40C /* TrioWatch_Watch_AppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFF7A8C2D25F97D0016C40C /* TrioWatch_Watch_AppUITests.swift */; };
@@ -997,6 +1004,13 @@
 		BD7DA9A62AE06E2B00601B20 /* BolusCalculatorConfigProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorConfigProvider.swift; sourceTree = "<group>"; };
 		BD7DA9A82AE06E9200601B20 /* BolusCalculatorStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorStateModel.swift; sourceTree = "<group>"; };
 		BD7DA9AB2AE06EB900601B20 /* BolusCalculatorConfigRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorConfigRootView.swift; sourceTree = "<group>"; };
+		BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleWatchManager.swift; sourceTree = "<group>"; };
+		BDA25EE52D260D5800035F34 /* WatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchState.swift; sourceTree = "<group>"; };
+		BDA25EFC2D261BF200035F34 /* WatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchState.swift; sourceTree = "<group>"; };
+		BDA25F1B2D26BD0300035F34 /* TrendShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendShape.swift; sourceTree = "<group>"; };
+		BDA25F1D2D26D5D800035F34 /* GlucoseChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseChartView.swift; sourceTree = "<group>"; };
+		BDA25F1F2D26D5FB00035F34 /* CarbsInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsInputView.swift; sourceTree = "<group>"; };
+		BDA25F212D26D62200035F34 /* BolusInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusInputView.swift; sourceTree = "<group>"; };
 		BDA6CC872CAF219800F942F9 /* TempTargetSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetSetup.swift; sourceTree = "<group>"; };
 		BDB3C1182C03DD1000CEEAA1 /* UserDefaultsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsExtension.swift; sourceTree = "<group>"; };
 		BDB899872C564509006F3298 /* ForecastChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastChart.swift; sourceTree = "<group>"; };
@@ -1024,7 +1038,7 @@
 		BDFF798B2D25AA890016C40C /* TrioWatch Watch AppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "TrioWatch Watch AppTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
 		BDFF79952D25AA890016C40C /* TrioWatch Watch AppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "TrioWatch Watch AppUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
 		BDFF7A832D25F97D0016C40C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
-		BDFF7A842D25F97D0016C40C /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
+		BDFF7A842D25F97D0016C40C /* TrioMainWatchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioMainWatchView.swift; sourceTree = "<group>"; };
 		BDFF7A852D25F97D0016C40C /* TrioWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioWatchApp.swift; sourceTree = "<group>"; };
 		BDFF7A8A2D25F97D0016C40C /* TrioWatch_Watch_AppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioWatch_Watch_AppTests.swift; sourceTree = "<group>"; };
 		BDFF7A8C2D25F97D0016C40C /* TrioWatch_Watch_AppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioWatch_Watch_AppUITests.swift; sourceTree = "<group>"; };
@@ -1984,6 +1998,7 @@
 		388E5A5925B6F0250019842D /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				BDA25EFC2D261BF200035F34 /* WatchState.swift */,
 				DD07CA132CE80B73002D45A9 /* TimeInRangeChartStyle.swift */,
 				DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */,
 				DD6D67E32C9C253500660C9B /* ColorSchemeOption.swift */,
@@ -2171,6 +2186,7 @@
 		38E8754D275556E100975559 /* WatchManager */ = {
 			isa = PBXGroup;
 			children = (
+				BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */,
 				CE94597D29E9E1EE0047C9C6 /* GarminManager.swift */,
 			);
 			path = WatchManager;
@@ -2461,6 +2477,18 @@
 			path = View;
 			sourceTree = "<group>";
 		};
+		BDA25F1A2D26BCE800035F34 /* Views */ = {
+			isa = PBXGroup;
+			children = (
+				BDA25F212D26D62200035F34 /* BolusInputView.swift */,
+				BDA25F1F2D26D5FB00035F34 /* CarbsInputView.swift */,
+				BDA25F1D2D26D5D800035F34 /* GlucoseChartView.swift */,
+				BDA25F1B2D26BD0300035F34 /* TrendShape.swift */,
+				BDFF7A842D25F97D0016C40C /* TrioMainWatchView.swift */,
+			);
+			path = Views;
+			sourceTree = "<group>";
+		};
 		BDDAF9F12D0055CC00B34E7A /* ChartElements */ = {
 			isa = PBXGroup;
 			children = (
@@ -2492,9 +2520,10 @@
 		BDFF7A9C2D25FA730016C40C /* Trio Watch App Extension */ = {
 			isa = PBXGroup;
 			children = (
+				BDA25F1A2D26BCE800035F34 /* Views */,
+				BDA25EE52D260D5800035F34 /* WatchState.swift */,
 				BDFF7A9E2D25FA970016C40C /* Preview Content */,
 				BDFF7A832D25F97D0016C40C /* Assets.xcassets */,
-				BDFF7A842D25F97D0016C40C /* ContentView.swift */,
 				BDFF7A852D25F97D0016C40C /* TrioWatchApp.swift */,
 			);
 			path = "Trio Watch App Extension";
@@ -3726,6 +3755,7 @@
 				DD9ECB712CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift in Sources */,
 				63E890B4D951EAA91C071D5C /* BasalProfileEditorStateModel.swift in Sources */,
 				38FEF3FA2737E42000574A46 /* BaseStateModel.swift in Sources */,
+				BDA25EFD2D261C0000035F34 /* WatchState.swift in Sources */,
 				CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */,
 				385CEA8225F23DFD002D6D5B /* NightscoutStatus.swift in Sources */,
 				DD17453C2C55BFAD00211FAC /* AlgorithmAdvancedSettingsProvider.swift in Sources */,
@@ -3909,6 +3939,7 @@
 				BD4ED4FD2CF9D5E8000EDC9C /* AppState.swift in Sources */,
 				DDF847DD2C5C28720049BB3B /* LiveActivitySettingsDataFlow.swift in Sources */,
 				8194B80890CDD6A3C13B0FEE /* SnoozeStateModel.swift in Sources */,
+				BDA25EE42D260CD500035F34 /* AppleWatchManager.swift in Sources */,
 				0437CE46C12535A56504EC19 /* SnoozeRootView.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
@@ -3939,8 +3970,13 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
-				BDFF7A882D25F97D0016C40C /* ContentView.swift in Sources */,
+				BDA25F222D26D62800035F34 /* BolusInputView.swift in Sources */,
+				BDFF7A882D25F97D0016C40C /* TrioMainWatchView.swift in Sources */,
+				BDA25F202D26D5FE00035F34 /* CarbsInputView.swift in Sources */,
+				BDA25F1C2D26BD0700035F34 /* TrendShape.swift in Sources */,
 				BDFF7A892D25F97D0016C40C /* TrioWatchApp.swift in Sources */,
+				BDA25F1E2D26D5DD00035F34 /* GlucoseChartView.swift in Sources */,
+				BDA25EE62D260D5E00035F34 /* WatchState.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

+ 0 - 18
Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme

@@ -380,15 +380,6 @@
             ReferencedContainer = "container:Trio.xcodeproj">
          </BuildableReference>
       </BuildableProductRunnable>
-      <MacroExpansion>
-         <BuildableReference
-            BuildableIdentifier = "primary"
-            BlueprintIdentifier = "388E595725AD948C0019842D"
-            BuildableName = "Trio.app"
-            BlueprintName = "Trio"
-            ReferencedContainer = "container:Trio.xcodeproj">
-         </BuildableReference>
-      </MacroExpansion>
       <CommandLineArguments>
          <CommandLineArgument
             argument = "-com.apple.CoreData.ConcurrencyDebug 1"
@@ -427,15 +418,6 @@
             ReferencedContainer = "container:Trio.xcodeproj">
          </BuildableReference>
       </BuildableProductRunnable>
-      <MacroExpansion>
-         <BuildableReference
-            BuildableIdentifier = "primary"
-            BlueprintIdentifier = "388E595725AD948C0019842D"
-            BuildableName = "Trio.app"
-            BlueprintName = "Trio"
-            ReferencedContainer = "container:Trio.xcodeproj">
-         </BuildableReference>
-      </MacroExpansion>
    </ProfileAction>
    <AnalyzeAction
       buildConfiguration = "Debug">

+ 1 - 0
Trio/Sources/Application/TrioApp.swift

@@ -49,6 +49,7 @@ import Swinject
         _ = resolver.resolve(CalendarManager.self)!
         _ = resolver.resolve(UserNotificationsManager.self)!
         _ = resolver.resolve(HealthKitManager.self)!
+        _ = resolver.resolve(WatchManager.self)
         _ = resolver.resolve(BluetoothStateManager.self)!
         _ = resolver.resolve(PluginManager.self)!
         _ = resolver.resolve(AlertPermissionsChecker.self)!

+ 1 - 0
Trio/Sources/Assemblies/ServiceAssembly.swift

@@ -18,6 +18,7 @@ final class ServiceAssembly: Assembly {
         container.register(HKHealthStore.self) { _ in HKHealthStore() }
         container.register(HealthKitManager.self) { r in BaseHealthKitManager(resolver: r) }
         container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) }
+        container.register(WatchManager.self) { r in BaseWatchManager(resolver: r) }
         container.register(GarminManager.self) { r in BaseGarminManager(resolver: r) }
         container.register(ContactImageManager.self) { r in BaseContactImageManager(resolver: r) }
         container.register(AlertPermissionsChecker.self) { r in AlertPermissionsChecker(resolver: r) }

+ 33 - 0
Trio/Sources/Models/WatchState.swift

@@ -0,0 +1,33 @@
+import Foundation
+
+// TODO: expand this for cob,iob etc
+struct WatchState: Hashable, Equatable, Sendable {
+    var currentGlucose: String?
+    var trend: String?
+    var delta: String?
+    var glucoseValues: [(date: Date, glucose: Double)] = []
+    var units: GlucoseUnits = .mmolL
+
+    static func == (lhs: WatchState, rhs: WatchState) -> Bool {
+        lhs.currentGlucose == rhs.currentGlucose &&
+            lhs.trend == rhs.trend &&
+            lhs.delta == rhs.delta &&
+            lhs.glucoseValues.count == rhs.glucoseValues.count &&
+            zip(lhs.glucoseValues, rhs.glucoseValues).allSatisfy {
+                $0.0.date == $0.1.date && $0.0.glucose == $0.1.glucose
+            } &&
+            lhs.units == rhs.units
+    }
+
+    func hash(into hasher: inout Hasher) {
+        hasher.combine(currentGlucose)
+        hasher.combine(trend)
+        hasher.combine(delta)
+        // Hash each element individually
+        for value in glucoseValues {
+            hasher.combine(value.date)
+            hasher.combine(value.glucose)
+        }
+        hasher.combine(units)
+    }
+}

+ 309 - 0
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -0,0 +1,309 @@
+import Combine
+import CoreData
+import Foundation
+import Swinject
+import WatchConnectivity
+
+/// Protocol defining the base functionality for Watch communication
+// TODO: Complete this
+protocol WatchManager {}
+
+/// Main implementation of the Watch communication manager
+/// Handles bidirectional communication between iPhone and Apple Watch
+final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchManager {
+    private var session: WCSession?
+
+    @Injected() private var glucoseStorage: GlucoseStorage!
+    @Injected() private var apsManager: APSManager!
+    @Injected() private var settingsManager: SettingsManager!
+
+    private var units: GlucoseUnits = .mgdL
+
+    private var coreDataPublisher: AnyPublisher<Set<NSManagedObject>, Never>?
+    private var subscriptions = Set<AnyCancellable>()
+
+    typealias PumpEvent = PumpEventStored.EventType
+
+    let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
+    let viewContext = CoreDataStack.shared.persistentContainer.viewContext
+
+    init(resolver: Resolver) {
+        super.init()
+        injectServices(resolver)
+        setupWatchSession()
+        units = settingsManager.settings.units
+
+        // Observer for OrefDetermination
+        coreDataPublisher =
+            changedObjectsOnManagedObjectContextDidSavePublisher()
+                .receive(on: DispatchQueue.global(qos: .background))
+                .share()
+                .eraseToAnyPublisher()
+
+        // Observer for glucose and manual glucose
+        glucoseStorage.updatePublisher
+            .receive(on: DispatchQueue.global(qos: .background))
+            .sink { [weak self] _ in
+                guard let self = self else { return }
+                Task {
+                    let state = await self.setupWatchState()
+                    self.sendGlucoseData(state)
+                }
+            }
+            .store(in: &subscriptions)
+    }
+
+    /// Sets up the WatchConnectivity session if the device supports it
+    private func setupWatchSession() {
+        if WCSession.isSupported() {
+            let session = WCSession.default
+            session.delegate = self
+            session.activate()
+            self.session = session
+
+            print("📱 Phone session setup - isPaired: \(session.isPaired)")
+        } else {
+            print("📱 WCSession is not supported on this device")
+        }
+    }
+
+    /// Attempts to reestablish the Watch connection if it becomes unreachable
+    private func retryConnection() {
+        guard let session = session else { return }
+
+        if !session.isReachable {
+            print("📱 Attempting to reactivate session...")
+            session.activate()
+        }
+    }
+
+    /// Prepares the current state data to be sent to the Watch
+    /// - Returns: WatchState containing current glucose readings and trends
+    private func setupWatchState() async -> WatchState {
+        let ids = await fetchGlucose()
+
+        // Get NSManagedObjects
+        let glucoseObjects: [GlucoseStored] = await CoreDataStack.shared
+            .getNSManagedObject(with: ids, context: glucoseFetchContext)
+
+        return await glucoseFetchContext.perform {
+            var watchState = WatchState()
+
+            guard let latestGlucose = glucoseObjects.first else {
+                return watchState
+            }
+
+            // Map glucose values
+            watchState.glucoseValues = glucoseObjects.compactMap { glucose in
+                guard let date = glucose.date else { return nil }
+                return (date: date, glucose: Double(glucose.glucose))
+            }
+            .sorted { $0.date < $1.date }
+
+            // Set current glucose with proper formatting
+            watchState.currentGlucose = "\(latestGlucose.glucose)"
+
+            // Convert direction to trend string
+            watchState.trend = latestGlucose.direction
+
+            // Calculate delta if we have at least 2 readings
+            if glucoseObjects.count >= 2 {
+                let deltaValue = glucoseObjects[0].glucose - glucoseObjects[1].glucose
+                let formattedDelta = Formatter.glucoseFormatter(for: self.units)
+                    .string(from: NSNumber(value: abs(deltaValue))) ?? "0"
+                watchState.delta = deltaValue < 0 ? "-\(formattedDelta)" : "+\(formattedDelta)"
+            }
+
+            // Set units
+            watchState.units = self.units
+
+            print(
+                "📱 Setup WatchState - currentGlucose: \(watchState.currentGlucose ?? "nil"), trend: \(watchState.trend ?? "nil"), delta: \(watchState.delta ?? "nil"), values: \(watchState.glucoseValues.count)"
+            )
+
+            return watchState
+        }
+    }
+
+    /// Fetches recent glucose readings from CoreData
+    /// - Returns: Array of NSManagedObjectIDs for glucose readings
+    private func fetchGlucose() async -> [NSManagedObjectID] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: glucoseFetchContext,
+            predicate: NSPredicate.glucose,
+            key: "date",
+            ascending: false,
+            fetchLimit: 288
+        )
+
+        return await glucoseFetchContext.perform {
+            guard let fetchedResults = results as? [GlucoseStored] else { return [] }
+
+            return fetchedResults.map(\.objectID)
+        }
+    }
+
+    // MARK: - Send Data to Watch
+
+    /// Sends the current glucose state to the connected Watch
+    /// - Parameter state: Current WatchState containing glucose data to be sent
+    func sendGlucoseData(_ state: WatchState) {
+        guard let session = session, session.isReachable else {
+            print("⌚️ Watch not reachable")
+            return
+        }
+
+        let message: [String: Any] = [
+            "currentGlucose": state.currentGlucose ?? "0",
+            "trend": state.trend ?? "?",
+            "delta": state.delta ?? "0",
+            "glucoseValues": state.glucoseValues.map { value in
+                [
+                    "glucose": value.glucose,
+                    "date": value.date.timeIntervalSince1970
+                ]
+            }
+        ]
+
+        print("📱 Sending to watch: currentGlucose: \(state.currentGlucose ?? "nil"), trend: \(state.trend ?? "nil")")
+
+        session.sendMessage(message, replyHandler: nil) { error in
+            print("❌ Error sending glucose data: \(error.localizedDescription)")
+        }
+    }
+
+    // MARK: - WCSessionDelegate
+
+    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
+        if let error = error {
+            print("📱 Phone session activation failed: \(error.localizedDescription)")
+            return
+        }
+
+        print("📱 Phone session activated with state: \(activationState.rawValue)")
+        print("📱 Phone isReachable after activation: \(session.isReachable)")
+
+        // Try to send initial data after activation
+        Task {
+            let state = await self.setupWatchState()
+            self.sendGlucoseData(state)
+        }
+    }
+
+    func session(_: WCSession, didReceiveMessage message: [String: Any]) {
+        DispatchQueue.main.async { [weak self] in
+            if let bolusAmount = message["bolus"] as? Double,
+               let isExternal = message["isExternal"] as? Bool
+            {
+                print("📱 Received \(isExternal ? "external insulin" : "bolus") request from watch: \(bolusAmount)U")
+                if isExternal {
+                    self?.handleExternalInsulin(Decimal(bolusAmount))
+                } else {
+                    self?.handleBolusRequest(Decimal(bolusAmount))
+                }
+            }
+
+            if let carbsAmount = message["carbs"] as? Int,
+               let timestamp = message["date"] as? TimeInterval
+            {
+                let date = Date(timeIntervalSince1970: timestamp)
+                print("📱 Received carbs request from watch: \(carbsAmount)g at \(date)")
+                self?.handleCarbsRequest(carbsAmount, date)
+            }
+        }
+    }
+
+    #if os(iOS)
+        func sessionDidBecomeInactive(_: WCSession) {}
+        func sessionDidDeactivate(_ session: WCSession) {
+            session.activate()
+        }
+    #endif
+
+    func sessionReachabilityDidChange(_ session: WCSession) {
+        print("📱 Phone reachability changed: \(session.isReachable)")
+
+        if session.isReachable {
+            // Try to send data when connection is established
+            Task {
+                let state = await self.setupWatchState()
+                self.sendGlucoseData(state)
+            }
+        } else {
+            // Try to reconnect after a short delay
+            DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
+                self?.retryConnection()
+            }
+        }
+    }
+
+    /// Handles external insulin entries received from the Watch
+    /// - Parameter amount: The insulin amount in units to be recorded
+    private func handleExternalInsulin(_ amount: Decimal) {
+        Task {
+            let context = CoreDataStack.shared.newTaskContext()
+
+            await context.perform {
+                // Create Bolus
+                let bolus = BolusStored(context: context)
+                bolus.amount = amount as NSDecimalNumber
+                bolus.isSMB = false
+                bolus.isExternal = true
+
+                // Create PumpEvent
+                let pumpEvent = PumpEventStored(context: context)
+                pumpEvent.id = UUID().uuidString
+                pumpEvent.timestamp = Date()
+                pumpEvent.type = PumpEvent.bolus.rawValue
+                pumpEvent.bolus = bolus
+                pumpEvent.isUploadedToNS = false
+                pumpEvent.isUploadedToHealth = false
+                pumpEvent.isUploadedToTidepool = false
+
+                do {
+                    guard context.hasChanges else { return }
+                    try context.save()
+                    print("📱 Saved external insulin and pump event from watch: \(amount)U")
+                } catch {
+                    print("❌ Error saving external insulin and pump event: \(error.localizedDescription)")
+                }
+            }
+        }
+    }
+
+    /// Processes bolus requests received from the Watch
+    /// - Parameter amount: The requested bolus amount in units
+    private func handleBolusRequest(_ amount: Decimal) {
+        Task {
+            await apsManager.enactBolus(amount: Double(amount), isSMB: false)
+            print("📱 Enacted bolus via APS Manager: \(amount)U")
+        }
+    }
+
+    /// Handles carbs entry requests received from the Watch
+    /// - Parameters:
+    ///   - amount: The carbs amount in grams
+    ///   - date: Timestamp for the carbs entry
+    private func handleCarbsRequest(_ amount: Int, _ date: Date) {
+        Task {
+            let context = CoreDataStack.shared.newTaskContext()
+
+            await context.perform {
+                let carbs = CarbEntryStored(context: context)
+                carbs.carbs = Double(truncating: amount as NSNumber)
+                carbs.date = date
+
+                // TODO: add FPU
+
+                do {
+                    guard context.hasChanges else { return }
+                    try context.save()
+                    print("📱 Saved carbs from watch: \(amount)g at \(date)")
+                } catch {
+                    print("❌ Error saving carbs: \(error.localizedDescription)")
+                }
+            }
+        }
+    }
+}