✨ Watch connectivity on iOS
This commit is contained in:
		| @@ -1,9 +1,11 @@ | ||||
| import Flutter | ||||
| import UIKit | ||||
| import WatchConnectivity | ||||
|  | ||||
| @main | ||||
| @objc class AppDelegate: FlutterAppDelegate { | ||||
|     let notifyDelegate = NotifyDelegate() | ||||
|     private var watchConnectivityService: WatchConnectivityService? | ||||
|      | ||||
|     override func application( | ||||
|         _ application: UIApplication, | ||||
| @@ -28,6 +30,55 @@ import UIKit | ||||
|          | ||||
|         GeneratedPluginRegistrant.register(with: self) | ||||
|          | ||||
|         if WCSession.isSupported() { | ||||
|             watchConnectivityService = WatchConnectivityService() | ||||
|         } | ||||
|          | ||||
|         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 | ||||
| //  WatchRunner Watch App | ||||
| @@ -6,16 +7,404 @@ | ||||
| // | ||||
|  | ||||
| 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 { | ||||
|     var body: some View { | ||||
|         VStack { | ||||
|             Image(systemName: "globe") | ||||
|                 .imageScale(.large) | ||||
|                 .foregroundStyle(.tint) | ||||
|             Text("Hello, world!") | ||||
|         } | ||||
|         .padding() | ||||
|         ExploreView() | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user