Compare commits
	
		
			144 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						ac4fa5eb85
	
				 | 
					
					
						|||
| 
						
						
							
						
						8857718709
	
				 | 
					
					
						|||
| 
						
						
							
						
						dd17b2b9c1
	
				 | 
					
					
						|||
| 
						
						
							
						
						848439f664
	
				 | 
					
					
						|||
| 
						
						
							
						
						f83117424d
	
				 | 
					
					
						|||
| 
						
						
							
						
						8c19c32c76
	
				 | 
					
					
						|||
| 
						
						
							
						
						d62b2bed80
	
				 | 
					
					
						|||
| 
						
						
							
						
						5a23eb1768
	
				 | 
					
					
						|||
| 
						
						
							
						
						5f6e4763d3
	
				 | 
					
					
						|||
| 
						
						
							
						
						580c36fb89
	
				 | 
					
					
						|||
| 
						
						
							
						
						6c25af3b30
	
				 | 
					
					
						|||
| 
						
						
							
						
						a1da72d447
	
				 | 
					
					
						|||
| 
						
						
							
						
						ab4120cc22
	
				 | 
					
					
						|||
| 
						
						
							
						
						52eff0fa25
	
				 | 
					
					
						|||
| 
						
						
							
						
						beeb28abf2
	
				 | 
					
					
						|||
| 
						
						
							
						
						c0ab3837ac
	
				 | 
					
					
						|||
| 
						
						
							
						
						59d38c0d8d
	
				 | 
					
					
						|||
| 
						
						
							
						
						bd2247ce86
	
				 | 
					
					
						|||
| 
						
						
							
						
						da2d3f7f17
	
				 | 
					
					
						|||
| 
						
						
							
						
						7497b77384
	
				 | 
					
					
						|||
| 
						
						
							
						
						f542d9fa97
	
				 | 
					
					
						|||
| 
						
						
							
						
						e70439870e
	
				 | 
					
					
						|||
| 
						
						
							
						
						d764b042fe
	
				 | 
					
					
						|||
| 
						
						
							
						
						a76b97d1d2
	
				 | 
					
					
						|||
| 
						
						
							
						
						cfbe6e580b
	
				 | 
					
					
						|||
| 
						
						
							
						
						f08b9e057f
	
				 | 
					
					
						|||
| 
						
						
							
						
						0509f37c96
	
				 | 
					
					
						|||
| 
						
						
							
						
						a7dc9ac6fa
	
				 | 
					
					
						|||
| 
						
						
							
						
						caf2f5f1f6
	
				 | 
					
					
						|||
| 
						
						
							
						
						12b79af3a2
	
				 | 
					
					
						|||
| 
						
						
							
						
						88f149584e
	
				 | 
					
					
						|||
| 
						
						
							
						
						877001b802
	
				 | 
					
					
						|||
| fec28f6223 | |||
| 85005ff9c3 | |||
| e3c92a3c55 | |||
| 9e9fbc5d6a | |||
| 8d1d836b52 | |||
| bc60ce5d42 | |||
| c093123e3a | |||
| 3de73538c7 | |||
| ba8d5cee09 | |||
| 
						
						
							
						
						5ee2e70442
	
				 | 
					
					
						|||
| 
						
						
							
						
						53a3a32907
	
				 | 
					
					
						|||
| 
						
						
							
						
						9a628779d9
	
				 | 
					
					
						|||
| 
						
						
							
						
						b60bd63d0c
	
				 | 
					
					
						|||
| 
						
						
							
						
						01cc71fd47
	
				 | 
					
					
						|||
| 
						
						
							
						
						a2b0cd0b6a
	
				 | 
					
					
						|||
| 
						
						
							
						
						7f971bcee3
	
				 | 
					
					
						|||
| 
						
						
							
						
						7de98a1731
	
				 | 
					
					
						|||
| 
						
						
							
						
						b52eb95b14
	
				 | 
					
					
						|||
| 
						
						
							
						
						b3ef7d6ad0
	
				 | 
					
					
						|||
| 
						
						
							
						
						d28c11940d
	
				 | 
					
					
						|||
| 
						
						
							
						
						504322c2dd
	
				 | 
					
					
						|||
| 
						
						
							
						
						a07ec3ca36
	
				 | 
					
					
						|||
| d96691e920 | |||
| 
						
						
							
						
						6273b2d917
	
				 | 
					
					
						|||
| 
						
						
							
						
						ab90d244b5
	
				 | 
					
					
						|||
| 
						
						
							
						
						dc6af6d9e5
	
				 | 
					
					
						|||
| 
						
						
							
						
						0ca801d963
	
				 | 
					
					
						|||
| 
						
						
							
						
						3edcdd72af
	
				 | 
					
					
						|||
| 
						
						
							
						
						402bb3fe04
	
				 | 
					
					
						|||
| 
						
						
							
						
						8ba55eb1be
	
				 | 
					
					
						|||
| 
						
						
							
						
						983ae2a1fc
	
				 | 
					
					
						|||
| 
						
						
							
						
						6fc94001b3
	
				 | 
					
					
						|||
| 
						
						
							
						
						44dbcfdc94
	
				 | 
					
					
						|||
| 
						
						
							
						
						b57caf56db
	
				 | 
					
					
						|||
| 
						
						
							
						
						dbcd1b6d36
	
				 | 
					
					
						|||
| 
						
						
							
						
						a8055de910
	
				 | 
					
					
						|||
| 
						
						
							
						
						49b15e7674
	
				 | 
					
					
						|||
| 
						
						
							
						
						e2369c40db
	
				 | 
					
					
						|||
| 
						
						
							
						
						44c5d91620
	
				 | 
					
					
						|||
| 
						
						
							
						
						7a5a2407b7
	
				 | 
					
					
						|||
| 
						
						
							
						
						234434f102
	
				 | 
					
					
						|||
| 
						
						
							
						
						9c3b228d02
	
				 | 
					
					
						|||
| 
						
						
							
						
						82682cae9a
	
				 | 
					
					
						|||
| 
						
						
							
						
						fcbd5fe680
	
				 | 
					
					
						|||
| 
						
						
							
						
						ad91b17af7
	
				 | 
					
					
						|||
| 
						
						
							
						
						24fa637329
	
				 | 
					
					
						|||
| 
						
						
							
						
						926ae5402f
	
				 | 
					
					
						|||
| 
						
						
							
						
						1a37d384e6
	
				 | 
					
					
						|||
| 
						
						
							
						
						d4cf598f69
	
				 | 
					
					
						|||
| 
						
						
							
						
						0106c08891
	
				 | 
					
					
						|||
| 
						
						
							
						
						9697def808
	
				 | 
					
					
						|||
| 
						
						
							
						
						6572875229
	
				 | 
					
					
						|||
| 
						
						
							
						
						66590b9079
	
				 | 
					
					
						|||
| 
						
						
							
						
						08b9604b55
	
				 | 
					
					
						|||
| 
						
						
							
						
						0602bbd277
	
				 | 
					
					
						|||
| 
						
						
							
						
						76e7ba7898
	
				 | 
					
					
						|||
| 
						
						
							
						
						6e6616b236
	
				 | 
					
					
						|||
| 
						
						
							
						
						071d51b25e
	
				 | 
					
					
						|||
| 
						
						
							
						
						a958362461
	
				 | 
					
					
						|||
| 
						
						
							
						
						6749bb00fe
	
				 | 
					
					
						|||
| 
						
						
							
						
						11fb20c673
	
				 | 
					
					
						|||
| 
						
						
							
						
						a7990f83db
	
				 | 
					
					
						|||
| 
						
						
							
						
						5f4cdf7937
	
				 | 
					
					
						|||
| 
						
						
							
						
						3330ca14dd
	
				 | 
					
					
						|||
| 
						
						
							
						
						1719b1c8fe
	
				 | 
					
					
						|||
| 
						
						
							
						
						3c2c51bfaf
	
				 | 
					
					
						|||
| 
						
						
							
						
						239d6750ff
	
				 | 
					
					
						|||
| 
						
						
							
						
						8b0c91977a
	
				 | 
					
					
						|||
| 
						
						
							
						
						f74cca8464
	
				 | 
					
					
						|||
| 
						
						
							
						
						08091d51bf
	
				 | 
					
					
						|||
| 
						
						
							
						
						481190811b
	
				 | 
					
					
						|||
| 
						
						
							
						
						4b32b65d1c
	
				 | 
					
					
						|||
| 
						
						
							
						
						50ac7109bb
	
				 | 
					
					
						|||
| 
						
						
							
						
						62da279c71
	
				 | 
					
					
						|||
| 
						
						
							
						
						fde6dbf891
	
				 | 
					
					
						|||
| 
						
						
							
						
						613bf4fb42
	
				 | 
					
					
						|||
| 
						
						
							
						
						00ae586016
	
				 | 
					
					
						|||
| 
						
						
							
						
						ea0d132dce
	
				 | 
					
					
						|||
| 
						
						
							
						
						aa2df1e847
	
				 | 
					
					
						|||
| 
						
						
							
						
						50672795f3
	
				 | 
					
					
						|||
| 
						
						
							
						
						383de9568d
	
				 | 
					
					
						|||
| 
						
						
							
						
						01fa228e45
	
				 | 
					
					
						|||
| 
						
						
							
						
						1e71ad33a6
	
				 | 
					
					
						|||
| 
						
						
							
						
						92c0260ecd
	
				 | 
					
					
						|||
| 
						
						
							
						
						0a161ad255
	
				 | 
					
					
						|||
| 
						
						
							
						
						c003f27b9a
	
				 | 
					
					
						|||
| 
						
						
							
						
						19db8309c4
	
				 | 
					
					
						|||
| 
						
						
							
						
						aa72ce08e8
	
				 | 
					
					
						|||
| 
						
						
							
						
						4639b00b86
	
				 | 
					
					
						|||
| 
						
						
							
						
						cc5460ea55
	
				 | 
					
					
						|||
| 
						
						
							
						
						eafac811e6
	
				 | 
					
					
						|||
| 
						
						
							
						
						e3be691596
	
				 | 
					
					
						|||
| 
						
						
							
						
						aa180a1358
	
				 | 
					
					
						|||
| 
						
						
							
						
						c2707b8af1
	
				 | 
					
					
						|||
| 
						
						
							
						
						62fd0500f3
	
				 | 
					
					
						|||
| 
						
						
							
						
						eeae865cc8
	
				 | 
					
					
						|||
| 
						
						
							
						
						cdf1413fe0
	
				 | 
					
					
						|||
| 
						
						
							
						
						327b4c04f1
	
				 | 
					
					
						|||
| 
						
						
							
						
						bd903ce29c
	
				 | 
					
					
						|||
| 
						
						
							
						
						1b8ecb15ce
	
				 | 
					
					
						|||
| 
						
						
							
						
						d4e380a97a
	
				 | 
					
					
						|||
| 
						
						
							
						
						126048b4fa
	
				 | 
					
					
						|||
| 
						
						
							
						
						8bec18813d
	
				 | 
					
					
						|||
| 
						
						
							
						
						1ae81794b1
	
				 | 
					
					
						|||
| 
						
						
							
						
						2a7d12de48
	
				 | 
					
					
						|||
| 
						
						
							
						
						64c60ead48
	
				 | 
					
					
						|||
| 001549b190 | |||
| 4595865ad3 | |||
| 
						 | 
					1834643167 | ||
| 
						 | 
					0e816eaa3e | ||
| 
						 | 
					7c1f24b824 | ||
| 
						 | 
					b7d44d96ba | 
@@ -62,3 +62,9 @@ If you want to build the release version, use the flutter build command. Learn m
 | 
			
		||||
```bash
 | 
			
		||||
flutter build <platform>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Known Issues
 | 
			
		||||
 | 
			
		||||
Due to the issues with the flutter build tools, [see](https://github.com/flutter/flutter/issues/160622).
 | 
			
		||||
 | 
			
		||||
Since there is a watchOS app for iOS, you're unable to use the flutter cli to run iOS app. Use xcode instead.
 | 
			
		||||
@@ -162,6 +162,7 @@
 | 
			
		||||
    "accountConnectionProviderGithub": "GitHub",
 | 
			
		||||
    "accountConnectionProviderDiscord": "Discord",
 | 
			
		||||
    "accountConnectionProviderAfdian": "Afdian",
 | 
			
		||||
    "accountConnectionProviderSpotify": "Spotify",
 | 
			
		||||
    "checkIn": "Check In",
 | 
			
		||||
    "checkInNone": "Not checked-in yet",
 | 
			
		||||
    "checkInNoneHint": "Get your fortune tips and daily rewards by checking in.",
 | 
			
		||||
@@ -469,6 +470,7 @@
 | 
			
		||||
    "pronouns": "Pronouns",
 | 
			
		||||
    "location": "Location",
 | 
			
		||||
    "timeZone": "Time Zone",
 | 
			
		||||
    "timezoneNotFound": "Time zone not found",
 | 
			
		||||
    "birthday": "Birthday",
 | 
			
		||||
    "selectADate": "Select a date",
 | 
			
		||||
    "checkInResultT0": "Worst",
 | 
			
		||||
@@ -871,6 +873,7 @@
 | 
			
		||||
    "pollShortTextAnswerPreview": "Short text answer (preview)",
 | 
			
		||||
    "award": "Award",
 | 
			
		||||
    "awardPost": "Award Post",
 | 
			
		||||
    "awardPoints": "Awarded {} points",
 | 
			
		||||
    "awardMessage": "Message",
 | 
			
		||||
    "awardMessageHint": "Enter your award message...",
 | 
			
		||||
    "awardAttitude": "Attitude",
 | 
			
		||||
@@ -1252,5 +1255,69 @@
 | 
			
		||||
    "availableWithYourPlan": "Available with your plan",
 | 
			
		||||
    "upgradeRequired": "Upgrade required",
 | 
			
		||||
    "settingsDisableAnimation": "Disable Animation",
 | 
			
		||||
    "addTag": "Add Tag"
 | 
			
		||||
    "addTag": "Add Tag",
 | 
			
		||||
    "postFeaturedOn": "Post featured on {}",
 | 
			
		||||
    "messageSentAt": "Sent at {}",
 | 
			
		||||
    "myTickets": "My Tickets",
 | 
			
		||||
    "drawHistory": "Draw History",
 | 
			
		||||
    "lottery": "Lottery",
 | 
			
		||||
    "noLotteryTickets": "No lottery tickets yet",
 | 
			
		||||
    "buyYourFirstTicket": "Buy your first lottery ticket to get started!",
 | 
			
		||||
    "buyTicket": "Buy Ticket",
 | 
			
		||||
    "ticketNumbers": "Numbers: {}, Special: {}",
 | 
			
		||||
    "cost": "Cost",
 | 
			
		||||
    "multiplier": "Multiplier",
 | 
			
		||||
    "prizeWon": "Prize Won",
 | 
			
		||||
    "pending": "Pending",
 | 
			
		||||
    "drawn": "Drawn",
 | 
			
		||||
    "won": "Won",
 | 
			
		||||
    "lost": "Lost",
 | 
			
		||||
    "noDrawHistory": "No draw history yet",
 | 
			
		||||
    "buyLotteryTicket": "Buy Lottery Ticket",
 | 
			
		||||
    "selectNumbers": "Select Numbers",
 | 
			
		||||
    "select5UniqueNumbers": "Select 5 unique numbers",
 | 
			
		||||
    "selectSpecialNumber": "Select Special Number",
 | 
			
		||||
    "selectMultiplier": "Select Multiplier",
 | 
			
		||||
    "baseCost": "Base Cost",
 | 
			
		||||
    "totalCost": "Total Cost",
 | 
			
		||||
    "prizeStructure": "Prize Structure",
 | 
			
		||||
    "enterPinToConfirmPurchase": "Enter your PIN to confirm purchase",
 | 
			
		||||
    "ticketPurchasedSuccessfully": "Ticket purchased successfully!",
 | 
			
		||||
    "winningNumbers": "Winning Numbers",
 | 
			
		||||
    "specialNumber": "Special Number",
 | 
			
		||||
    "totalTickets": "Total Tickets",
 | 
			
		||||
    "totalWinners": "Total Winners",
 | 
			
		||||
    "prizePool": "Prize Pool",
 | 
			
		||||
    "enterPinToConfirmPayment": "Enter your PIN code to confirm payment",
 | 
			
		||||
    "purchase": "Purchase",
 | 
			
		||||
    "multiplierLabel": "Multiplier",
 | 
			
		||||
    "specialOnly": "Special Only",
 | 
			
		||||
    "matches": "Matches",
 | 
			
		||||
    "thoughtDefaultTopic": "Reflection",
 | 
			
		||||
    "thoughtAiName": "SN-chan",
 | 
			
		||||
    "thoughtUserName": "You",
 | 
			
		||||
    "thoughtStreamingHint": "Sn-chan is thinking...",
 | 
			
		||||
    "thoughtInputHint": "Ask sn-chan anything...",
 | 
			
		||||
    "thoughtNewConversation": "Start New Conversation",
 | 
			
		||||
    "thoughtParseError": "Failed to parse AI response",
 | 
			
		||||
    "thoughtFunctionCall": "Function Call",
 | 
			
		||||
    "aiThought": "AI Thought",
 | 
			
		||||
    "aiThoughtTitle": "Let sn-chan think",
 | 
			
		||||
    "postReferenceUnavailable": "Referenced post is unavailable",
 | 
			
		||||
    "fabLocation": "FAB Location",
 | 
			
		||||
    "activities": "Activities",
 | 
			
		||||
    "presenceTypeGaming": "Playing",
 | 
			
		||||
    "presenceTypeMusic": "Listening to Music",
 | 
			
		||||
    "presenceTypeWorkout": "Working out",
 | 
			
		||||
    "articleCompose": "Compose Article",
 | 
			
		||||
    "backToHub": "Back to Hub",
 | 
			
		||||
    "advancedFilters": "Advanced Filters",
 | 
			
		||||
    "searchPosts": "Search Posts",
 | 
			
		||||
    "sortBy": "Sort by",
 | 
			
		||||
    "fromDate": "From Date",
 | 
			
		||||
    "toDate": "To Date",
 | 
			
		||||
    "popularity": "Popularity",
 | 
			
		||||
    "descendingOrder": "Descending Order",
 | 
			
		||||
    "selectDate": "Select Date",
 | 
			
		||||
    "pinnedPosts": "Pinned Posts"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -158,11 +158,11 @@
 | 
			
		||||
    "checkIn": "签到",
 | 
			
		||||
    "checkInNone": "尚未签到",
 | 
			
		||||
    "checkInNoneHint": "通过签到获取您的财富提示和每日奖励。",
 | 
			
		||||
    "checkInResultLevel0": "最差运气",
 | 
			
		||||
    "checkInResultLevel1": "坏运气",
 | 
			
		||||
    "checkInResultLevel2": "一个普通的日常",
 | 
			
		||||
    "checkInResultLevel3": "好运",
 | 
			
		||||
    "checkInResultLevel4": "最佳运气",
 | 
			
		||||
    "checkInResultLevel0": "大凶",
 | 
			
		||||
    "checkInResultLevel1": "凶",
 | 
			
		||||
    "checkInResultLevel2": "中平",
 | 
			
		||||
    "checkInResultLevel3": "吉",
 | 
			
		||||
    "checkInResultLevel4": "大吉",
 | 
			
		||||
    "checkInActivityTitle": "{} 在 {} 签到并获得了 {}",
 | 
			
		||||
    "eventCalander": "活动日历",
 | 
			
		||||
    "eventCalanderEmpty": "该日无活动。",
 | 
			
		||||
@@ -344,7 +344,7 @@
 | 
			
		||||
    "accountSettingsHelpContent": "此页面允许您管理您的帐户安全性、隐私和其他设置。如果您需要帮助,请联系管理员。",
 | 
			
		||||
    "unauthorized": "未授权",
 | 
			
		||||
    "unauthorizedHint": "您未登录或会话已过期,请重新登录。",
 | 
			
		||||
    "publisherBelongsTo": "属于",
 | 
			
		||||
    "publisherBelongsTo": "属于 {}",
 | 
			
		||||
    "postContent": "内容",
 | 
			
		||||
    "postSettings": "设置",
 | 
			
		||||
    "postPublisherUnselected": "未指定发布者",
 | 
			
		||||
@@ -1081,5 +1081,14 @@
 | 
			
		||||
    "postPublish": "发布帖子",
 | 
			
		||||
    "restoreDraftTitle": "恢复草稿",
 | 
			
		||||
    "restoreDraftMessage": "发现了一个草稿。你想要恢复它吗?",
 | 
			
		||||
    "draft": "草稿"
 | 
			
		||||
    "draft": "草稿",
 | 
			
		||||
    "thoughtDefaultTopic": "寻思",
 | 
			
		||||
    "thoughtAiName": "SN 酱",
 | 
			
		||||
    "thoughtUserName": "您",
 | 
			
		||||
    "thoughtStreamingHint": "SN 酱正在思考...",
 | 
			
		||||
    "thoughtInputHint": "问 SN 酱任何问题...",
 | 
			
		||||
    "thoughtNewConversation": "开始新对话",
 | 
			
		||||
    "thoughtParseError": "解析 AI 响应失败",
 | 
			
		||||
    "aiThought": "寻思",
 | 
			
		||||
    "aiThoughtTitle": "让 SN 酱寻思寻思"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								assets/images/oidc/spotify.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 23 KiB  | 
							
								
								
									
										15
									
								
								ios/Podfile
									
									
									
									
									
								
							
							
						
						@@ -1,4 +1,3 @@
 | 
			
		||||
# Uncomment this line to define a global platform for your project
 | 
			
		||||
platform :ios, '15.0'
 | 
			
		||||
 | 
			
		||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
 | 
			
		||||
@@ -32,6 +31,8 @@ target 'Runner' do
 | 
			
		||||
  use_modular_headers!
 | 
			
		||||
 | 
			
		||||
  pod 'Alamofire'
 | 
			
		||||
  pod 'Kingfisher', '~> 8.0'
 | 
			
		||||
  pod 'KingfisherWebP'
 | 
			
		||||
 | 
			
		||||
  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
 | 
			
		||||
 | 
			
		||||
@@ -41,8 +42,6 @@ target 'Runner' do
 | 
			
		||||
 | 
			
		||||
  target 'SolianNotificationService' do
 | 
			
		||||
    inherit! :search_paths
 | 
			
		||||
    pod 'Kingfisher', '~> 8.0'
 | 
			
		||||
    pod 'Alamofire'
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  target 'SolianShareExtension' do
 | 
			
		||||
@@ -50,6 +49,16 @@ target 'Runner' do
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
target 'Solian Watch App' do
 | 
			
		||||
  platform :watchos, '11.0'
 | 
			
		||||
 | 
			
		||||
  use_frameworks!
 | 
			
		||||
  use_modular_headers!
 | 
			
		||||
 | 
			
		||||
  pod 'Kingfisher', '~> 8.0'
 | 
			
		||||
  pod 'KingfisherWebP'
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
post_install do |installer|
 | 
			
		||||
  installer.pods_project.targets.each do |target|
 | 
			
		||||
    flutter_additional_ios_build_settings(target)
 | 
			
		||||
 
 | 
			
		||||
@@ -218,8 +218,23 @@ PODS:
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - irondash_engine_context (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - Kingfisher (8.6.0)
 | 
			
		||||
  - livekit_client (2.5.0):
 | 
			
		||||
  - Kingfisher (8.6.1)
 | 
			
		||||
  - KingfisherWebP (1.7.2):
 | 
			
		||||
    - Kingfisher (~> 8.0)
 | 
			
		||||
    - libwebp (>= 1.1.0)
 | 
			
		||||
  - libwebp (1.5.0):
 | 
			
		||||
    - libwebp/demux (= 1.5.0)
 | 
			
		||||
    - libwebp/mux (= 1.5.0)
 | 
			
		||||
    - libwebp/sharpyuv (= 1.5.0)
 | 
			
		||||
    - libwebp/webp (= 1.5.0)
 | 
			
		||||
  - libwebp/demux (1.5.0):
 | 
			
		||||
    - libwebp/webp
 | 
			
		||||
  - libwebp/mux (1.5.0):
 | 
			
		||||
    - libwebp/demux
 | 
			
		||||
  - libwebp/sharpyuv (1.5.0)
 | 
			
		||||
  - libwebp/webp (1.5.0):
 | 
			
		||||
    - libwebp/sharpyuv
 | 
			
		||||
  - livekit_client (2.5.3):
 | 
			
		||||
    - Flutter
 | 
			
		||||
    - flutter_webrtc
 | 
			
		||||
    - WebRTC-SDK (= 137.7151.04)
 | 
			
		||||
@@ -333,6 +348,7 @@ DEPENDENCIES:
 | 
			
		||||
  - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
 | 
			
		||||
  - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`)
 | 
			
		||||
  - Kingfisher (~> 8.0)
 | 
			
		||||
  - KingfisherWebP
 | 
			
		||||
  - livekit_client (from `.symlinks/plugins/livekit_client/ios`)
 | 
			
		||||
  - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
 | 
			
		||||
  - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
 | 
			
		||||
@@ -375,6 +391,8 @@ SPEC REPOS:
 | 
			
		||||
    - GoogleDataTransport
 | 
			
		||||
    - GoogleUtilities
 | 
			
		||||
    - Kingfisher
 | 
			
		||||
    - KingfisherWebP
 | 
			
		||||
    - libwebp
 | 
			
		||||
    - nanopb
 | 
			
		||||
    - OrderedSet
 | 
			
		||||
    - PromisesObjC
 | 
			
		||||
@@ -517,10 +535,12 @@ SPEC CHECKSUMS:
 | 
			
		||||
  GoogleAppMeasurement: 1e718274b7e015cefd846ac1fcf7820c70dc017d
 | 
			
		||||
  GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
 | 
			
		||||
  GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
 | 
			
		||||
  image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
 | 
			
		||||
  image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
 | 
			
		||||
  irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
 | 
			
		||||
  Kingfisher: 64278f126a815d0e2d391cdf71311b85882c4de0
 | 
			
		||||
  livekit_client: a6f5fa86ac28ccd7ded53626a5379961db311ab4
 | 
			
		||||
  Kingfisher: 7ac7a7288653787a54206b11a3c74f49ab650f1f
 | 
			
		||||
  KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402
 | 
			
		||||
  libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
 | 
			
		||||
  livekit_client: 86c8af579274e4b7a215185a8080db2d4e176f40
 | 
			
		||||
  local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
 | 
			
		||||
  media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
 | 
			
		||||
  media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
 | 
			
		||||
@@ -529,8 +549,8 @@ SPEC CHECKSUMS:
 | 
			
		||||
  OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
 | 
			
		||||
  package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
 | 
			
		||||
  pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c
 | 
			
		||||
  path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
 | 
			
		||||
  pointer_interceptor_ios: ec847ef8b0915778bed2b2cef636f4d177fa8eed
 | 
			
		||||
  path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
 | 
			
		||||
  pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0
 | 
			
		||||
  PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
 | 
			
		||||
  PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
 | 
			
		||||
  receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
 | 
			
		||||
@@ -538,7 +558,7 @@ SPEC CHECKSUMS:
 | 
			
		||||
  SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
 | 
			
		||||
  SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
 | 
			
		||||
  share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
 | 
			
		||||
  shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
 | 
			
		||||
  shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
 | 
			
		||||
  sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418
 | 
			
		||||
  sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
 | 
			
		||||
  sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b
 | 
			
		||||
@@ -546,11 +566,11 @@ SPEC CHECKSUMS:
 | 
			
		||||
  super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
 | 
			
		||||
  SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
 | 
			
		||||
  syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
 | 
			
		||||
  url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
 | 
			
		||||
  url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
 | 
			
		||||
  volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
 | 
			
		||||
  wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
 | 
			
		||||
  WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
 | 
			
		||||
 | 
			
		||||
PODFILE CHECKSUM: c818292390b02fa379036ea099713a332bd7193f
 | 
			
		||||
PODFILE CHECKSUM: 585198f58dca90ac6492607c83a8d17045ab3852
 | 
			
		||||
 | 
			
		||||
COCOAPODS: 1.16.2
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,8 @@
 | 
			
		||||
		1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
 | 
			
		||||
		331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
 | 
			
		||||
		3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
 | 
			
		||||
		5D8143680678FCD1D1827271 /* Pods_Solian_Watch_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C9C046CF867AE03DC170F861 /* Pods_Solian_Watch_App.framework */; };
 | 
			
		||||
		7310A7DF2EB10963002C0FD3 /* Solian Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 7310A7D42EB10962002C0FD3 /* Solian Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
 | 
			
		||||
		73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */; };
 | 
			
		||||
		73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
 | 
			
		||||
		73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
 | 
			
		||||
@@ -58,6 +60,17 @@
 | 
			
		||||
/* End PBXContainerItemProxy section */
 | 
			
		||||
 | 
			
		||||
/* Begin PBXCopyFilesBuildPhase section */
 | 
			
		||||
		7310A7DE2EB10963002C0FD3 /* Embed Watch Content */ = {
 | 
			
		||||
			isa = PBXCopyFilesBuildPhase;
 | 
			
		||||
			buildActionMask = 12;
 | 
			
		||||
			dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
 | 
			
		||||
			dstSubfolderSpec = 16;
 | 
			
		||||
			files = (
 | 
			
		||||
				7310A7DF2EB10963002C0FD3 /* Solian Watch App.app in Embed Watch Content */,
 | 
			
		||||
			);
 | 
			
		||||
			name = "Embed Watch Content";
 | 
			
		||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
			
		||||
		};
 | 
			
		||||
		73268D1D2DEAFD670076E970 /* Embed Foundation Extensions */ = {
 | 
			
		||||
			isa = PBXCopyFilesBuildPhase;
 | 
			
		||||
			buildActionMask = 2147483647;
 | 
			
		||||
@@ -84,6 +97,8 @@
 | 
			
		||||
/* End PBXCopyFilesBuildPhase section */
 | 
			
		||||
 | 
			
		||||
/* Begin PBXFileReference section */
 | 
			
		||||
		0ECC3D56D018DD87FC342699 /* Pods-Solian Watch App.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Solian Watch App.profile.xcconfig"; path = "Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App.profile.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		103EA2362B9E9F127016A1F1 /* Pods-WatchRunner Watch App.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WatchRunner Watch App.profile.xcconfig"; path = "Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App.profile.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		14118AC858B441AB16B7309E /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
 | 
			
		||||
		1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
 | 
			
		||||
@@ -91,15 +106,18 @@
 | 
			
		||||
		17FAB080A9C53193ABD9C15B /* Pods-SolianShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolianShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-SolianShareExtension/Pods-SolianShareExtension.debug.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		192FDACE67D7CB6AED15C634 /* Pods-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.debug.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		1C14F71D23E4371602065522 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		2440CEDEAAD6D51FDA95FA62 /* Pods-Solian Watch App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Solian Watch App.release.xcconfig"; path = "Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App.release.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		252A83CE6862573BB856ED8E /* Pods-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.release.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		27C66EFB5A705F1A822C3EB0 /* Pods-SolianShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolianShareExtension.release.xcconfig"; path = "Target Support Files/Pods-SolianShareExtension/Pods-SolianShareExtension.release.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		29812C17FFBE7DBBC7203981 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 | 
			
		||||
		2D2457F8B2E6EF9C0F935035 /* Pods-NotificationService.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.profile.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.profile.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		31EA49B10397BD4145AD765E /* Pods-Solian Watch App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Solian Watch App.debug.xcconfig"; path = "Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App.debug.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
 | 
			
		||||
		331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 | 
			
		||||
		39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SolianNotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 | 
			
		||||
		3A1C47BD29CC6AC2587D4DBE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
 | 
			
		||||
		7310A7D42EB10962002C0FD3 /* Solian Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Solian Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
 | 
			
		||||
		737E920B2DB6A9FF00BE9CDB /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
 | 
			
		||||
		73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianBroadcastExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
 | 
			
		||||
		73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReplayKit.framework; path = System/Library/Frameworks/ReplayKit.framework; sourceTree = SDKROOT; };
 | 
			
		||||
@@ -111,6 +129,7 @@
 | 
			
		||||
		74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
 | 
			
		||||
		7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
 | 
			
		||||
		7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SolianShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 | 
			
		||||
		86D60BA96DA647E1B11AA7F0 /* Pods-WatchRunner Watch App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WatchRunner Watch App.debug.xcconfig"; path = "Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App.debug.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		8B40620B1EEBB09456406A3C /* Pods-SolianNotificationService.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolianNotificationService.profile.xcconfig"; path = "Target Support Files/Pods-SolianNotificationService/Pods-SolianNotificationService.profile.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
 | 
			
		||||
		9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
 | 
			
		||||
@@ -120,10 +139,12 @@
 | 
			
		||||
		97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
 | 
			
		||||
		97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 | 
			
		||||
		9AE244813FCDFAA941430393 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
 | 
			
		||||
		A2EB1DAFDE9B8E6D88BBF7A3 /* Pods-WatchRunner Watch App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WatchRunner Watch App.release.xcconfig"; path = "Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App.release.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		A499FDB2082EB000933AA8C5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		A85FF612AE7623A9934E57CE /* Pods-SolianShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolianShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-SolianShareExtension/Pods-SolianShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		AA0CA8A3E15DEE023BB27438 /* Pods_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 | 
			
		||||
		B93771F2A63E4148DC6142F7 /* Pods-SolianNotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolianNotificationService.release.xcconfig"; path = "Target Support Files/Pods-SolianNotificationService/Pods-SolianNotificationService.release.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		C9C046CF867AE03DC170F861 /* Pods_Solian_Watch_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Solian_Watch_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 | 
			
		||||
		E6B10A9A85BECA2E576C91FF /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
		F6D834CA86410B09796B312B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 | 
			
		||||
		F830F535CB92E3F2E1653A11 /* Pods-SolianNotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolianNotificationService.debug.xcconfig"; path = "Target Support Files/Pods-SolianNotificationService/Pods-SolianNotificationService.debug.xcconfig"; sourceTree = "<group>"; };
 | 
			
		||||
@@ -162,6 +183,13 @@
 | 
			
		||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
 | 
			
		||||
 | 
			
		||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
 | 
			
		||||
		7310A7D52EB10962002C0FD3 /* Solian Watch App */ = {
 | 
			
		||||
			isa = PBXFileSystemSynchronizedRootGroup;
 | 
			
		||||
			exceptions = (
 | 
			
		||||
			);
 | 
			
		||||
			path = "Solian Watch App";
 | 
			
		||||
			sourceTree = "<group>";
 | 
			
		||||
		};
 | 
			
		||||
		73268D272DEB012A0076E970 /* Services */ = {
 | 
			
		||||
			isa = PBXFileSystemSynchronizedRootGroup;
 | 
			
		||||
			exceptions = (
 | 
			
		||||
@@ -205,6 +233,14 @@
 | 
			
		||||
			);
 | 
			
		||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
			
		||||
		};
 | 
			
		||||
		7310A7D12EB10962002C0FD3 /* Frameworks */ = {
 | 
			
		||||
			isa = PBXFrameworksBuildPhase;
 | 
			
		||||
			buildActionMask = 2147483647;
 | 
			
		||||
			files = (
 | 
			
		||||
				5D8143680678FCD1D1827271 /* Pods_Solian_Watch_App.framework in Frameworks */,
 | 
			
		||||
			);
 | 
			
		||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
			
		||||
		};
 | 
			
		||||
		73ACDFA82E3D0E6100B63535 /* Frameworks */ = {
 | 
			
		||||
			isa = PBXFrameworksBuildPhase;
 | 
			
		||||
			buildActionMask = 2147483647;
 | 
			
		||||
@@ -258,6 +294,7 @@
 | 
			
		||||
				7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */,
 | 
			
		||||
				73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */,
 | 
			
		||||
				73ACDFB82E3D0E6100B63535 /* UIKit.framework */,
 | 
			
		||||
				C9C046CF867AE03DC170F861 /* Pods_Solian_Watch_App.framework */,
 | 
			
		||||
			);
 | 
			
		||||
			name = Frameworks;
 | 
			
		||||
			sourceTree = "<group>";
 | 
			
		||||
@@ -280,6 +317,12 @@
 | 
			
		||||
				17FAB080A9C53193ABD9C15B /* Pods-SolianShareExtension.debug.xcconfig */,
 | 
			
		||||
				27C66EFB5A705F1A822C3EB0 /* Pods-SolianShareExtension.release.xcconfig */,
 | 
			
		||||
				A85FF612AE7623A9934E57CE /* Pods-SolianShareExtension.profile.xcconfig */,
 | 
			
		||||
				86D60BA96DA647E1B11AA7F0 /* Pods-WatchRunner Watch App.debug.xcconfig */,
 | 
			
		||||
				A2EB1DAFDE9B8E6D88BBF7A3 /* Pods-WatchRunner Watch App.release.xcconfig */,
 | 
			
		||||
				103EA2362B9E9F127016A1F1 /* Pods-WatchRunner Watch App.profile.xcconfig */,
 | 
			
		||||
				31EA49B10397BD4145AD765E /* Pods-Solian Watch App.debug.xcconfig */,
 | 
			
		||||
				2440CEDEAAD6D51FDA95FA62 /* Pods-Solian Watch App.release.xcconfig */,
 | 
			
		||||
				0ECC3D56D018DD87FC342699 /* Pods-Solian Watch App.profile.xcconfig */,
 | 
			
		||||
			);
 | 
			
		||||
			path = Pods;
 | 
			
		||||
			sourceTree = "<group>";
 | 
			
		||||
