✨ 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