✨ Watch connectivity on iOS
This commit is contained in:
		| @@ -1,9 +1,11 @@ | |||||||
| import Flutter | import Flutter | ||||||
| import UIKit | import UIKit | ||||||
|  | import WatchConnectivity | ||||||
|  |  | ||||||
| @main | @main | ||||||
| @objc class AppDelegate: FlutterAppDelegate { | @objc class AppDelegate: FlutterAppDelegate { | ||||||
|     let notifyDelegate = NotifyDelegate() |     let notifyDelegate = NotifyDelegate() | ||||||
|  |     private var watchConnectivityService: WatchConnectivityService? | ||||||
|      |      | ||||||
|     override func application( |     override func application( | ||||||
|         _ application: UIApplication, |         _ application: UIApplication, | ||||||
| @@ -28,6 +30,55 @@ import UIKit | |||||||
|          |          | ||||||
|         GeneratedPluginRegistrant.register(with: self) |         GeneratedPluginRegistrant.register(with: self) | ||||||
|          |          | ||||||
|  |         if WCSession.isSupported() { | ||||||
|  |             watchConnectivityService = WatchConnectivityService() | ||||||
|  |         } | ||||||
|  |          | ||||||
|         return super.application(application, didFinishLaunchingWithOptions: launchOptions) |         return super.application(application, didFinishLaunchingWithOptions: launchOptions) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class WatchConnectivityService: NSObject, WCSessionDelegate { | ||||||
|  |     private let session: WCSession | ||||||
|  |  | ||||||
|  |     override init() { | ||||||
|  |         self.session = .default | ||||||
|  |         super.init() | ||||||
|  |         print("[iOS] Activating WCSession") | ||||||
|  |         self.session.delegate = self | ||||||
|  |         self.session.activate() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { | ||||||
|  |         if let error = error { | ||||||
|  |             print("[iOS] WCSession activation failed with error: \(error.localizedDescription)") | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |         print("[iOS] WCSession activated with state: \(activationState.rawValue)") | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     func sessionDidBecomeInactive(_ session: WCSession) {} | ||||||
|  |      | ||||||
|  |     func sessionDidDeactivate(_ session: WCSession) { | ||||||
|  |         session.activate() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) { | ||||||
|  |         print("[iOS] Received message: \(message)") | ||||||
|  |         if let request = message["request"] as? String, request == "data" { | ||||||
|  |             let token = UserDefaults.standard.getFlutterToken() | ||||||
|  |             let serverUrl = UserDefaults.standard.getServerUrl() | ||||||
|  |              | ||||||
|  |             print("[iOS] Retrieved token: \(token ?? "nil")") | ||||||
|  |             print("[iOS] Retrieved serverUrl: \(serverUrl)") | ||||||
|  |              | ||||||
|  |             var data: [String: Any] = ["serverUrl": serverUrl] | ||||||
|  |             if let token = token { | ||||||
|  |                 data["token"] = token | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             print("[iOS] Replying with data: \(data)") | ||||||
|  |             replyHandler(data) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  |  | ||||||
| // | // | ||||||
| //  ContentView.swift | //  ContentView.swift | ||||||
| //  WatchRunner Watch App | //  WatchRunner Watch App | ||||||
| @@ -6,16 +7,404 @@ | |||||||
| // | // | ||||||
|  |  | ||||||
| import SwiftUI | import SwiftUI | ||||||
|  | import Combine | ||||||
|  | import WatchConnectivity | ||||||
|  |  | ||||||
|  | // MARK: - App State | ||||||
|  |  | ||||||
|  | @MainActor | ||||||
|  | class AppState: ObservableObject { | ||||||
|  |     @Published var token: String? = nil | ||||||
|  |     @Published var serverUrl: String? = nil | ||||||
|  |     @Published var isReady = false | ||||||
|  |      | ||||||
|  |     private var wcService = WatchConnectivityService() | ||||||
|  |     private var cancellables = Set<AnyCancellable>() | ||||||
|  |  | ||||||
|  |     init() { | ||||||
|  |         wcService.$token.combineLatest(wcService.$serverUrl) | ||||||
|  |             .receive(on: DispatchQueue.main) | ||||||
|  |             .sink { [weak self] token, serverUrl in | ||||||
|  |                 self?.token = token | ||||||
|  |                 self?.serverUrl = serverUrl | ||||||
|  |                 if token != nil && serverUrl != nil { | ||||||
|  |                     self?.isReady = true | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             .store(in: &cancellables) | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     func requestData() { | ||||||
|  |         wcService.requestDataFromPhone() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // MARK: - Watch Connectivity | ||||||
|  |  | ||||||
|  | class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject { | ||||||
|  |     @Published var token: String? | ||||||
|  |     @Published var serverUrl: String? | ||||||
|  |  | ||||||
|  |     private let session: WCSession | ||||||
|  |  | ||||||
|  |     override init() { | ||||||
|  |         self.session = .default | ||||||
|  |         super.init() | ||||||
|  |         print("[watchOS] Activating WCSession") | ||||||
|  |         self.session.delegate = self | ||||||
|  |         self.session.activate() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { | ||||||
|  |         if let error = error { | ||||||
|  |             print("[watchOS] WCSession activation failed with error: \(error.localizedDescription)") | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |         print("[watchOS] WCSession activated with state: \(activationState.rawValue)") | ||||||
|  |         if activationState == .activated { | ||||||
|  |             requestDataFromPhone() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func session(_ session: WCSession, didReceiveMessage message: [String : Any]) { | ||||||
|  |         print("[watchOS] Received message: \(message)") | ||||||
|  |         DispatchQueue.main.async { | ||||||
|  |             if let token = message["token"] as? String { | ||||||
|  |                 self.token = token | ||||||
|  |             } | ||||||
|  |             if let serverUrl = message["serverUrl"] as? String { | ||||||
|  |                 self.serverUrl = serverUrl | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func requestDataFromPhone() { | ||||||
|  |         guard session.isReachable else { | ||||||
|  |             print("[watchOS] Phone is not reachable") | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         print("[watchOS] Requesting data from phone") | ||||||
|  |         session.sendMessage(["request": "data"]) { [weak self] response in | ||||||
|  |             print("[watchOS] Received reply: \(response)") | ||||||
|  |             DispatchQueue.main.async { | ||||||
|  |                 if let token = response["token"] as? String { | ||||||
|  |                     self?.token = token | ||||||
|  |                 } | ||||||
|  |                 if let serverUrl = response["serverUrl"] as? String { | ||||||
|  |                     self?.serverUrl = serverUrl | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } errorHandler: { error in | ||||||
|  |             print("[watchOS] sendMessage failed with error: \(error.localizedDescription)") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | // MARK: - Models | ||||||
|  |  | ||||||
|  | struct AppToken: Codable { | ||||||
|  |     let token: String | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct SnActivity: Codable, Identifiable { | ||||||
|  |     let id: String | ||||||
|  |     let type: String | ||||||
|  |     let data: ActivityData? | ||||||
|  |     let createdAt: Date | ||||||
|  |      | ||||||
|  |     enum CodingKeys: String, CodingKey { | ||||||
|  |         case id, type, data, createdAt | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | enum ActivityData: Codable { | ||||||
|  |     case post(SnPost) | ||||||
|  |     case discovery(DiscoveryData) | ||||||
|  |     case unknown | ||||||
|  |  | ||||||
|  |     init(from decoder: Decoder) throws { | ||||||
|  |         let container = try decoder.singleValueContainer() | ||||||
|  |         if let post = try? container.decode(SnPost.self) { | ||||||
|  |             self = .post(post) | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |         if let discoveryData = try? container.decode(DiscoveryData.self) { | ||||||
|  |             self = .discovery(discoveryData) | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |         self = .unknown | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func encode(to encoder: Encoder) throws { | ||||||
|  |         // Not needed for decoding | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct SnPost: Codable, Identifiable { | ||||||
|  |     let id: String | ||||||
|  |     let content: String? | ||||||
|  |     let title: String? | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct DiscoveryData: Codable { | ||||||
|  |     let items: [DiscoveryItem] | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct DiscoveryItem: Codable, Identifiable { | ||||||
|  |     var id = UUID() | ||||||
|  |     let type: String | ||||||
|  |     let data: DiscoveryItemData | ||||||
|  |  | ||||||
|  |     enum CodingKeys: String, CodingKey { | ||||||
|  |         case type, data | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | enum DiscoveryItemData: Codable { | ||||||
|  |     case realm(SnRealm) | ||||||
|  |     case publisher(SnPublisher) | ||||||
|  |     case article(SnWebArticle) | ||||||
|  |     case unknown | ||||||
|  |  | ||||||
|  |     init(from decoder: Decoder) throws { | ||||||
|  |         let container = try decoder.singleValueContainer() | ||||||
|  |         if let realm = try? container.decode(SnRealm.self) { | ||||||
|  |             self = .realm(realm) | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |         if let publisher = try? container.decode(SnPublisher.self) { | ||||||
|  |             self = .publisher(publisher) | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |         if let article = try? container.decode(SnWebArticle.self) { | ||||||
|  |             self = .article(article) | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |         self = .unknown | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     func encode(to encoder: Encoder) throws { | ||||||
|  |         // Not needed for decoding | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct SnRealm: Codable, Identifiable { | ||||||
|  |     let id: String | ||||||
|  |     let name: String | ||||||
|  |     let description: String? | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct SnPublisher: Codable, Identifiable { | ||||||
|  |     let id: String | ||||||
|  |     let name: String | ||||||
|  |     let description: String? | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct SnWebArticle: Codable, Identifiable { | ||||||
|  |     let id: String | ||||||
|  |     let title: String | ||||||
|  |     let url: String | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | // MARK: - Network Service | ||||||
|  |  | ||||||
|  | class NetworkService { | ||||||
|  |     private let session = URLSession.shared | ||||||
|  |  | ||||||
|  |     func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> [SnActivity] { | ||||||
|  |         guard let baseURL = URL(string: serverUrl) else {  | ||||||
|  |             throw URLError(.badURL) | ||||||
|  |         } | ||||||
|  |         var components = URLComponents(url: baseURL.appendingPathComponent("/sphere/activities"), resolvingAgainstBaseURL: false)! | ||||||
|  |         var queryItems = [URLQueryItem(name: "take", value: "20")] | ||||||
|  |         if filter.lowercased() != "explore" { | ||||||
|  |             queryItems.append(URLQueryItem(name: "filter", value: filter.lowercased())) | ||||||
|  |         } | ||||||
|  |         if let cursor = cursor { | ||||||
|  |             queryItems.append(URLQueryItem(name: "cursor", value: cursor)) | ||||||
|  |         } | ||||||
|  |         components.queryItems = queryItems | ||||||
|  |  | ||||||
|  |         var request = URLRequest(url: components.url!) | ||||||
|  |         request.httpMethod = "GET" | ||||||
|  |         request.setValue("application/json", forHTTPHeaderField: "Accept") | ||||||
|  |          | ||||||
|  |         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") | ||||||
|  |         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") | ||||||
|  |  | ||||||
|  |         let (data, _) = try await session.data(for: request) | ||||||
|  |          | ||||||
|  |         let decoder = JSONDecoder() | ||||||
|  |         decoder.dateDecodingStrategy = .iso8601 | ||||||
|  |          | ||||||
|  |         return try decoder.decode([SnActivity].self, from: data) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // MARK: - View Models | ||||||
|  |  | ||||||
|  | @MainActor | ||||||
|  | class ActivityViewModel: ObservableObject { | ||||||
|  |     @Published var activities: [SnActivity] = [] | ||||||
|  |     @Published var isLoading = false | ||||||
|  |     @Published var errorMessage: String? | ||||||
|  |  | ||||||
|  |     private let networkService = NetworkService() | ||||||
|  |     let filter: String | ||||||
|  |  | ||||||
|  |     init(filter: String) { | ||||||
|  |         self.filter = filter | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func fetchActivities(appState: AppState) async { | ||||||
|  |         guard !isLoading, appState.isReady, let token = appState.token, let serverUrl = appState.serverUrl else { return } | ||||||
|  |         isLoading = true | ||||||
|  |         errorMessage = nil | ||||||
|  |  | ||||||
|  |         do { | ||||||
|  |             let fetchedActivities = try await networkService.fetchActivities(filter: filter, token: token, serverUrl: serverUrl) | ||||||
|  |             self.activities = fetchedActivities | ||||||
|  |         } catch { | ||||||
|  |             self.errorMessage = error.localizedDescription | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         isLoading = false | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // MARK: - Views | ||||||
|  |  | ||||||
|  | struct ActivityListView: View { | ||||||
|  |     @StateObject private var viewModel: ActivityViewModel | ||||||
|  |     @EnvironmentObject var appState: AppState | ||||||
|  |  | ||||||
|  |     init(filter: String) { | ||||||
|  |         _viewModel = StateObject(wrappedValue: ActivityViewModel(filter: filter)) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         Group { | ||||||
|  |             if viewModel.isLoading { | ||||||
|  |                 ProgressView() | ||||||
|  |             } else if let errorMessage = viewModel.errorMessage { | ||||||
|  |                 VStack { | ||||||
|  |                     Text("Error") | ||||||
|  |                         .font(.headline) | ||||||
|  |                     Text(errorMessage) | ||||||
|  |                         .font(.caption) | ||||||
|  |                 } | ||||||
|  |             } else { | ||||||
|  |                 List(viewModel.activities) { activity in | ||||||
|  |                     switch activity.type { | ||||||
|  |                     case "posts.new", "posts.new.replies": | ||||||
|  |                         if case .post(let post) = activity.data { | ||||||
|  |                             PostRowView(post: post) | ||||||
|  |                         } | ||||||
|  |                     case "discovery": | ||||||
|  |                          if case .discovery(let discoveryData) = activity.data { | ||||||
|  |                              DiscoveryView(discoveryData: discoveryData) | ||||||
|  |                          } | ||||||
|  |                     default: | ||||||
|  |                         Text("Unknown activity type: \(activity.type)") | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .task { | ||||||
|  |             await viewModel.fetchActivities(appState: appState) | ||||||
|  |         } | ||||||
|  |         .navigationTitle(viewModel.filter) | ||||||
|  |         .navigationBarTitleDisplayMode(.inline) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct PostRowView: View { | ||||||
|  |     let post: SnPost | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(alignment: .leading, spacing: 4) { | ||||||
|  |             Text(post.title ?? "Post") | ||||||
|  |                 .font(.headline) | ||||||
|  |             if let content = post.content { | ||||||
|  |                 Text(content) | ||||||
|  |                     .font(.body) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct DiscoveryView: View { | ||||||
|  |     let discoveryData: DiscoveryData | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(alignment: .leading) { | ||||||
|  |             Text("Discovery") | ||||||
|  |                 .font(.headline) | ||||||
|  |                 .padding(.bottom, 2) | ||||||
|  |             ForEach(discoveryData.items) { item in | ||||||
|  |                 switch item.data { | ||||||
|  |                 case .realm(let realm): | ||||||
|  |                     Text("Realm: \(realm.name)") | ||||||
|  |                 case .publisher(let publisher): | ||||||
|  |                     Text("Publisher: \(publisher.name)") | ||||||
|  |                 case .article(let article): | ||||||
|  |                     Text("Article: \(article.title)") | ||||||
|  |                 case .unknown: | ||||||
|  |                     Text("Unknown discovery item") | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | // The main view with the TabView for filtering. | ||||||
|  | struct ExploreView: View { | ||||||
|  |     @StateObject private var appState = AppState() | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         Group { | ||||||
|  |             if appState.isReady { | ||||||
|  |                 TabView { | ||||||
|  |                     NavigationStack { | ||||||
|  |                         ActivityListView(filter: "Explore") | ||||||
|  |                     } | ||||||
|  |                     .tabItem { | ||||||
|  |                         Label("Explore", systemImage: "safari") | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     NavigationStack { | ||||||
|  |                         ActivityListView(filter: "Subscriptions") | ||||||
|  |                     } | ||||||
|  |                     .tabItem { | ||||||
|  |                         Label("Subscriptions", systemImage: "star") | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     NavigationStack { | ||||||
|  |                         ActivityListView(filter: "Friends") | ||||||
|  |                     } | ||||||
|  |                     .tabItem { | ||||||
|  |                         Label("Friends", systemImage: "person.2") | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 .environmentObject(appState) | ||||||
|  |             } else { | ||||||
|  |                 ProgressView { Text("Connecting to phone...") } | ||||||
|  |                 .onAppear { | ||||||
|  |                     appState.requestData() | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // The root view of the app. | ||||||
| struct ContentView: View { | struct ContentView: View { | ||||||
|     var body: some View { |     var body: some View { | ||||||
|         VStack { |         ExploreView() | ||||||
|             Image(systemName: "globe") |  | ||||||
|                 .imageScale(.large) |  | ||||||
|                 .foregroundStyle(.tint) |  | ||||||
|             Text("Hello, world!") |  | ||||||
|         } |  | ||||||
|         .padding() |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user