@@ -303,6 +346,7 @@
 | 
			
		||||
				73CDD67B2DEC00480059D95D /* SolianNotificationService */,
 | 
			
		||||
				73C305CF2E0BE878009035B9 /* SolianShareExtension */,
 | 
			
		||||
				73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */,
 | 
			
		||||
				7310A7D52EB10962002C0FD3 /* Solian Watch App */,
 | 
			
		||||
				97C146EF1CF9000F007C117D /* Products */,
 | 
			
		||||
				331C8082294A63A400263BE5 /* RunnerTests */,
 | 
			
		||||
				91E124CE95BCB4DCD890160D /* Pods */,
 | 
			
		||||
@@ -319,6 +363,7 @@
 | 
			
		||||
				73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */,
 | 
			
		||||
				73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */,
 | 
			
		||||
				73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */,
 | 
			
		||||
				7310A7D42EB10962002C0FD3 /* Solian Watch App.app */,
 | 
			
		||||
			);
 | 
			
		||||
			name = Products;
 | 
			
		||||
			sourceTree = "<group>";
 | 
			
		||||
@@ -363,6 +408,28 @@
 | 
			
		||||
			productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
 | 
			
		||||
			productType = "com.apple.product-type.bundle.unit-test";
 | 
			
		||||
		};
 | 
			
		||||
		7310A7D32EB10962002C0FD3 /* Solian Watch App */ = {
 | 
			
		||||
			isa = PBXNativeTarget;
 | 
			
		||||
			buildConfigurationList = 7310A7E32EB10963002C0FD3 /* Build configuration list for PBXNativeTarget "Solian Watch App" */;
 | 
			
		||||
			buildPhases = (
 | 
			
		||||
				DDEDA1BA6278B94F0F7B9B61 /* [CP] Check Pods Manifest.lock */,
 | 
			
		||||
				7310A7D02EB10962002C0FD3 /* Sources */,
 | 
			
		||||
				7310A7D12EB10962002C0FD3 /* Frameworks */,
 | 
			
		||||
				7310A7D22EB10962002C0FD3 /* Resources */,
 | 
			
		||||
				E29ECA5954168075BDB000DC /* [CP] Embed Pods Frameworks */,
 | 
			
		||||
			);
 | 
			
		||||
			buildRules = (
 | 
			
		||||
			);
 | 
			
		||||
			dependencies = (
 | 
			
		||||
			);
 | 
			
		||||
			fileSystemSynchronizedGroups = (
 | 
			
		||||
				7310A7D52EB10962002C0FD3 /* Solian Watch App */,
 | 
			
		||||
			);
 | 
			
		||||
			name = "Solian Watch App";
 | 
			
		||||
			productName = "WatchRunner Watch App";
 | 
			
		||||
			productReference = 7310A7D42EB10962002C0FD3 /* Solian Watch App.app */;
 | 
			
		||||
			productType = "com.apple.product-type.application";
 | 
			
		||||
		};
 | 
			
		||||
		73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */ = {
 | 
			
		||||
			isa = PBXNativeTarget;
 | 
			
		||||
			buildConfigurationList = 73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */;
 | 
			
		||||
@@ -434,6 +501,7 @@
 | 
			
		||||
				97C146EA1CF9000F007C117D /* Sources */,
 | 
			
		||||
				97C146EB1CF9000F007C117D /* Frameworks */,
 | 
			
		||||
				73268D1D2DEAFD670076E970 /* Embed Foundation Extensions */,
 | 
			
		||||
				7310A7DE2EB10963002C0FD3 /* Embed Watch Content */,
 | 
			
		||||
				97C146EC1CF9000F007C117D /* Resources */,
 | 
			
		||||
				9705A1C41CF9048500538489 /* Embed Frameworks */,
 | 
			
		||||
				3B06AD1E1E4923F5004D2608 /* Thin Binary */,
 | 
			
		||||
@@ -463,7 +531,7 @@
 | 
			
		||||
			isa = PBXProject;
 | 
			
		||||
			attributes = {
 | 
			
		||||
				BuildIndependentTargetsInParallel = YES;
 | 
			
		||||
				LastSwiftUpdateCheck = 1640;
 | 
			
		||||
				LastSwiftUpdateCheck = 2600;
 | 
			
		||||
				LastUpgradeCheck = 1510;
 | 
			
		||||
				ORGANIZATIONNAME = "";
 | 
			
		||||
				TargetAttributes = {
 | 
			
		||||
@@ -471,6 +539,9 @@
 | 
			
		||||
						CreatedOnToolsVersion = 14.0;
 | 
			
		||||
						TestTargetID = 97C146ED1CF9000F007C117D;
 | 
			
		||||
					};
 | 
			
		||||
					7310A7D32EB10962002C0FD3 = {
 | 
			
		||||
						CreatedOnToolsVersion = 26.0.1;
 | 
			
		||||
					};
 | 
			
		||||
					73ACDFAA2E3D0E6100B63535 = {
 | 
			
		||||
						CreatedOnToolsVersion = 16.4;
 | 
			
		||||
					};
 | 
			
		||||
@@ -504,6 +575,7 @@
 | 
			
		||||
				73CDD6792DEC00480059D95D /* SolianNotificationService */,
 | 
			
		||||
				73C305CD2E0BE878009035B9 /* SolianShareExtension */,
 | 
			
		||||
				73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */,
 | 
			
		||||
				7310A7D32EB10962002C0FD3 /* Solian Watch App */,
 | 
			
		||||
			);
 | 
			
		||||
		};
 | 
			
		||||
/* End PBXProject section */
 | 
			
		||||
@@ -516,6 +588,13 @@
 | 
			
		||||
			);
 | 
			
		||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
			
		||||
		};
 | 
			
		||||
		7310A7D22EB10962002C0FD3 /* Resources */ = {
 | 
			
		||||
			isa = PBXResourcesBuildPhase;
 | 
			
		||||
			buildActionMask = 2147483647;
 | 
			
		||||
			files = (
 | 
			
		||||
			);
 | 
			
		||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
			
		||||
		};
 | 
			
		||||
		73ACDFA92E3D0E6100B63535 /* Resources */ = {
 | 
			
		||||
			isa = PBXResourcesBuildPhase;
 | 
			
		||||
			buildActionMask = 2147483647;
 | 
			
		||||
@@ -683,6 +762,45 @@
 | 
			
		||||
			shellPath = /bin/sh;
 | 
			
		||||
			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
 | 
			
		||||
		};
 | 
			
		||||
		DDEDA1BA6278B94F0F7B9B61 /* [CP] Check Pods Manifest.lock */ = {
 | 
			
		||||
			isa = PBXShellScriptBuildPhase;
 | 
			
		||||
			buildActionMask = 2147483647;
 | 
			
		||||
			files = (
 | 
			
		||||
			);
 | 
			
		||||
			inputFileListPaths = (
 | 
			
		||||
			);
 | 
			
		||||
			inputPaths = (
 | 
			
		||||
				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
 | 
			
		||||
				"${PODS_ROOT}/Manifest.lock",
 | 
			
		||||
			);
 | 
			
		||||
			name = "[CP] Check Pods Manifest.lock";
 | 
			
		||||
			outputFileListPaths = (
 | 
			
		||||
			);
 | 
			
		||||
			outputPaths = (
 | 
			
		||||
				"$(DERIVED_FILE_DIR)/Pods-Solian Watch App-checkManifestLockResult.txt",
 | 
			
		||||
			);
 | 
			
		||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
			
		||||
			shellPath = /bin/sh;
 | 
			
		||||
			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
 | 
			
		||||
			showEnvVarsInLog = 0;
 | 
			
		||||
		};
 | 
			
		||||
		E29ECA5954168075BDB000DC /* [CP] Embed Pods Frameworks */ = {
 | 
			
		||||
			isa = PBXShellScriptBuildPhase;
 | 
			
		||||
			buildActionMask = 2147483647;
 | 
			
		||||
			files = (
 | 
			
		||||
			);
 | 
			
		||||
			inputFileListPaths = (
 | 
			
		||||
				"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks-${CONFIGURATION}-input-files.xcfilelist",
 | 
			
		||||
			);
 | 
			
		||||
			name = "[CP] Embed Pods Frameworks";
 | 
			
		||||
			outputFileListPaths = (
 | 
			
		||||
				"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist",
 | 
			
		||||
			);
 | 
			
		||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
			
		||||
			shellPath = /bin/sh;
 | 
			
		||||
			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks.sh\"\n";
 | 
			
		||||
			showEnvVarsInLog = 0;
 | 
			
		||||
		};
 | 
			
		||||
		E86CDE9D6464F4F52B910856 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = {
 | 
			
		||||
			isa = PBXShellScriptBuildPhase;
 | 
			
		||||
			buildActionMask = 2147483647;
 | 
			
		||||
@@ -734,6 +852,13 @@
 | 
			
		||||
			);
 | 
			
		||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
			
		||||
		};
 | 
			
		||||
		7310A7D02EB10962002C0FD3 /* Sources */ = {
 | 
			
		||||
			isa = PBXSourcesBuildPhase;
 | 
			
		||||
			buildActionMask = 2147483647;
 | 
			
		||||
			files = (
 | 
			
		||||
			);
 | 
			
		||||
			runOnlyForDeploymentPostprocessing = 0;
 | 
			
		||||
		};
 | 
			
		||||
		73ACDFA72E3D0E6100B63535 /* Sources */ = {
 | 
			
		||||
			isa = PBXSourcesBuildPhase;
 | 
			
		||||
			buildActionMask = 2147483647;
 | 
			
		||||
@@ -873,6 +998,7 @@
 | 
			
		||||
				CUSTOM_GROUP_ID = group.solsynth.solian;
 | 
			
		||||
				DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
			
		||||
				ENABLE_BITCODE = NO;
 | 
			
		||||
				EXCLUDED_SOURCE_FILE_NAMES = "";
 | 
			
		||||
				INFOPLIST_FILE = Runner/Info.plist;
 | 
			
		||||
				INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
			
		||||
				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
 | 
			
		||||
@@ -883,10 +1009,12 @@
 | 
			
		||||
				);
 | 
			
		||||
				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian;
 | 
			
		||||
				PRODUCT_NAME = "$(TARGET_NAME)";
 | 
			
		||||
				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
 | 
			
		||||
				SWIFT_ENABLE_EXPLICIT_MODULES = "$(SWIFT_USE_INTEGRATED_DRIVER)";
 | 
			
		||||
				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
 | 
			
		||||
				SWIFT_VERSION = 5.0;
 | 
			
		||||
				VERSIONING_SYSTEM = "apple-generic";
 | 
			
		||||
				WATCHOS_DEPLOYMENT_TARGET = 11.6;
 | 
			
		||||
			};
 | 
			
		||||
			name = Profile;
 | 
			
		||||
		};
 | 
			
		||||
@@ -894,6 +1022,7 @@
 | 
			
		||||
			isa = XCBuildConfiguration;
 | 
			
		||||
			baseConfigurationReference = 14DFD79BE7C26E51B117583C /* Pods-RunnerTests.debug.xcconfig */;
 | 
			
		||||
			buildSettings = {
 | 
			
		||||
				ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES;
 | 
			
		||||
				BUNDLE_LOADER = "$(TEST_HOST)";
 | 
			
		||||
				CODE_SIGN_STYLE = Automatic;
 | 
			
		||||
				CURRENT_PROJECT_VERSION = 1;
 | 
			
		||||
@@ -902,6 +1031,8 @@
 | 
			
		||||
				MARKETING_VERSION = 1.0;
 | 
			
		||||
				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests;
 | 
			
		||||
				PRODUCT_NAME = "$(TARGET_NAME)";
 | 
			
		||||
				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
 | 
			
		||||
				SUPPORTS_MACCATALYST = YES;
 | 
			
		||||
				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
 | 
			
		||||
				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
 | 
			
		||||
				SWIFT_VERSION = 5.0;
 | 
			
		||||
@@ -913,6 +1044,7 @@
 | 
			
		||||
			isa = XCBuildConfiguration;
 | 
			
		||||
			baseConfigurationReference = 14118AC858B441AB16B7309E /* Pods-RunnerTests.release.xcconfig */;
 | 
			
		||||
			buildSettings = {
 | 
			
		||||
				ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES;
 | 
			
		||||
				BUNDLE_LOADER = "$(TEST_HOST)";
 | 
			
		||||
				CODE_SIGN_STYLE = Automatic;
 | 
			
		||||
				CURRENT_PROJECT_VERSION = 1;
 | 
			
		||||
@@ -921,6 +1053,8 @@
 | 
			
		||||
				MARKETING_VERSION = 1.0;
 | 
			
		||||
				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests;
 | 
			
		||||
				PRODUCT_NAME = "$(TARGET_NAME)";
 | 
			
		||||
				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
 | 
			
		||||
				SUPPORTS_MACCATALYST = YES;
 | 
			
		||||
				SWIFT_VERSION = 5.0;
 | 
			
		||||
				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
 | 
			
		||||
			};
 | 
			
		||||
@@ -930,6 +1064,7 @@
 | 
			
		||||
			isa = XCBuildConfiguration;
 | 
			
		||||
			baseConfigurationReference = E6B10A9A85BECA2E576C91FF /* Pods-RunnerTests.profile.xcconfig */;
 | 
			
		||||
			buildSettings = {
 | 
			
		||||
				ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES;
 | 
			
		||||
				BUNDLE_LOADER = "$(TEST_HOST)";
 | 
			
		||||
				CODE_SIGN_STYLE = Automatic;
 | 
			
		||||
				CURRENT_PROJECT_VERSION = 1;
 | 
			
		||||
@@ -938,11 +1073,162 @@
 | 
			
		||||
				MARKETING_VERSION = 1.0;
 | 
			
		||||
				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests;
 | 
			
		||||
				PRODUCT_NAME = "$(TARGET_NAME)";
 | 
			
		||||
				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
 | 
			
		||||
				SUPPORTS_MACCATALYST = YES;
 | 
			
		||||
				SWIFT_VERSION = 5.0;
 | 
			
		||||
				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
 | 
			
		||||
			};
 | 
			
		||||
			name = Profile;
 | 
			
		||||
		};
 | 
			
		||||
		7310A7E02EB10963002C0FD3 /* Debug */ = {
 | 
			
		||||
			isa = XCBuildConfiguration;
 | 
			
		||||
			baseConfigurationReference = 31EA49B10397BD4145AD765E /* Pods-Solian Watch App.debug.xcconfig */;
 | 
			
		||||
			buildSettings = {
 | 
			
		||||
				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 | 
			
		||||
				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
 | 
			
		||||
				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 | 
			
		||||
				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
 | 
			
		||||
				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
 | 
			
		||||
				CLANG_ENABLE_OBJC_WEAK = YES;
 | 
			
		||||
				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
 | 
			
		||||
				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
 | 
			
		||||
				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
 | 
			
		||||
				CODE_SIGN_STYLE = Automatic;
 | 
			
		||||
				CURRENT_PROJECT_VERSION = 1;
 | 
			
		||||
				DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
			
		||||
				ENABLE_PREVIEWS = YES;
 | 
			
		||||
				ENABLE_USER_SCRIPT_SANDBOXING = NO;
 | 
			
		||||
				GCC_C_LANGUAGE_STANDARD = gnu17;
 | 
			
		||||
				GENERATE_INFOPLIST_FILE = YES;
 | 
			
		||||
				INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist";
 | 
			
		||||
				INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
			
		||||
				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
 | 
			
		||||
				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian;
 | 
			
		||||
				INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
 | 
			
		||||
				IPHONEOS_DEPLOYMENT_TARGET = 18.6;
 | 
			
		||||
				LD_RUNPATH_SEARCH_PATHS = (
 | 
			
		||||
					"$(inherited)",
 | 
			
		||||
					"@executable_path/Frameworks",
 | 
			
		||||
				);
 | 
			
		||||
				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
 | 
			
		||||
				MARKETING_VERSION = 1.0;
 | 
			
		||||
				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
 | 
			
		||||
				MTL_FAST_MATH = YES;
 | 
			
		||||
				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.watchkitapp;
 | 
			
		||||
				PRODUCT_NAME = "$(TARGET_NAME)";
 | 
			
		||||
				SDKROOT = watchos;
 | 
			
		||||
				SKIP_INSTALL = YES;
 | 
			
		||||
				STRING_CATALOG_GENERATE_SYMBOLS = YES;
 | 
			
		||||
				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
 | 
			
		||||
				SWIFT_APPROACHABLE_CONCURRENCY = YES;
 | 
			
		||||
				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
 | 
			
		||||
				SWIFT_EMIT_LOC_STRINGS = YES;
 | 
			
		||||
				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
 | 
			
		||||
				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
 | 
			
		||||
				SWIFT_VERSION = 5.0;
 | 
			
		||||
				TARGETED_DEVICE_FAMILY = 4;
 | 
			
		||||
				WATCHOS_DEPLOYMENT_TARGET = 11.6;
 | 
			
		||||
			};
 | 
			
		||||
			name = Debug;
 | 
			
		||||
		};
 | 
			
		||||
		7310A7E12EB10963002C0FD3 /* Release */ = {
 | 
			
		||||
			isa = XCBuildConfiguration;
 | 
			
		||||
			baseConfigurationReference = 2440CEDEAAD6D51FDA95FA62 /* Pods-Solian Watch App.release.xcconfig */;
 | 
			
		||||
			buildSettings = {
 | 
			
		||||
				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 | 
			
		||||
				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
 | 
			
		||||
				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 | 
			
		||||
				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
 | 
			
		||||
				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
 | 
			
		||||
				CLANG_ENABLE_OBJC_WEAK = YES;
 | 
			
		||||
				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
 | 
			
		||||
				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
 | 
			
		||||
				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
 | 
			
		||||
				CODE_SIGN_STYLE = Automatic;
 | 
			
		||||
				CURRENT_PROJECT_VERSION = 1;
 | 
			
		||||
				DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
			
		||||
				ENABLE_PREVIEWS = YES;
 | 
			
		||||
				ENABLE_USER_SCRIPT_SANDBOXING = NO;
 | 
			
		||||
				GCC_C_LANGUAGE_STANDARD = gnu17;
 | 
			
		||||
				GENERATE_INFOPLIST_FILE = YES;
 | 
			
		||||
				INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist";
 | 
			
		||||
				INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
			
		||||
				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
 | 
			
		||||
				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian;
 | 
			
		||||
				INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
 | 
			
		||||
				IPHONEOS_DEPLOYMENT_TARGET = 18.6;
 | 
			
		||||
				LD_RUNPATH_SEARCH_PATHS = (
 | 
			
		||||
					"$(inherited)",
 | 
			
		||||
					"@executable_path/Frameworks",
 | 
			
		||||
				);
 | 
			
		||||
				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
 | 
			
		||||
				MARKETING_VERSION = 1.0;
 | 
			
		||||
				MTL_FAST_MATH = YES;
 | 
			
		||||
				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.watchkitapp;
 | 
			
		||||
				PRODUCT_NAME = "$(TARGET_NAME)";
 | 
			
		||||
				SDKROOT = watchos;
 | 
			
		||||
				SKIP_INSTALL = YES;
 | 
			
		||||
				STRING_CATALOG_GENERATE_SYMBOLS = YES;
 | 
			
		||||
				SUPPORTED_PLATFORMS = "watchsimulator watchos";
 | 
			
		||||
				SWIFT_APPROACHABLE_CONCURRENCY = YES;
 | 
			
		||||
				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
 | 
			
		||||
				SWIFT_EMIT_LOC_STRINGS = YES;
 | 
			
		||||
				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
 | 
			
		||||
				SWIFT_VERSION = 5.0;
 | 
			
		||||
				TARGETED_DEVICE_FAMILY = 4;
 | 
			
		||||
				WATCHOS_DEPLOYMENT_TARGET = 11.6;
 | 
			
		||||
			};
 | 
			
		||||
			name = Release;
 | 
			
		||||
		};
 | 
			
		||||
		7310A7E22EB10963002C0FD3 /* Profile */ = {
 | 
			
		||||
			isa = XCBuildConfiguration;
 | 
			
		||||
			baseConfigurationReference = 0ECC3D56D018DD87FC342699 /* Pods-Solian Watch App.profile.xcconfig */;
 | 
			
		||||
			buildSettings = {
 | 
			
		||||
				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 | 
			
		||||
				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
 | 
			
		||||
				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 | 
			
		||||
				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
 | 
			
		||||
				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
 | 
			
		||||
				CLANG_ENABLE_OBJC_WEAK = YES;
 | 
			
		||||
				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
 | 
			
		||||
				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
 | 
			
		||||
				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
 | 
			
		||||
				CODE_SIGN_STYLE = Automatic;
 | 
			
		||||
				CURRENT_PROJECT_VERSION = 1;
 | 
			
		||||
				DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
			
		||||
				ENABLE_PREVIEWS = YES;
 | 
			
		||||
				ENABLE_USER_SCRIPT_SANDBOXING = NO;
 | 
			
		||||
				GCC_C_LANGUAGE_STANDARD = gnu17;
 | 
			
		||||
				GENERATE_INFOPLIST_FILE = YES;
 | 
			
		||||
				INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist";
 | 
			
		||||
				INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
			
		||||
				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
 | 
			
		||||
				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian;
 | 
			
		||||
				INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
 | 
			
		||||
				IPHONEOS_DEPLOYMENT_TARGET = 18.6;
 | 
			
		||||
				LD_RUNPATH_SEARCH_PATHS = (
 | 
			
		||||
					"$(inherited)",
 | 
			
		||||
					"@executable_path/Frameworks",
 | 
			
		||||
				);
 | 
			
		||||
				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
 | 
			
		||||
				MARKETING_VERSION = 1.0;
 | 
			
		||||
				MTL_FAST_MATH = YES;
 | 
			
		||||
				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.watchkitapp;
 | 
			
		||||
				PRODUCT_NAME = "$(TARGET_NAME)";
 | 
			
		||||
				SDKROOT = watchos;
 | 
			
		||||
				SKIP_INSTALL = YES;
 | 
			
		||||
				STRING_CATALOG_GENERATE_SYMBOLS = YES;
 | 
			
		||||
				SUPPORTED_PLATFORMS = "watchsimulator watchos";
 | 
			
		||||
				SWIFT_APPROACHABLE_CONCURRENCY = YES;
 | 
			
		||||
				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
 | 
			
		||||
				SWIFT_EMIT_LOC_STRINGS = YES;
 | 
			
		||||
				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
 | 
			
		||||
				SWIFT_VERSION = 5.0;
 | 
			
		||||
				TARGETED_DEVICE_FAMILY = 4;
 | 
			
		||||
				WATCHOS_DEPLOYMENT_TARGET = 11.6;
 | 
			
		||||
			};
 | 
			
		||||
			name = Profile;
 | 
			
		||||
		};
 | 
			
		||||
		73ACDFC42E3D0E6100B63535 /* Debug */ = {
 | 
			
		||||
			isa = XCBuildConfiguration;
 | 
			
		||||
			buildSettings = {
 | 
			
		||||
@@ -976,6 +1262,7 @@
 | 
			
		||||
				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension;
 | 
			
		||||
				PRODUCT_NAME = "$(TARGET_NAME)";
 | 
			
		||||
				SKIP_INSTALL = YES;
 | 
			
		||||
				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
 | 
			
		||||
				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
 | 
			
		||||
				SWIFT_EMIT_LOC_STRINGS = YES;
 | 
			
		||||
				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
 | 
			
		||||
@@ -1016,6 +1303,7 @@
 | 
			
		||||
				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension;
 | 
			
		||||
				PRODUCT_NAME = "$(TARGET_NAME)";
 | 
			
		||||
				SKIP_INSTALL = YES;
 | 
			
		||||
				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
 | 
			
		||||
				SWIFT_EMIT_LOC_STRINGS = YES;
 | 
			
		||||
				SWIFT_VERSION = 5.0;
 | 
			
		||||
				TARGETED_DEVICE_FAMILY = "1,2";
 | 
			
		||||
@@ -1054,6 +1342,7 @@
 | 
			
		||||
				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension;
 | 
			
		||||
				PRODUCT_NAME = "$(TARGET_NAME)";
 | 
			
		||||
				SKIP_INSTALL = YES;
 | 
			
		||||
				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
 | 
			
		||||
				SWIFT_EMIT_LOC_STRINGS = YES;
 | 
			
		||||
				SWIFT_VERSION = 5.0;
 | 
			
		||||
				TARGETED_DEVICE_FAMILY = "1,2";
 | 
			
		||||
@@ -1095,6 +1384,7 @@
 | 
			
		||||
				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianShareExtension;
 | 
			
		||||
				PRODUCT_NAME = "$(TARGET_NAME)";
 | 
			
		||||
				SKIP_INSTALL = YES;
 | 
			
		||||
				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
 | 
			
		||||
				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
 | 
			
		||||
				SWIFT_EMIT_LOC_STRINGS = YES;
 | 
			
		||||
				SWIFT_ENABLE_EXPLICIT_MODULES = NO;
 | 
			
		||||
@@ -1138,6 +1428,7 @@
 | 
			
		||||
				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianShareExtension;
 | 
			
		||||
				PRODUCT_NAME = "$(TARGET_NAME)";
 | 
			
		||||
				SKIP_INSTALL = YES;
 | 
			
		||||
				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
 | 
			
		||||
				SWIFT_EMIT_LOC_STRINGS = YES;
 | 
			
		||||
				SWIFT_ENABLE_EXPLICIT_MODULES = NO;
 | 
			
		||||
				SWIFT_VERSION = 5.0;
 | 
			
		||||
@@ -1179,6 +1470,7 @@
 | 
			
		||||
				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianShareExtension;
 | 
			
		||||
				PRODUCT_NAME = "$(TARGET_NAME)";
 | 
			
		||||
				SKIP_INSTALL = YES;
 | 
			
		||||
				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
 | 
			
		||||
				SWIFT_EMIT_LOC_STRINGS = YES;
 | 
			
		||||
				SWIFT_ENABLE_EXPLICIT_MODULES = NO;
 | 
			
		||||
				SWIFT_VERSION = 5.0;
 | 
			
		||||
@@ -1428,6 +1720,7 @@
 | 
			
		||||
				CUSTOM_GROUP_ID = group.solsynth.solian;
 | 
			
		||||
				DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
			
		||||
				ENABLE_BITCODE = NO;
 | 
			
		||||
				EXCLUDED_SOURCE_FILE_NAMES = "";
 | 
			
		||||
				INFOPLIST_FILE = Runner/Info.plist;
 | 
			
		||||
				INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
			
		||||
				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
 | 
			
		||||
@@ -1443,6 +1736,7 @@
 | 
			
		||||
				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
 | 
			
		||||
				SWIFT_VERSION = 5.0;
 | 
			
		||||
				VERSIONING_SYSTEM = "apple-generic";
 | 
			
		||||
				WATCHOS_DEPLOYMENT_TARGET = 11.6;
 | 
			
		||||
			};
 | 
			
		||||
			name = Debug;
 | 
			
		||||
		};
 | 
			
		||||
@@ -1457,6 +1751,7 @@
 | 
			
		||||
				CUSTOM_GROUP_ID = group.solsynth.solian;
 | 
			
		||||
				DEVELOPMENT_TEAM = W7HPZ53V6B;
 | 
			
		||||
				ENABLE_BITCODE = NO;
 | 
			
		||||
				EXCLUDED_SOURCE_FILE_NAMES = "";
 | 
			
		||||
				INFOPLIST_FILE = Runner/Info.plist;
 | 
			
		||||
				INFOPLIST_KEY_CFBundleDisplayName = Solian;
 | 
			
		||||
				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
 | 
			
		||||
@@ -1465,12 +1760,15 @@
 | 
			
		||||
					"$(inherited)",
 | 
			
		||||
					"@executable_path/Frameworks",
 | 
			
		||||
				);
 | 
			
		||||
				ONLY_ACTIVE_ARCH = NO;
 | 
			
		||||
				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian;
 | 
			
		||||
				PRODUCT_NAME = "$(TARGET_NAME)";
 | 
			
		||||
				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
 | 
			
		||||
				SWIFT_ENABLE_EXPLICIT_MODULES = "$(SWIFT_USE_INTEGRATED_DRIVER)";
 | 
			
		||||
				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
 | 
			
		||||
				SWIFT_VERSION = 5.0;
 | 
			
		||||
				VERSIONING_SYSTEM = "apple-generic";
 | 
			
		||||
				WATCHOS_DEPLOYMENT_TARGET = 11.6;
 | 
			
		||||
			};
 | 
			
		||||
			name = Release;
 | 
			
		||||
		};
 | 
			
		||||
@@ -1487,6 +1785,16 @@
 | 
			
		||||
			defaultConfigurationIsVisible = 0;
 | 
			
		||||
			defaultConfigurationName = Release;
 | 
			
		||||
		};
 | 
			
		||||
		7310A7E32EB10963002C0FD3 /* Build configuration list for PBXNativeTarget "Solian Watch App" */ = {
 | 
			
		||||
			isa = XCConfigurationList;
 | 
			
		||||
			buildConfigurations = (
 | 
			
		||||
				7310A7E02EB10963002C0FD3 /* Debug */,
 | 
			
		||||
				7310A7E12EB10963002C0FD3 /* Release */,
 | 
			
		||||
				7310A7E22EB10963002C0FD3 /* Profile */,
 | 
			
		||||
			);
 | 
			
		||||
			defaultConfigurationIsVisible = 0;
 | 
			
		||||
			defaultConfigurationName = Release;
 | 
			
		||||
		};
 | 
			
		||||
		73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */ = {
 | 
			
		||||
			isa = XCConfigurationList;
 | 
			
		||||
			buildConfigurations = (
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,20 @@
 | 
			
		||||
               ReferencedContainer = "container:Runner.xcodeproj">
 | 
			
		||||
            </BuildableReference>
 | 
			
		||||
         </BuildActionEntry>
 | 
			
		||||
         <BuildActionEntry
 | 
			
		||||
            buildForTesting = "YES"
 | 
			
		||||
            buildForRunning = "YES"
 | 
			
		||||
            buildForProfiling = "YES"
 | 
			
		||||
            buildForArchiving = "YES"
 | 
			
		||||
            buildForAnalyzing = "YES">
 | 
			
		||||
            <BuildableReference
 | 
			
		||||
               BuildableIdentifier = "primary"
 | 
			
		||||
               BlueprintIdentifier = "7310A7D32EB10962002C0FD3"
 | 
			
		||||
               BuildableName = "Solian Watch App.app"
 | 
			
		||||
               BlueprintName = "Solian Watch App"
 | 
			
		||||
               ReferencedContainer = "container:Runner.xcodeproj">
 | 
			
		||||
            </BuildableReference>
 | 
			
		||||
         </BuildActionEntry>
 | 
			
		||||
      </BuildActionEntries>
 | 
			
		||||
   </BuildAction>
 | 
			
		||||
   <TestAction
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,18 @@
 | 
			
		||||
import Flutter
 | 
			
		||||
import UIKit
 | 
			
		||||
import WatchConnectivity
 | 
			
		||||
 | 
			
		||||
@main
 | 
			
		||||
@objc class AppDelegate: FlutterAppDelegate {
 | 
			
		||||
    let notifyDelegate = NotifyDelegate()
 | 
			
		||||
    
 | 
			
		||||
    private static var sharedWatchConnectivityService: WatchConnectivityService?
 | 
			
		||||
 | 
			
		||||
    override func application(
 | 
			
		||||
        _ application: UIApplication,
 | 
			
		||||
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
 | 
			
		||||
    ) -> Bool {
 | 
			
		||||
        UNUserNotificationCenter.current().delegate = notifyDelegate
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        let replyableMessageCategory = UNNotificationCategory(
 | 
			
		||||
            identifier: "CHAT_MESSAGE",
 | 
			
		||||
            actions: [
 | 
			
		||||
@@ -23,11 +25,85 @@ import UIKit
 | 
			
		||||
            intentIdentifiers: [],
 | 
			
		||||
            options: []
 | 
			
		||||
        )
 | 
			
		||||
        
 | 
			
		||||
        UNUserNotificationCenter.current().setNotificationCategories([replyableMessageCategory])
 | 
			
		||||
        
 | 
			
		||||
        GeneratedPluginRegistrant.register(with: self)
 | 
			
		||||
        
 | 
			
		||||
        // Always initialize and retain a strong reference
 | 
			
		||||
        if WCSession.isSupported() {
 | 
			
		||||
            AppDelegate.sharedWatchConnectivityService = WatchConnectivityService.shared
 | 
			
		||||
        } else {
 | 
			
		||||
            print("[iOS] WCSession not supported on this device.")
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final class WatchConnectivityService: NSObject, WCSessionDelegate {
 | 
			
		||||
    static let shared = WatchConnectivityService()
 | 
			
		||||
    private let session: WCSession = .default
 | 
			
		||||
    
 | 
			
		||||
    private override init() {
 | 
			
		||||
        super.init()
 | 
			
		||||
        print("[iOS] Activating WCSession...")
 | 
			
		||||
        session.delegate = self
 | 
			
		||||
        session.activate()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // MARK: - WCSessionDelegate
 | 
			
		||||
 | 
			
		||||
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
 | 
			
		||||
        if let error = error {
 | 
			
		||||
            print("[iOS] WCSession activation failed: \(error.localizedDescription)")
 | 
			
		||||
        } else {
 | 
			
		||||
            print("[iOS] WCSession activated with state: \(activationState.rawValue)")
 | 
			
		||||
            if activationState == .activated {
 | 
			
		||||
                sendDataToWatch()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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()
 | 
			
		||||
            
 | 
			
		||||
            var data: [String: Any] = ["serverUrl": serverUrl ?? ""]
 | 
			
		||||
            if let token = token {
 | 
			
		||||
                data["token"] = token
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            print("[iOS] Replying with data: \(data)")
 | 
			
		||||
            replyHandler(data)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func sendDataToWatch() {
 | 
			
		||||
        guard session.activationState == .activated else {
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        let token = UserDefaults.standard.getFlutterToken()
 | 
			
		||||
        let serverUrl = UserDefaults.standard.getServerUrl()
 | 
			
		||||
        
 | 
			
		||||
        var data: [String: Any] = ["serverUrl": serverUrl ?? ""]
 | 
			
		||||
        if let token = token {
 | 
			
		||||
            data["token"] = token
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        do {
 | 
			
		||||
            try session.updateApplicationContext(data)
 | 
			
		||||
            print("[iOS] Sent application context: \(data)")
 | 
			
		||||
        } catch {
 | 
			
		||||
            print("[iOS] Failed to send application context: \(error.localizedDescription)")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1 +1,334 @@
 | 
			
		||||
{"images":[{"size":"20x20","idiom":"universal","filename":"Icon-App-20x20@2x.png","scale":"2x","platform":"ios"},{"size":"20x20","idiom":"universal","filename":"Icon-App-20x20@3x.png","scale":"3x","platform":"ios"},{"size":"29x29","idiom":"universal","filename":"Icon-App-29x29@2x.png","scale":"2x","platform":"ios"},{"size":"29x29","idiom":"universal","filename":"Icon-App-29x29@3x.png","scale":"3x","platform":"ios"},{"size":"38x38","idiom":"universal","filename":"Icon-App-38x38@2x.png","scale":"2x","platform":"ios"},{"size":"38x38","idiom":"universal","filename":"Icon-App-38x38@3x.png","scale":"3x","platform":"ios"},{"size":"40x40","idiom":"universal","filename":"Icon-App-40x40@2x.png","scale":"2x","platform":"ios"},{"size":"40x40","idiom":"universal","filename":"Icon-App-40x40@3x.png","scale":"3x","platform":"ios"},{"size":"60x60","idiom":"universal","filename":"Icon-App-60x60@2x.png","scale":"2x","platform":"ios"},{"size":"60x60","idiom":"universal","filename":"Icon-App-60x60@3x.png","scale":"3x","platform":"ios"},{"size":"64x64","idiom":"universal","filename":"Icon-App-64x64@2x.png","scale":"2x","platform":"ios"},{"size":"64x64","idiom":"universal","filename":"Icon-App-64x64@3x.png","scale":"3x","platform":"ios"},{"size":"68x68","idiom":"universal","filename":"Icon-App-68x68@2x.png","scale":"2x","platform":"ios"},{"size":"76x76","idiom":"universal","filename":"Icon-App-76x76@2x.png","scale":"2x","platform":"ios"},{"size":"83.5x83.5","idiom":"universal","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x","platform":"ios"},{"size":"1024x1024","idiom":"universal","filename":"Icon-App-1024x1024@1x.png","scale":"1x","platform":"ios"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"},{"size":"20x20","idiom":"universal","filename":"Icon-App-Dark-20x20@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"20x20","idiom":"universal","filename":"Icon-App-Dark-20x20@3x.png","scale":"3x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"29x29","idiom":"universal","filename":"Icon-App-Dark-29x29@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"29x29","idiom":"universal","filename":"Icon-App-Dark-29x29@3x.png","scale":"3x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"38x38","idiom":"universal","filename":"Icon-App-Dark-38x38@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"38x38","idiom":"universal","filename":"Icon-App-Dark-38x38@3x.png","scale":"3x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"40x40","idiom":"universal","filename":"Icon-App-Dark-40x40@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"40x40","idiom":"universal","filename":"Icon-App-Dark-40x40@3x.png","scale":"3x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"60x60","idiom":"universal","filename":"Icon-App-Dark-60x60@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"60x60","idiom":"universal","filename":"Icon-App-Dark-60x60@3x.png","scale":"3x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"64x64","idiom":"universal","filename":"Icon-App-Dark-64x64@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"64x64","idiom":"universal","filename":"Icon-App-Dark-64x64@3x.png","scale":"3x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"68x68","idiom":"universal","filename":"Icon-App-Dark-68x68@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"76x76","idiom":"universal","filename":"Icon-App-Dark-76x76@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"83.5x83.5","idiom":"universal","filename":"Icon-App-Dark-83.5x83.5@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"1024x1024","idiom":"universal","filename":"Icon-App-Dark-1024x1024@1x.png","scale":"1x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]}],"info":{"version":1,"author":"xcode"}}
 | 
			
		||||
{
 | 
			
		||||
  "images" : [
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-20x20@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "20x20"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-20x20@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "20x20"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-29x29@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "29x29"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-29x29@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "29x29"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-38x38@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "38x38"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-38x38@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "38x38"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-40x40@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "40x40"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-40x40@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "40x40"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-60x60@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "60x60"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-60x60@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "60x60"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-64x64@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "64x64"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-64x64@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "64x64"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-68x68@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "68x68"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-76x76@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "76x76"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-83.5x83.5@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "83.5x83.5"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-1024x1024@1x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "1x",
 | 
			
		||||
      "size" : "1024x1024"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-20x20@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "20x20"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-20x20@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "20x20"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-29x29@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "29x29"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-29x29@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "29x29"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-38x38@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "38x38"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-38x38@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "38x38"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-40x40@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "40x40"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-40x40@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "40x40"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-60x60@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "60x60"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-60x60@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "60x60"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-64x64@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "64x64"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-64x64@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "64x64"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-68x68@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "68x68"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-76x76@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "76x76"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-83.5x83.5@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "83.5x83.5"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "appearances" : [
 | 
			
		||||
        {
 | 
			
		||||
          "appearance" : "luminosity",
 | 
			
		||||
          "value" : "dark"
 | 
			
		||||
        }
 | 
			
		||||
      ],
 | 
			
		||||
      "filename" : "Icon-App-Dark-1024x1024@1x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "1x",
 | 
			
		||||
      "size" : "1024x1024"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "Icon-App-1024x1024@1x.png",
 | 
			
		||||
      "idiom" : "ios-marketing",
 | 
			
		||||
      "scale" : "1x",
 | 
			
		||||
      "size" : "1024x1024"
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "info" : {
 | 
			
		||||
    "author" : "xcode",
 | 
			
		||||
    "version" : 1
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 295 B  | 
| 
		 Before Width: | Height: | Size: 282 B  | 
| 
		 Before Width: | Height: | Size: 406 B  | 
| 
		 Before Width: | Height: | Size: 762 B  | 
@@ -95,6 +95,8 @@
 | 
			
		||||
		<string>UIInterfaceOrientationLandscapeRight</string>
 | 
			
		||||
		<string>UIInterfaceOrientationPortrait</string>
 | 
			
		||||
	</array>
 | 
			
		||||
	<key>WKCompanionAppBundleIdentifier</key>
 | 
			
		||||
	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
 | 
			
		||||
	<key>UISupportedInterfaceOrientations~ipad</key>
 | 
			
		||||
	<array>
 | 
			
		||||
		<string>UIInterfaceOrientationLandscapeLeft</string>
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@
 | 
			
		||||
import Foundation
 | 
			
		||||
 | 
			
		||||
func getAttachmentUrl(for identifier: String) -> String {
 | 
			
		||||
    let serverBaseUrl = "https://api.solian.app"
 | 
			
		||||
    let serverBaseUrl = UserDefaults.standard.getServerUrl()
 | 
			
		||||
    
 | 
			
		||||
    return identifier.starts(with: "http") ? identifier : "\(serverBaseUrl)/drive/files/\(identifier)"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -26,6 +26,6 @@ extension UserDefaults {
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func getServerUrl(forKey key: String = "app_server_url") -> String {
 | 
			
		||||
        return self.getFlutterValue(forKey: key) ?? "https://nt.solian.app"
 | 
			
		||||
        return self.getFlutterValue(forKey: key) ?? "https://api.solian.app"
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,15 @@
 | 
			
		||||
{
 | 
			
		||||
  "colors" : [
 | 
			
		||||
    {
 | 
			
		||||
      "color" : {
 | 
			
		||||
        "platform" : "universal",
 | 
			
		||||
        "reference" : "systemIndigoColor"
 | 
			
		||||
      },
 | 
			
		||||
      "idiom" : "universal"
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "info" : {
 | 
			
		||||
    "author" : "xcode",
 | 
			
		||||
    "version" : 1
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,318 @@
 | 
			
		||||
{
 | 
			
		||||
  "images" : [
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-20x20@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "20x20"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-20x20@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "20x20"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-29x29@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "29x29"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-29x29@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "29x29"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-38x38@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "38x38"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-38x38@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "38x38"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-40x40@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "40x40"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-40x40@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "40x40"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-60x60@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "60x60"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-60x60@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "60x60"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-64x64@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "64x64"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-64x64@3x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "3x",
 | 
			
		||||
      "size" : "64x64"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-68x68@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "68x68"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-76x76@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "76x76"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-83.5x83.5@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "83.5x83.5"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-ios-1024x1024.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "ios",
 | 
			
		||||
      "size" : "1024x1024"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-mac-16x16.png",
 | 
			
		||||
      "idiom" : "mac",
 | 
			
		||||
      "scale" : "1x",
 | 
			
		||||
      "size" : "16x16"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-mac-16x16@2x.png",
 | 
			
		||||
      "idiom" : "mac",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "16x16"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-mac-32x32.png",
 | 
			
		||||
      "idiom" : "mac",
 | 
			
		||||
      "scale" : "1x",
 | 
			
		||||
      "size" : "32x32"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-mac-32x32@2x.png",
 | 
			
		||||
      "idiom" : "mac",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "32x32"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-mac-128x128.png",
 | 
			
		||||
      "idiom" : "mac",
 | 
			
		||||
      "scale" : "1x",
 | 
			
		||||
      "size" : "128x128"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-mac-128x128@2x.png",
 | 
			
		||||
      "idiom" : "mac",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "128x128"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-mac-256x256.png",
 | 
			
		||||
      "idiom" : "mac",
 | 
			
		||||
      "scale" : "1x",
 | 
			
		||||
      "size" : "256x256"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-mac-256x256@2x.png",
 | 
			
		||||
      "idiom" : "mac",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "256x256"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-mac-512x512.png",
 | 
			
		||||
      "idiom" : "mac",
 | 
			
		||||
      "scale" : "1x",
 | 
			
		||||
      "size" : "512x512"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-mac-512x512@2x.png",
 | 
			
		||||
      "idiom" : "mac",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "512x512"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-22x22@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "22x22"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-24x24@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "24x24"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-27.5x27.5@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "27.5x27.5"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-29x29@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "29x29"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-30x30@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "30x30"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-32x32@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "32x32"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-33x33@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "33x33"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-40x40@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "40x40"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-43.5x43.5@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "43.5x43.5"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-44x44@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "44x44"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-46x46@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "46x46"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-50x50@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "50x50"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-51x51@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "51x51"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-54x54@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "54x54"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-86x86@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "86x86"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-98x98@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "98x98"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-108x108@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "108x108"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-117x117@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "117x117"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-129x129@2x.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "scale" : "2x",
 | 
			
		||||
      "size" : "129x129"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon-watchos-1024x1024.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "platform" : "watchos",
 | 
			
		||||
      "size" : "1024x1024"
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "info" : {
 | 
			
		||||
    "author" : "xcode",
 | 
			
		||||
    "version" : 1
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
		 After Width: | Height: | Size: 45 KiB  | 
| 
		 After Width: | Height: | Size: 1.5 KiB  | 
| 
		 After Width: | Height: | Size: 2.5 KiB  | 
| 
		 After Width: | Height: | Size: 2.4 KiB  | 
| 
		 After Width: | Height: | Size: 4.1 KiB  | 
| 
		 After Width: | Height: | Size: 3.4 KiB  | 
| 
		 After Width: | Height: | Size: 5.7 KiB  | 
| 
		 After Width: | Height: | Size: 3.6 KiB  | 
| 
		 After Width: | Height: | Size: 6.1 KiB  | 
| 
		 After Width: | Height: | Size: 6.1 KiB  | 
| 
		 After Width: | Height: | Size: 9.6 KiB  | 
| 
		 After Width: | Height: | Size: 6.6 KiB  | 
| 
		 After Width: | Height: | Size: 10 KiB  | 
| 
		 After Width: | Height: | Size: 7.0 KiB  | 
| 
		 After Width: | Height: | Size: 7.8 KiB  | 
| 
		 After Width: | Height: | Size: 8.8 KiB  | 
| 
		 After Width: | Height: | Size: 6.6 KiB  | 
| 
		 After Width: | Height: | Size: 14 KiB  | 
| 
		 After Width: | Height: | Size: 473 B  | 
| 
		 After Width: | Height: | Size: 1.2 KiB  | 
| 
		 After Width: | Height: | Size: 14 KiB  | 
| 
		 After Width: | Height: | Size: 30 KiB  | 
| 
		 After Width: | Height: | Size: 1.2 KiB  | 
| 
		 After Width: | Height: | Size: 2.7 KiB  | 
| 
		 After Width: | Height: | Size: 30 KiB  | 
| 
		 After Width: | Height: | Size: 45 KiB  | 
| 
		 After Width: | Height: | Size: 45 KiB  | 
| 
		 After Width: | Height: | Size: 12 KiB  | 
| 
		 After Width: | Height: | Size: 13 KiB  | 
| 
		 After Width: | Height: | Size: 14 KiB  | 
| 
		 After Width: | Height: | Size: 1.7 KiB  | 
| 
		 After Width: | Height: | Size: 1.9 KiB  | 
| 
		 After Width: | Height: | Size: 2.3 KiB  | 
| 
		 After Width: | Height: | Size: 2.4 KiB  | 
| 
		 After Width: | Height: | Size: 2.5 KiB  | 
| 
		 After Width: | Height: | Size: 2.7 KiB  | 
| 
		 After Width: | Height: | Size: 2.9 KiB  | 
| 
		 After Width: | Height: | Size: 3.6 KiB  | 
| 
		 After Width: | Height: | Size: 4.1 KiB  | 
| 
		 After Width: | Height: | Size: 4.2 KiB  | 
| 
		 After Width: | Height: | Size: 4.4 KiB  | 
| 
		 After Width: | Height: | Size: 4.9 KiB  | 
| 
		 After Width: | Height: | Size: 5.0 KiB  | 
| 
		 After Width: | Height: | Size: 5.3 KiB  | 
| 
		 After Width: | Height: | Size: 9.1 KiB  | 
| 
		 After Width: | Height: | Size: 10 KiB  | 
							
								
								
									
										6
									
								
								ios/Solian Watch App/Assets.xcassets/Contents.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "info" : {
 | 
			
		||||
    "author" : "xcode",
 | 
			
		||||
    "version" : 1
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										21
									
								
								ios/Solian Watch App/Assets.xcassets/Logo.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,21 @@
 | 
			
		||||
{
 | 
			
		||||
  "images" : [
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "icon.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "scale" : "1x"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "scale" : "2x"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "scale" : "3x"
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "info" : {
 | 
			
		||||
    "author" : "xcode",
 | 
			
		||||
    "version" : 1
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								ios/Solian Watch App/Assets.xcassets/Logo.imageset/icon.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 70 KiB  | 
							
								
								
									
										50
									
								
								ios/Solian Watch App/ContentView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,50 @@
 | 
			
		||||
//
 | 
			
		||||
//  ContentView.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/28.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
// The root view of the app.
 | 
			
		||||
struct ContentView: View {
 | 
			
		||||
    @StateObject private var appState = AppState()
 | 
			
		||||
    @State private var selection: Panel? = .explore
 | 
			
		||||
    
 | 
			
		||||
    enum Panel: Hashable {
 | 
			
		||||
        case explore
 | 
			
		||||
        case chat
 | 
			
		||||
        case notifications
 | 
			
		||||
        case account
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        NavigationSplitView {
 | 
			
		||||
            List(selection: $selection) {
 | 
			
		||||
                AppInfoHeaderView()
 | 
			
		||||
                    .listRowBackground(Color.clear)
 | 
			
		||||
                    .environmentObject(appState)
 | 
			
		||||
                
 | 
			
		||||
                Label("Explore", systemImage: "globe.fill").tag(Panel.explore)
 | 
			
		||||
                Label("Chat", systemImage: "message.fill").tag(Panel.chat)
 | 
			
		||||
                Label("Notifications", systemImage: "bell.fill").tag(Panel.notifications)
 | 
			
		||||
                Label("Account", systemImage: "person.circle.fill").tag(Panel.account)
 | 
			
		||||
            }
 | 
			
		||||
            .listStyle(.automatic)
 | 
			
		||||
        } detail: {
 | 
			
		||||
            switch selection {
 | 
			
		||||
            case .explore:
 | 
			
		||||
                ExploreView().environmentObject(appState)
 | 
			
		||||
            case .chat:
 | 
			
		||||
                ChatView().environmentObject(appState)
 | 
			
		||||
            case .notifications:
 | 
			
		||||
                NotificationView().environmentObject(appState)
 | 
			
		||||
            case .account:
 | 
			
		||||
                AccountView().environmentObject(appState)
 | 
			
		||||
            case .none:
 | 
			
		||||
                Text("Select a panel")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										88
									
								
								ios/Solian Watch App/Layouts/FlowLayout.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,88 @@
 | 
			
		||||
//
 | 
			
		||||
//  FlowLayout.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
// MARK: - Custom Layouts
 | 
			
		||||
 | 
			
		||||
struct FlowLayout: Layout {
 | 
			
		||||
    var alignment: HorizontalAlignment = .leading
 | 
			
		||||
    var spacing: CGFloat = 10
 | 
			
		||||
 | 
			
		||||
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
 | 
			
		||||
        let containerWidth = proposal.width ?? 0
 | 
			
		||||
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
 | 
			
		||||
 | 
			
		||||
        var currentX: CGFloat = 0
 | 
			
		||||
        var currentY: CGFloat = 0
 | 
			
		||||
        var lineHeight: CGFloat = 0
 | 
			
		||||
        var totalHeight: CGFloat = 0
 | 
			
		||||
 | 
			
		||||
        for size in sizes {
 | 
			
		||||
            if currentX + size.width > containerWidth {
 | 
			
		||||
                // New line
 | 
			
		||||
                currentX = 0
 | 
			
		||||
                currentY += lineHeight + spacing
 | 
			
		||||
                totalHeight = currentY + size.height
 | 
			
		||||
                lineHeight = 0
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            currentX += size.width + spacing
 | 
			
		||||
            lineHeight = max(lineHeight, size.height)
 | 
			
		||||
        }
 | 
			
		||||
        totalHeight = currentY + lineHeight
 | 
			
		||||
 | 
			
		||||
        return CGSize(width: containerWidth, height: totalHeight)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
 | 
			
		||||
        let containerWidth = bounds.width
 | 
			
		||||
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
 | 
			
		||||
 | 
			
		||||
        var currentX: CGFloat = 0
 | 
			
		||||
        var currentY: CGFloat = 0
 | 
			
		||||
        var lineHeight: CGFloat = 0
 | 
			
		||||
        var lineElements: [(offset: Int, size: CGSize)] = []
 | 
			
		||||
 | 
			
		||||
        func placeLine() {
 | 
			
		||||
            let lineWidth = lineElements.map { $0.size.width }.reduce(0, +) + CGFloat(lineElements.count - 1) * spacing
 | 
			
		||||
            var startX: CGFloat = 0
 | 
			
		||||
            switch alignment {
 | 
			
		||||
            case .leading:
 | 
			
		||||
                startX = bounds.minX
 | 
			
		||||
            case .center:
 | 
			
		||||
                startX = bounds.minX + (containerWidth - lineWidth) / 2
 | 
			
		||||
            case .trailing:
 | 
			
		||||
                startX = bounds.maxX - lineWidth
 | 
			
		||||
            default:
 | 
			
		||||
                startX = bounds.minX
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var xOffset = startX
 | 
			
		||||
            for (offset, size) in lineElements {
 | 
			
		||||
                subviews[offset].place(at: CGPoint(x: xOffset, y: bounds.minY + currentY), proposal: ProposedViewSize(size)) // Use bounds.minY + currentY
 | 
			
		||||
                xOffset += size.width + spacing
 | 
			
		||||
            }
 | 
			
		||||
            lineElements.removeAll() // Clear elements for the next line
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        for (offset, size) in sizes.enumerated() {
 | 
			
		||||
            if currentX + size.width > containerWidth && !lineElements.isEmpty {
 | 
			
		||||
                // New line
 | 
			
		||||
                placeLine()
 | 
			
		||||
                currentX = 0
 | 
			
		||||
                currentY += lineHeight + spacing
 | 
			
		||||
                lineHeight = 0
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            lineElements.append((offset, size))
 | 
			
		||||
            currentX += size.width + spacing
 | 
			
		||||
            lineHeight = max(lineHeight, size.height)
 | 
			
		||||
        }
 | 
			
		||||
        placeLine() // Place the last line
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										365
									
								
								ios/Solian Watch App/Models/Models.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,365 @@
 | 
			
		||||
//  Models.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import Foundation
 | 
			
		||||
 | 
			
		||||
// 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 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 title: String?
 | 
			
		||||
    let content: String?
 | 
			
		||||
    let publisher: SnPublisher
 | 
			
		||||
    let attachments: [SnCloudFile]
 | 
			
		||||
    let tags: [SnPostTag]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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 nick: String?
 | 
			
		||||
    let description: String?
 | 
			
		||||
    let picture: SnCloudFile?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct SnCloudFile: Codable, Identifiable {
 | 
			
		||||
    let id: String
 | 
			
		||||
    let mimeType: String?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct SnPostTag: Codable, Identifiable {
 | 
			
		||||
    let id: String
 | 
			
		||||
    let slug: String
 | 
			
		||||
    let name: String?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct SnWebArticle: Codable, Identifiable {
 | 
			
		||||
    let id: String
 | 
			
		||||
    let title: String
 | 
			
		||||
    let url: String
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct SnNotification: Codable, Identifiable {
 | 
			
		||||
    let id: String
 | 
			
		||||
    let topic: String
 | 
			
		||||
    let title: String
 | 
			
		||||
    let subtitle: String
 | 
			
		||||
    let content: String
 | 
			
		||||
    let meta: [String: AnyCodable]?
 | 
			
		||||
    let priority: Int
 | 
			
		||||
    let viewedAt: Date?
 | 
			
		||||
    let accountId: String
 | 
			
		||||
    let createdAt: Date
 | 
			
		||||
    let updatedAt: Date
 | 
			
		||||
    let deletedAt: Date?
 | 
			
		||||
 | 
			
		||||
    enum CodingKeys: String, CodingKey {
 | 
			
		||||
        case id
 | 
			
		||||
        case topic
 | 
			
		||||
        case title
 | 
			
		||||
        case subtitle
 | 
			
		||||
        case content
 | 
			
		||||
        case meta
 | 
			
		||||
        case priority
 | 
			
		||||
        case viewedAt = "viewedAt"
 | 
			
		||||
        case accountId = "accountId"
 | 
			
		||||
        case createdAt = "createdAt"
 | 
			
		||||
        case updatedAt = "updatedAt"
 | 
			
		||||
        case deletedAt = "deletedAt"
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct AnyCodable: Codable {
 | 
			
		||||
    let value: Any
 | 
			
		||||
 | 
			
		||||
    init(_ value: Any) {
 | 
			
		||||
        self.value = value
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    init(from decoder: Decoder) throws {
 | 
			
		||||
        let container = try decoder.singleValueContainer()
 | 
			
		||||
        if let intValue = try? container.decode(Int.self) {
 | 
			
		||||
            value = intValue
 | 
			
		||||
        } else if let doubleValue = try? container.decode(Double.self) {
 | 
			
		||||
            value = doubleValue
 | 
			
		||||
        } else if let boolValue = try? container.decode(Bool.self) {
 | 
			
		||||
            value = boolValue
 | 
			
		||||
        } else if let stringValue = try? container.decode(String.self) {
 | 
			
		||||
            value = stringValue
 | 
			
		||||
        } else if let arrayValue = try? container.decode([AnyCodable].self) {
 | 
			
		||||
            value = arrayValue
 | 
			
		||||
        } else if let dictValue = try? container.decode([String: AnyCodable].self) {
 | 
			
		||||
            value = dictValue
 | 
			
		||||
        } else {
 | 
			
		||||
            value = NSNull()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func encode(to encoder: Encoder) throws {
 | 
			
		||||
        var container = encoder.singleValueContainer()
 | 
			
		||||
        switch value {
 | 
			
		||||
        case let intValue as Int:
 | 
			
		||||
            try container.encode(intValue)
 | 
			
		||||
        case let doubleValue as Double:
 | 
			
		||||
            try container.encode(doubleValue)
 | 
			
		||||
        case let boolValue as Bool:
 | 
			
		||||
            try container.encode(boolValue)
 | 
			
		||||
        case let stringValue as String:
 | 
			
		||||
            try container.encode(stringValue)
 | 
			
		||||
        case let arrayValue as [AnyCodable]:
 | 
			
		||||
            try container.encode(arrayValue)
 | 
			
		||||
        case let dictValue as [String: AnyCodable]:
 | 
			
		||||
            try container.encode(dictValue)
 | 
			
		||||
        default:
 | 
			
		||||
            try container.encodeNil()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct NotificationResponse {
 | 
			
		||||
    let notifications: [SnNotification]
 | 
			
		||||
    let total: Int
 | 
			
		||||
    let hasMore: Bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ActivityResponse {
 | 
			
		||||
    let activities: [SnActivity]
 | 
			
		||||
    let hasMore: Bool
 | 
			
		||||
    let nextCursor: String?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct SnAccount: Codable {
 | 
			
		||||
    let id: String
 | 
			
		||||
    let name: String
 | 
			
		||||
    let nick: String
 | 
			
		||||
    let profile: SnUserProfile
 | 
			
		||||
    let createdAt: Date
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct SnUserProfile: Codable {
 | 
			
		||||
    let bio: String?
 | 
			
		||||
    let picture: SnCloudFile?
 | 
			
		||||
    let background: SnCloudFile?
 | 
			
		||||
    let level: Int
 | 
			
		||||
    let experience: Int
 | 
			
		||||
    let levelingProgress: Double
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct SnAccountStatus: Codable {
 | 
			
		||||
    let id: String
 | 
			
		||||
    let attitude: Int
 | 
			
		||||
    let isOnline: Bool
 | 
			
		||||
    let isInvisible: Bool
 | 
			
		||||
    let isNotDisturb: Bool
 | 
			
		||||
    let isCustomized: Bool
 | 
			
		||||
    let label: String
 | 
			
		||||
    let meta: [String: AnyCodable]?
 | 
			
		||||
    let clearedAt: Date?
 | 
			
		||||
    let accountId: String
 | 
			
		||||
    let createdAt: Date
 | 
			
		||||
    let updatedAt: Date
 | 
			
		||||
    let deletedAt: Date?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// MARK: - Chat Models
 | 
			
		||||
 | 
			
		||||
struct SnChatRoom: Codable, Identifiable {
 | 
			
		||||
    let id: String
 | 
			
		||||
    let name: String?
 | 
			
		||||
    let description: String?
 | 
			
		||||
    let type: Int
 | 
			
		||||
    let isPublic: Bool
 | 
			
		||||
    let isCommunity: Bool
 | 
			
		||||
    let picture: SnCloudFile?
 | 
			
		||||
    let background: SnCloudFile?
 | 
			
		||||
    let realmId: String?
 | 
			
		||||
    let realm: SnRealm?
 | 
			
		||||
    let createdAt: Date
 | 
			
		||||
    let updatedAt: Date
 | 
			
		||||
    let deletedAt: Date?
 | 
			
		||||
    let members: [SnChatMember]?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct SnChatMessage: Codable, Identifiable {
 | 
			
		||||
    let id: String
 | 
			
		||||
    let type: String
 | 
			
		||||
    let content: String?
 | 
			
		||||
    let nonce: String?
 | 
			
		||||
    let meta: [String: AnyCodable]
 | 
			
		||||
    let membersMentioned: [String]?
 | 
			
		||||
    let editedAt: Date?
 | 
			
		||||
    let attachments: [SnCloudFile]
 | 
			
		||||
    let reactions: [SnChatReaction]
 | 
			
		||||
    let repliedMessageId: String?
 | 
			
		||||
    let forwardedMessageId: String?
 | 
			
		||||
    let senderId: String
 | 
			
		||||
    let sender: SnChatMember
 | 
			
		||||
    let chatRoomId: String
 | 
			
		||||
    let createdAt: Date
 | 
			
		||||
    let updatedAt: Date
 | 
			
		||||
    let deletedAt: Date?
 | 
			
		||||
 | 
			
		||||
    enum CodingKeys: String, CodingKey {
 | 
			
		||||
        case id, type, content, nonce, meta, membersMentioned, editedAt, attachments, reactions, repliedMessageId, forwardedMessageId, senderId, sender, chatRoomId, createdAt, updatedAt, deletedAt
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    init(from decoder: Decoder) throws {
 | 
			
		||||
        let container = try decoder.container(keyedBy: CodingKeys.self)
 | 
			
		||||
        id = try container.decode(String.self, forKey: .id)
 | 
			
		||||
        type = try container.decode(String.self, forKey: .type)
 | 
			
		||||
        content = try container.decodeIfPresent(String.self, forKey: .content)
 | 
			
		||||
        nonce = try container.decodeIfPresent(String.self, forKey: .nonce)
 | 
			
		||||
        meta = try container.decode([String: AnyCodable].self, forKey: .meta)
 | 
			
		||||
        membersMentioned = try container.decodeIfPresent([String].self, forKey: .membersMentioned) ?? []
 | 
			
		||||
        editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
 | 
			
		||||
        attachments = try container.decode([SnCloudFile].self, forKey: .attachments)
 | 
			
		||||
        reactions = try container.decode([SnChatReaction].self, forKey: .reactions)
 | 
			
		||||
        repliedMessageId = try container.decodeIfPresent(String.self, forKey: .repliedMessageId)
 | 
			
		||||
        forwardedMessageId = try container.decodeIfPresent(String.self, forKey: .forwardedMessageId)
 | 
			
		||||
        senderId = try container.decode(String.self, forKey: .senderId)
 | 
			
		||||
        sender = try container.decode(SnChatMember.self, forKey: .sender)
 | 
			
		||||
        chatRoomId = try container.decode(String.self, forKey: .chatRoomId)
 | 
			
		||||
        createdAt = try container.decode(Date.self, forKey: .createdAt)
 | 
			
		||||
        updatedAt = try container.decode(Date.self, forKey: .updatedAt)
 | 
			
		||||
        deletedAt = try container.decodeIfPresent(Date.self, forKey: .deletedAt)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct SnChatReaction: Codable, Identifiable {
 | 
			
		||||
    let id: String
 | 
			
		||||
    let messageId: String
 | 
			
		||||
    let senderId: String
 | 
			
		||||
    let sender: SnChatMember
 | 
			
		||||
    let symbol: String
 | 
			
		||||
    let attitude: Int
 | 
			
		||||
    let createdAt: Date
 | 
			
		||||
    let updatedAt: Date
 | 
			
		||||
    let deletedAt: Date?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct SnChatMember: Codable, Identifiable {
 | 
			
		||||
    let id: String
 | 
			
		||||
    let chatRoomId: String
 | 
			
		||||
    let chatRoom: SnChatRoom?
 | 
			
		||||
    let accountId: String
 | 
			
		||||
    let account: SnAccount
 | 
			
		||||
    let nick: String?
 | 
			
		||||
    let role: Int
 | 
			
		||||
    let notify: Int
 | 
			
		||||
    let joinedAt: Date?
 | 
			
		||||
    let breakUntil: Date?
 | 
			
		||||
    let timeoutUntil: Date?
 | 
			
		||||
    let isBot: Bool
 | 
			
		||||
    let status: SnAccountStatus?
 | 
			
		||||
    let createdAt: Date
 | 
			
		||||
    let updatedAt: Date
 | 
			
		||||
    let deletedAt: Date?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct SnChatSummary: Codable {
 | 
			
		||||
    let unreadCount: Int
 | 
			
		||||
    let lastMessage: SnChatMessage?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ChatRoomsResponse {
 | 
			
		||||
    let rooms: [SnChatRoom]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ChatInvitesResponse {
 | 
			
		||||
    let invites: [SnChatMember]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct MessageSyncResponse: Codable {
 | 
			
		||||
    let messages: [SnChatMessage]
 | 
			
		||||
    let currentTimestamp: Date
 | 
			
		||||
 | 
			
		||||
    enum CodingKeys: String, CodingKey {
 | 
			
		||||
        case messages
 | 
			
		||||
        case currentTimestamp = "current_timestamp"
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										95
									
								
								ios/Solian Watch App/Services/ImageLoader.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,95 @@
 | 
			
		||||
//
 | 
			
		||||
//  ImageLoader.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
import Kingfisher
 | 
			
		||||
import KingfisherWebP
 | 
			
		||||
import Combine
 | 
			
		||||
 | 
			
		||||
// MARK: - Image Loader
 | 
			
		||||
 | 
			
		||||
@MainActor
 | 
			
		||||
class ImageLoader: ObservableObject {
 | 
			
		||||
    @Published var image: Image?
 | 
			
		||||
    @Published var errorMessage: String?
 | 
			
		||||
    @Published var isLoading = false
 | 
			
		||||
 | 
			
		||||
    private var currentTask: DownloadTask?
 | 
			
		||||
 | 
			
		||||
    init() {}
 | 
			
		||||
 | 
			
		||||
    deinit {
 | 
			
		||||
        currentTask?.cancel()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func loadImage(from initialUrl: URL, token: String) async {
 | 
			
		||||
        isLoading = true
 | 
			
		||||
        errorMessage = nil
 | 
			
		||||
        image = nil
 | 
			
		||||
 | 
			
		||||
        // Create request modifier for authorization
 | 
			
		||||
        let modifier = AnyModifier { request in
 | 
			
		||||
            var r = request
 | 
			
		||||
            r.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | 
			
		||||
            r.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | 
			
		||||
            return r
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Use WebP processor as default since the app seems to handle WebP images
 | 
			
		||||
        let processor = WebPProcessor.default
 | 
			
		||||
 | 
			
		||||
        // Use KingfisherManager to retrieve image with caching
 | 
			
		||||
        currentTask = KingfisherManager.shared.retrieveImage(
 | 
			
		||||
            with: initialUrl,
 | 
			
		||||
            options: [
 | 
			
		||||
                .requestModifier(modifier),
 | 
			
		||||
                .processor(processor),
 | 
			
		||||
                .cacheOriginalImage, // Cache the original image data
 | 
			
		||||
                .loadDiskFileSynchronously // Load from disk cache synchronously if available
 | 
			
		||||
            ]
 | 
			
		||||
        ) { [weak self] result in
 | 
			
		||||
            guard let self = self else { return }
 | 
			
		||||
 | 
			
		||||
            Task { @MainActor in
 | 
			
		||||
                switch result {
 | 
			
		||||
                case .success(let value):
 | 
			
		||||
                    self.image = Image(uiImage: value.image)
 | 
			
		||||
                    self.isLoading = false
 | 
			
		||||
                case .failure(_):
 | 
			
		||||
                    // If WebP processor fails (likely due to format), try with default processor
 | 
			
		||||
                    let defaultProcessor = DefaultImageProcessor.default
 | 
			
		||||
                    self.currentTask = KingfisherManager.shared.retrieveImage(
 | 
			
		||||
                        with: initialUrl,
 | 
			
		||||
                        options: [
 | 
			
		||||
                            .requestModifier(modifier),
 | 
			
		||||
                            .processor(defaultProcessor),
 | 
			
		||||
                            .cacheOriginalImage,
 | 
			
		||||
                            .loadDiskFileSynchronously
 | 
			
		||||
                        ]
 | 
			
		||||
                    ) { [weak self] fallbackResult in
 | 
			
		||||
                        guard let self = self else { return }
 | 
			
		||||
 | 
			
		||||
                        Task { @MainActor in
 | 
			
		||||
                            switch fallbackResult {
 | 
			
		||||
                            case .success(let value):
 | 
			
		||||
                                self.image = Image(uiImage: value.image)
 | 
			
		||||
                            case .failure(let fallbackError):
 | 
			
		||||
                                self.errorMessage = fallbackError.localizedDescription
 | 
			
		||||
                                print("[watchOS] Image loading failed: \(fallbackError.localizedDescription)")
 | 
			
		||||
                            }
 | 
			
		||||
                            self.isLoading = false
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func cancel() {
 | 
			
		||||
        currentTask?.cancel()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										643
									
								
								ios/Solian Watch App/Services/NetworkService.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,643 @@
 | 
			
		||||
//
 | 
			
		||||
//  NetworkService.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29. //
 | 
			
		||||
 | 
			
		||||
import Combine
 | 
			
		||||
import Foundation
 | 
			
		||||
 | 
			
		||||
// MARK: - WebSocket Data Structures
 | 
			
		||||
 | 
			
		||||
enum WebSocketState: Equatable {
 | 
			
		||||
    case connected
 | 
			
		||||
    case connecting
 | 
			
		||||
    case disconnected
 | 
			
		||||
    case serverDown
 | 
			
		||||
    case duplicateDevice
 | 
			
		||||
    case error(String)
 | 
			
		||||
    
 | 
			
		||||
    // Equatable conformance
 | 
			
		||||
    static func == (lhs: WebSocketState, rhs: WebSocketState) -> Bool {
 | 
			
		||||
        switch (lhs, rhs) {
 | 
			
		||||
        case (.connected, .connected),
 | 
			
		||||
            (.connecting, .connecting),
 | 
			
		||||
            (.disconnected, .disconnected),
 | 
			
		||||
            (.serverDown, .serverDown),
 | 
			
		||||
            (.duplicateDevice, .duplicateDevice):
 | 
			
		||||
            return true
 | 
			
		||||
        case let (.error(a), .error(b)):
 | 
			
		||||
            return a == b
 | 
			
		||||
        default:
 | 
			
		||||
            return false
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct WebSocketPacket {
 | 
			
		||||
    let type: String
 | 
			
		||||
    let data: [String: Any]?
 | 
			
		||||
    let endpoint: String?
 | 
			
		||||
    let errorMessage: String?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// MARK: - Network Service
 | 
			
		||||
 | 
			
		||||
class NetworkService {
 | 
			
		||||
    private let session: URLSession
 | 
			
		||||
    
 | 
			
		||||
    init() {
 | 
			
		||||
        let config = URLSessionConfiguration.ephemeral
 | 
			
		||||
        config.waitsForConnectivity = true
 | 
			
		||||
        session = URLSession(configuration: config)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Add a serial queue for WebSocket operations
 | 
			
		||||
    private let webSocketQueue = DispatchQueue(label: "com.solian.websocketQueue")
 | 
			
		||||
    
 | 
			
		||||
    func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> ActivityResponse {
 | 
			
		||||
        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
 | 
			
		||||
        decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
			
		||||
        
 | 
			
		||||
        let activities = try decoder.decode([SnActivity].self, from: data)
 | 
			
		||||
        
 | 
			
		||||
        let hasMore = (activities.first?.type ?? "empty") != "empty"
 | 
			
		||||
        let nextCursor = activities.isEmpty ? nil : activities.map { $0.createdAt }.min()?.ISO8601Format()
 | 
			
		||||
        
 | 
			
		||||
        return ActivityResponse(activities: activities, hasMore: hasMore, nextCursor: nextCursor)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func createPost(title: String, content: String, token: String, serverUrl: String) async throws {
 | 
			
		||||
        guard let baseURL = URL(string: serverUrl) else {
 | 
			
		||||
            throw URLError(.badURL)
 | 
			
		||||
        }
 | 
			
		||||
        let url = baseURL.appendingPathComponent("/sphere/posts")
 | 
			
		||||
        
 | 
			
		||||
        var request = URLRequest(url: url)
 | 
			
		||||
        request.httpMethod = "POST"
 | 
			
		||||
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 | 
			
		||||
        request.setValue("application/json", forHTTPHeaderField: "Accept")
 | 
			
		||||
        request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | 
			
		||||
        request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | 
			
		||||
        
 | 
			
		||||
        let body: [String: Any] = ["title": title, "content": content]
 | 
			
		||||
        request.httpBody = try JSONSerialization.data(withJSONObject: body)
 | 
			
		||||
        
 | 
			
		||||
        let (data, response) = try await session.data(for: request)
 | 
			
		||||
        
 | 
			
		||||
        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 {
 | 
			
		||||
            let responseBody = String(data: data, encoding: .utf8) ?? ""
 | 
			
		||||
            print("[watchOS] createPost failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
 | 
			
		||||
            throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func fetchNotifications(offset: Int = 0, take: Int = 20, token: String, serverUrl: String) async throws -> NotificationResponse {
 | 
			
		||||
        guard let baseURL = URL(string: serverUrl) else {
 | 
			
		||||
            throw URLError(.badURL)
 | 
			
		||||
        }
 | 
			
		||||
        var components = URLComponents(url: baseURL.appendingPathComponent("/ring/notifications"), resolvingAgainstBaseURL: false)!
 | 
			
		||||
        let queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))]
 | 
			
		||||
        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, response) = try await session.data(for: request)
 | 
			
		||||
        
 | 
			
		||||
        let decoder = JSONDecoder()
 | 
			
		||||
        decoder.dateDecodingStrategy = .iso8601
 | 
			
		||||
        decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
			
		||||
        
 | 
			
		||||
        let notifications = try decoder.decode([SnNotification].self, from: data)
 | 
			
		||||
        
 | 
			
		||||
        let httpResponse = response as? HTTPURLResponse
 | 
			
		||||
        let total = Int(httpResponse?.value(forHTTPHeaderField: "X-Total") ?? "0") ?? 0
 | 
			
		||||
        let hasMore = offset + notifications.count < total
 | 
			
		||||
        
 | 
			
		||||
        return NotificationResponse(notifications: notifications, total: total, hasMore: hasMore)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func fetchUserProfile(token: String, serverUrl: String) async throws -> SnAccount {
 | 
			
		||||
        guard let baseURL = URL(string: serverUrl) else {
 | 
			
		||||
            throw URLError(.badURL)
 | 
			
		||||
        }
 | 
			
		||||
        let url = baseURL.appendingPathComponent("/pass/accounts/me")
 | 
			
		||||
        
 | 
			
		||||
        var request = URLRequest(url: 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
 | 
			
		||||
        decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
			
		||||
        
 | 
			
		||||
        return try decoder.decode(SnAccount.self, from: data)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func fetchAccountStatus(token: String, serverUrl: String) async throws -> SnAccountStatus? {
 | 
			
		||||
        guard let baseURL = URL(string: serverUrl) else {
 | 
			
		||||
            throw URLError(.badURL)
 | 
			
		||||
        }
 | 
			
		||||
        let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses")
 | 
			
		||||
        
 | 
			
		||||
        var request = URLRequest(url: 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, response) = try await session.data(for: request)
 | 
			
		||||
        
 | 
			
		||||
        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 404 {
 | 
			
		||||
            return nil
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        let decoder = JSONDecoder()
 | 
			
		||||
        decoder.dateDecodingStrategy = .iso8601
 | 
			
		||||
        decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
			
		||||
        
 | 
			
		||||
        return try decoder.decode(SnAccountStatus.self, from: data)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func createOrUpdateStatus(attitude: Int, isInvisible: Bool, isNotDisturb: Bool, label: String?, token: String, serverUrl: String) async throws -> SnAccountStatus {
 | 
			
		||||
        // Check if there\'s already a customized status
 | 
			
		||||
        let existingStatus = try? await fetchAccountStatus(token: token, serverUrl: serverUrl)
 | 
			
		||||
        let method = (existingStatus?.isCustomized == true) ? "PATCH" : "POST"
 | 
			
		||||
        
 | 
			
		||||
        guard let baseURL = URL(string: serverUrl) else {
 | 
			
		||||
            throw URLError(.badURL)
 | 
			
		||||
        }
 | 
			
		||||
        let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses")
 | 
			
		||||
        
 | 
			
		||||
        var request = URLRequest(url: url)
 | 
			
		||||
        request.httpMethod = method
 | 
			
		||||
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 | 
			
		||||
        request.setValue("application/json", forHTTPHeaderField: "Accept")
 | 
			
		||||
        request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | 
			
		||||
        request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | 
			
		||||
        
 | 
			
		||||
        var body: [String: Any] = [
 | 
			
		||||
            "attitude": attitude,
 | 
			
		||||
            "is_invisible": isInvisible,
 | 
			
		||||
            "is_not_disturb": isNotDisturb,
 | 
			
		||||
        ]
 | 
			
		||||
        
 | 
			
		||||
        if let label = label, !label.isEmpty {
 | 
			
		||||
            body["label"] = label
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        request.httpBody = try JSONSerialization.data(withJSONObject: body)
 | 
			
		||||
        
 | 
			
		||||
        let (data, response) = try await session.data(for: request)
 | 
			
		||||
        
 | 
			
		||||
        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 && httpResponse.statusCode != 200 {
 | 
			
		||||
            let responseBody = String(data: data, encoding: .utf8) ?? ""
 | 
			
		||||
            print("[watchOS] createOrUpdateStatus failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
 | 
			
		||||
            throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        let decoder = JSONDecoder()
 | 
			
		||||
        decoder.dateDecodingStrategy = .iso8601
 | 
			
		||||
        decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
			
		||||
        
 | 
			
		||||
        return try decoder.decode(SnAccountStatus.self, from: data)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func clearStatus(token: String, serverUrl: String) async throws {
 | 
			
		||||
        guard let baseURL = URL(string: serverUrl) else {
 | 
			
		||||
            throw URLError(.badURL)
 | 
			
		||||
        }
 | 
			
		||||
        let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses")
 | 
			
		||||
        
 | 
			
		||||
        var request = URLRequest(url: url)
 | 
			
		||||
        request.httpMethod = "DELETE"
 | 
			
		||||
        request.setValue("application/json", forHTTPHeaderField: "Accept")
 | 
			
		||||
        request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | 
			
		||||
        request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | 
			
		||||
        
 | 
			
		||||
        let (data, response) = try await session.data(for: request)
 | 
			
		||||
        
 | 
			
		||||
        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 204 {
 | 
			
		||||
            let responseBody = String(data: data, encoding: .utf8) ?? ""
 | 
			
		||||
            print("[watchOS] clearStatus failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
 | 
			
		||||
            throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // MARK: - Chat API Methods
 | 
			
		||||
    
 | 
			
		||||
    func fetchChatRooms(token: String, serverUrl: String) async throws -> ChatRoomsResponse {
 | 
			
		||||
        guard let baseURL = URL(string: serverUrl) else {
 | 
			
		||||
            throw URLError(.badURL)
 | 
			
		||||
        }
 | 
			
		||||
        let url = baseURL.appendingPathComponent("/sphere/chat")
 | 
			
		||||
        
 | 
			
		||||
        var request = URLRequest(url: 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
 | 
			
		||||
        decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
			
		||||
        
 | 
			
		||||
        let rooms = try decoder.decode([SnChatRoom].self, from: data)
 | 
			
		||||
        return ChatRoomsResponse(rooms: rooms)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func fetchChatRoom(identifier: String, token: String, serverUrl: String) async throws -> SnChatRoom {
 | 
			
		||||
        guard let baseURL = URL(string: serverUrl) else {
 | 
			
		||||
            throw URLError(.badURL)
 | 
			
		||||
        }
 | 
			
		||||
        let url = baseURL.appendingPathComponent("/sphere/chat/\(identifier)")
 | 
			
		||||
        
 | 
			
		||||
        var request = URLRequest(url: 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, response) = try await session.data(for: request)
 | 
			
		||||
        
 | 
			
		||||
        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 404 {
 | 
			
		||||
            throw URLError(.resourceUnavailable)
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        let decoder = JSONDecoder()
 | 
			
		||||
        decoder.dateDecodingStrategy = .iso8601
 | 
			
		||||
        decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
			
		||||
        
 | 
			
		||||
        return try decoder.decode(SnChatRoom.self, from: data)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func fetchChatInvites(token: String, serverUrl: String) async throws -> ChatInvitesResponse {
 | 
			
		||||
        guard let baseURL = URL(string: serverUrl) else {
 | 
			
		||||
            throw URLError(.badURL)
 | 
			
		||||
        }
 | 
			
		||||
        let url = baseURL.appendingPathComponent("/sphere/chat/invites")
 | 
			
		||||
        
 | 
			
		||||
        var request = URLRequest(url: 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
 | 
			
		||||
        decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
			
		||||
        
 | 
			
		||||
        let invites = try decoder.decode([SnChatMember].self, from: data)
 | 
			
		||||
        return ChatInvitesResponse(invites: invites)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func acceptChatInvite(chatRoomId: String, token: String, serverUrl: String) async throws {
 | 
			
		||||
        guard let baseURL = URL(string: serverUrl) else {
 | 
			
		||||
            throw URLError(.badURL)
 | 
			
		||||
        }
 | 
			
		||||
        let url = baseURL.appendingPathComponent("/sphere/chat/invites/\(chatRoomId)/accept")
 | 
			
		||||
        
 | 
			
		||||
        var request = URLRequest(url: url)
 | 
			
		||||
        request.httpMethod = "POST"
 | 
			
		||||
        request.setValue("application/json", forHTTPHeaderField: "Accept")
 | 
			
		||||
        request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | 
			
		||||
        request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | 
			
		||||
        
 | 
			
		||||
        let (data, response) = try await session.data(for: request)
 | 
			
		||||
        
 | 
			
		||||
        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
 | 
			
		||||
            let responseBody = String(data: data, encoding: .utf8) ?? ""
 | 
			
		||||
            print("[watchOS] acceptChatInvite failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
 | 
			
		||||
            throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func declineChatInvite(chatRoomId: String, token: String, serverUrl: String) async throws {
 | 
			
		||||
        guard let baseURL = URL(string: serverUrl) else {
 | 
			
		||||
            throw URLError(.badURL)
 | 
			
		||||
        }
 | 
			
		||||
        let url = baseURL.appendingPathComponent("/sphere/chat/invites/\(chatRoomId)/decline")
 | 
			
		||||
        
 | 
			
		||||
        var request = URLRequest(url: url)
 | 
			
		||||
        request.httpMethod = "POST"
 | 
			
		||||
        request.setValue("application/json", forHTTPHeaderField: "Accept")
 | 
			
		||||
        request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | 
			
		||||
        request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
 | 
			
		||||
        
 | 
			
		||||
        let (data, response) = try await session.data(for: request)
 | 
			
		||||
        
 | 
			
		||||
        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
 | 
			
		||||
            let responseBody = String(data: data, encoding: .utf8) ?? ""
 | 
			
		||||
            print("[watchOS] declineChatInvite failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
 | 
			
		||||
            throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // MARK: - Message API Methods
 | 
			
		||||
    
 | 
			
		||||
    func fetchChatMessages(chatRoomId: String, token: String, serverUrl: String, before: Date? = nil, take: Int = 50) async throws -> [SnChatMessage] {
 | 
			
		||||
        guard let baseURL = URL(string: serverUrl) else {
 | 
			
		||||
            throw URLError(.badURL)
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Try a different pattern: /sphere/chat/messages with roomId as query param
 | 
			
		||||
        var components = URLComponents(
 | 
			
		||||
            url: baseURL.appendingPathComponent("/sphere/chat/\(chatRoomId)/messages"),
 | 
			
		||||
            resolvingAgainstBaseURL: false
 | 
			
		||||
        )!
 | 
			
		||||
        var queryItems = [
 | 
			
		||||
            URLQueryItem(name: "take", value: String(take)),
 | 
			
		||||
        ]
 | 
			
		||||
        if let before = before {
 | 
			
		||||
            queryItems.append(URLQueryItem(name: "before", value: ISO8601DateFormatter().string(from: before)))
 | 
			
		||||
        }
 | 
			
		||||
        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, response) = try await session.data(for: request)
 | 
			
		||||
        
 | 
			
		||||
        if let httpResponse = response as? HTTPURLResponse {
 | 
			
		||||
            _ = String(data: data, encoding: .utf8) ?? "Unable to decode response body"
 | 
			
		||||
            
 | 
			
		||||
            if httpResponse.statusCode != 200 {
 | 
			
		||||
                print("[watchOS] fetchChatMessages failed with status \(httpResponse.statusCode)")
 | 
			
		||||
                throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Check if data is empty
 | 
			
		||||
        if data.isEmpty {
 | 
			
		||||
            print("[watchOS] fetchChatMessages received empty response data")
 | 
			
		||||
            return []
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        let decoder = JSONDecoder()
 | 
			
		||||
        decoder.dateDecodingStrategy = .iso8601
 | 
			
		||||
        decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
			
		||||
        
 | 
			
		||||
        do {
 | 
			
		||||
            let messages = try decoder.decode([SnChatMessage].self, from: data)
 | 
			
		||||
            print("[watchOS] fetchChatMessages successfully decoded \(messages.count) messages")
 | 
			
		||||
            return messages
 | 
			
		||||
        } catch {
 | 
			
		||||
            print("error: ", error)
 | 
			
		||||
            throw error
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // MARK: - WebSocket
 | 
			
		||||
 | 
			
		||||
    private var webSocketTask: URLSessionWebSocketTask?
 | 
			
		||||
    private var heartbeatTimer: Timer?
 | 
			
		||||
    private var reconnectTimer: Timer?
 | 
			
		||||
    private var isDisconnectingManually = false
 | 
			
		||||
 | 
			
		||||
    private var lastToken: String?
 | 
			
		||||
    private var lastServerUrl: String?
 | 
			
		||||
 | 
			
		||||
    private var heartbeatAt: Date?
 | 
			
		||||
    var heartbeatDelay: TimeInterval?
 | 
			
		||||
 | 
			
		||||
    private let connectLock = NSLock()
 | 
			
		||||
    
 | 
			
		||||
    private let packetSubject = PassthroughSubject<WebSocketPacket, Error>()
 | 
			
		||||
    private let stateSubject = CurrentValueSubject<WebSocketState, Never>(.disconnected) // Changed to CurrentValueSubject
 | 
			
		||||
    
 | 
			
		||||
    private var currentConnectionState: WebSocketState = .disconnected { // New property
 | 
			
		||||
        didSet {
 | 
			
		||||
            // Only send updates if the state has actually changed
 | 
			
		||||
            if oldValue != currentConnectionState {
 | 
			
		||||
                stateSubject.send(currentConnectionState)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    var packetStream: AnyPublisher<WebSocketPacket, Error> {
 | 
			
		||||
        packetSubject.eraseToAnyPublisher()
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    var stateStream: AnyPublisher<WebSocketState, Never> {
 | 
			
		||||
        stateSubject.eraseToAnyPublisher()
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func connectWebSocket(token: String, serverUrl: String) {
 | 
			
		||||
        webSocketQueue.async { [weak self] in
 | 
			
		||||
            guard let self = self else { return }
 | 
			
		||||
 | 
			
		||||
            self.connectLock.lock()
 | 
			
		||||
            defer { self.connectLock.unlock() }
 | 
			
		||||
            
 | 
			
		||||
            // Prevent redundant connection attempts
 | 
			
		||||
            if self.currentConnectionState == .connecting || self.currentConnectionState == .connected {
 | 
			
		||||
                print("[WebSocket] Already connecting or connected, ignoring new connect request.")
 | 
			
		||||
                return
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            self.currentConnectionState = .connecting
 | 
			
		||||
 | 
			
		||||
            // Ensure any existing task is cancelled before starting a new one
 | 
			
		||||
            self.webSocketTask?.cancel(with: .goingAway, reason: nil)
 | 
			
		||||
            self.webSocketTask = nil
 | 
			
		||||
 | 
			
		||||
            self.isDisconnectingManually = false // Reset this flag for a new connection attempt
 | 
			
		||||
 | 
			
		||||
            self.lastToken = token
 | 
			
		||||
            self.lastServerUrl = serverUrl
 | 
			
		||||
 | 
			
		||||
            guard var urlComponents = URLComponents(string: serverUrl) else {
 | 
			
		||||
                self.currentConnectionState = .error("Invalid server URL")
 | 
			
		||||
                return
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            urlComponents.scheme = urlComponents.scheme?.replacingOccurrences(of: "http", with: "ws")
 | 
			
		||||
            urlComponents.path = "/ws"
 | 
			
		||||
            urlComponents.queryItems = [URLQueryItem(name: "deviceAlt", value: "watch")]
 | 
			
		||||
 | 
			
		||||
            guard let url = urlComponents.url else {
 | 
			
		||||
                self.currentConnectionState = .error("Invalid WebSocket URL")
 | 
			
		||||
                return
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var request = URLRequest(url: url)
 | 
			
		||||
            request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
 | 
			
		||||
            request.addValue("application/json", forHTTPHeaderField: "Content-Type")
 | 
			
		||||
            
 | 
			
		||||
            print("[WebSocket] Trying connecting to \(url)")
 | 
			
		||||
            
 | 
			
		||||
            self.webSocketTask = self.session.webSocketTask(with: request)
 | 
			
		||||
            self.webSocketTask?.resume()
 | 
			
		||||
 | 
			
		||||
            self.listenForWebSocketMessages()
 | 
			
		||||
            self.scheduleHeartbeat()
 | 
			
		||||
            self.currentConnectionState = .connected
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func listenForWebSocketMessages() {
 | 
			
		||||
        // Ensure webSocketTask is still valid before attempting to receive
 | 
			
		||||
        guard let task = webSocketTask else {
 | 
			
		||||
            print("[WebSocket] listenForWebSocketMessages: webSocketTask is nil, stopping listen.")
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        task.receive { [weak self] result in
 | 
			
		||||
            guard let self = self else { return }
 | 
			
		||||
            
 | 
			
		||||
            switch result {
 | 
			
		||||
            case .failure(let error):
 | 
			
		||||
                print("[WebSocket] Error in receiving message: \(error)")
 | 
			
		||||
                // Only attempt to reconnect if not manually disconnecting
 | 
			
		||||
                if !self.isDisconnectingManually {
 | 
			
		||||
                    self.currentConnectionState = .error(error.localizedDescription)
 | 
			
		||||
                    self.scheduleReconnect()
 | 
			
		||||
                } else {
 | 
			
		||||
                    // If manually disconnecting, just ensure state is disconnected
 | 
			
		||||
                    self.currentConnectionState = .disconnected
 | 
			
		||||
                }
 | 
			
		||||
            case .success(let message):
 | 
			
		||||
                switch message {
 | 
			
		||||
                case .string(let text):
 | 
			
		||||
                    self.handleWebSocketMessage(text: text)
 | 
			
		||||
                case .data(let data):
 | 
			
		||||
                    if let text = String(data: data, encoding: .utf8) {
 | 
			
		||||
                        self.handleWebSocketMessage(text: text)
 | 
			
		||||
                    }
 | 
			
		||||
                @unknown default:
 | 
			
		||||
                    break
 | 
			
		||||
                }
 | 
			
		||||
                // Continue listening for next message only if task is still valid
 | 
			
		||||
                if self.webSocketTask === task { // Check if it's the same task
 | 
			
		||||
                    self.listenForWebSocketMessages()
 | 
			
		||||
                } else {
 | 
			
		||||
                    print("[WebSocket] listenForWebSocketMessages: Task changed, stopping listen for old task.")
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    private func handleWebSocketMessage(text: String) {
 | 
			
		||||
        guard let data = text.data(using: .utf8) else {
 | 
			
		||||
            print("[WebSocket] Could not convert message to data")
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        do {
 | 
			
		||||
            if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
 | 
			
		||||
               let type = json["type"] as? String
 | 
			
		||||
            {
 | 
			
		||||
                let packet = WebSocketPacket(
 | 
			
		||||
                    type: type,
 | 
			
		||||
                    data: json["data"] as? [String: Any],
 | 
			
		||||
                    endpoint: json["endpoint"] as? String,
 | 
			
		||||
                    errorMessage: json["errorMessage"] as? String
 | 
			
		||||
                )
 | 
			
		||||
                
 | 
			
		||||
                print("[WebSocket] Received packet: \(packet.type) \(packet.errorMessage ?? "")")
 | 
			
		||||
                
 | 
			
		||||
                if packet.type == "error.dupe" {
 | 
			
		||||
                    self.currentConnectionState = .duplicateDevice
 | 
			
		||||
                    self.disconnectWebSocket()
 | 
			
		||||
                    return
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                if packet.type == "pong" {
 | 
			
		||||
                    if let beatAt = self.heartbeatAt {
 | 
			
		||||
                        let now = Date()
 | 
			
		||||
                        self.heartbeatDelay = now.timeIntervalSince(beatAt)
 | 
			
		||||
                        print("[WebSocket] Server respond last heartbeat for \((self.heartbeatDelay ?? 0) * 1000) ms")
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                self.packetSubject.send(packet)
 | 
			
		||||
            }
 | 
			
		||||
        } catch {
 | 
			
		||||
            print("[WebSocket] Could not parse message json: \(error.localizedDescription)")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    private func scheduleReconnect() {
 | 
			
		||||
        reconnectTimer?.invalidate()
 | 
			
		||||
        reconnectTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
 | 
			
		||||
            guard let self = self, let token = self.lastToken, let serverUrl = self.lastServerUrl else { return }
 | 
			
		||||
            print("[WebSocket] Attempting to reconnect...")
 | 
			
		||||
            
 | 
			
		||||
            // No need to call disconnectWebSocket here, connectWebSocket will handle cancelling old task
 | 
			
		||||
            self.isDisconnectingManually = false // Reset for the new connection attempt
 | 
			
		||||
            
 | 
			
		||||
            self.connectWebSocket(token: token, serverUrl: serverUrl)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    private func scheduleHeartbeat() {
 | 
			
		||||
        heartbeatTimer?.invalidate()
 | 
			
		||||
        heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in
 | 
			
		||||
            self?.beatTheHeart()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    private func beatTheHeart() {
 | 
			
		||||
        heartbeatAt = Date()
 | 
			
		||||
        print("[WebSocket] We\'re beating the heart! \(String(describing: self.heartbeatAt))")
 | 
			
		||||
        sendWebSocketMessage(message: "{\"type\":\"ping\"}")
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func sendWebSocketMessage(message: String) {
 | 
			
		||||
        webSocketTask?.send(.string(message)) { error in
 | 
			
		||||
            if let error = error {
 | 
			
		||||
                print("[WebSocket] Error sending message: \(error.localizedDescription)")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func disconnectWebSocket() {
 | 
			
		||||
        isDisconnectingManually = true
 | 
			
		||||
        reconnectTimer?.invalidate()
 | 
			
		||||
        heartbeatTimer?.invalidate()
 | 
			
		||||
        
 | 
			
		||||
        // Cancel the task and then nil it out
 | 
			
		||||
        webSocketTask?.cancel(with: .goingAway, reason: nil)
 | 
			
		||||
        webSocketTask = nil // Set to nil immediately after cancelling
 | 
			
		||||
        
 | 
			
		||||
        self.currentConnectionState = .disconnected
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										58
									
								
								ios/Solian Watch App/State/AppState.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,58 @@
 | 
			
		||||
//
 | 
			
		||||
//  AppState.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
import Combine
 | 
			
		||||
 | 
			
		||||
// MARK: - App State
 | 
			
		||||
 | 
			
		||||
@MainActor
 | 
			
		||||
class AppState: ObservableObject {
 | 
			
		||||
    @Published var token: String? = nil
 | 
			
		||||
    @Published var serverUrl: String? = nil
 | 
			
		||||
    @Published var isReady = false
 | 
			
		||||
    @Published var errorMessage: String? = nil
 | 
			
		||||
 | 
			
		||||
    let networkService = NetworkService()
 | 
			
		||||
    private var wcService = WatchConnectivityService()
 | 
			
		||||
    private var cancellables = Set<AnyCancellable>()
 | 
			
		||||
    private var hasAttemptedConnection = false
 | 
			
		||||
 | 
			
		||||
    init() {
 | 
			
		||||
        wcService.$token.combineLatest(wcService.$serverUrl, wcService.$isFetched, wcService.$errorMessage)
 | 
			
		||||
            .receive(on: DispatchQueue.main)
 | 
			
		||||
            .sink { [weak self] (token: String?, serverUrl: String?, isFetched: Bool?, errorMessage: String?) in
 | 
			
		||||
                guard let self = self else { return }
 | 
			
		||||
                
 | 
			
		||||
                self.token = token
 | 
			
		||||
                self.serverUrl = serverUrl
 | 
			
		||||
                self.errorMessage = errorMessage
 | 
			
		||||
 | 
			
		||||
                if let token = token, let serverUrl = serverUrl, !token.isEmpty, !serverUrl.isEmpty {
 | 
			
		||||
                    self.isReady = true
 | 
			
		||||
                    // Only connect once when we have valid credentials and tried fetch from phone
 | 
			
		||||
                    if !self.hasAttemptedConnection && isFetched == true {
 | 
			
		||||
                        self.hasAttemptedConnection = true
 | 
			
		||||
                        print("[AppState] Connecting WebSocket to server: \(serverUrl)")
 | 
			
		||||
                        self.networkService.connectWebSocket(token: token, serverUrl: serverUrl)
 | 
			
		||||
                    }
 | 
			
		||||
                } else {
 | 
			
		||||
                    self.isReady = false
 | 
			
		||||
                    if self.hasAttemptedConnection {
 | 
			
		||||
                        self.hasAttemptedConnection = false
 | 
			
		||||
                        // Disconnect WebSocket if token or serverUrl become invalid
 | 
			
		||||
                        self.networkService.disconnectWebSocket()
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            .store(in: &cancellables)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    func requestData() {
 | 
			
		||||
        wcService.requestDataFromPhone()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										113
									
								
								ios/Solian Watch App/State/WatchConnectivityService.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,113 @@
 | 
			
		||||
import WatchConnectivity
 | 
			
		||||
import Combine
 | 
			
		||||
import Foundation
 | 
			
		||||
 | 
			
		||||
class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject {
 | 
			
		||||
    @Published var token: String?
 | 
			
		||||
    @Published var serverUrl: String?
 | 
			
		||||
    @Published var isFetched: Bool?
 | 
			
		||||
    @Published var errorMessage: String?
 | 
			
		||||
 | 
			
		||||
    private let session: WCSession
 | 
			
		||||
    private let userDefaults = UserDefaults.standard
 | 
			
		||||
    private let tokenKey = "token"
 | 
			
		||||
    private let serverUrlKey = "serverUrl"
 | 
			
		||||
 | 
			
		||||
    override init() {
 | 
			
		||||
        self.session = .default
 | 
			
		||||
        super.init()
 | 
			
		||||
        print("[watchOS] Activating WCSession")
 | 
			
		||||
        self.session.delegate = self
 | 
			
		||||
        self.session.activate()
 | 
			
		||||
        
 | 
			
		||||
        // Load cached data
 | 
			
		||||
        self.token = userDefaults.string(forKey: tokenKey)
 | 
			
		||||
        self.serverUrl = userDefaults.string(forKey: serverUrlKey)
 | 
			
		||||
        self.isFetched = false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
 | 
			
		||||
        if let error = error {
 | 
			
		||||
            print("[watchOS] WCSession activation failed with error: \(error.localizedDescription)")
 | 
			
		||||
            DispatchQueue.main.async {
 | 
			
		||||
                self.errorMessage = "WCSession activation failed: \(error.localizedDescription)"
 | 
			
		||||
            }
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        print("[watchOS] WCSession activated with state: \(activationState.rawValue)")
 | 
			
		||||
        if activationState == .activated {
 | 
			
		||||
            requestDataFromPhone()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
 | 
			
		||||
        print("[watchOS] Received application context: \(applicationContext)")
 | 
			
		||||
        DispatchQueue.main.async {
 | 
			
		||||
            if let token = applicationContext["token"] as? String {
 | 
			
		||||
                self.token = token
 | 
			
		||||
                self.userDefaults.set(token, forKey: self.tokenKey)
 | 
			
		||||
            }
 | 
			
		||||
            if let serverUrl = applicationContext["serverUrl"] as? String {
 | 
			
		||||
                self.serverUrl = serverUrl
 | 
			
		||||
                self.userDefaults.set(serverUrl, forKey: self.serverUrlKey)
 | 
			
		||||
            }
 | 
			
		||||
            self.isFetched = true
 | 
			
		||||
            self.errorMessage = nil
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
                self.userDefaults.set(token, forKey: self.tokenKey)
 | 
			
		||||
            }
 | 
			
		||||
            if let serverUrl = message["serverUrl"] as? String {
 | 
			
		||||
                self.serverUrl = serverUrl
 | 
			
		||||
                self.userDefaults.set(serverUrl, forKey: self.serverUrlKey)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func requestDataFromPhone() {
 | 
			
		||||
    // Check if we already have valid data to avoid unnecessary requests
 | 
			
		||||
    if let token = self.token, let serverUrl = self.serverUrl, !token.isEmpty, !serverUrl.isEmpty {
 | 
			
		||||
        print("[watchOS] Skipped fetch - already have valid data")
 | 
			
		||||
        self.isFetched = true
 | 
			
		||||
        return
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    guard session.activationState == .activated else {
 | 
			
		||||
        print("[watchOS] Session not activated yet, state: \(session.activationState.rawValue)")
 | 
			
		||||
        DispatchQueue.main.async {
 | 
			
		||||
            self.errorMessage = "Session not ready yet"
 | 
			
		||||
        }
 | 
			
		||||
        return
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    print("[watchOS] Requesting data from phone")
 | 
			
		||||
    session.sendMessage(["request": "data"]) { [weak self] response in
 | 
			
		||||
        guard let self = self else { return }
 | 
			
		||||
        print("[watchOS] Received reply: \(response)")
 | 
			
		||||
        DispatchQueue.main.async {
 | 
			
		||||
            self.isFetched = true
 | 
			
		||||
            if let token = response["token"] as? String {
 | 
			
		||||
                self.token = token
 | 
			
		||||
                self.userDefaults.set(token, forKey: self.tokenKey)
 | 
			
		||||
            }
 | 
			
		||||
            if let serverUrl = response["serverUrl"] as? String {
 | 
			
		||||
                self.serverUrl = serverUrl
 | 
			
		||||
                self.userDefaults.set(serverUrl, forKey: self.serverUrlKey)
 | 
			
		||||
            }
 | 
			
		||||
            self.errorMessage = nil // Clear any previous errors
 | 
			
		||||
        }
 | 
			
		||||
    } errorHandler: { error in
 | 
			
		||||
        print("[watchOS] sendMessage failed with error: \(error.localizedDescription)")
 | 
			
		||||
        DispatchQueue.main.async {
 | 
			
		||||
            self.errorMessage = "Failed to get data from phone: \(error.localizedDescription)"
 | 
			
		||||
            // Don't set isFetched = true on error - allow retry
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								ios/Solian Watch App/Utils/AttachmentUtils.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,20 @@
 | 
			
		||||
//
 | 
			
		||||
//  AttachmentUtils.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import Foundation
 | 
			
		||||
 | 
			
		||||
// MARK: - Helper Functions
 | 
			
		||||
 | 
			
		||||
func getAttachmentUrl(for fileId: String, serverUrl: String) -> URL? {
 | 
			
		||||
    let urlString: String
 | 
			
		||||
    if fileId.starts(with: "http") {
 | 
			
		||||
        urlString = fileId
 | 
			
		||||
    } else {
 | 
			
		||||
        urlString = "\(serverUrl)/drive/files/\(fileId)"
 | 
			
		||||
    }
 | 
			
		||||
    return URL(string: urlString)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										73
									
								
								ios/Solian Watch App/ViewModels/ActivityViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,73 @@
 | 
			
		||||
//
 | 
			
		||||
//  ActivityViewModel.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import Foundation
 | 
			
		||||
import Combine
 | 
			
		||||
 | 
			
		||||
// MARK: - View Models
 | 
			
		||||
 | 
			
		||||
@MainActor
 | 
			
		||||
class ActivityViewModel: ObservableObject {
 | 
			
		||||
    @Published var activities: [SnActivity] = []
 | 
			
		||||
    @Published var isLoading = false
 | 
			
		||||
    @Published var isLoadingMore = false
 | 
			
		||||
    @Published var errorMessage: String?
 | 
			
		||||
    @Published var hasMore = false
 | 
			
		||||
 | 
			
		||||
    private let networkService = NetworkService()
 | 
			
		||||
    let filter: String
 | 
			
		||||
    private var isMock = false
 | 
			
		||||
    private var hasFetched = false
 | 
			
		||||
    private var nextCursor: String?
 | 
			
		||||
 | 
			
		||||
    init(filter: String, mockActivities: [SnActivity]? = nil) {
 | 
			
		||||
        self.filter = filter
 | 
			
		||||
        if let mockActivities = mockActivities {
 | 
			
		||||
            self.activities = mockActivities
 | 
			
		||||
            self.isMock = true
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func fetchActivities(token: String, serverUrl: String) async {
 | 
			
		||||
        if isMock || hasFetched { return }
 | 
			
		||||
        guard !isLoading else { return }
 | 
			
		||||
        isLoading = true
 | 
			
		||||
        errorMessage = nil
 | 
			
		||||
        hasFetched = true
 | 
			
		||||
        nextCursor = nil
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            let response = try await networkService.fetchActivities(filter: filter, cursor: nil, token: token, serverUrl: serverUrl)
 | 
			
		||||
            self.activities = response.activities
 | 
			
		||||
            self.hasMore = response.hasMore
 | 
			
		||||
            self.nextCursor = response.nextCursor
 | 
			
		||||
        } catch {
 | 
			
		||||
            self.errorMessage = error.localizedDescription
 | 
			
		||||
            print("[watchOS] fetchActivities failed with error: \(error)")
 | 
			
		||||
            hasFetched = false
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isLoading = false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func loadMoreActivities(token: String, serverUrl: String) async {
 | 
			
		||||
        guard !isLoadingMore && hasMore && nextCursor != nil else { return }
 | 
			
		||||
        isLoadingMore = true
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            let response = try await networkService.fetchActivities(filter: filter, cursor: nextCursor, token: token, serverUrl: serverUrl)
 | 
			
		||||
            self.activities.append(contentsOf: response.activities)
 | 
			
		||||
            self.hasMore = response.hasMore
 | 
			
		||||
            self.nextCursor = response.nextCursor
 | 
			
		||||
        } catch {
 | 
			
		||||
            self.errorMessage = error.localizedDescription
 | 
			
		||||
            print("[watchOS] loadMoreActivities failed with error: \(error)")
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isLoadingMore = false
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										35
									
								
								ios/Solian Watch App/ViewModels/ComposePostViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,35 @@
 | 
			
		||||
//
 | 
			
		||||
//  ComposePostViewModel.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import Foundation
 | 
			
		||||
import Combine
 | 
			
		||||
 | 
			
		||||
@MainActor
 | 
			
		||||
class ComposePostViewModel: ObservableObject {
 | 
			
		||||
    @Published var title = ""
 | 
			
		||||
    @Published var content = ""
 | 
			
		||||
    @Published var isPosting = false
 | 
			
		||||
    @Published var errorMessage: String?
 | 
			
		||||
    @Published var didPost = false
 | 
			
		||||
 | 
			
		||||
    private let networkService = NetworkService()
 | 
			
		||||
 | 
			
		||||
    func createPost(token: String, serverUrl: String) async {
 | 
			
		||||
        guard !isPosting else { return }
 | 
			
		||||
        isPosting = true
 | 
			
		||||
        errorMessage = nil
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            try await networkService.createPost(title: title, content: content, token: token, serverUrl: serverUrl)
 | 
			
		||||
            didPost = true
 | 
			
		||||
        } catch {
 | 
			
		||||
            errorMessage = error.localizedDescription
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isPosting = false
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										284
									
								
								ios/Solian Watch App/Views/AccountView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,284 @@
 | 
			
		||||
//
 | 
			
		||||
//  AccountView.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/30.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
struct AccountView: View {
 | 
			
		||||
    @EnvironmentObject var appState: AppState
 | 
			
		||||
    @State private var user: SnAccount?
 | 
			
		||||
    @State private var status: SnAccountStatus?
 | 
			
		||||
    @State private var isLoading = false
 | 
			
		||||
    @State private var error: Error?
 | 
			
		||||
    @State private var showingClearConfirmation = false
 | 
			
		||||
 | 
			
		||||
    @StateObject private var profileImageLoader = ImageLoader()
 | 
			
		||||
    @StateObject private var bannerImageLoader = ImageLoader()
 | 
			
		||||
 | 
			
		||||
    private let networkService = NetworkService()
 | 
			
		||||
    
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        ScrollView {
 | 
			
		||||
            if isLoading {
 | 
			
		||||
                ProgressView()
 | 
			
		||||
                    .padding()
 | 
			
		||||
            } else if let error = error {
 | 
			
		||||
                VStack {
 | 
			
		||||
                    Text("Failed to load account")
 | 
			
		||||
                        .foregroundColor(.red)
 | 
			
		||||
                    Text(error.localizedDescription)
 | 
			
		||||
                        .font(.caption)
 | 
			
		||||
                        .foregroundColor(.secondary)
 | 
			
		||||
                }
 | 
			
		||||
                .padding()
 | 
			
		||||
            } else if let user = user {
 | 
			
		||||
                VStack(spacing: 16) {
 | 
			
		||||
                    // Banner
 | 
			
		||||
                    if user.profile.background != nil {
 | 
			
		||||
                        if bannerImageLoader.isLoading {
 | 
			
		||||
                            ProgressView()
 | 
			
		||||
                                .frame(height: 80)
 | 
			
		||||
                        } else if let bannerImage = bannerImageLoader.image {
 | 
			
		||||
                            bannerImage
 | 
			
		||||
                                .resizable()
 | 
			
		||||
                                .aspectRatio(contentMode: .fill)
 | 
			
		||||
                                .frame(height: 80)
 | 
			
		||||
                                .clipped()
 | 
			
		||||
                                .cornerRadius(8)
 | 
			
		||||
                        } else if bannerImageLoader.errorMessage != nil {
 | 
			
		||||
                            Rectangle()
 | 
			
		||||
                                .fill(Color.gray.opacity(0.3))
 | 
			
		||||
                                .frame(height: 80)
 | 
			
		||||
                                .cornerRadius(8)
 | 
			
		||||
                        } else {
 | 
			
		||||
                            Rectangle()
 | 
			
		||||
                                .fill(Color.gray.opacity(0.3))
 | 
			
		||||
                                .frame(height: 80)
 | 
			
		||||
                                .cornerRadius(8)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    
 | 
			
		||||
                    // Profile Picture
 | 
			
		||||
                    HStack(spacing: 16)
 | 
			
		||||
                    {
 | 
			
		||||
                        if profileImageLoader.isLoading {
 | 
			
		||||
                            ProgressView()
 | 
			
		||||
                                .frame(width: 60, height: 60)
 | 
			
		||||
                        } else if let profileImage = profileImageLoader.image {
 | 
			
		||||
                            profileImage
 | 
			
		||||
                                .resizable()
 | 
			
		||||
                                .frame(width: 60, height: 60)
 | 
			
		||||
                                .clipShape(Circle())
 | 
			
		||||
                        } else if profileImageLoader.errorMessage != nil {
 | 
			
		||||
                            Circle()
 | 
			
		||||
                                .fill(Color.red.opacity(0.3))
 | 
			
		||||
                                .frame(width: 60, height: 60)
 | 
			
		||||
                                .overlay(
 | 
			
		||||
                                    Image(systemName: "exclamationmark.triangle")
 | 
			
		||||
                                        .resizable()
 | 
			
		||||
                                        .scaledToFit()
 | 
			
		||||
                                        .foregroundColor(.red)
 | 
			
		||||
                                )
 | 
			
		||||
                        } else {
 | 
			
		||||
                            Circle()
 | 
			
		||||
                                .fill(Color.gray.opacity(0.3))
 | 
			
		||||
                                .frame(width: 60, height: 60)
 | 
			
		||||
                                .overlay(
 | 
			
		||||
                                    Image(systemName: "person.circle.fill")
 | 
			
		||||
                                        .resizable()
 | 
			
		||||
                                        .scaledToFit()
 | 
			
		||||
                                        .foregroundColor(.gray)
 | 
			
		||||
                                )
 | 
			
		||||
                        }
 | 
			
		||||
                        
 | 
			
		||||
                        // Username and Handle
 | 
			
		||||
                        VStack(alignment: .leading) {
 | 
			
		||||
                            Text(user.nick)
 | 
			
		||||
                                .font(.headline)
 | 
			
		||||
                            Text("@\(user.name)")
 | 
			
		||||
                                .font(.caption)
 | 
			
		||||
                                .foregroundColor(.secondary)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    
 | 
			
		||||
                    // Status
 | 
			
		||||
                    VStack(alignment: .leading, spacing: 8) {
 | 
			
		||||
                        HStack {
 | 
			
		||||
                            Text("Status")
 | 
			
		||||
                                .font(.subheadline)
 | 
			
		||||
                                .foregroundColor(.secondary)
 | 
			
		||||
                            Spacer()
 | 
			
		||||
                            if status?.isCustomized == true {
 | 
			
		||||
                                Button(action: {
 | 
			
		||||
                                    showingClearConfirmation = true
 | 
			
		||||
                                }) {
 | 
			
		||||
                                    ZStack {
 | 
			
		||||
                                        Circle()
 | 
			
		||||
                                            .fill(Color.red.opacity(0.1))
 | 
			
		||||
                                            .frame(width: 28, height: 28)
 | 
			
		||||
                                        Image(systemName: "trash")
 | 
			
		||||
                                            .foregroundColor(.red)
 | 
			
		||||
                                    }
 | 
			
		||||
                                }
 | 
			
		||||
                                .buttonStyle(.plain)
 | 
			
		||||
                                .frame(width: 28, height: 28)
 | 
			
		||||
                            }
 | 
			
		||||
                            NavigationLink(
 | 
			
		||||
                                destination: StatusCreationView(initialStatus: status?.isCustomized == true ? status : nil)
 | 
			
		||||
                                    .environmentObject(appState)
 | 
			
		||||
                            ) {
 | 
			
		||||
                                ZStack {
 | 
			
		||||
                                    Circle()
 | 
			
		||||
                                        .fill(Color.blue.opacity(0.1))
 | 
			
		||||
                                        .frame(width: 28, height: 28)
 | 
			
		||||
                                    Image(systemName: "pencil")
 | 
			
		||||
                                        .foregroundColor(.blue)
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                            .buttonStyle(.plain)
 | 
			
		||||
                            .frame(width: 28, height: 28)
 | 
			
		||||
                        }
 | 
			
		||||
                        
 | 
			
		||||
                        if let status = status {
 | 
			
		||||
                            VStack(alignment: .leading, spacing: 4) {
 | 
			
		||||
                                HStack {
 | 
			
		||||
                                    Circle()
 | 
			
		||||
                                        .fill(status.isOnline ? Color.green : Color.gray)
 | 
			
		||||
                                        .frame(width: 8, height: 8)
 | 
			
		||||
                                    Text(status.label.isEmpty ? "No status" : status.label)
 | 
			
		||||
                                        .font(.body)
 | 
			
		||||
                                }
 | 
			
		||||
                                
 | 
			
		||||
                                if status.isInvisible {
 | 
			
		||||
                                    Text("Invisible")
 | 
			
		||||
                                        .font(.caption)
 | 
			
		||||
                                        .foregroundColor(.secondary)
 | 
			
		||||
                                }
 | 
			
		||||
                                if status.isNotDisturb {
 | 
			
		||||
                                    Text("Do Not Disturb")
 | 
			
		||||
                                        .font(.caption)
 | 
			
		||||
                                        .foregroundColor(.secondary)
 | 
			
		||||
                                }
 | 
			
		||||
                                if let clearedAt = status.clearedAt {
 | 
			
		||||
                                    Text("Clears: \(clearedAt.formatted(date: .abbreviated, time: .shortened))")
 | 
			
		||||
                                        .font(.caption)
 | 
			
		||||
                                        .foregroundColor(.secondary)
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                        } else {
 | 
			
		||||
                            Text("No status set")
 | 
			
		||||
                                .font(.body)
 | 
			
		||||
                                .foregroundColor(.secondary)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    
 | 
			
		||||
                    // Level and Progress
 | 
			
		||||
                    VStack(alignment: .leading, spacing: 8) {
 | 
			
		||||
                        Text("Level \(user.profile.level)")
 | 
			
		||||
                            .font(.title3)
 | 
			
		||||
                            .bold()
 | 
			
		||||
                        ProgressView(value: user.profile.levelingProgress)
 | 
			
		||||
                            .progressViewStyle(LinearProgressViewStyle())
 | 
			
		||||
                            .frame(height: 8)
 | 
			
		||||
                        Text("Experience: \(user.profile.experience)")
 | 
			
		||||
                            .font(.caption)
 | 
			
		||||
                            .foregroundColor(.secondary)
 | 
			
		||||
                    }
 | 
			
		||||
                    
 | 
			
		||||
                    // Bio
 | 
			
		||||
                    if let bio = user.profile.bio, !bio.isEmpty {
 | 
			
		||||
                        Text(bio)
 | 
			
		||||
                            .font(.body)
 | 
			
		||||
                            .multilineTextAlignment(.center)
 | 
			
		||||
                            .foregroundColor(.secondary)
 | 
			
		||||
                            .frame(alignment: .leading)
 | 
			
		||||
                    } else {
 | 
			
		||||
                        Text("No bio available")
 | 
			
		||||
                            .font(.body)
 | 
			
		||||
                            .foregroundColor(.secondary)
 | 
			
		||||
                            .frame(alignment: .leading)
 | 
			
		||||
                    }
 | 
			
		||||
                    
 | 
			
		||||
                    // Member since
 | 
			
		||||
                    Text("Joined at \(user.createdAt.formatted(.dateTime.month(.abbreviated).year()))")
 | 
			
		||||
                        .font(.caption)
 | 
			
		||||
                        .foregroundColor(.secondary)
 | 
			
		||||
                        .frame(alignment: .leading)
 | 
			
		||||
                }
 | 
			
		||||
                .padding()
 | 
			
		||||
                // Load images when user data is available
 | 
			
		||||
                .task(id: user.profile.picture?.id) {
 | 
			
		||||
                    if let serverUrl = appState.serverUrl, let pictureId = user.profile.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token {
 | 
			
		||||
                        await profileImageLoader.loadImage(from: imageUrl, token: token)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .task(id: user.profile.background?.id) {
 | 
			
		||||
                    if let serverUrl = appState.serverUrl, let backgroundId = user.profile.background?.id, let imageUrl = getAttachmentUrl(for: backgroundId, serverUrl: serverUrl), let token = appState.token {
 | 
			
		||||
                        await bannerImageLoader.loadImage(from: imageUrl, token: token)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                Text("No account data")
 | 
			
		||||
                    .padding()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .navigationTitle("Account")
 | 
			
		||||
        .confirmationDialog("Clear Status", isPresented: $showingClearConfirmation) {
 | 
			
		||||
            Button("Clear Status", role: .destructive) {
 | 
			
		||||
                Task {
 | 
			
		||||
                    await clearStatus()
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            Button("Cancel", role: .cancel) {}
 | 
			
		||||
        } message: {
 | 
			
		||||
            Text("Are you sure you want to clear your status? This action cannot be undone.")
 | 
			
		||||
        }
 | 
			
		||||
        .onAppear {
 | 
			
		||||
            Task.detached {
 | 
			
		||||
                await loadUserProfile()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    private func loadUserProfile() async {
 | 
			
		||||
        guard let token = appState.token, let serverUrl = appState.serverUrl else {
 | 
			
		||||
            error = NSError(domain: "AccountView", code: 1, userInfo: [NSLocalizedDescriptionKey: "Authentication not available"])
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isLoading = true
 | 
			
		||||
        error = nil
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            user = try await networkService.fetchUserProfile(token: token, serverUrl: serverUrl)
 | 
			
		||||
            status = try await networkService.fetchAccountStatus(token: token, serverUrl: serverUrl)
 | 
			
		||||
        } catch {
 | 
			
		||||
            self.error = error
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isLoading = false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func clearStatus() async {
 | 
			
		||||
        guard let token = appState.token, let serverUrl = appState.serverUrl else {
 | 
			
		||||
            error = NSError(domain: "AccountView", code: 1, userInfo: [NSLocalizedDescriptionKey: "Authentication not available"])
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            try await networkService.clearStatus(token: token, serverUrl: serverUrl)
 | 
			
		||||
            // Refresh status after clearing
 | 
			
		||||
            status = try await networkService.fetchAccountStatus(token: token, serverUrl: serverUrl)
 | 
			
		||||
        } catch {
 | 
			
		||||
            self.error = error
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#Preview {
 | 
			
		||||
    AccountView()
 | 
			
		||||
        .environmentObject(AppState())
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										86
									
								
								ios/Solian Watch App/Views/ActivityListView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,86 @@
 | 
			
		||||
//
 | 
			
		||||
//  ActivityListView.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
// MARK: - Views
 | 
			
		||||
 | 
			
		||||
struct ActivityListView: View {
 | 
			
		||||
    @StateObject private var viewModel: ActivityViewModel
 | 
			
		||||
    @EnvironmentObject var appState: AppState
 | 
			
		||||
 | 
			
		||||
    init(filter: String, mockActivities: [SnActivity]? = nil) {
 | 
			
		||||
        _viewModel = StateObject(wrappedValue: ActivityViewModel(filter: filter, mockActivities: mockActivities))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        Group {
 | 
			
		||||
            if viewModel.isLoading {
 | 
			
		||||
                ProgressView()
 | 
			
		||||
            } else if let errorMessage = viewModel.errorMessage {
 | 
			
		||||
                VStack {
 | 
			
		||||
                    Text("Error fetching data")
 | 
			
		||||
                        .font(.headline)
 | 
			
		||||
                    Text(errorMessage)
 | 
			
		||||
                        .font(.caption)
 | 
			
		||||
                        .lineLimit(nil)
 | 
			
		||||
                }
 | 
			
		||||
                .padding()
 | 
			
		||||
            } else if viewModel.activities.isEmpty {
 | 
			
		||||
                Text("No activities found.")
 | 
			
		||||
            } else {
 | 
			
		||||
                List {
 | 
			
		||||
                    ForEach(viewModel.activities) { activity in
 | 
			
		||||
                        switch activity.type {
 | 
			
		||||
                        case "posts.new", "posts.new.replies":
 | 
			
		||||
                            if case .post(let post) = activity.data {
 | 
			
		||||
                                NavigationLink(
 | 
			
		||||
                                    destination: PostDetailView(post: post).environmentObject(appState)
 | 
			
		||||
                                ) {
 | 
			
		||||
                                    PostRowView(post: post)
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                        case "discovery":
 | 
			
		||||
                            if case .discovery(let discoveryData) = activity.data {
 | 
			
		||||
                                DiscoveryView(discoveryData: discoveryData)
 | 
			
		||||
                            }
 | 
			
		||||
                        default:
 | 
			
		||||
                            Text("Unknown activity type: \(activity.type)")
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    if viewModel.hasMore {
 | 
			
		||||
                        if viewModel.isLoadingMore {
 | 
			
		||||
                            HStack {
 | 
			
		||||
                                Spacer()
 | 
			
		||||
                                ProgressView()
 | 
			
		||||
                                Spacer()
 | 
			
		||||
                            }
 | 
			
		||||
                        } else {
 | 
			
		||||
                            Button("Load More") {
 | 
			
		||||
                                Task {
 | 
			
		||||
                                    if let token = appState.token, let serverUrl = appState.serverUrl {
 | 
			
		||||
                                        await viewModel.loadMoreActivities(token: token, serverUrl: serverUrl)
 | 
			
		||||
                                    }
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                            .frame(maxWidth: .infinity)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .onAppear {
 | 
			
		||||
            if appState.isReady, let token = appState.token, let serverUrl = appState.serverUrl {
 | 
			
		||||
                Task.detached {
 | 
			
		||||
                    await viewModel.fetchActivities(token: token, serverUrl: serverUrl)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .navigationTitle(viewModel.filter)
 | 
			
		||||
        .navigationBarTitleDisplayMode(.inline)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										62
									
								
								ios/Solian Watch App/Views/AppInfoHeaderView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,62 @@
 | 
			
		||||
//
 | 
			
		||||
//  AppInfoHeader.swift
 | 
			
		||||
//  Runner
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/30.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import Combine
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
struct AppInfoHeaderView : View {
 | 
			
		||||
    @EnvironmentObject var appState: AppState // Access AppState
 | 
			
		||||
    @State private var webSocketConnectionState: WebSocketState = .disconnected // New state for WebSocket status
 | 
			
		||||
    @State private var cancellables = Set<AnyCancellable>() // For managing subscriptions
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        VStack(alignment: .leading) {
 | 
			
		||||
            HStack(spacing: 12) {
 | 
			
		||||
                Image("Logo")
 | 
			
		||||
                    .resizable()
 | 
			
		||||
                    .frame(width: 40, height: 40)
 | 
			
		||||
                
 | 
			
		||||
                VStack(alignment: .leading) {
 | 
			
		||||
                    Text("Solian").font(.headline)
 | 
			
		||||
                    Text("for Apple Watch").font(.system(size: 11))
 | 
			
		||||
                    
 | 
			
		||||
                    // Display WebSocket connection status
 | 
			
		||||
                    Text(webSocketStatusMessage)
 | 
			
		||||
                        .font(.system(size: 10))
 | 
			
		||||
                        .foregroundColor(.secondary)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .onAppear {
 | 
			
		||||
            setupWebSocketListeners()
 | 
			
		||||
        }
 | 
			
		||||
        .onDisappear {
 | 
			
		||||
            cancellables.forEach { $0.cancel() }
 | 
			
		||||
            cancellables.removeAll()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private var webSocketStatusMessage: String {
 | 
			
		||||
        switch webSocketConnectionState {
 | 
			
		||||
        case .connected: return "Connected"
 | 
			
		||||
        case .connecting: return "Connecting..."
 | 
			
		||||
        case .disconnected: return "Disconnected"
 | 
			
		||||
        case .serverDown: return "Server Down"
 | 
			
		||||
        case .duplicateDevice: return "Duplicate Device"
 | 
			
		||||
        case .error(let msg): return "Error: \(msg)"
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func setupWebSocketListeners() {
 | 
			
		||||
        appState.networkService.stateStream
 | 
			
		||||
            .receive(on: DispatchQueue.main)
 | 
			
		||||
            .sink { state in
 | 
			
		||||
                webSocketConnectionState = state
 | 
			
		||||
            }
 | 
			
		||||
            .store(in: &cancellables)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										109
									
								
								ios/Solian Watch App/Views/AttachmentView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,109 @@
 | 
			
		||||
//
 | 
			
		||||
//  AttachmentImageView.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
import AVKit
 | 
			
		||||
import AVFoundation
 | 
			
		||||
 | 
			
		||||
struct AttachmentView: View {
 | 
			
		||||
    let attachment: SnCloudFile
 | 
			
		||||
    @EnvironmentObject var appState: AppState
 | 
			
		||||
    @StateObject private var imageLoader = ImageLoader()
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        Group {
 | 
			
		||||
            if let mimeType = attachment.mimeType {
 | 
			
		||||
                if mimeType.starts(with: "image") {
 | 
			
		||||
                    if let serverUrl = appState.serverUrl, let imageUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl) {
 | 
			
		||||
                        NavigationLink(
 | 
			
		||||
                            destination: ImageViewer(imageUrl: imageUrl).environmentObject(appState)
 | 
			
		||||
                        ) {
 | 
			
		||||
                            if imageLoader.isLoading {
 | 
			
		||||
                                ProgressView()
 | 
			
		||||
                            } else if let image = imageLoader.image {
 | 
			
		||||
                                image
 | 
			
		||||
                                    .resizable()
 | 
			
		||||
                                    .aspectRatio(contentMode: .fit)
 | 
			
		||||
                                    .frame(maxWidth: .infinity)
 | 
			
		||||
                                    .cornerRadius(8)
 | 
			
		||||
                            } else if let errorMessage = imageLoader.errorMessage {
 | 
			
		||||
                                Text("Failed to load attachment: \(errorMessage)")
 | 
			
		||||
                                    .font(.caption)
 | 
			
		||||
                                    .foregroundColor(.red)
 | 
			
		||||
                                    .cornerRadius(8)
 | 
			
		||||
                            } else {
 | 
			
		||||
                                Text("File: \(attachment.id)")
 | 
			
		||||
                                    .cornerRadius(8)
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        .buttonStyle(PlainButtonStyle())
 | 
			
		||||
                    } else {
 | 
			
		||||
                        Text("Image URL not available.")
 | 
			
		||||
                    }
 | 
			
		||||
                } else if mimeType.starts(with: "video") {
 | 
			
		||||
                    if let serverUrl = appState.serverUrl, let videoUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl) {
 | 
			
		||||
                        NavigationLink(destination: VideoPlayerView(videoUrl: videoUrl)) {
 | 
			
		||||
                            if imageLoader.isLoading {
 | 
			
		||||
                                ProgressView()
 | 
			
		||||
                            } else if let image = imageLoader.image {
 | 
			
		||||
                                ZStack {
 | 
			
		||||
                                    image
 | 
			
		||||
                                        .resizable()
 | 
			
		||||
                                        .aspectRatio(contentMode: .fit)
 | 
			
		||||
                                        .frame(maxWidth: .infinity)
 | 
			
		||||
                                        .cornerRadius(8)
 | 
			
		||||
 | 
			
		||||
                                    Image(systemName: "play.circle.fill")
 | 
			
		||||
                                        .resizable()
 | 
			
		||||
                                        .scaledToFit()
 | 
			
		||||
                                        .frame(width: 36, height: 36)
 | 
			
		||||
                                        .foregroundColor(.white)
 | 
			
		||||
                                        .shadow(color: .black.opacity(0.6), radius: 4, x: 0, y: 2)
 | 
			
		||||
                                }
 | 
			
		||||
                            } else if imageLoader.errorMessage != nil {
 | 
			
		||||
                                Image(systemName: "play.rectangle.fill")
 | 
			
		||||
                                    .resizable()
 | 
			
		||||
                                    .aspectRatio(contentMode: .fit)
 | 
			
		||||
                                    .frame(maxWidth: .infinity)
 | 
			
		||||
                                    .foregroundColor(.gray)
 | 
			
		||||
                                    .cornerRadius(8)
 | 
			
		||||
                            } else {
 | 
			
		||||
                                ProgressView()
 | 
			
		||||
                                    .cornerRadius(8)
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        .buttonStyle(PlainButtonStyle())
 | 
			
		||||
                    } else {
 | 
			
		||||
                        Text("Video URL not available.")
 | 
			
		||||
                    }
 | 
			
		||||
                } else if mimeType.starts(with: "audio") {
 | 
			
		||||
                    if let serverUrl = appState.serverUrl, let audioUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl) {
 | 
			
		||||
                        AudioPlayerView(audioUrl: audioUrl)
 | 
			
		||||
                    } else {
 | 
			
		||||
                        Text("Cannot play audio: URL not available.")
 | 
			
		||||
                    }
 | 
			
		||||
                } else {
 | 
			
		||||
                    Text("Unsupported media type: \(mimeType)")
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                Text("File: \(attachment.id) (No MIME type)")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .task(id: attachment.id) {
 | 
			
		||||
            if let serverUrl = appState.serverUrl, let attachmentUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl), let token = appState.token {
 | 
			
		||||
                if attachment.mimeType?.starts(with: "image") == true {
 | 
			
		||||
                    await imageLoader.loadImage(from: attachmentUrl, token: token)
 | 
			
		||||
                }
 | 
			
		||||
                if attachment.mimeType?.starts(with: "video") == true {
 | 
			
		||||
                    let thumbnailUrl = attachmentUrl
 | 
			
		||||
                        .appending(queryItems: [URLQueryItem(name: "thumbnail", value: "true")]) // Construct thumbnail URL
 | 
			
		||||
                    await imageLoader.loadImage(from: thumbnailUrl, token: token)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										47
									
								
								ios/Solian Watch App/Views/AudioPlayerView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,47 @@
 | 
			
		||||
 | 
			
		||||
//
 | 
			
		||||
//  AudioPlayerView.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
import AVFoundation
 | 
			
		||||
 | 
			
		||||
struct AudioPlayerView: View {
 | 
			
		||||
    let audioUrl: URL
 | 
			
		||||
    @State private var player: AVPlayer?
 | 
			
		||||
    @State private var isPlaying: Bool = false
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        VStack {
 | 
			
		||||
            if player != nil {
 | 
			
		||||
                Button(action: togglePlayPause) {
 | 
			
		||||
                    Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
 | 
			
		||||
                        .font(.largeTitle)
 | 
			
		||||
                }
 | 
			
		||||
                .buttonStyle(.plain)
 | 
			
		||||
            } else {
 | 
			
		||||
                Text("Loading audio...")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .onAppear {
 | 
			
		||||
            player = AVPlayer(url: audioUrl)
 | 
			
		||||
        }
 | 
			
		||||
        .onDisappear {
 | 
			
		||||
            player?.pause()
 | 
			
		||||
            player = nil
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func togglePlayPause() {
 | 
			
		||||
        guard let player = player else { return }
 | 
			
		||||
        if isPlaying {
 | 
			
		||||
            player.pause()
 | 
			
		||||
        } else {
 | 
			
		||||
            player.play()
 | 
			
		||||
        }
 | 
			
		||||
        isPlaying.toggle()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										785
									
								
								ios/Solian Watch App/Views/ChatViews.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,785 @@
 | 
			
		||||
//
 | 
			
		||||
//  ChatView.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/30.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
struct ChatView: View {
 | 
			
		||||
    @EnvironmentObject var appState: AppState
 | 
			
		||||
    @State private var selectedTab = 0
 | 
			
		||||
    @State private var chatRooms: [SnChatRoom] = []
 | 
			
		||||
    @State private var chatInvites: [SnChatMember] = []
 | 
			
		||||
    @State private var isLoading = false
 | 
			
		||||
    @State private var error: Error?
 | 
			
		||||
    @State private var showingInvites = false
 | 
			
		||||
 | 
			
		||||
    private let tabs = ["All", "Direct", "Group"]
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        TabView(selection: $selectedTab) {
 | 
			
		||||
            ForEach(0..<tabs.count, id: \.self) { index in
 | 
			
		||||
                VStack {
 | 
			
		||||
                    if isLoading {
 | 
			
		||||
                        ProgressView()
 | 
			
		||||
                    } else if error != nil {
 | 
			
		||||
                        VStack {
 | 
			
		||||
                            Text("Error loading chats")
 | 
			
		||||
                                .font(.caption)
 | 
			
		||||
                            Button("Retry") {
 | 
			
		||||
                                Task {
 | 
			
		||||
                                    await loadChatRooms()
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                            .font(.caption2)
 | 
			
		||||
                        }
 | 
			
		||||
                    } else {
 | 
			
		||||
                        ChatRoomListView(
 | 
			
		||||
                            chatRooms: filteredChatRooms(for: index),
 | 
			
		||||
                            selectedTab: index
 | 
			
		||||
                        )
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .tabItem {
 | 
			
		||||
                    Text(tabs[index])
 | 
			
		||||
                }
 | 
			
		||||
                .tag(index)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .tabViewStyle(.page)
 | 
			
		||||
        .navigationTitle("Chat")
 | 
			
		||||
        .toolbar {
 | 
			
		||||
            ToolbarItem(placement: .topBarTrailing) {
 | 
			
		||||
                Button {
 | 
			
		||||
                    showingInvites = true
 | 
			
		||||
                } label: {
 | 
			
		||||
                    ZStack {
 | 
			
		||||
                        Image(systemName: "envelope")
 | 
			
		||||
                        if !chatInvites.isEmpty {
 | 
			
		||||
                            Circle()
 | 
			
		||||
                                .fill(Color.red)
 | 
			
		||||
                                .frame(width: 8, height: 8)
 | 
			
		||||
                                .offset(x: 8, y: -8)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .sheet(isPresented: $showingInvites) {
 | 
			
		||||
            ChatInvitesView(invites: $chatInvites, appState: appState)
 | 
			
		||||
        }
 | 
			
		||||
        .onAppear {
 | 
			
		||||
            Task.detached {
 | 
			
		||||
                await loadChatRooms()
 | 
			
		||||
                await loadChatInvites()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func filteredChatRooms(for tabIndex: Int) -> [SnChatRoom] {
 | 
			
		||||
        switch tabIndex {
 | 
			
		||||
        case 0: // All
 | 
			
		||||
            return chatRooms
 | 
			
		||||
        case 1: // Direct
 | 
			
		||||
            return chatRooms.filter { $0.type == 1 }
 | 
			
		||||
        case 2: // Group
 | 
			
		||||
            return chatRooms.filter { $0.type != 1 }
 | 
			
		||||
        default:
 | 
			
		||||
            return chatRooms
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func loadChatRooms() async {
 | 
			
		||||
        guard let token = appState.token, let serverUrl = appState.serverUrl else { return }
 | 
			
		||||
 | 
			
		||||
        isLoading = true
 | 
			
		||||
        error = nil
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            let response = try await appState.networkService.fetchChatRooms(token: token, serverUrl: serverUrl)
 | 
			
		||||
            chatRooms = response.rooms
 | 
			
		||||
        } catch {
 | 
			
		||||
            self.error = error
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isLoading = false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func loadChatInvites() async {
 | 
			
		||||
        guard let token = appState.token, let serverUrl = appState.serverUrl else { return }
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            let response = try await appState.networkService.fetchChatInvites(token: token, serverUrl: serverUrl)
 | 
			
		||||
            chatInvites = response.invites
 | 
			
		||||
        } catch {
 | 
			
		||||
            // Handle error silently for invites
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ChatRoomListView: View {
 | 
			
		||||
    let chatRooms: [SnChatRoom]
 | 
			
		||||
    let selectedTab: Int
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        if chatRooms.isEmpty {
 | 
			
		||||
            VStack {
 | 
			
		||||
                Image(systemName: "message")
 | 
			
		||||
                    .font(.largeTitle)
 | 
			
		||||
                    .foregroundColor(.secondary)
 | 
			
		||||
                Text("No chats yet")
 | 
			
		||||
                    .font(.caption)
 | 
			
		||||
                    .foregroundColor(.secondary)
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            List(chatRooms) { room in
 | 
			
		||||
                ChatRoomListItem(room: room)
 | 
			
		||||
            }
 | 
			
		||||
            .listStyle(.plain)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ChatRoomListItem: View {
 | 
			
		||||
    let room: SnChatRoom
 | 
			
		||||
    @EnvironmentObject var appState: AppState
 | 
			
		||||
    @StateObject private var avatarLoader = ImageLoader()
 | 
			
		||||
 | 
			
		||||
    private var displayName: String {
 | 
			
		||||
        if room.type == 1, let members = room.members, !members.isEmpty {
 | 
			
		||||
            // For direct messages, show the other member's name
 | 
			
		||||
            return members[0].account.nick
 | 
			
		||||
        } else {
 | 
			
		||||
            // For group chats, show room name or fallback
 | 
			
		||||
            return room.name ?? "Group Chat"
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private var subtitle: String {
 | 
			
		||||
        if room.type == 1, let members = room.members, members.count > 1 {
 | 
			
		||||
            // For direct messages, show member usernames
 | 
			
		||||
            return members.map { "@\($0.account.name)" }.joined(separator: ", ")
 | 
			
		||||
        } else if let description = room.description {
 | 
			
		||||
            // For group chats with description
 | 
			
		||||
            return description
 | 
			
		||||
        } else {
 | 
			
		||||
            // Fallback
 | 
			
		||||
            return ""
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private var avatarPictureId: String? {
 | 
			
		||||
        if room.type == 1, let members = room.members, !members.isEmpty {
 | 
			
		||||
            // For direct messages, use the other member's avatar
 | 
			
		||||
            return members[0].account.profile.picture?.id
 | 
			
		||||
        } else {
 | 
			
		||||
            // For group chats, use room picture
 | 
			
		||||
            return room.picture?.id
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        NavigationLink(
 | 
			
		||||
            destination: ChatRoomView(room: room)
 | 
			
		||||
                .environmentObject(appState)
 | 
			
		||||
        ) {
 | 
			
		||||
            HStack {
 | 
			
		||||
                // Avatar using ImageLoader pattern
 | 
			
		||||
                Group {
 | 
			
		||||
                    if avatarLoader.isLoading {
 | 
			
		||||
                        ProgressView()
 | 
			
		||||
                            .frame(width: 32, height: 32)
 | 
			
		||||
                    } else if let image = avatarLoader.image {
 | 
			
		||||
                        image
 | 
			
		||||
                            .resizable()
 | 
			
		||||
                            .frame(width: 32, height: 32)
 | 
			
		||||
                            .clipShape(Circle())
 | 
			
		||||
                    } else if avatarLoader.errorMessage != nil {
 | 
			
		||||
                        // Error state - show fallback
 | 
			
		||||
                        Circle()
 | 
			
		||||
                            .fill(Color.gray.opacity(0.3))
 | 
			
		||||
                            .frame(width: 32, height: 32)
 | 
			
		||||
                            .overlay(
 | 
			
		||||
                                Text(displayName.prefix(1).uppercased())
 | 
			
		||||
                                    .font(.system(size: 12, weight: .medium))
 | 
			
		||||
                                    .foregroundColor(.primary)
 | 
			
		||||
                            )
 | 
			
		||||
                    } else {
 | 
			
		||||
                        // No image available - show initial
 | 
			
		||||
                        Circle()
 | 
			
		||||
                            .fill(Color.gray.opacity(0.3))
 | 
			
		||||
                            .frame(width: 32, height: 32)
 | 
			
		||||
                            .overlay(
 | 
			
		||||
                                Text(displayName.prefix(1).uppercased())
 | 
			
		||||
                                    .font(.system(size: 12, weight: .medium))
 | 
			
		||||
                                    .foregroundColor(.primary)
 | 
			
		||||
                            )
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .task(id: avatarPictureId) {
 | 
			
		||||
                    if let serverUrl = appState.serverUrl,
 | 
			
		||||
                       let pictureId = avatarPictureId,
 | 
			
		||||
                       let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl),
 | 
			
		||||
                       let token = appState.token {
 | 
			
		||||
                        await avatarLoader.loadImage(from: imageUrl, token: token)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                VStack(alignment: .leading, spacing: 2) {
 | 
			
		||||
                    Text(displayName)
 | 
			
		||||
                        .font(.system(size: 14, weight: .medium))
 | 
			
		||||
                        .lineLimit(1)
 | 
			
		||||
 | 
			
		||||
                    if !subtitle.isEmpty {
 | 
			
		||||
                        Text(subtitle)
 | 
			
		||||
                            .font(.system(size: 12))
 | 
			
		||||
                            .foregroundColor(.secondary)
 | 
			
		||||
                            .lineLimit(1)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                Spacer()
 | 
			
		||||
 | 
			
		||||
                // Unread count badge placeholder
 | 
			
		||||
                // In a full implementation, this would show unread count
 | 
			
		||||
            }
 | 
			
		||||
            .padding(.vertical, 4)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
import Combine
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
struct ChatRoomView: View {
 | 
			
		||||
    let room: SnChatRoom
 | 
			
		||||
    @EnvironmentObject var appState: AppState
 | 
			
		||||
    @State private var messages: [SnChatMessage] = []
 | 
			
		||||
    @State private var isLoading = false
 | 
			
		||||
    @State private var error: Error?
 | 
			
		||||
    @State private var wsState: WebSocketState = .disconnected // New state for WebSocket status
 | 
			
		||||
    @State private var hasLoadedMessages = false // Track if messages have been loaded
 | 
			
		||||
    @State private var messageText = "" // Text input for sending messages
 | 
			
		||||
    @State private var isSending = false // Track sending state
 | 
			
		||||
    @State private var isInputHidden = false // Track if input should be hidden during scrolling
 | 
			
		||||
    @State private var scrollTimer: Timer? // Timer to show input after scrolling stops
 | 
			
		||||
 | 
			
		||||
    @State private var cancellables = Set<AnyCancellable>() // For managing subscriptions
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        VStack {
 | 
			
		||||
            // Display WebSocket connection status
 | 
			
		||||
            if (wsState != .connected)
 | 
			
		||||
            {
 | 
			
		||||
                Text(webSocketStatusMessage)
 | 
			
		||||
                    .font(.caption2)
 | 
			
		||||
                    .foregroundColor(.secondary)
 | 
			
		||||
                    .padding(.vertical, 2)
 | 
			
		||||
                    .animation(.easeInOut, value: wsState) // Animate status changes
 | 
			
		||||
                    .transition(.opacity)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if isLoading {
 | 
			
		||||
                ProgressView()
 | 
			
		||||
            } else if error != nil {
 | 
			
		||||
                VStack {
 | 
			
		||||
                    Text("Error loading messages")
 | 
			
		||||
                        .font(.caption)
 | 
			
		||||
                    Button("Retry") {
 | 
			
		||||
                        Task {
 | 
			
		||||
                            await loadMessages()
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    .font(.caption2)
 | 
			
		||||
                }
 | 
			
		||||
            } else if messages.isEmpty {
 | 
			
		||||
                VStack {
 | 
			
		||||
                    Image(systemName: "bubble.left")
 | 
			
		||||
                        .font(.largeTitle)
 | 
			
		||||
                        .foregroundColor(.secondary)
 | 
			
		||||
                    Text("No messages yet")
 | 
			
		||||
                        .font(.caption)
 | 
			
		||||
                        .foregroundColor(.secondary)
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                ScrollViewReader { scrollView in
 | 
			
		||||
                    ScrollView {
 | 
			
		||||
                        LazyVStack(alignment: .leading, spacing: 8) {
 | 
			
		||||
                            ForEach(messages) { message in
 | 
			
		||||
                                ChatMessageItem(message: message)
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        .padding(.horizontal)
 | 
			
		||||
                        .padding(.vertical, 8)
 | 
			
		||||
                        .padding(.bottom, 8)
 | 
			
		||||
                    }
 | 
			
		||||
                    .onAppear {
 | 
			
		||||
                        // Scroll to bottom when messages load
 | 
			
		||||
                        if let lastMessage = messages.last {
 | 
			
		||||
                            scrollView.scrollTo(lastMessage.id, anchor: .bottom)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    .onChange(of: messages.count) { _, _ in
 | 
			
		||||
                        // Scroll to bottom when new messages arrive
 | 
			
		||||
                        if let lastMessage = messages.last {
 | 
			
		||||
                            withAnimation {
 | 
			
		||||
                                scrollView.scrollTo(lastMessage.id, anchor: .bottom)
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    .onScrollPhaseChange { _, phase  in
 | 
			
		||||
                        switch phase {
 | 
			
		||||
                        case .interacting:
 | 
			
		||||
                            if !isInputHidden {
 | 
			
		||||
                                withAnimation(.easeOut(duration: 0.2)) {
 | 
			
		||||
                                    isInputHidden = true
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                        case .idle:
 | 
			
		||||
                            withAnimation(.easeIn(duration: 0.3)) {
 | 
			
		||||
                                isInputHidden = false
 | 
			
		||||
                            }
 | 
			
		||||
                        default: break
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Message input area
 | 
			
		||||
            if !isInputHidden {
 | 
			
		||||
                HStack(spacing: 8) {
 | 
			
		||||
                    TextField("Send message...", text: $messageText)
 | 
			
		||||
                        .font(.system(size: 14))
 | 
			
		||||
                        .disabled(isSending)
 | 
			
		||||
                        .frame(height: 40)
 | 
			
		||||
 | 
			
		||||
                    Button {
 | 
			
		||||
                        Task {
 | 
			
		||||
                            await sendMessage()
 | 
			
		||||
                        }
 | 
			
		||||
                    } label: {
 | 
			
		||||
                        if isSending {
 | 
			
		||||
                            ProgressView()
 | 
			
		||||
                                .frame(width: 20, height: 20)
 | 
			
		||||
                        } else {
 | 
			
		||||
                            Image(systemName: "arrow.up.circle.fill")
 | 
			
		||||
                                .resizable()
 | 
			
		||||
                                .frame(width: 20, height: 20)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    .labelStyle(.iconOnly)
 | 
			
		||||
                    .buttonStyle(.automatic)
 | 
			
		||||
                    .disabled(messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
 | 
			
		||||
                    .frame(width: 40, height: 40)
 | 
			
		||||
                }
 | 
			
		||||
                .padding(.horizontal)
 | 
			
		||||
                .padding(.top, 8)
 | 
			
		||||
                .transition(.move(edge: .bottom).combined(with: .opacity))
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .navigationTitle(room.name ?? "Chat")
 | 
			
		||||
        .task {
 | 
			
		||||
            await loadMessages()
 | 
			
		||||
        }
 | 
			
		||||
        .onAppear {
 | 
			
		||||
            setupWebSocketListeners()
 | 
			
		||||
        }
 | 
			
		||||
        .onDisappear {
 | 
			
		||||
            cancellables.forEach { $0.cancel() }
 | 
			
		||||
            cancellables.removeAll()
 | 
			
		||||
            scrollTimer?.invalidate()
 | 
			
		||||
            scrollTimer = nil
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private var webSocketStatusMessage: String {
 | 
			
		||||
        switch wsState {
 | 
			
		||||
        case .connected: return "Connected"
 | 
			
		||||
        case .connecting: return "Connecting..."
 | 
			
		||||
        case .disconnected: return "Disconnected"
 | 
			
		||||
        case .serverDown: return "Server Down"
 | 
			
		||||
        case .duplicateDevice: return "Duplicate Device"
 | 
			
		||||
        case .error(let msg): return "Error: \(msg)"
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func loadMessages() async {
 | 
			
		||||
        // Prevent reloading if already loaded
 | 
			
		||||
        guard !hasLoadedMessages else { return }
 | 
			
		||||
 | 
			
		||||
        guard let token = appState.token, let serverUrl = appState.serverUrl else {
 | 
			
		||||
            isLoading = false
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isLoading = true
 | 
			
		||||
        error = nil
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            let messages = try await appState.networkService.fetchChatMessages(
 | 
			
		||||
                chatRoomId: room.id,
 | 
			
		||||
                token: token,
 | 
			
		||||
                serverUrl: serverUrl
 | 
			
		||||
            )
 | 
			
		||||
            // Sort with newest messages first (for flipped list, newest will appear at bottom)
 | 
			
		||||
            self.messages = messages.sorted { $0.createdAt < $1.createdAt }
 | 
			
		||||
            hasLoadedMessages = true
 | 
			
		||||
        } catch {
 | 
			
		||||
            print("[watchOS] Error loading messages: \(error.localizedDescription)")
 | 
			
		||||
            self.error = error
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isLoading = false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func sendMessage() async {
 | 
			
		||||
        let content = messageText.trimmingCharacters(in: .whitespacesAndNewlines)
 | 
			
		||||
        guard !content.isEmpty,
 | 
			
		||||
              let token = appState.token,
 | 
			
		||||
              let serverUrl = appState.serverUrl else { return }
 | 
			
		||||
 | 
			
		||||
        isSending = true
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            // Generate a nonce for the message
 | 
			
		||||
            let nonce = UUID().uuidString
 | 
			
		||||
 | 
			
		||||
            // Prepare the request data
 | 
			
		||||
            let messageData: [String: Any] = [
 | 
			
		||||
                "content": content,
 | 
			
		||||
                "attachments_id": [], // Empty for now, can be extended for attachments
 | 
			
		||||
                "meta": [:],
 | 
			
		||||
                "nonce": nonce
 | 
			
		||||
            ]
 | 
			
		||||
 | 
			
		||||
            // Create the URL
 | 
			
		||||
            guard let url = URL(string: "\(serverUrl)/sphere/chat/\(room.id)/messages") else {
 | 
			
		||||
                throw URLError(.badURL)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Create the request
 | 
			
		||||
            var request = URLRequest(url: url)
 | 
			
		||||
            request.httpMethod = "POST"
 | 
			
		||||
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
 | 
			
		||||
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 | 
			
		||||
            request.httpBody = try JSONSerialization.data(withJSONObject: messageData, options: [])
 | 
			
		||||
 | 
			
		||||
            // Send the request
 | 
			
		||||
            let (data, response) = try await URLSession.shared.data(for: request)
 | 
			
		||||
 | 
			
		||||
            guard let httpResponse = response as? HTTPURLResponse,
 | 
			
		||||
                  (200...299).contains(httpResponse.statusCode) else {
 | 
			
		||||
                throw URLError(.badServerResponse)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Parse the response to get the sent message
 | 
			
		||||
            let decoder = JSONDecoder()
 | 
			
		||||
            decoder.dateDecodingStrategy = .iso8601
 | 
			
		||||
            decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
			
		||||
            let sentMessage = try decoder.decode(SnChatMessage.self, from: data)
 | 
			
		||||
 | 
			
		||||
            // Add the message to the local list
 | 
			
		||||
            messages.append(sentMessage)
 | 
			
		||||
 | 
			
		||||
            // Clear the input
 | 
			
		||||
            messageText = ""
 | 
			
		||||
 | 
			
		||||
        } catch {
 | 
			
		||||
            print("[watchOS] Error sending message: \(error.localizedDescription)")
 | 
			
		||||
            // Could show an error alert here
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isSending = false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func sendReadReceipt() {
 | 
			
		||||
        let data: [String: Any] = ["chat_room_id": room.id]
 | 
			
		||||
        let packet: [String: Any] = ["type": "messages.read", "data": data, "endpoint": "sphere"]
 | 
			
		||||
        if let jsonData = try? JSONSerialization.data(withJSONObject: packet, options: []),
 | 
			
		||||
           let jsonString = String(data: jsonData, encoding: .utf8) {
 | 
			
		||||
            appState.networkService.sendWebSocketMessage(message: jsonString)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func setupWebSocketListeners() {
 | 
			
		||||
        // Listen for WebSocket packets (new messages)
 | 
			
		||||
        appState.networkService.packetStream
 | 
			
		||||
            .receive(on: DispatchQueue.main) // Ensure UI updates on main thread
 | 
			
		||||
            .sink(receiveCompletion: { completion in
 | 
			
		||||
                if case .failure(let err) = completion {
 | 
			
		||||
                    print("[ChatRoomView] WebSocket packet stream error: \(err.localizedDescription)")
 | 
			
		||||
                }
 | 
			
		||||
            }, receiveValue: { packet in
 | 
			
		||||
                if ["messages.new", "messages.update", "messages.delete"].contains(packet.type),
 | 
			
		||||
                   let messageData = packet.data {
 | 
			
		||||
                    do {
 | 
			
		||||
                        let jsonData = try JSONSerialization.data(withJSONObject: messageData, options: [])
 | 
			
		||||
                        let decoder = JSONDecoder()
 | 
			
		||||
                        decoder.dateDecodingStrategy = .iso8601
 | 
			
		||||
                        decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
			
		||||
                        let message = try decoder.decode(SnChatMessage.self, from: jsonData)
 | 
			
		||||
 | 
			
		||||
                        if message.chatRoomId == room.id {
 | 
			
		||||
                            switch packet.type {
 | 
			
		||||
                            case "messages.new":
 | 
			
		||||
                                if message.type.hasPrefix("call") {
 | 
			
		||||
                                    // TODO: Handle ongoing call
 | 
			
		||||
                                }
 | 
			
		||||
                                if !messages.contains(where: { $0.id == message.id }) {
 | 
			
		||||
                                    messages.append(message)
 | 
			
		||||
                                }
 | 
			
		||||
                                sendReadReceipt()
 | 
			
		||||
                            case "messages.update":
 | 
			
		||||
                                if let index = messages.firstIndex(where: { $0.id == message.id }) {
 | 
			
		||||
                                    messages[index] = message
 | 
			
		||||
                                }
 | 
			
		||||
                            case "messages.delete":
 | 
			
		||||
                                messages.removeAll(where: { $0.id == message.id })
 | 
			
		||||
                            default:
 | 
			
		||||
                                break
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    } catch {
 | 
			
		||||
                        print("[ChatRoomView] Error decoding message from websocket: \(error.localizedDescription)")
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            })
 | 
			
		||||
            .store(in: &cancellables)
 | 
			
		||||
 | 
			
		||||
        // Listen for WebSocket connection state changes
 | 
			
		||||
        appState.networkService.stateStream
 | 
			
		||||
            .receive(on: DispatchQueue.main) // Ensure UI updates on main thread
 | 
			
		||||
            .sink { state in
 | 
			
		||||
                wsState = state
 | 
			
		||||
            }
 | 
			
		||||
            .store(in: &cancellables)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ChatMessageItem: View {
 | 
			
		||||
    let message: SnChatMessage
 | 
			
		||||
    @EnvironmentObject var appState: AppState
 | 
			
		||||
    @StateObject private var avatarLoader = ImageLoader()
 | 
			
		||||
 | 
			
		||||
    private var avatarPictureId: String? {
 | 
			
		||||
        message.sender.account.profile.picture?.id
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        HStack(alignment: .top, spacing: 8) {
 | 
			
		||||
            // Avatar
 | 
			
		||||
            Group {
 | 
			
		||||
                if avatarLoader.isLoading {
 | 
			
		||||
                    ProgressView()
 | 
			
		||||
                        .frame(width: 24, height: 24)
 | 
			
		||||
                } else if let image = avatarLoader.image {
 | 
			
		||||
                    image
 | 
			
		||||
                        .resizable()
 | 
			
		||||
                        .frame(width: 24, height: 24)
 | 
			
		||||
                        .clipShape(Circle())
 | 
			
		||||
                } else {
 | 
			
		||||
                    Circle()
 | 
			
		||||
                        .fill(Color.gray.opacity(0.3))
 | 
			
		||||
                        .frame(width: 24, height: 24)
 | 
			
		||||
                        .overlay(
 | 
			
		||||
                            Text(message.sender.account.nick.prefix(1).uppercased())
 | 
			
		||||
                                .font(.system(size: 10, weight: .medium))
 | 
			
		||||
                                .foregroundColor(.primary)
 | 
			
		||||
                        )
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            .task(id: avatarPictureId) {
 | 
			
		||||
                if let serverUrl = appState.serverUrl,
 | 
			
		||||
                   let pictureId = avatarPictureId,
 | 
			
		||||
                   let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl),
 | 
			
		||||
                   let token = appState.token {
 | 
			
		||||
                    await avatarLoader.loadImage(from: imageUrl, token: token)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            VStack(alignment: .leading, spacing: 4) {
 | 
			
		||||
                HStack {
 | 
			
		||||
                    Text(message.sender.account.nick)
 | 
			
		||||
                        .font(.system(size: 12, weight: .medium))
 | 
			
		||||
                    Spacer()
 | 
			
		||||
                    Text(message.createdAt, style: .time)
 | 
			
		||||
                        .font(.system(size: 10))
 | 
			
		||||
                        .foregroundColor(.secondary)
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if let content = message.content, !content.isEmpty {
 | 
			
		||||
                    Text(content)
 | 
			
		||||
                        .font(.system(size: 14))
 | 
			
		||||
                        .lineLimit(nil)
 | 
			
		||||
                        .fixedSize(horizontal: false, vertical: true)
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if !message.attachments.isEmpty {
 | 
			
		||||
                    AttachmentView(attachment: message.attachments[0])
 | 
			
		||||
                    if message.attachments.count > 1 {
 | 
			
		||||
                        HStack(spacing: 8) {
 | 
			
		||||
                            Image(systemName: "paperclip.circle.fill")
 | 
			
		||||
                                .frame(width: 12, height: 12)
 | 
			
		||||
                                .foregroundStyle(.gray)
 | 
			
		||||
                            Text("\(message.attachments.count - 1)+ attachments")
 | 
			
		||||
                                .font(.footnote)
 | 
			
		||||
                                .foregroundStyle(.gray)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .padding(.vertical, 4)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ChatInvitesView: View {
 | 
			
		||||
    @Binding var invites: [SnChatMember]
 | 
			
		||||
    let appState: AppState
 | 
			
		||||
    @Environment(\.dismiss) private var dismiss
 | 
			
		||||
    @State private var isLoading = false
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        NavigationView {
 | 
			
		||||
            VStack {
 | 
			
		||||
                if invites.isEmpty {
 | 
			
		||||
                    VStack {
 | 
			
		||||
                        Image(systemName: "envelope.open")
 | 
			
		||||
                            .font(.largeTitle)
 | 
			
		||||
                            .foregroundColor(.secondary)
 | 
			
		||||
                        Text("No invites")
 | 
			
		||||
                            .font(.caption)
 | 
			
		||||
                            .foregroundColor(.secondary)
 | 
			
		||||
                    }
 | 
			
		||||
                } else {
 | 
			
		||||
                    List(invites) { invite in
 | 
			
		||||
                        ChatInviteItem(invite: invite, appState: appState, invites: $invites)
 | 
			
		||||
                    }
 | 
			
		||||
                    .listStyle(.plain)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            .navigationTitle("Invites")
 | 
			
		||||
            .navigationBarTitleDisplayMode(.inline)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ChatInviteItem: View {
 | 
			
		||||
    let invite: SnChatMember
 | 
			
		||||
    let appState: AppState
 | 
			
		||||
    @Binding var invites: [SnChatMember]
 | 
			
		||||
    @State private var isAccepting = false
 | 
			
		||||
    @State private var isDeclining = false
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        VStack(alignment: .leading, spacing: 8) {
 | 
			
		||||
            HStack {
 | 
			
		||||
                Circle()
 | 
			
		||||
                    .fill(Color.gray.opacity(0.3))
 | 
			
		||||
                    .frame(width: 24, height: 24)
 | 
			
		||||
                    .overlay(
 | 
			
		||||
                        Text((invite.chatRoom?.name ?? "C").prefix(1).uppercased())
 | 
			
		||||
                            .font(.system(size: 10, weight: .medium))
 | 
			
		||||
                            .foregroundColor(.primary)
 | 
			
		||||
                    )
 | 
			
		||||
 | 
			
		||||
                VStack(alignment: .leading, spacing: 2) {
 | 
			
		||||
                    Text(invite.chatRoom?.name ?? "Unknown Chat")
 | 
			
		||||
                        .font(.system(size: 14, weight: .medium))
 | 
			
		||||
                        .lineLimit(1)
 | 
			
		||||
 | 
			
		||||
                    HStack(spacing: 4) {
 | 
			
		||||
                        Text(invite.role == 100 ? "Owner" : invite.role >= 50 ? "Moderator" : "Member")
 | 
			
		||||
                            .font(.system(size: 12))
 | 
			
		||||
                            .foregroundColor(.secondary)
 | 
			
		||||
 | 
			
		||||
                        if invite.chatRoom?.type == 1 {
 | 
			
		||||
                            Text("Direct")
 | 
			
		||||
                                .font(.system(size: 12))
 | 
			
		||||
                                .foregroundColor(.blue)
 | 
			
		||||
                                .padding(.horizontal, 4)
 | 
			
		||||
                                .padding(.vertical, 2)
 | 
			
		||||
                                .background(Color.blue.opacity(0.1))
 | 
			
		||||
                                .cornerRadius(4)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                Spacer()
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            HStack(spacing: 8) {
 | 
			
		||||
                Button {
 | 
			
		||||
                    Task {
 | 
			
		||||
                        await acceptInvite()
 | 
			
		||||
                    }
 | 
			
		||||
                } label: {
 | 
			
		||||
                    if isAccepting {
 | 
			
		||||
                        ProgressView()
 | 
			
		||||
                            .frame(width: 20, height: 20)
 | 
			
		||||
                    } else {
 | 
			
		||||
                        Image(systemName: "checkmark")
 | 
			
		||||
                            .frame(width: 20, height: 20)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .disabled(isAccepting || isDeclining)
 | 
			
		||||
 | 
			
		||||
                Button {
 | 
			
		||||
                    Task {
 | 
			
		||||
                        await declineInvite()
 | 
			
		||||
                    }
 | 
			
		||||
                } label: {
 | 
			
		||||
                    if isDeclining {
 | 
			
		||||
                        ProgressView()
 | 
			
		||||
                            .frame(width: 20, height: 20)
 | 
			
		||||
                    } else {
 | 
			
		||||
                        Image(systemName: "xmark")
 | 
			
		||||
                            .frame(width: 20, height: 20)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .disabled(isAccepting || isDeclining)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .padding(.vertical, 8)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func acceptInvite() async {
 | 
			
		||||
        guard let token = appState.token,
 | 
			
		||||
              let serverUrl = appState.serverUrl,
 | 
			
		||||
              let chatRoomId = invite.chatRoom?.id else { return }
 | 
			
		||||
 | 
			
		||||
        isAccepting = true
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            try await appState.networkService.acceptChatInvite(chatRoomId: chatRoomId, token: token, serverUrl: serverUrl)
 | 
			
		||||
            // Remove from invites list
 | 
			
		||||
            invites.removeAll { $0.id == invite.id }
 | 
			
		||||
        } catch {
 | 
			
		||||
            // Handle error - could show alert
 | 
			
		||||
            print("Failed to accept invite: \(error)")
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isAccepting = false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func declineInvite() async {
 | 
			
		||||
        guard let token = appState.token,
 | 
			
		||||
              let serverUrl = appState.serverUrl,
 | 
			
		||||
              let chatRoomId = invite.chatRoom?.id else { return }
 | 
			
		||||
 | 
			
		||||
        isDeclining = true
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            try await appState.networkService.declineChatInvite(chatRoomId: chatRoomId, token: token, serverUrl: serverUrl)
 | 
			
		||||
            // Remove from invites list
 | 
			
		||||
            invites.removeAll { $0.id == invite.id }
 | 
			
		||||
        } catch {
 | 
			
		||||
            // Handle error - could show alert
 | 
			
		||||
            print("Failed to decline invite: \(error)")
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isDeclining = false
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										53
									
								
								ios/Solian Watch App/Views/ComposePostView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,53 @@
 | 
			
		||||
//
 | 
			
		||||
//  ComposePostView.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
struct ComposePostView: View {
 | 
			
		||||
    @StateObject private var viewModel = ComposePostViewModel()
 | 
			
		||||
    @EnvironmentObject var appState: AppState
 | 
			
		||||
    @Environment(\.dismiss) private var dismiss
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        NavigationStack {
 | 
			
		||||
            Form {
 | 
			
		||||
                TextField("Title", text: $viewModel.title)
 | 
			
		||||
                TextField("Content", text: $viewModel.content)
 | 
			
		||||
            }
 | 
			
		||||
            .navigationTitle("New Post")
 | 
			
		||||
            .toolbar {
 | 
			
		||||
                ToolbarItem(placement: .cancellationAction) {
 | 
			
		||||
                    Button("Cancel", systemImage: "xmark") {
 | 
			
		||||
                        dismiss()
 | 
			
		||||
                    }
 | 
			
		||||
                    .labelStyle(.iconOnly)
 | 
			
		||||
                }
 | 
			
		||||
                ToolbarItem(placement: .confirmationAction) {
 | 
			
		||||
                    Button("Post", systemImage: "square.and.arrow.up") {
 | 
			
		||||
                        Task {
 | 
			
		||||
                            if let token = appState.token, let serverUrl = appState.serverUrl {
 | 
			
		||||
                                await viewModel.createPost(token: token, serverUrl: serverUrl)
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    .labelStyle(.iconOnly)
 | 
			
		||||
                    .disabled(viewModel.isPosting)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            .onChange(of: viewModel.didPost) {
 | 
			
		||||
                if viewModel.didPost {
 | 
			
		||||
                    dismiss()
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            .alert("Error", isPresented: .constant(viewModel.errorMessage != nil), actions: {
 | 
			
		||||
                Button("OK") { viewModel.errorMessage = nil }
 | 
			
		||||
            }, message: {
 | 
			
		||||
                Text(viewModel.errorMessage ?? "")
 | 
			
		||||
            })
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										110
									
								
								ios/Solian Watch App/Views/DiscoveryViews.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,110 @@
 | 
			
		||||
//
 | 
			
		||||
//  DiscoveryViews.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
struct DiscoveryView: View {
 | 
			
		||||
    let discoveryData: DiscoveryData
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        NavigationLink(destination: DiscoveryDetailView(discoveryData: discoveryData)) {
 | 
			
		||||
            VStack(alignment: .leading) {
 | 
			
		||||
                Text("Discovery")
 | 
			
		||||
                    .font(.headline)
 | 
			
		||||
                Text("\(discoveryData.items.count) new items to discover")
 | 
			
		||||
                    .font(.subheadline)
 | 
			
		||||
                    .foregroundColor(.secondary)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct DiscoveryDetailView: View {
 | 
			
		||||
    let discoveryData: DiscoveryData
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        List(discoveryData.items) { item in
 | 
			
		||||
            NavigationLink(destination: destinationView(for: item)) {
 | 
			
		||||
                itemView(for: item)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .navigationTitle("Discovery")
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @ViewBuilder
 | 
			
		||||
    private func itemView(for item: DiscoveryItem) -> some View {
 | 
			
		||||
        VStack(alignment: .leading) {
 | 
			
		||||
            switch item.data {
 | 
			
		||||
            case .realm(let realm):
 | 
			
		||||
                Text("Realm").font(.headline)
 | 
			
		||||
                Text(realm.name).foregroundColor(.secondary)
 | 
			
		||||
            case .publisher(let publisher):
 | 
			
		||||
                Text("Publisher").font(.headline)
 | 
			
		||||
                Text(publisher.name).foregroundColor(.secondary)
 | 
			
		||||
            case .article(let article):
 | 
			
		||||
                Text("Article").font(.headline)
 | 
			
		||||
                Text(article.title).foregroundColor(.secondary)
 | 
			
		||||
            case .unknown:
 | 
			
		||||
                Text("Unknown item")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    @ViewBuilder
 | 
			
		||||
    private func destinationView(for item: DiscoveryItem) -> some View {
 | 
			
		||||
        switch item.data {
 | 
			
		||||
        case .realm(let realm):
 | 
			
		||||
            RealmDetailView(realm: realm)
 | 
			
		||||
        case .publisher(let publisher):
 | 
			
		||||
            PublisherDetailView(publisher: publisher)
 | 
			
		||||
        case .article(let article):
 | 
			
		||||
            ArticleDetailView(article: article)
 | 
			
		||||
        case .unknown:
 | 
			
		||||
            Text("Detail view not available")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct RealmDetailView: View {
 | 
			
		||||
    let realm: SnRealm
 | 
			
		||||
    
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        VStack(alignment: .leading, spacing: 8) {
 | 
			
		||||
            Text(realm.name).font(.headline)
 | 
			
		||||
            if let description = realm.description {
 | 
			
		||||
                Text(description).font(.body)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .navigationTitle("Realm")
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct PublisherDetailView: View {
 | 
			
		||||
    let publisher: SnPublisher
 | 
			
		||||
    
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        VStack(alignment: .leading, spacing: 8) {
 | 
			
		||||
            Text(publisher.name).font(.headline)
 | 
			
		||||
            if let description = publisher.description {
 | 
			
		||||
                Text(description).font(.body)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .navigationTitle("Publisher")
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ArticleDetailView: View {
 | 
			
		||||
    let article: SnWebArticle
 | 
			
		||||
    
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        VStack(alignment: .leading, spacing: 8) {
 | 
			
		||||
            Text(article.title).font(.headline)
 | 
			
		||||
            Text(article.url).font(.caption).foregroundColor(.secondary)
 | 
			
		||||
        }
 | 
			
		||||
        .navigationTitle("Article")
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										67
									
								
								ios/Solian Watch App/Views/ExploreView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,67 @@
 | 
			
		||||
//
 | 
			
		||||
//  ExploreView.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
// The main view with the TabView for filtering.
 | 
			
		||||
struct ExploreView: View {
 | 
			
		||||
    @EnvironmentObject private var appState: AppState
 | 
			
		||||
    @State private var isComposing = false
 | 
			
		||||
    @State private var selectedTab: String = "Explore"
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        NavigationStack {
 | 
			
		||||
            if appState.isReady {
 | 
			
		||||
                TabView(selection: $selectedTab) {
 | 
			
		||||
                    ActivityListView(filter: "Explore")
 | 
			
		||||
                        .tag("Explore")
 | 
			
		||||
                        .tabItem {
 | 
			
		||||
                            Label("Explore", systemImage: "safari")
 | 
			
		||||
                        }
 | 
			
		||||
                        .labelStyle(.titleOnly)
 | 
			
		||||
 | 
			
		||||
                    ActivityListView(filter: "Subscriptions")
 | 
			
		||||
                        .tag("Subscriptions")
 | 
			
		||||
                        .tabItem {
 | 
			
		||||
                            Label("Subscriptions", systemImage: "star")
 | 
			
		||||
                        }
 | 
			
		||||
                        .labelStyle(.titleOnly)
 | 
			
		||||
 | 
			
		||||
                    ActivityListView(filter: "Friends")
 | 
			
		||||
                        .tag("Friends")
 | 
			
		||||
                        .tabItem {
 | 
			
		||||
                            Label("Friends", systemImage: "person.2")
 | 
			
		||||
                        }
 | 
			
		||||
                        .labelStyle(.titleOnly)
 | 
			
		||||
                }
 | 
			
		||||
                .navigationTitle(selectedTab)
 | 
			
		||||
                .toolbar {
 | 
			
		||||
                    ToolbarItem(placement: .primaryAction) {
 | 
			
		||||
                        Button(action: { isComposing = true }) {
 | 
			
		||||
                            Label("Compose", systemImage: "plus")
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                VStack {
 | 
			
		||||
                    ProgressView { Text("Syncing...") }
 | 
			
		||||
                    Button("Retry") {
 | 
			
		||||
                        appState.requestData()
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .sheet(isPresented: $isComposing) {
 | 
			
		||||
            ComposePostView()
 | 
			
		||||
        }
 | 
			
		||||
        .alert("Error", isPresented: .constant(appState.errorMessage != nil), actions: {
 | 
			
		||||
            Button("OK") { appState.errorMessage = nil }
 | 
			
		||||
        }, message: {
 | 
			
		||||
            Text(appState.errorMessage ?? "")
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										34
									
								
								ios/Solian Watch App/Views/ImageViewer.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,34 @@
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
struct ImageViewer: View {
 | 
			
		||||
    let imageUrl: URL
 | 
			
		||||
    @EnvironmentObject var appState: AppState
 | 
			
		||||
    @StateObject private var imageLoader = ImageLoader()
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        Group {
 | 
			
		||||
            if imageLoader.isLoading {
 | 
			
		||||
                ProgressView()
 | 
			
		||||
            } else if let image = imageLoader.image {
 | 
			
		||||
                image
 | 
			
		||||
                    .resizable()
 | 
			
		||||
                    .aspectRatio(contentMode: .fit)
 | 
			
		||||
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
 | 
			
		||||
                    .scaledToFit()
 | 
			
		||||
            } else if let errorMessage = imageLoader.errorMessage {
 | 
			
		||||
                Text("Failed to load image: \(errorMessage)")
 | 
			
		||||
                    .font(.caption)
 | 
			
		||||
                    .foregroundColor(.red)
 | 
			
		||||
            } else {
 | 
			
		||||
                Text("Failed to load image.")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .task(id: imageUrl) {
 | 
			
		||||
            if let token = appState.token {
 | 
			
		||||
                await imageLoader.loadImage(from: imageUrl, token: token)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .navigationTitle("Image")
 | 
			
		||||
        .navigationBarTitleDisplayMode(.inline)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										198
									
								
								ios/Solian Watch App/Views/NotificationView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,198 @@
 | 
			
		||||
 | 
			
		||||
//
 | 
			
		||||
//  NotificationView.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
import Combine
 | 
			
		||||
 | 
			
		||||
@MainActor
 | 
			
		||||
class NotificationViewModel: ObservableObject {
 | 
			
		||||
    @Published var notifications = [SnNotification]()
 | 
			
		||||
    @Published var isLoading = false
 | 
			
		||||
    @Published var isLoadingMore = false
 | 
			
		||||
    @Published var errorMessage: String?
 | 
			
		||||
    @Published var hasMore = false
 | 
			
		||||
 | 
			
		||||
    private let networkService = NetworkService()
 | 
			
		||||
    private var hasFetched = false
 | 
			
		||||
    private var offset = 0
 | 
			
		||||
    private let pageSize = 20
 | 
			
		||||
 | 
			
		||||
    func fetchNotifications(token: String, serverUrl: String) async {
 | 
			
		||||
        if hasFetched { return }
 | 
			
		||||
        guard !isLoading else { return }
 | 
			
		||||
        isLoading = true
 | 
			
		||||
        errorMessage = nil
 | 
			
		||||
        hasFetched = true
 | 
			
		||||
        offset = 0
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            let response = try await networkService.fetchNotifications(offset: offset, take: pageSize, token: token, serverUrl: serverUrl)
 | 
			
		||||
            self.notifications = response.notifications
 | 
			
		||||
            self.hasMore = response.hasMore
 | 
			
		||||
            offset += response.notifications.count
 | 
			
		||||
        } catch {
 | 
			
		||||
            self.errorMessage = error.localizedDescription
 | 
			
		||||
            print("[watchOS] fetchNotifications failed with error: \(error)")
 | 
			
		||||
            hasFetched = false
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isLoading = false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func loadMoreNotifications(token: String, serverUrl: String) async {
 | 
			
		||||
        guard !isLoadingMore && hasMore else { return }
 | 
			
		||||
        isLoadingMore = true
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            let response = try await networkService.fetchNotifications(offset: offset, take: pageSize, token: token, serverUrl: serverUrl)
 | 
			
		||||
            self.notifications.append(contentsOf: response.notifications)
 | 
			
		||||
            self.hasMore = response.hasMore
 | 
			
		||||
            offset += response.notifications.count
 | 
			
		||||
        } catch {
 | 
			
		||||
            self.errorMessage = error.localizedDescription
 | 
			
		||||
            print("[watchOS] loadMoreNotifications failed with error: \(error)")
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isLoadingMore = false
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct NotificationView: View {
 | 
			
		||||
    @EnvironmentObject var appState: AppState
 | 
			
		||||
    @StateObject private var viewModel = NotificationViewModel()
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        Group {
 | 
			
		||||
            if viewModel.isLoading {
 | 
			
		||||
                ProgressView()
 | 
			
		||||
            } else if let errorMessage = viewModel.errorMessage {
 | 
			
		||||
                VStack {
 | 
			
		||||
                    Text("Error")
 | 
			
		||||
                        .font(.headline)
 | 
			
		||||
                    Text(errorMessage)
 | 
			
		||||
                        .font(.caption)
 | 
			
		||||
                    Button("Retry") {
 | 
			
		||||
                        Task {
 | 
			
		||||
                            if let token = appState.token, let serverUrl = appState.serverUrl {
 | 
			
		||||
                                await viewModel.fetchNotifications(token: token, serverUrl: serverUrl)
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .padding()
 | 
			
		||||
            } else if viewModel.notifications.isEmpty {
 | 
			
		||||
                Text("No notifications")
 | 
			
		||||
            } else {
 | 
			
		||||
                List {
 | 
			
		||||
                    ForEach(viewModel.notifications) { notification in
 | 
			
		||||
                        NavigationLink(destination: NotificationDetailView(notification: notification)) {
 | 
			
		||||
                            VStack(alignment: .leading, spacing: 4) {
 | 
			
		||||
                                HStack {
 | 
			
		||||
                                    Text(notification.title)
 | 
			
		||||
                                        .font(.headline)
 | 
			
		||||
                                    Spacer()
 | 
			
		||||
                                    if notification.viewedAt == nil {
 | 
			
		||||
                                        Circle()
 | 
			
		||||
                                            .fill(Color.blue)
 | 
			
		||||
                                            .frame(width: 8, height: 8)
 | 
			
		||||
                                    }
 | 
			
		||||
                                }
 | 
			
		||||
                                if !notification.subtitle.isEmpty {
 | 
			
		||||
                                    Text(notification.subtitle)
 | 
			
		||||
                                        .font(.subheadline)
 | 
			
		||||
                                        .foregroundColor(.secondary)
 | 
			
		||||
                                }
 | 
			
		||||
                                if notification.content.count > 100 {
 | 
			
		||||
                                    Text(notification.content.prefix(100) + "...")
 | 
			
		||||
                                        .font(.caption)
 | 
			
		||||
                                        .foregroundColor(.gray)
 | 
			
		||||
                                        .lineLimit(2)
 | 
			
		||||
                                } else {
 | 
			
		||||
                                    Text(notification.content)
 | 
			
		||||
                                        .font(.caption)
 | 
			
		||||
                                        .foregroundColor(.gray)
 | 
			
		||||
                                        .lineLimit(2)
 | 
			
		||||
                                }
 | 
			
		||||
                                Text(notification.createdAt, style: .relative)
 | 
			
		||||
                                    .font(.caption2)
 | 
			
		||||
                                    .foregroundColor(.gray)
 | 
			
		||||
                            }
 | 
			
		||||
                            .padding(.vertical, 8)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    if viewModel.hasMore {
 | 
			
		||||
                        if viewModel.isLoadingMore {
 | 
			
		||||
                            HStack {
 | 
			
		||||
                                Spacer()
 | 
			
		||||
                                ProgressView()
 | 
			
		||||
                                Spacer()
 | 
			
		||||
                            }
 | 
			
		||||
                        } else {
 | 
			
		||||
                            Button("Load More") {
 | 
			
		||||
                                Task {
 | 
			
		||||
                                    if let token = appState.token, let serverUrl = appState.serverUrl {
 | 
			
		||||
                                        await viewModel.loadMoreNotifications(token: token, serverUrl: serverUrl)
 | 
			
		||||
                                    }
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                            .frame(maxWidth: .infinity)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .onAppear {
 | 
			
		||||
            if appState.isReady, let token = appState.token, let serverUrl = appState.serverUrl {
 | 
			
		||||
                Task.detached {
 | 
			
		||||
                    await viewModel.fetchNotifications(token: token, serverUrl: serverUrl)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .navigationTitle("Notifications")
 | 
			
		||||
        .navigationBarTitleDisplayMode(.inline)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct NotificationDetailView: View {
 | 
			
		||||
    let notification: SnNotification
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        ScrollView {
 | 
			
		||||
            VStack(alignment: .leading, spacing: 16) {
 | 
			
		||||
                Text(notification.title)
 | 
			
		||||
                    .font(.headline)
 | 
			
		||||
                
 | 
			
		||||
                if !notification.subtitle.isEmpty {
 | 
			
		||||
                    Text(notification.subtitle)
 | 
			
		||||
                        .font(.subheadline)
 | 
			
		||||
                        .foregroundColor(.secondary)
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                Text(notification.content)
 | 
			
		||||
                    .font(.body)
 | 
			
		||||
                
 | 
			
		||||
                HStack {
 | 
			
		||||
                    Text(notification.createdAt, style: .date)
 | 
			
		||||
                    Text("·")
 | 
			
		||||
                    Text(notification.createdAt, style: .time)
 | 
			
		||||
                }
 | 
			
		||||
                .font(.caption)
 | 
			
		||||
                .foregroundColor(.gray)
 | 
			
		||||
                
 | 
			
		||||
                if notification.viewedAt == nil {
 | 
			
		||||
                    Text("Unread")
 | 
			
		||||
                        .font(.caption)
 | 
			
		||||
                        .foregroundColor(.blue)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            .padding()
 | 
			
		||||
        }
 | 
			
		||||
        .navigationTitle("Notification")
 | 
			
		||||
        .navigationBarTitleDisplayMode(.inline)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										151
									
								
								ios/Solian Watch App/Views/PostViews.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,151 @@
 | 
			
		||||
//
 | 
			
		||||
//  PostViews.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/29.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
struct PostRowView: View {
 | 
			
		||||
    let post: SnPost
 | 
			
		||||
    @EnvironmentObject var appState: AppState
 | 
			
		||||
    @StateObject private var imageLoader = ImageLoader() // Instantiate ImageLoader
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        VStack(alignment: .leading, spacing: 4) {
 | 
			
		||||
            HStack {
 | 
			
		||||
                if imageLoader.isLoading {
 | 
			
		||||
                    ProgressView()
 | 
			
		||||
                        .frame(width: 24, height: 24)
 | 
			
		||||
                } else if let image = imageLoader.image {
 | 
			
		||||
                    image
 | 
			
		||||
                        .resizable()
 | 
			
		||||
                        .frame(width: 24, height: 24)
 | 
			
		||||
                        .clipShape(Circle())
 | 
			
		||||
                } else if let errorMessage = imageLoader.errorMessage {
 | 
			
		||||
                    Text("Failed: \(errorMessage)")
 | 
			
		||||
                        .font(.caption)
 | 
			
		||||
                        .foregroundColor(.red)
 | 
			
		||||
                        .frame(width: 24, height: 24)
 | 
			
		||||
                } else {
 | 
			
		||||
                    // Placeholder if no image and not loading
 | 
			
		||||
                    Image(systemName: "person.circle.fill")
 | 
			
		||||
                        .resizable()
 | 
			
		||||
                        .frame(width: 24, height: 24)
 | 
			
		||||
                        .clipShape(Circle())
 | 
			
		||||
                        .foregroundColor(.gray)
 | 
			
		||||
                }
 | 
			
		||||
                Text(post.publisher.nick ?? post.publisher.name)
 | 
			
		||||
                    .font(.subheadline)
 | 
			
		||||
                    .bold()
 | 
			
		||||
            }
 | 
			
		||||
            .task(id: post.publisher.picture?.id) { // Use task(id:) to reload image when pictureId changes
 | 
			
		||||
                if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token {
 | 
			
		||||
                    await imageLoader.loadImage(from: imageUrl, token: token)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            if let title = post.title, !title.isEmpty {
 | 
			
		||||
                Text(title)
 | 
			
		||||
                    .font(.headline)
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            if let content = post.content, !content.isEmpty {
 | 
			
		||||
                Text(content)
 | 
			
		||||
                    .font(.body)
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            if !post.attachments.isEmpty {
 | 
			
		||||
                AttachmentView(attachment: post.attachments[0])
 | 
			
		||||
                if post.attachments.count > 1 {
 | 
			
		||||
                    HStack(spacing: 8) {
 | 
			
		||||
                        Image(systemName: "paperclip.circle.fill")
 | 
			
		||||
                            .frame(width: 12, height: 12)
 | 
			
		||||
                            .foregroundStyle(.gray)
 | 
			
		||||
                        Text("\(post.attachments.count - 1)+ attachments")
 | 
			
		||||
                            .font(.footnote)
 | 
			
		||||
                            .foregroundStyle(.gray)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }.padding(.vertical)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct PostDetailView: View {
 | 
			
		||||
    let post: SnPost
 | 
			
		||||
    @EnvironmentObject var appState: AppState
 | 
			
		||||
    @StateObject private var publisherImageLoader = ImageLoader() // Instantiate ImageLoader for publisher avatar
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        ScrollView {
 | 
			
		||||
            VStack(alignment: .leading, spacing: 8) {
 | 
			
		||||
                HStack {
 | 
			
		||||
                    if publisherImageLoader.isLoading {
 | 
			
		||||
                        ProgressView()
 | 
			
		||||
                            .frame(width: 32, height: 32)
 | 
			
		||||
                    } else if let image = publisherImageLoader.image {
 | 
			
		||||
                        image
 | 
			
		||||
                            .resizable()
 | 
			
		||||
                            .frame(width: 32, height: 32)
 | 
			
		||||
                            .clipShape(Circle())
 | 
			
		||||
                    } else if let errorMessage = publisherImageLoader.errorMessage {
 | 
			
		||||
                        Text("Failed: \(errorMessage)")
 | 
			
		||||
                            .font(.caption)
 | 
			
		||||
                            .foregroundColor(.red)
 | 
			
		||||
                            .frame(width: 32, height: 32)
 | 
			
		||||
                    } else {
 | 
			
		||||
                        Image(systemName: "person.circle.fill")
 | 
			
		||||
                            .resizable()
 | 
			
		||||
                            .frame(width: 32, height: 32)
 | 
			
		||||
                            .clipShape(Circle())
 | 
			
		||||
                            .foregroundColor(.gray)
 | 
			
		||||
                    }
 | 
			
		||||
                    Text("@\(post.publisher.name)")
 | 
			
		||||
                        .font(.headline)
 | 
			
		||||
                }
 | 
			
		||||
                // Use task(id:) to reload image when pictureId changes
 | 
			
		||||
                .task(id: post.publisher.picture?.id) {
 | 
			
		||||
                    if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token {
 | 
			
		||||
                        await publisherImageLoader.loadImage(from: imageUrl, token: token)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                if let title = post.title, !title.isEmpty {
 | 
			
		||||
                    Text(title)
 | 
			
		||||
                        .font(.title2)
 | 
			
		||||
                        .bold()
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                if let content = post.content, !content.isEmpty {
 | 
			
		||||
                    Text(content)
 | 
			
		||||
                        .font(.body)
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                if !post.attachments.isEmpty {
 | 
			
		||||
                    Text("Attachments").font(.headline)
 | 
			
		||||
                    ForEach(post.attachments) { attachment in
 | 
			
		||||
                        AttachmentView(attachment: attachment)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                if !post.tags.isEmpty {
 | 
			
		||||
                    Text("Tags").font(.headline)
 | 
			
		||||
                    FlowLayout(alignment: .leading, spacing: 4) {
 | 
			
		||||
                        ForEach(post.tags) { tag in
 | 
			
		||||
                            Text("#\(tag.name ?? tag.slug)")
 | 
			
		||||
                                .font(.caption)
 | 
			
		||||
                                .padding(.horizontal, 8)
 | 
			
		||||
                                .padding(.vertical, 3)
 | 
			
		||||
                                .background(Capsule().fill(Color.accentColor.opacity(0.2)))
 | 
			
		||||
                                .cornerRadius(5)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            .padding()
 | 
			
		||||
        }
 | 
			
		||||
        .navigationTitle("Post")
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										132
									
								
								ios/Solian Watch App/Views/StatusCreationView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,132 @@
 | 
			
		||||
//
 | 
			
		||||
//  StatusCreationView.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/30.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
struct StatusCreationView: View {
 | 
			
		||||
    @EnvironmentObject var appState: AppState
 | 
			
		||||
    @Environment(\.dismiss) var dismiss
 | 
			
		||||
    
 | 
			
		||||
    let initialStatus: SnAccountStatus?
 | 
			
		||||
    
 | 
			
		||||
    @State private var attitude: Int
 | 
			
		||||
    @State private var isInvisible: Bool
 | 
			
		||||
    @State private var isNotDisturb: Bool
 | 
			
		||||
    @State private var label: String
 | 
			
		||||
    @State private var isSubmitting: Bool = false
 | 
			
		||||
    @State private var error: Error? = nil
 | 
			
		||||
    
 | 
			
		||||
    private let networkService = NetworkService()
 | 
			
		||||
    
 | 
			
		||||
    init(initialStatus: SnAccountStatus? = nil) {
 | 
			
		||||
        self.initialStatus = initialStatus
 | 
			
		||||
        _attitude = State(initialValue: initialStatus?.attitude ?? 1)
 | 
			
		||||
        _isInvisible = State(initialValue: initialStatus?.isInvisible ?? false)
 | 
			
		||||
        _isNotDisturb = State(initialValue: initialStatus?.isNotDisturb ?? false)
 | 
			
		||||
        _label = State(initialValue: initialStatus?.label ?? "")
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        ScrollView {
 | 
			
		||||
            VStack(spacing: 16) {
 | 
			
		||||
                // Title
 | 
			
		||||
                Text("Set Status")
 | 
			
		||||
                    .font(.headline)
 | 
			
		||||
                    .padding(.top)
 | 
			
		||||
                
 | 
			
		||||
                // Label TextField
 | 
			
		||||
                TextField("Status label", text: $label)
 | 
			
		||||
                    .textFieldStyle(.automatic)
 | 
			
		||||
                    .padding(.horizontal)
 | 
			
		||||
                
 | 
			
		||||
                // Attitude Picker
 | 
			
		||||
                VStack(alignment: .leading, spacing: 8) {
 | 
			
		||||
                    Text("Mood")
 | 
			
		||||
                        .font(.subheadline)
 | 
			
		||||
                        .foregroundColor(.secondary)
 | 
			
		||||
                    
 | 
			
		||||
                    Picker("Attitude", selection: $attitude) {
 | 
			
		||||
                        Text("😊 Positive").tag(0)
 | 
			
		||||
                        Text("😐 Neutral").tag(1)
 | 
			
		||||
                        Text("😢 Negative").tag(2)
 | 
			
		||||
                    }
 | 
			
		||||
                    .pickerStyle(.wheel)
 | 
			
		||||
                    .frame(height: 80)
 | 
			
		||||
                }
 | 
			
		||||
                .padding(.horizontal)
 | 
			
		||||
                
 | 
			
		||||
                // Toggles
 | 
			
		||||
                VStack(spacing: 12) {
 | 
			
		||||
                    Toggle("Invisible", isOn: $isInvisible)
 | 
			
		||||
                        .padding(.horizontal)
 | 
			
		||||
                    
 | 
			
		||||
                    Toggle("Do Not Disturb", isOn: $isNotDisturb)
 | 
			
		||||
                        .padding(.horizontal)
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                // Error message
 | 
			
		||||
                if let error = error {
 | 
			
		||||
                    Text("Error: \(error.localizedDescription)")
 | 
			
		||||
                        .foregroundColor(.red)
 | 
			
		||||
                        .font(.caption)
 | 
			
		||||
                        .padding(.horizontal)
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                // Buttons
 | 
			
		||||
                HStack(spacing: 12) {
 | 
			
		||||
                    Button("Cancel") {
 | 
			
		||||
                        dismiss()
 | 
			
		||||
                    }
 | 
			
		||||
                    .buttonStyle(.automatic)
 | 
			
		||||
                    
 | 
			
		||||
                    Button(isSubmitting ? "Saving..." : "Save") {
 | 
			
		||||
                        Task {
 | 
			
		||||
                            await submitStatus()
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    .buttonStyle(.automatic)
 | 
			
		||||
                    .disabled(isSubmitting)
 | 
			
		||||
                }
 | 
			
		||||
                .padding(.horizontal)
 | 
			
		||||
                .padding(.bottom)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .navigationTitle("Status")
 | 
			
		||||
        .navigationBarTitleDisplayMode(.inline)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    private func submitStatus() async {
 | 
			
		||||
        guard let token = appState.token, let serverUrl = appState.serverUrl else {
 | 
			
		||||
            error = NSError(domain: "StatusCreationView", code: 1, userInfo: [NSLocalizedDescriptionKey: "Authentication not available"])
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        isSubmitting = true
 | 
			
		||||
        error = nil
 | 
			
		||||
        
 | 
			
		||||
        do {
 | 
			
		||||
            _ = try await networkService.createOrUpdateStatus(
 | 
			
		||||
                attitude: attitude,
 | 
			
		||||
                isInvisible: isInvisible,
 | 
			
		||||
                isNotDisturb: isNotDisturb,
 | 
			
		||||
                label: label.isEmpty ? nil : label,
 | 
			
		||||
                token: token,
 | 
			
		||||
                serverUrl: serverUrl
 | 
			
		||||
            )
 | 
			
		||||
            dismiss()
 | 
			
		||||
        } catch {
 | 
			
		||||
            self.error = error
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        isSubmitting = false
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#Preview {
 | 
			
		||||
    StatusCreationView()
 | 
			
		||||
        .environmentObject(AppState())
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										12
									
								
								ios/Solian Watch App/Views/VideoPlayerView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,12 @@
 | 
			
		||||
import SwiftUI
 | 
			
		||||
import AVKit
 | 
			
		||||
import AVFoundation
 | 
			
		||||
 | 
			
		||||
struct VideoPlayerView: View {
 | 
			
		||||
    let videoUrl: URL
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        VideoPlayer(player: AVPlayer(url: videoUrl))
 | 
			
		||||
            .edgesIgnoringSafeArea(.all) // Make it full screen
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										17
									
								
								ios/Solian Watch App/WatchRunnerApp.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,17 @@
 | 
			
		||||
//
 | 
			
		||||
//  WatchRunnerApp.swift
 | 
			
		||||
//  WatchRunner Watch App
 | 
			
		||||
//
 | 
			
		||||
//  Created by LittleSheep on 2025/10/28.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
@main
 | 
			
		||||
struct WatchRunner_Watch_AppApp: App {
 | 
			
		||||
    var body: some Scene {
 | 
			
		||||
        WindowGroup {
 | 
			
		||||
            ContentView()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -9,6 +9,7 @@ import UserNotifications
 | 
			
		||||
import Intents
 | 
			
		||||
import Kingfisher
 | 
			
		||||
import UniformTypeIdentifiers
 | 
			
		||||
import KingfisherWebP
 | 
			
		||||
 | 
			
		||||
enum ParseNotificationPayloadError: Error {
 | 
			
		||||
    case missingMetadata(String)
 | 
			
		||||
@@ -24,6 +25,11 @@ class NotificationService: UNNotificationServiceExtension {
 | 
			
		||||
        _ request: UNNotificationRequest,
 | 
			
		||||
        withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
 | 
			
		||||
    ) {
 | 
			
		||||
        KingfisherManager.shared.defaultOptions += [
 | 
			
		||||
          .processor(WebPProcessor.default),
 | 
			
		||||
          .cacheSerializer(WebPSerializer.default)
 | 
			
		||||
        ]
 | 
			
		||||
        
 | 
			
		||||
        self.contentHandler = contentHandler
 | 
			
		||||
        guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
 | 
			
		||||
            contentHandler(request.content)
 | 
			
		||||
@@ -57,44 +63,58 @@ class NotificationService: UNNotificationServiceExtension {
 | 
			
		||||
        guard let meta = content.userInfo["meta"] as? [AnyHashable: Any] else {
 | 
			
		||||
            throw ParseNotificationPayloadError.missingMetadata("The notification has no meta.")
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        let pfpIdentifier = meta["pfp"] as? String
 | 
			
		||||
        
 | 
			
		||||
        let metaCopy = meta as? [String: Any] ?? [:]
 | 
			
		||||
        let pfpUrl = pfpIdentifier != nil ? getAttachmentUrl(for: pfpIdentifier!) : nil
 | 
			
		||||
        
 | 
			
		||||
        let targetSize = 512
 | 
			
		||||
        let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
 | 
			
		||||
        
 | 
			
		||||
        KingfisherManager.shared.retrieveImage(with: URL(string: pfpUrl!)!, options: [.processor(scaleProcessor)], completionHandler: { result in
 | 
			
		||||
            var image: Data?
 | 
			
		||||
            switch result {
 | 
			
		||||
            case .success(let value):
 | 
			
		||||
                image = value.image.pngData()
 | 
			
		||||
            case .failure(let error):
 | 
			
		||||
                print("Unable to get pfp url: \(error)")
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            let handle = INPersonHandle(value: "\(metaCopy["user_id"] ?? "")", type: .unknown)
 | 
			
		||||
 | 
			
		||||
        let handle = INPersonHandle(value: "\(metaCopy["user_id"] ?? "")", type: .unknown)
 | 
			
		||||
 | 
			
		||||
        let completeNotificationProcessing: (Data?) -> Void = { imageData in
 | 
			
		||||
            let sender = INPerson(
 | 
			
		||||
                personHandle: handle,
 | 
			
		||||
                nameComponents: PersonNameComponents(nickname: "\(metaCopy["sender_name"] ?? "")"),
 | 
			
		||||
                displayName: content.title,
 | 
			
		||||
                image: image == nil ? nil : INImage(imageData: image!),
 | 
			
		||||
                image: imageData == nil ? nil : INImage(imageData: imageData!),
 | 
			
		||||
                contactIdentifier: nil,
 | 
			
		||||
                customIdentifier: nil
 | 
			
		||||
            )
 | 
			
		||||
            
 | 
			
		||||
 | 
			
		||||
            let intent = self.createMessageIntent(with: sender, meta: metaCopy, body: content.body)
 | 
			
		||||
            self.donateInteraction(for: intent)
 | 
			
		||||
            let updatedContent = try? request.content.updating(from: intent)
 | 
			
		||||
            content.categoryIdentifier = "CHAT_MESSAGE"
 | 
			
		||||
            if let updatedContent = updatedContent {
 | 
			
		||||
                self.contentHandler?(updatedContent)
 | 
			
		||||
 | 
			
		||||
            if let updatedContent = try? request.content.updating(from: intent) {
 | 
			
		||||
                if let mutableContent = updatedContent.mutableCopy() as? UNMutableNotificationContent {
 | 
			
		||||
                    mutableContent.categoryIdentifier = "CHAT_MESSAGE"
 | 
			
		||||
                    self.contentHandler?(mutableContent)
 | 
			
		||||
                } else {
 | 
			
		||||
                    self.contentHandler?(updatedContent)
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                content.categoryIdentifier = "CHAT_MESSAGE"
 | 
			
		||||
                self.contentHandler?(content)
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if let pfpUrl = pfpUrl, let url = URL(string: pfpUrl) {
 | 
			
		||||
            let targetSize = 512
 | 
			
		||||
            let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
 | 
			
		||||
 | 
			
		||||
            KingfisherManager.shared.retrieveImage(with: url, options: [
 | 
			
		||||
                .processor(scaleProcessor)
 | 
			
		||||
            ], completionHandler: { result in
 | 
			
		||||
                var image: Data?
 | 
			
		||||
                switch result {
 | 
			
		||||
                case .success(let value):
 | 
			
		||||
                    image = value.image.pngData()
 | 
			
		||||
                case .failure(let error):
 | 
			
		||||
                    print("Unable to get pfp url: \(error)")
 | 
			
		||||
                }
 | 
			
		||||
                completeNotificationProcessing(image)
 | 
			
		||||
            })
 | 
			
		||||
        } else {
 | 
			
		||||
            completeNotificationProcessing(nil)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    private func handleDefaultNotification(content: UNMutableNotificationContent) throws {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								ios/WatchRunner-Watch-App-Info.plist
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,10 @@
 | 
			
		||||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 | 
			
		||||
<plist version="1.0">
 | 
			
		||||
<dict>
 | 
			
		||||
	<key>UIBackgroundModes</key>
 | 
			
		||||
	<array>
 | 
			
		||||
		<string>remote-notification</string>
 | 
			
		||||
	</array>
 | 
			
		||||
</dict>
 | 
			
		||||
</plist>
 | 
			
		||||
@@ -120,13 +120,24 @@ void main() async {
 | 
			
		||||
      windowButtonVisibility: true,
 | 
			
		||||
    );
 | 
			
		||||
    windowManager.waitUntilReadyToShow(windowOptions, () async {
 | 
			
		||||
      final env = Platform.environment;
 | 
			
		||||
      final isWayland = env.containsKey('WAYLAND_DISPLAY');
 | 
			
		||||
 | 
			
		||||
      if (isWayland) {
 | 
			
		||||
        try {
 | 
			
		||||
          await windowManager.setAsFrameless();
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          debugPrint('[Wayland] setAsFrameless failed: $e');
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      await windowManager.setMinimumSize(defaultSize);
 | 
			
		||||
      await windowManager.show();
 | 
			
		||||
      await windowManager.focus();
 | 
			
		||||
      final opacity = prefs.getDouble(kAppWindowOpacity) ?? 1.0;
 | 
			
		||||
      await windowManager.setOpacity(opacity);
 | 
			
		||||
      talker.info(
 | 
			
		||||
        "[SplashScreen] Desktop window is ready with size: ${initialSize.width}x${initialSize.height}",
 | 
			
		||||
        "[SplashScreen] Desktop window is ready with size: ${initialSize.width}x${initialSize.height}"
 | 
			
		||||
        "${isWayland ? " (Wayland frameless fix applied)" : ""}",
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -19,8 +19,8 @@ sealed class SnNotableDay with _$SnNotableDay {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
sealed class SnActivity with _$SnActivity {
 | 
			
		||||
  const factory SnActivity({
 | 
			
		||||
sealed class SnTimelineEvent with _$SnTimelineEvent {
 | 
			
		||||
  const factory SnTimelineEvent({
 | 
			
		||||
    required String id,
 | 
			
		||||
    required String type,
 | 
			
		||||
    required String resourceIdentifier,
 | 
			
		||||
@@ -28,10 +28,10 @@ sealed class SnActivity with _$SnActivity {
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
  }) = _SnActivity;
 | 
			
		||||
  }) = _SnTimelineEvent;
 | 
			
		||||
 | 
			
		||||
  factory SnActivity.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnActivityFromJson(json);
 | 
			
		||||
  factory SnTimelineEvent.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnTimelineEventFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
@@ -74,3 +74,29 @@ sealed class SnEventCalendarEntry with _$SnEventCalendarEntry {
 | 
			
		||||
  factory SnEventCalendarEntry.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnEventCalendarEntryFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@freezed
 | 
			
		||||
sealed class SnPresenceActivity with _$SnPresenceActivity {
 | 
			
		||||
  const factory SnPresenceActivity({
 | 
			
		||||
    required String id,
 | 
			
		||||
    required int type,
 | 
			
		||||
    required String? manualId,
 | 
			
		||||
    required String? title,
 | 
			
		||||
    required String? subtitle,
 | 
			
		||||
    required String? caption,
 | 
			
		||||
    required String? titleUrl,
 | 
			
		||||
    required String? subtitleUrl,
 | 
			
		||||
    required String? smallImage,
 | 
			
		||||
    required String? largeImage,
 | 
			
		||||
    required Map<String, dynamic>? meta,
 | 
			
		||||
    required int leaseMinutes,
 | 
			
		||||
    required DateTime leaseExpiresAt,
 | 
			
		||||
    required String accountId,
 | 
			
		||||
    required DateTime createdAt,
 | 
			
		||||
    required DateTime updatedAt,
 | 
			
		||||
    required DateTime? deletedAt,
 | 
			
		||||
  }) = _SnPresenceActivity;
 | 
			
		||||
 | 
			
		||||
  factory SnPresenceActivity.fromJson(Map<String, dynamic> json) =>
 | 
			
		||||
      _$SnPresenceActivityFromJson(json);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -288,22 +288,22 @@ as List<int>,
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
mixin _$SnActivity {
 | 
			
		||||
mixin _$SnTimelineEvent {
 | 
			
		||||
 | 
			
		||||
 String get id; String get type; String get resourceIdentifier; dynamic get data; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
 | 
			
		||||
/// Create a copy of SnActivity
 | 
			
		||||
/// Create a copy of SnTimelineEvent
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@pragma('vm:prefer-inline')
 | 
			
		||||
$SnActivityCopyWith<SnActivity> get copyWith => _$SnActivityCopyWithImpl<SnActivity>(this as SnActivity, _$identity);
 | 
			
		||||
$SnTimelineEventCopyWith<SnTimelineEvent> get copyWith => _$SnTimelineEventCopyWithImpl<SnTimelineEvent>(this as SnTimelineEvent, _$identity);
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnActivity to a JSON map.
 | 
			
		||||
  /// Serializes this SnTimelineEvent to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
bool operator ==(Object other) {
 | 
			
		||||
  return identical(this, other) || (other.runtimeType == runtimeType&&other is SnActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
 | 
			
		||||
  return identical(this, other) || (other.runtimeType == runtimeType&&other is SnTimelineEvent&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@@ -312,15 +312,15 @@ int get hashCode => Object.hash(runtimeType,id,type,resourceIdentifier,const Dee
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
String toString() {
 | 
			
		||||
  return 'SnActivity(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
 | 
			
		||||
  return 'SnTimelineEvent(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class $SnActivityCopyWith<$Res>  {
 | 
			
		||||
  factory $SnActivityCopyWith(SnActivity value, $Res Function(SnActivity) _then) = _$SnActivityCopyWithImpl;
 | 
			
		||||
abstract mixin class $SnTimelineEventCopyWith<$Res>  {
 | 
			
		||||
  factory $SnTimelineEventCopyWith(SnTimelineEvent value, $Res Function(SnTimelineEvent) _then) = _$SnTimelineEventCopyWithImpl;
 | 
			
		||||
@useResult
 | 
			
		||||
$Res call({
 | 
			
		||||
 String id, String type, String resourceIdentifier, dynamic data, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
 | 
			
		||||
@@ -331,14 +331,14 @@ $Res call({
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class _$SnActivityCopyWithImpl<$Res>
 | 
			
		||||
    implements $SnActivityCopyWith<$Res> {
 | 
			
		||||
  _$SnActivityCopyWithImpl(this._self, this._then);
 | 
			
		||||
class _$SnTimelineEventCopyWithImpl<$Res>
 | 
			
		||||
    implements $SnTimelineEventCopyWith<$Res> {
 | 
			
		||||
  _$SnTimelineEventCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final SnActivity _self;
 | 
			
		||||
  final $Res Function(SnActivity) _then;
 | 
			
		||||
  final SnTimelineEvent _self;
 | 
			
		||||
  final $Res Function(SnTimelineEvent) _then;
 | 
			
		||||
 | 
			
		||||
/// Create a copy of SnActivity
 | 
			
		||||
/// Create a copy of SnTimelineEvent
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? resourceIdentifier = null,Object? data = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
 | 
			
		||||
  return _then(_self.copyWith(
 | 
			
		||||
@@ -356,8 +356,8 @@ as DateTime?,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// Adds pattern-matching-related methods to [SnActivity].
 | 
			
		||||
extension SnActivityPatterns on SnActivity {
 | 
			
		||||
/// Adds pattern-matching-related methods to [SnTimelineEvent].
 | 
			
		||||
extension SnTimelineEventPatterns on SnTimelineEvent {
 | 
			
		||||
/// A variant of `map` that fallback to returning `orElse`.
 | 
			
		||||
///
 | 
			
		||||
/// It is equivalent to doing:
 | 
			
		||||
@@ -370,10 +370,10 @@ extension SnActivityPatterns on SnActivity {
 | 
			
		||||
/// }
 | 
			
		||||
/// ```
 | 
			
		||||
 | 
			
		||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnActivity value)?  $default,{required TResult orElse(),}){
 | 
			
		||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnTimelineEvent value)?  $default,{required TResult orElse(),}){
 | 
			
		||||
final _that = this;
 | 
			
		||||
switch (_that) {
 | 
			
		||||
case _SnActivity() when $default != null:
 | 
			
		||||
case _SnTimelineEvent() when $default != null:
 | 
			
		||||
return $default(_that);case _:
 | 
			
		||||
  return orElse();
 | 
			
		||||
 | 
			
		||||
@@ -392,10 +392,10 @@ return $default(_that);case _:
 | 
			
		||||
/// }
 | 
			
		||||
/// ```
 | 
			
		||||
 | 
			
		||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnActivity value)  $default,){
 | 
			
		||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnTimelineEvent value)  $default,){
 | 
			
		||||
final _that = this;
 | 
			
		||||
switch (_that) {
 | 
			
		||||
case _SnActivity():
 | 
			
		||||
case _SnTimelineEvent():
 | 
			
		||||
return $default(_that);}
 | 
			
		||||
}
 | 
			
		||||
/// A variant of `map` that fallback to returning `null`.
 | 
			
		||||
@@ -410,10 +410,10 @@ return $default(_that);}
 | 
			
		||||
/// }
 | 
			
		||||
/// ```
 | 
			
		||||
 | 
			
		||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnActivity value)?  $default,){
 | 
			
		||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnTimelineEvent value)?  $default,){
 | 
			
		||||
final _that = this;
 | 
			
		||||
switch (_that) {
 | 
			
		||||
case _SnActivity() when $default != null:
 | 
			
		||||
case _SnTimelineEvent() when $default != null:
 | 
			
		||||
return $default(_that);case _:
 | 
			
		||||
  return null;
 | 
			
		||||
 | 
			
		||||
@@ -433,7 +433,7 @@ return $default(_that);case _:
 | 
			
		||||
 | 
			
		||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String type,  String resourceIdentifier,  dynamic data,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this;
 | 
			
		||||
switch (_that) {
 | 
			
		||||
case _SnActivity() when $default != null:
 | 
			
		||||
case _SnTimelineEvent() when $default != null:
 | 
			
		||||
return $default(_that.id,_that.type,_that.resourceIdentifier,_that.data,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
 | 
			
		||||
  return orElse();
 | 
			
		||||
 | 
			
		||||
@@ -454,7 +454,7 @@ return $default(_that.id,_that.type,_that.resourceIdentifier,_that.data,_that.cr
 | 
			
		||||
 | 
			
		||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String type,  String resourceIdentifier,  dynamic data,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this;
 | 
			
		||||
switch (_that) {
 | 
			
		||||
case _SnActivity():
 | 
			
		||||
case _SnTimelineEvent():
 | 
			
		||||
return $default(_that.id,_that.type,_that.resourceIdentifier,_that.data,_that.createdAt,_that.updatedAt,_that.deletedAt);}
 | 
			
		||||
}
 | 
			
		||||
/// A variant of `when` that fallback to returning `null`
 | 
			
		||||
@@ -471,7 +471,7 @@ return $default(_that.id,_that.type,_that.resourceIdentifier,_that.data,_that.cr
 | 
			
		||||
 | 
			
		||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String type,  String resourceIdentifier,  dynamic data,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this;
 | 
			
		||||
switch (_that) {
 | 
			
		||||
case _SnActivity() when $default != null:
 | 
			
		||||
case _SnTimelineEvent() when $default != null:
 | 
			
		||||
return $default(_that.id,_that.type,_that.resourceIdentifier,_that.data,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
 | 
			
		||||
  return null;
 | 
			
		||||
 | 
			
		||||
@@ -483,9 +483,9 @@ return $default(_that.id,_that.type,_that.resourceIdentifier,_that.data,_that.cr
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@JsonSerializable()
 | 
			
		||||
 | 
			
		||||
class _SnActivity implements SnActivity {
 | 
			
		||||
  const _SnActivity({required this.id, required this.type, required this.resourceIdentifier, required this.data, required this.createdAt, required this.updatedAt, required this.deletedAt});
 | 
			
		||||
  factory _SnActivity.fromJson(Map<String, dynamic> json) => _$SnActivityFromJson(json);
 | 
			
		||||
class _SnTimelineEvent implements SnTimelineEvent {
 | 
			
		||||
  const _SnTimelineEvent({required this.id, required this.type, required this.resourceIdentifier, required this.data, required this.createdAt, required this.updatedAt, required this.deletedAt});
 | 
			
		||||
  factory _SnTimelineEvent.fromJson(Map<String, dynamic> json) => _$SnTimelineEventFromJson(json);
 | 
			
		||||
 | 
			
		||||
@override final  String id;
 | 
			
		||||
@override final  String type;
 | 
			
		||||
@@ -495,20 +495,20 @@ class _SnActivity implements SnActivity {
 | 
			
		||||
@override final  DateTime updatedAt;
 | 
			
		||||
@override final  DateTime? deletedAt;
 | 
			
		||||
 | 
			
		||||
/// Create a copy of SnActivity
 | 
			
		||||
/// Create a copy of SnTimelineEvent
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@pragma('vm:prefer-inline')
 | 
			
		||||
_$SnActivityCopyWith<_SnActivity> get copyWith => __$SnActivityCopyWithImpl<_SnActivity>(this, _$identity);
 | 
			
		||||
_$SnTimelineEventCopyWith<_SnTimelineEvent> get copyWith => __$SnTimelineEventCopyWithImpl<_SnTimelineEvent>(this, _$identity);
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
Map<String, dynamic> toJson() {
 | 
			
		||||
  return _$SnActivityToJson(this, );
 | 
			
		||||
  return _$SnTimelineEventToJson(this, );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
bool operator ==(Object other) {
 | 
			
		||||
  return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
 | 
			
		||||
  return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnTimelineEvent&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@@ -517,15 +517,15 @@ int get hashCode => Object.hash(runtimeType,id,type,resourceIdentifier,const Dee
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
String toString() {
 | 
			
		||||
  return 'SnActivity(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
 | 
			
		||||
  return 'SnTimelineEvent(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class _$SnActivityCopyWith<$Res> implements $SnActivityCopyWith<$Res> {
 | 
			
		||||
  factory _$SnActivityCopyWith(_SnActivity value, $Res Function(_SnActivity) _then) = __$SnActivityCopyWithImpl;
 | 
			
		||||
abstract mixin class _$SnTimelineEventCopyWith<$Res> implements $SnTimelineEventCopyWith<$Res> {
 | 
			
		||||
  factory _$SnTimelineEventCopyWith(_SnTimelineEvent value, $Res Function(_SnTimelineEvent) _then) = __$SnTimelineEventCopyWithImpl;
 | 
			
		||||
@override @useResult
 | 
			
		||||
$Res call({
 | 
			
		||||
 String id, String type, String resourceIdentifier, dynamic data, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
 | 
			
		||||
@@ -536,17 +536,17 @@ $Res call({
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class __$SnActivityCopyWithImpl<$Res>
 | 
			
		||||
    implements _$SnActivityCopyWith<$Res> {
 | 
			
		||||
  __$SnActivityCopyWithImpl(this._self, this._then);
 | 
			
		||||
class __$SnTimelineEventCopyWithImpl<$Res>
 | 
			
		||||
    implements _$SnTimelineEventCopyWith<$Res> {
 | 
			
		||||
  __$SnTimelineEventCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final _SnActivity _self;
 | 
			
		||||
  final $Res Function(_SnActivity) _then;
 | 
			
		||||
  final _SnTimelineEvent _self;
 | 
			
		||||
  final $Res Function(_SnTimelineEvent) _then;
 | 
			
		||||
 | 
			
		||||
/// Create a copy of SnActivity
 | 
			
		||||
/// Create a copy of SnTimelineEvent
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? resourceIdentifier = null,Object? data = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
 | 
			
		||||
  return _then(_SnActivity(
 | 
			
		||||
  return _then(_SnTimelineEvent(
 | 
			
		||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,resourceIdentifier: null == resourceIdentifier ? _self.resourceIdentifier : resourceIdentifier // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
@@ -1425,4 +1425,317 @@ $SnCheckInResultCopyWith<$Res>? get checkInResult {
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
mixin _$SnPresenceActivity {
 | 
			
		||||
 | 
			
		||||
 String get id; int get type; String? get manualId; String? get title; String? get subtitle; String? get caption; String? get titleUrl; String? get subtitleUrl; String? get smallImage; String? get largeImage; Map<String, dynamic>? get meta; int get leaseMinutes; DateTime get leaseExpiresAt; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
 | 
			
		||||
/// Create a copy of SnPresenceActivity
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@pragma('vm:prefer-inline')
 | 
			
		||||
$SnPresenceActivityCopyWith<SnPresenceActivity> get copyWith => _$SnPresenceActivityCopyWithImpl<SnPresenceActivity>(this as SnPresenceActivity, _$identity);
 | 
			
		||||
 | 
			
		||||
  /// Serializes this SnPresenceActivity to a JSON map.
 | 
			
		||||
  Map<String, dynamic> toJson();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
bool operator ==(Object other) {
 | 
			
		||||
  return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPresenceActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.manualId, manualId) || other.manualId == manualId)&&(identical(other.title, title) || other.title == title)&&(identical(other.subtitle, subtitle) || other.subtitle == subtitle)&&(identical(other.caption, caption) || other.caption == caption)&&(identical(other.titleUrl, titleUrl) || other.titleUrl == titleUrl)&&(identical(other.subtitleUrl, subtitleUrl) || other.subtitleUrl == subtitleUrl)&&(identical(other.smallImage, smallImage) || other.smallImage == smallImage)&&(identical(other.largeImage, largeImage) || other.largeImage == largeImage)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.leaseMinutes, leaseMinutes) || other.leaseMinutes == leaseMinutes)&&(identical(other.leaseExpiresAt, leaseExpiresAt) || other.leaseExpiresAt == leaseExpiresAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@override
 | 
			
		||||
int get hashCode => Object.hash(runtimeType,id,type,manualId,title,subtitle,caption,titleUrl,subtitleUrl,smallImage,largeImage,const DeepCollectionEquality().hash(meta),leaseMinutes,leaseExpiresAt,accountId,createdAt,updatedAt,deletedAt);
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
String toString() {
 | 
			
		||||
  return 'SnPresenceActivity(id: $id, type: $type, manualId: $manualId, title: $title, subtitle: $subtitle, caption: $caption, titleUrl: $titleUrl, subtitleUrl: $subtitleUrl, smallImage: $smallImage, largeImage: $largeImage, meta: $meta, leaseMinutes: $leaseMinutes, leaseExpiresAt: $leaseExpiresAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class $SnPresenceActivityCopyWith<$Res>  {
 | 
			
		||||
  factory $SnPresenceActivityCopyWith(SnPresenceActivity value, $Res Function(SnPresenceActivity) _then) = _$SnPresenceActivityCopyWithImpl;
 | 
			
		||||
@useResult
 | 
			
		||||
$Res call({
 | 
			
		||||
 String id, int type, String? manualId, String? title, String? subtitle, String? caption, String? titleUrl, String? subtitleUrl, String? smallImage, String? largeImage, Map<String, dynamic>? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class _$SnPresenceActivityCopyWithImpl<$Res>
 | 
			
		||||
    implements $SnPresenceActivityCopyWith<$Res> {
 | 
			
		||||
  _$SnPresenceActivityCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final SnPresenceActivity _self;
 | 
			
		||||
  final $Res Function(SnPresenceActivity) _then;
 | 
			
		||||
 | 
			
		||||
/// Create a copy of SnPresenceActivity
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? manualId = freezed,Object? title = freezed,Object? subtitle = freezed,Object? caption = freezed,Object? titleUrl = freezed,Object? subtitleUrl = freezed,Object? smallImage = freezed,Object? largeImage = freezed,Object? meta = freezed,Object? leaseMinutes = null,Object? leaseExpiresAt = null,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
 | 
			
		||||
  return _then(_self.copyWith(
 | 
			
		||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as int,manualId: freezed == manualId ? _self.manualId : manualId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,subtitle: freezed == subtitle ? _self.subtitle : subtitle // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,caption: freezed == caption ? _self.caption : caption // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,titleUrl: freezed == titleUrl ? _self.titleUrl : titleUrl // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,subtitleUrl: freezed == subtitleUrl ? _self.subtitleUrl : subtitleUrl // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,smallImage: freezed == smallImage ? _self.smallImage : smallImage // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,largeImage: freezed == largeImage ? _self.largeImage : largeImage // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,meta: freezed == meta ? _self.meta : meta // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as Map<String, dynamic>?,leaseMinutes: null == leaseMinutes ? _self.leaseMinutes : leaseMinutes // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as int,leaseExpiresAt: null == leaseExpiresAt ? _self.leaseExpiresAt : leaseExpiresAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime?,
 | 
			
		||||
  ));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// Adds pattern-matching-related methods to [SnPresenceActivity].
 | 
			
		||||
extension SnPresenceActivityPatterns on SnPresenceActivity {
 | 
			
		||||
/// A variant of `map` that fallback to returning `orElse`.
 | 
			
		||||
///
 | 
			
		||||
/// It is equivalent to doing:
 | 
			
		||||
/// ```dart
 | 
			
		||||
/// switch (sealedClass) {
 | 
			
		||||
///   case final Subclass value:
 | 
			
		||||
///     return ...;
 | 
			
		||||
///   case _:
 | 
			
		||||
///     return orElse();
 | 
			
		||||
/// }
 | 
			
		||||
/// ```
 | 
			
		||||
 | 
			
		||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnPresenceActivity value)?  $default,{required TResult orElse(),}){
 | 
			
		||||
final _that = this;
 | 
			
		||||
switch (_that) {
 | 
			
		||||
case _SnPresenceActivity() when $default != null:
 | 
			
		||||
return $default(_that);case _:
 | 
			
		||||
  return orElse();
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
/// A `switch`-like method, using callbacks.
 | 
			
		||||
///
 | 
			
		||||
/// Callbacks receives the raw object, upcasted.
 | 
			
		||||
/// It is equivalent to doing:
 | 
			
		||||
/// ```dart
 | 
			
		||||
/// switch (sealedClass) {
 | 
			
		||||
///   case final Subclass value:
 | 
			
		||||
///     return ...;
 | 
			
		||||
///   case final Subclass2 value:
 | 
			
		||||
///     return ...;
 | 
			
		||||
/// }
 | 
			
		||||
/// ```
 | 
			
		||||
 | 
			
		||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnPresenceActivity value)  $default,){
 | 
			
		||||
final _that = this;
 | 
			
		||||
switch (_that) {
 | 
			
		||||
case _SnPresenceActivity():
 | 
			
		||||
return $default(_that);}
 | 
			
		||||
}
 | 
			
		||||
/// A variant of `map` that fallback to returning `null`.
 | 
			
		||||
///
 | 
			
		||||
/// It is equivalent to doing:
 | 
			
		||||
/// ```dart
 | 
			
		||||
/// switch (sealedClass) {
 | 
			
		||||
///   case final Subclass value:
 | 
			
		||||
///     return ...;
 | 
			
		||||
///   case _:
 | 
			
		||||
///     return null;
 | 
			
		||||
/// }
 | 
			
		||||
/// ```
 | 
			
		||||
 | 
			
		||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnPresenceActivity value)?  $default,){
 | 
			
		||||
final _that = this;
 | 
			
		||||
switch (_that) {
 | 
			
		||||
case _SnPresenceActivity() when $default != null:
 | 
			
		||||
return $default(_that);case _:
 | 
			
		||||
  return null;
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
/// A variant of `when` that fallback to an `orElse` callback.
 | 
			
		||||
///
 | 
			
		||||
/// It is equivalent to doing:
 | 
			
		||||
/// ```dart
 | 
			
		||||
/// switch (sealedClass) {
 | 
			
		||||
///   case Subclass(:final field):
 | 
			
		||||
///     return ...;
 | 
			
		||||
///   case _:
 | 
			
		||||
///     return orElse();
 | 
			
		||||
/// }
 | 
			
		||||
/// ```
 | 
			
		||||
 | 
			
		||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  int type,  String? manualId,  String? title,  String? subtitle,  String? caption,  String? titleUrl,  String? subtitleUrl,  String? smallImage,  String? largeImage,  Map<String, dynamic>? meta,  int leaseMinutes,  DateTime leaseExpiresAt,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this;
 | 
			
		||||
switch (_that) {
 | 
			
		||||
case _SnPresenceActivity() when $default != null:
 | 
			
		||||
return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_that.caption,_that.titleUrl,_that.subtitleUrl,_that.smallImage,_that.largeImage,_that.meta,_that.leaseMinutes,_that.leaseExpiresAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
 | 
			
		||||
  return orElse();
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
/// A `switch`-like method, using callbacks.
 | 
			
		||||
///
 | 
			
		||||
/// As opposed to `map`, this offers destructuring.
 | 
			
		||||
/// It is equivalent to doing:
 | 
			
		||||
/// ```dart
 | 
			
		||||
/// switch (sealedClass) {
 | 
			
		||||
///   case Subclass(:final field):
 | 
			
		||||
///     return ...;
 | 
			
		||||
///   case Subclass2(:final field2):
 | 
			
		||||
///     return ...;
 | 
			
		||||
/// }
 | 
			
		||||
/// ```
 | 
			
		||||
 | 
			
		||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  int type,  String? manualId,  String? title,  String? subtitle,  String? caption,  String? titleUrl,  String? subtitleUrl,  String? smallImage,  String? largeImage,  Map<String, dynamic>? meta,  int leaseMinutes,  DateTime leaseExpiresAt,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this;
 | 
			
		||||
switch (_that) {
 | 
			
		||||
case _SnPresenceActivity():
 | 
			
		||||
return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_that.caption,_that.titleUrl,_that.subtitleUrl,_that.smallImage,_that.largeImage,_that.meta,_that.leaseMinutes,_that.leaseExpiresAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);}
 | 
			
		||||
}
 | 
			
		||||
/// A variant of `when` that fallback to returning `null`
 | 
			
		||||
///
 | 
			
		||||
/// It is equivalent to doing:
 | 
			
		||||
/// ```dart
 | 
			
		||||
/// switch (sealedClass) {
 | 
			
		||||
///   case Subclass(:final field):
 | 
			
		||||
///     return ...;
 | 
			
		||||
///   case _:
 | 
			
		||||
///     return null;
 | 
			
		||||
/// }
 | 
			
		||||
/// ```
 | 
			
		||||
 | 
			
		||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  int type,  String? manualId,  String? title,  String? subtitle,  String? caption,  String? titleUrl,  String? subtitleUrl,  String? smallImage,  String? largeImage,  Map<String, dynamic>? meta,  int leaseMinutes,  DateTime leaseExpiresAt,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this;
 | 
			
		||||
switch (_that) {
 | 
			
		||||
case _SnPresenceActivity() when $default != null:
 | 
			
		||||
return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_that.caption,_that.titleUrl,_that.subtitleUrl,_that.smallImage,_that.largeImage,_that.meta,_that.leaseMinutes,_that.leaseExpiresAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
 | 
			
		||||
  return null;
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
@JsonSerializable()
 | 
			
		||||
 | 
			
		||||
class _SnPresenceActivity implements SnPresenceActivity {
 | 
			
		||||
  const _SnPresenceActivity({required this.id, required this.type, required this.manualId, required this.title, required this.subtitle, required this.caption, required this.titleUrl, required this.subtitleUrl, required this.smallImage, required this.largeImage, required final  Map<String, dynamic>? meta, required this.leaseMinutes, required this.leaseExpiresAt, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta;
 | 
			
		||||
  factory _SnPresenceActivity.fromJson(Map<String, dynamic> json) => _$SnPresenceActivityFromJson(json);
 | 
			
		||||
 | 
			
		||||
@override final  String id;
 | 
			
		||||
@override final  int type;
 | 
			
		||||
@override final  String? manualId;
 | 
			
		||||
@override final  String? title;
 | 
			
		||||
@override final  String? subtitle;
 | 
			
		||||
@override final  String? caption;
 | 
			
		||||
@override final  String? titleUrl;
 | 
			
		||||
@override final  String? subtitleUrl;
 | 
			
		||||
@override final  String? smallImage;
 | 
			
		||||
@override final  String? largeImage;
 | 
			
		||||
 final  Map<String, dynamic>? _meta;
 | 
			
		||||
@override Map<String, dynamic>? get meta {
 | 
			
		||||
  final value = _meta;
 | 
			
		||||
  if (value == null) return null;
 | 
			
		||||
  if (_meta is EqualUnmodifiableMapView) return _meta;
 | 
			
		||||
  // ignore: implicit_dynamic_type
 | 
			
		||||
  return EqualUnmodifiableMapView(value);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@override final  int leaseMinutes;
 | 
			
		||||
@override final  DateTime leaseExpiresAt;
 | 
			
		||||
@override final  String accountId;
 | 
			
		||||
@override final  DateTime createdAt;
 | 
			
		||||
@override final  DateTime updatedAt;
 | 
			
		||||
@override final  DateTime? deletedAt;
 | 
			
		||||
 | 
			
		||||
/// Create a copy of SnPresenceActivity
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@pragma('vm:prefer-inline')
 | 
			
		||||
_$SnPresenceActivityCopyWith<_SnPresenceActivity> get copyWith => __$SnPresenceActivityCopyWithImpl<_SnPresenceActivity>(this, _$identity);
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
Map<String, dynamic> toJson() {
 | 
			
		||||
  return _$SnPresenceActivityToJson(this, );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
bool operator ==(Object other) {
 | 
			
		||||
  return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPresenceActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.manualId, manualId) || other.manualId == manualId)&&(identical(other.title, title) || other.title == title)&&(identical(other.subtitle, subtitle) || other.subtitle == subtitle)&&(identical(other.caption, caption) || other.caption == caption)&&(identical(other.titleUrl, titleUrl) || other.titleUrl == titleUrl)&&(identical(other.subtitleUrl, subtitleUrl) || other.subtitleUrl == subtitleUrl)&&(identical(other.smallImage, smallImage) || other.smallImage == smallImage)&&(identical(other.largeImage, largeImage) || other.largeImage == largeImage)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.leaseMinutes, leaseMinutes) || other.leaseMinutes == leaseMinutes)&&(identical(other.leaseExpiresAt, leaseExpiresAt) || other.leaseExpiresAt == leaseExpiresAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@JsonKey(includeFromJson: false, includeToJson: false)
 | 
			
		||||
@override
 | 
			
		||||
int get hashCode => Object.hash(runtimeType,id,type,manualId,title,subtitle,caption,titleUrl,subtitleUrl,smallImage,largeImage,const DeepCollectionEquality().hash(_meta),leaseMinutes,leaseExpiresAt,accountId,createdAt,updatedAt,deletedAt);
 | 
			
		||||
 | 
			
		||||
@override
 | 
			
		||||
String toString() {
 | 
			
		||||
  return 'SnPresenceActivity(id: $id, type: $type, manualId: $manualId, title: $title, subtitle: $subtitle, caption: $caption, titleUrl: $titleUrl, subtitleUrl: $subtitleUrl, smallImage: $smallImage, largeImage: $largeImage, meta: $meta, leaseMinutes: $leaseMinutes, leaseExpiresAt: $leaseExpiresAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// @nodoc
 | 
			
		||||
abstract mixin class _$SnPresenceActivityCopyWith<$Res> implements $SnPresenceActivityCopyWith<$Res> {
 | 
			
		||||
  factory _$SnPresenceActivityCopyWith(_SnPresenceActivity value, $Res Function(_SnPresenceActivity) _then) = __$SnPresenceActivityCopyWithImpl;
 | 
			
		||||
@override @useResult
 | 
			
		||||
$Res call({
 | 
			
		||||
 String id, int type, String? manualId, String? title, String? subtitle, String? caption, String? titleUrl, String? subtitleUrl, String? smallImage, String? largeImage, Map<String, dynamic>? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
/// @nodoc
 | 
			
		||||
class __$SnPresenceActivityCopyWithImpl<$Res>
 | 
			
		||||
    implements _$SnPresenceActivityCopyWith<$Res> {
 | 
			
		||||
  __$SnPresenceActivityCopyWithImpl(this._self, this._then);
 | 
			
		||||
 | 
			
		||||
  final _SnPresenceActivity _self;
 | 
			
		||||
  final $Res Function(_SnPresenceActivity) _then;
 | 
			
		||||
 | 
			
		||||
/// Create a copy of SnPresenceActivity
 | 
			
		||||
/// with the given fields replaced by the non-null parameter values.
 | 
			
		||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? manualId = freezed,Object? title = freezed,Object? subtitle = freezed,Object? caption = freezed,Object? titleUrl = freezed,Object? subtitleUrl = freezed,Object? smallImage = freezed,Object? largeImage = freezed,Object? meta = freezed,Object? leaseMinutes = null,Object? leaseExpiresAt = null,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
 | 
			
		||||
  return _then(_SnPresenceActivity(
 | 
			
		||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as int,manualId: freezed == manualId ? _self.manualId : manualId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,subtitle: freezed == subtitle ? _self.subtitle : subtitle // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,caption: freezed == caption ? _self.caption : caption // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,titleUrl: freezed == titleUrl ? _self.titleUrl : titleUrl // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,subtitleUrl: freezed == subtitleUrl ? _self.subtitleUrl : subtitleUrl // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,smallImage: freezed == smallImage ? _self.smallImage : smallImage // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,largeImage: freezed == largeImage ? _self.largeImage : largeImage // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String?,meta: freezed == meta ? _self._meta : meta // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as Map<String, dynamic>?,leaseMinutes: null == leaseMinutes ? _self.leaseMinutes : leaseMinutes // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as int,leaseExpiresAt: null == leaseExpiresAt ? _self.leaseExpiresAt : leaseExpiresAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
 | 
			
		||||
as DateTime?,
 | 
			
		||||
  ));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// dart format on
 | 
			
		||||
 
 | 
			
		||||
@@ -27,20 +27,21 @@ Map<String, dynamic> _$SnNotableDayToJson(_SnNotableDay instance) =>
 | 
			
		||||
      'holidays': instance.holidays,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
_SnActivity _$SnActivityFromJson(Map<String, dynamic> json) => _SnActivity(
 | 
			
		||||
  id: json['id'] as String,
 | 
			
		||||
  type: json['type'] as String,
 | 
			
		||||
  resourceIdentifier: json['resource_identifier'] as String,
 | 
			
		||||
  data: json['data'],
 | 
			
		||||
  createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
  updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
  deletedAt:
 | 
			
		||||
      json['deleted_at'] == null
 | 
			
		||||
          ? null
 | 
			
		||||
          : DateTime.parse(json['deleted_at'] as String),
 | 
			
		||||
);
 | 
			
		||||
_SnTimelineEvent _$SnTimelineEventFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _SnTimelineEvent(
 | 
			
		||||
      id: json['id'] as String,
 | 
			
		||||
      type: json['type'] as String,
 | 
			
		||||
      resourceIdentifier: json['resource_identifier'] as String,
 | 
			
		||||
      data: json['data'],
 | 
			
		||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
      deletedAt:
 | 
			
		||||
          json['deleted_at'] == null
 | 
			
		||||
              ? null
 | 
			
		||||
              : DateTime.parse(json['deleted_at'] as String),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$SnActivityToJson(_SnActivity instance) =>
 | 
			
		||||
Map<String, dynamic> _$SnTimelineEventToJson(_SnTimelineEvent instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'type': instance.type,
 | 
			
		||||
@@ -121,3 +122,48 @@ Map<String, dynamic> _$SnEventCalendarEntryToJson(
 | 
			
		||||
  'check_in_result': instance.checkInResult?.toJson(),
 | 
			
		||||
  'statuses': instance.statuses.map((e) => e.toJson()).toList(),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
_SnPresenceActivity _$SnPresenceActivityFromJson(Map<String, dynamic> json) =>
 | 
			
		||||
    _SnPresenceActivity(
 | 
			
		||||
      id: json['id'] as String,
 | 
			
		||||
      type: (json['type'] as num).toInt(),
 | 
			
		||||
      manualId: json['manual_id'] as String?,
 | 
			
		||||
      title: json['title'] as String?,
 | 
			
		||||
      subtitle: json['subtitle'] as String?,
 | 
			
		||||
      caption: json['caption'] as String?,
 | 
			
		||||
      titleUrl: json['title_url'] as String?,
 | 
			
		||||
      subtitleUrl: json['subtitle_url'] as String?,
 | 
			
		||||
      smallImage: json['small_image'] as String?,
 | 
			
		||||
      largeImage: json['large_image'] as String?,
 | 
			
		||||
      meta: json['meta'] as Map<String, dynamic>?,
 | 
			
		||||
      leaseMinutes: (json['lease_minutes'] as num).toInt(),
 | 
			
		||||
      leaseExpiresAt: DateTime.parse(json['lease_expires_at'] as String),
 | 
			
		||||
      accountId: json['account_id'] as String,
 | 
			
		||||
      createdAt: DateTime.parse(json['created_at'] as String),
 | 
			
		||||
      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
			
		||||
      deletedAt:
 | 
			
		||||
          json['deleted_at'] == null
 | 
			
		||||
              ? null
 | 
			
		||||
              : DateTime.parse(json['deleted_at'] as String),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
Map<String, dynamic> _$SnPresenceActivityToJson(_SnPresenceActivity instance) =>
 | 
			
		||||
    <String, dynamic>{
 | 
			
		||||
      'id': instance.id,
 | 
			
		||||
      'type': instance.type,
 | 
			
		||||
      'manual_id': instance.manualId,
 | 
			
		||||
      'title': instance.title,
 | 
			
		||||
      'subtitle': instance.subtitle,
 | 
			
		||||
      'caption': instance.caption,
 | 
			
		||||
      'title_url': instance.titleUrl,
 | 
			
		||||
      'subtitle_url': instance.subtitleUrl,
 | 
			
		||||
      'small_image': instance.smallImage,
 | 
			
		||||
      'large_image': instance.largeImage,
 | 
			
		||||
      'meta': instance.meta,
 | 
			
		||||
      'lease_minutes': instance.leaseMinutes,
 | 
			
		||||
      'lease_expires_at': instance.leaseExpiresAt.toIso8601String(),
 | 
			
		||||
      'account_id': instance.accountId,
 | 
			
		||||
      'created_at': instance.createdAt.toIso8601String(),
 | 
			
		||||
      'updated_at': instance.updatedAt.toIso8601String(),
 | 
			
		||||
      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ part 'poll.g.dart';
 | 
			
		||||
@freezed
 | 
			
		||||
sealed class SnPollWithStats with _$SnPollWithStats {
 | 
			
		||||
  const factory SnPollWithStats({
 | 
			
		||||
    required Map<String, dynamic>? userAnswer,
 | 
			
		||||
    required SnPollAnswer? userAnswer,
 | 
			
		||||
    @Default({}) Map<String, dynamic> stats,
 | 
			
		||||
    required String id,
 | 
			
		||||
    required List<SnPollQuestion> questions,
 | 
			
		||||
 
 | 
			
		||||