Compare commits
	
		
			227 Commits
		
	
	
		
			113309257e
			...
			v3
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | ||
| c6594ea2ce | |||
| 3bec6e683e | |||
| 83e92e2eed | |||
|  | b7d44d96ba | ||
| a83b929d42 | |||
| 9423affa75 | |||
| cda23db609 | |||
| 61074bc5a3 | |||
| 5feafa9255 | |||
| e604577c1f | |||
| af0ddd1273 | |||
| 8a6bb34808 | |||
| 4ef8445c77 | |||
| ec39ad6ca3 | |||
| eabb3154f1 | |||
| 910bf20eef | |||
| 5efa9b2ae8 | |||
| dd3e39e891 | |||
| b6896ded23 | |||
| f28a73ff9c | |||
| a014b64235 | |||
| 7e0e7c20d7 | |||
| 389fa515ba | |||
| 681ead02eb | |||
| 8d1c145b0b | |||
| 51b4754182 | |||
| 8a2b321701 | |||
| f685a7a249 | |||
| 76009147e9 | |||
| ce12f28e56 | |||
| 3604373a1e | |||
| 9704a4c2c7 | |||
| 67def56ad1 | |||
| 1be33916af | |||
| e8ff1bfd22 | |||
| 3ae56f3d89 | |||
| 707143e998 | |||
| 1fd34eb2a3 | |||
| d7ca41e946 | |||
| ad9fb0719a | |||
| e2d315afd4 | |||
| 6124dbfd79 | |||
| 5327f04ec0 | |||
| 41c56a2319 | |||
| f9d033542e | |||
| 91784e65e6 | |||
| 9d39c6a825 | |||
| 537e49f1a4 | |||
| 75bbd4df71 | |||
| 6ef4580d93 | |||
| 6ffd498761 | |||
| 27157e7cc1 | |||
| bbb07d574a | |||
| c660a419e2 | |||
| c3f61467c8 | |||
| 9bc47df452 | |||
| 9ef8ca4d45 | |||
| b55cbd08d1 | |||
| 8c6bd0feaa | |||
| 7dd4b20628 | |||
| fec0cb7640 | |||
| 75deb04a2b | |||
| 7c7ed21a96 | |||
| a201f20793 | |||
| 598c51bc1a | |||
| e1ea61c5f1 | |||
| ac424bde36 | |||
| b43b70df3f | |||
| 4321aa621a | |||
| d5d275fb43 | |||
| 6bb3307144 | |||
| 391604d4a2 | |||
| 1d9361c12f | |||
| a129b9cdd0 | |||
| 3bf815ac61 | |||
| 77bae4d6fd | |||
| 0a301c4c9b | |||
| 27b390a51c | |||
| 018386d14e | |||
| 3825d7c6c7 | |||
| bf930291e4 | |||
| a8c4988790 | |||
| 28dd204b1a | |||
| 3cbc1a59a7 | |||
| 277e9ae3d1 | |||
| 27b3ca25b7 | |||
| f871cd3b62 | |||
| a8a59ee30c | |||
| 2cd1416a13 | |||
| 6be7dfbc61 | |||
| 1abbd85614 | |||
| 31ac5ad07c | |||
| ae2ba495e9 | |||
| 637aa44548 | |||
| 44dbfc36d9 | |||
| 5dbe7371cb | |||
| 6c91093198 | |||
| 3f640b7898 | |||
| 7db164fda6 | |||
| 6df1d96cc9 | |||
| 122a796f8c | |||
| fbc7812a16 | |||
| 0b1a23e81a | |||
| c87e6cfe07 | |||
| 53d51b8a0e | |||
| 337ae39e08 | |||
| 8fe3a664a6 | |||
| 3bfc0b8181 | |||
| ac2951479b | |||
| 2bfd13d843 | |||
| 28db6f9f01 | |||
| a4f7b8415d | |||
| 2255d3d591 | |||
| 97792ae734 | |||
| a5d13250cc | |||
| de9e235d0c | |||
| 56fb5451cd | |||
| 870de961f5 | |||
| 22bf6d1c33 | |||
| 5b62f89531 | |||
| b1326d8f04 | |||
| fffca4a78c | |||
| 42bd7f97cb | |||
| 6377856ae0 | |||
| 0f1c52b9e3 | |||
| 6ed6f60fbc | |||
| e65a414065 | |||
| 214d5c4a53 | |||
| fe33931304 | 
| @@ -62,3 +62,9 @@ If you want to build the release version, use the flutter build command. Learn m | |||||||
| ```bash | ```bash | ||||||
| flutter build <platform> | 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. | ||||||
| @@ -75,3 +75,4 @@ dependencies { | |||||||
| flutter { | flutter { | ||||||
|     source = "../.." |     source = "../.." | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -51,6 +51,12 @@ | |||||||
|                 <data android:scheme="http" android:host="solian.app" /> |                 <data android:scheme="http" android:host="solian.app" /> | ||||||
|                 <data android:scheme="https" /> |                 <data android:scheme="https" /> | ||||||
|             </intent-filter> |             </intent-filter> | ||||||
|  |             <intent-filter android:autoVerify="true"> | ||||||
|  |                 <action android:name="android.intent.action.VIEW" /> | ||||||
|  |                 <category android:name="android.intent.category.DEFAULT" /> | ||||||
|  |                 <category android:name="android.intent.category.BROWSABLE" /> | ||||||
|  |                 <data android:scheme="solian" /> | ||||||
|  |             </intent-filter> | ||||||
|  |  | ||||||
|             <!-- Share Intent Filters --> |             <!-- Share Intent Filters --> | ||||||
|             <intent-filter> |             <intent-filter> | ||||||
|   | |||||||
| @@ -48,6 +48,8 @@ | |||||||
|     "deletePublisherHint": "Are you sure to delete this publisher? This will also deleted all the post and collections under this publisher.", |     "deletePublisherHint": "Are you sure to delete this publisher? This will also deleted all the post and collections under this publisher.", | ||||||
|     "deletePost": "Delete Post", |     "deletePost": "Delete Post", | ||||||
|     "deletePostHint": "Are you sure to delete this post?", |     "deletePostHint": "Are you sure to delete this post?", | ||||||
|  |     "deleteMessage": "Delete Message", | ||||||
|  |     "deleteMessageConfirmation": "Are you sure you want to delete this message?", | ||||||
|     "copyLink": "Copy Link", |     "copyLink": "Copy Link", | ||||||
|     "postCreateAccountTitle": "Thanks for joining!", |     "postCreateAccountTitle": "Thanks for joining!", | ||||||
|     "postCreateAccountNext": "What's next?", |     "postCreateAccountNext": "What's next?", | ||||||
| @@ -133,6 +135,11 @@ | |||||||
|     "reactionPositive": "Postive", |     "reactionPositive": "Postive", | ||||||
|     "reactionNegative": "Negative", |     "reactionNegative": "Negative", | ||||||
|     "reactionNeutral": "Neutral", |     "reactionNeutral": "Neutral", | ||||||
|  |     "customReaction": "Custom Reaction", | ||||||
|  |     "customReactions": "Custom Reactions", | ||||||
|  |     "stickerPlaceholder": "Sticker Placeholder", | ||||||
|  |     "reactionAttitude": "Reaction Attitude", | ||||||
|  |     "addReaction": "Add Reaction", | ||||||
|     "connectionConnected": "Connected", |     "connectionConnected": "Connected", | ||||||
|     "connectionDisconnected": "Disconnected", |     "connectionDisconnected": "Disconnected", | ||||||
|     "connectionReconnecting": "Reconnecting", |     "connectionReconnecting": "Reconnecting", | ||||||
| @@ -164,8 +171,8 @@ | |||||||
|     "checkInResultLevel3": "Good Luck", |     "checkInResultLevel3": "Good Luck", | ||||||
|     "checkInResultLevel4": "Best Luck", |     "checkInResultLevel4": "Best Luck", | ||||||
|     "checkInActivityTitle": "{} checked in on {} and got a {}", |     "checkInActivityTitle": "{} checked in on {} and got a {}", | ||||||
|     "eventCalander": "Event Calander", |     "eventCalendar": "Event Calendar", | ||||||
|     "eventCalanderEmpty": "No events on that day.", |     "eventCalendarEmpty": "No events on that day.", | ||||||
|     "fortuneGraph": "Fortune Trend", |     "fortuneGraph": "Fortune Trend", | ||||||
|     "noFortuneData": "No fortune data available for this month.", |     "noFortuneData": "No fortune data available for this month.", | ||||||
|     "creatorHub": "Creator Hub", |     "creatorHub": "Creator Hub", | ||||||
| @@ -251,11 +258,16 @@ | |||||||
|     "translatorBadgeName": "Translator", |     "translatorBadgeName": "Translator", | ||||||
|     "translatorBadgeDescription": "Helping translate Solar Network into different languages", |     "translatorBadgeDescription": "Helping translate Solar Network into different languages", | ||||||
|     "wallet": "Wallet", |     "wallet": "Wallet", | ||||||
|  |     "walletStats": "Wallet Statistics", | ||||||
|  |     "totalTransactions": "Total Transactions", | ||||||
|  |     "totalOrders": "Total Orders", | ||||||
|  |     "totalIncome": "Total Income", | ||||||
|  |     "totalOutgoing": "Total Outgoing", | ||||||
|  |     "netBalance": "Net Balance", | ||||||
|     "walletCurrencyPoints": "New Solar Points", |     "walletCurrencyPoints": "New Solar Points", | ||||||
|     "walletCurrencyShortPoints": "NSP", |     "walletCurrencyShortPoints": "NSP", | ||||||
|     "walletCurrencyGolds": "The Solar Dollars", |     "walletCurrencyGolds": "The Solar Dollars", | ||||||
|     "walletCurrencyShortGolds": "NSD", |     "walletCurrencyShortGolds": "NSD", | ||||||
|     "retry": "Retry", |  | ||||||
|     "creatorHubUnselectedHint": "Pick / create a publisher to get started.", |     "creatorHubUnselectedHint": "Pick / create a publisher to get started.", | ||||||
|     "relationships": "Relationships", |     "relationships": "Relationships", | ||||||
|     "addFriend": "Send a Friend Request", |     "addFriend": "Send a Friend Request", | ||||||
| @@ -306,6 +318,8 @@ | |||||||
|     "settingsBackgroundImageClear": "Clear Background Image", |     "settingsBackgroundImageClear": "Clear Background Image", | ||||||
|     "settingsBackgroundGenerateColor": "Generate color scheme from Bacground Image", |     "settingsBackgroundGenerateColor": "Generate color scheme from Bacground Image", | ||||||
|     "messageNone": "No content to display", |     "messageNone": "No content to display", | ||||||
|  |     "messageUpdateLinks": "Server generated links previews", | ||||||
|  |     "messageUpdateEdited": "Edited a message", | ||||||
|     "unreadMessages": { |     "unreadMessages": { | ||||||
|         "one": "{} unread message", |         "one": "{} unread message", | ||||||
|         "other": "{} unread messages" |         "other": "{} unread messages" | ||||||
| @@ -319,6 +333,7 @@ | |||||||
|     "settingsAprilFoolFeatures": "April Fool Features", |     "settingsAprilFoolFeatures": "April Fool Features", | ||||||
|     "settingsEnterToSend": "Enter to Send", |     "settingsEnterToSend": "Enter to Send", | ||||||
|     "settingsTransparentAppBar": "Transparent App Bar", |     "settingsTransparentAppBar": "Transparent App Bar", | ||||||
|  |     "settingsCardBackgroundOpacity": "Card Background Opacity", | ||||||
|     "settingsCustomFonts": "Custom Fonts", |     "settingsCustomFonts": "Custom Fonts", | ||||||
|     "settingsCustomFontsHint": "Custom fonts will be used for all text in the app. Make sure it is installed on your device.", |     "settingsCustomFontsHint": "Custom fonts will be used for all text in the app. Make sure it is installed on your device.", | ||||||
|     "settingsColorScheme": "Color Scheme", |     "settingsColorScheme": "Color Scheme", | ||||||
| @@ -366,7 +381,6 @@ | |||||||
|     "authFactorSecretHint": "Create an secret for this factor.", |     "authFactorSecretHint": "Create an secret for this factor.", | ||||||
|     "authFactorQrCodeScan": "Scan this QR code with your authenticator app to set up TOTP authentication", |     "authFactorQrCodeScan": "Scan this QR code with your authenticator app to set up TOTP authentication", | ||||||
|     "authFactorNoQrCode": "No QR code available for this authentication factor", |     "authFactorNoQrCode": "No QR code available for this authentication factor", | ||||||
|     "cancel": "Cancel", |  | ||||||
|     "confirm": "Confirm", |     "confirm": "Confirm", | ||||||
|     "authFactorAdditional": "One more step", |     "authFactorAdditional": "One more step", | ||||||
|     "authFactorHint": "Contact method hint", |     "authFactorHint": "Contact method hint", | ||||||
| @@ -465,7 +479,6 @@ | |||||||
|     "accountProfileView": "View Profile", |     "accountProfileView": "View Profile", | ||||||
|     "unspecified": "Unspecified", |     "unspecified": "Unspecified", | ||||||
|     "added": "Added", |     "added": "Added", | ||||||
|     "preview": "Preview", |  | ||||||
|     "togglePreview": "Toggle Preview", |     "togglePreview": "Toggle Preview", | ||||||
|     "subscribe": "Subscribe", |     "subscribe": "Subscribe", | ||||||
|     "unsubscribe": "Unsubscribe", |     "unsubscribe": "Unsubscribe", | ||||||
| @@ -476,6 +489,7 @@ | |||||||
|     "pinCode": "PIN Code", |     "pinCode": "PIN Code", | ||||||
|     "biometric": "Biometric", |     "biometric": "Biometric", | ||||||
|     "enterPinToConfirm": "Enter your 6-digit PIN to confirm payment", |     "enterPinToConfirm": "Enter your 6-digit PIN to confirm payment", | ||||||
|  |     "enterPin": "Enter your PIN code", | ||||||
|     "clearPin": "Clear PIN", |     "clearPin": "Clear PIN", | ||||||
|     "useBiometricToConfirm": "Use biometric authentication to confirm payment", |     "useBiometricToConfirm": "Use biometric authentication to confirm payment", | ||||||
|     "touchSensorToAuthenticate": "Touch the sensor to authenticate", |     "touchSensorToAuthenticate": "Touch the sensor to authenticate", | ||||||
| @@ -625,6 +639,10 @@ | |||||||
|     "chatNotJoined": "You have not joined this chat yet.", |     "chatNotJoined": "You have not joined this chat yet.", | ||||||
|     "chatUnableJoin": "You can't join this chat due to it's access control settings.", |     "chatUnableJoin": "You can't join this chat due to it's access control settings.", | ||||||
|     "chatJoin": "Join the Chat", |     "chatJoin": "Join the Chat", | ||||||
|  |     "chatReplyingTo": "Replying to {}", | ||||||
|  |     "chatForwarding": "Forwarding message", | ||||||
|  |     "chatEditing": "Editing message", | ||||||
|  |     "chatNoContent": "No content", | ||||||
|     "realmJoin": "Join the Realm", |     "realmJoin": "Join the Realm", | ||||||
|     "realmJoinSuccess": "Successfully joined the realm.", |     "realmJoinSuccess": "Successfully joined the realm.", | ||||||
|     "search": "Search", |     "search": "Search", | ||||||
| @@ -853,6 +871,7 @@ | |||||||
|     "pollShortTextAnswerPreview": "Short text answer (preview)", |     "pollShortTextAnswerPreview": "Short text answer (preview)", | ||||||
|     "award": "Award", |     "award": "Award", | ||||||
|     "awardPost": "Award Post", |     "awardPost": "Award Post", | ||||||
|  |     "awardPoints": "Awarded {} points", | ||||||
|     "awardMessage": "Message", |     "awardMessage": "Message", | ||||||
|     "awardMessageHint": "Enter your award message...", |     "awardMessageHint": "Enter your award message...", | ||||||
|     "awardAttitude": "Attitude", |     "awardAttitude": "Attitude", | ||||||
| @@ -1009,6 +1028,10 @@ | |||||||
|     "searchLinks": "Links", |     "searchLinks": "Links", | ||||||
|     "searchAttachments": "Attachments", |     "searchAttachments": "Attachments", | ||||||
|     "noMessagesFound": "No messages found", |     "noMessagesFound": "No messages found", | ||||||
|  |     "Searching...": "Searching...", | ||||||
|  |     "searchError": "Search failed. Please try again.", | ||||||
|  |     "tryDifferentKeywords": "Try different keywords or remove search filters", | ||||||
|  |     "retry": "Retry", | ||||||
|     "openInBrowser": "Open in Browser", |     "openInBrowser": "Open in Browser", | ||||||
|     "highlightPost": "Highlight Post", |     "highlightPost": "Highlight Post", | ||||||
|     "filters": "Filters", |     "filters": "Filters", | ||||||
| @@ -1047,6 +1070,7 @@ | |||||||
|     "iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">", |     "iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">", | ||||||
|     "parseIframe": "Parse Iframe", |     "parseIframe": "Parse Iframe", | ||||||
|     "messageActions": "Message Actions", |     "messageActions": "Message Actions", | ||||||
|  |     "messageContent": "Message Content", | ||||||
|     "viewEmbedLoadHint": "Tap to load", |     "viewEmbedLoadHint": "Tap to load", | ||||||
|     "levelingStage1": "Novice", |     "levelingStage1": "Novice", | ||||||
|     "levelingStage2": "Apprentice", |     "levelingStage2": "Apprentice", | ||||||
| @@ -1080,5 +1104,203 @@ | |||||||
|     "deleteRecycledFiles": "Delete Recycled Files", |     "deleteRecycledFiles": "Delete Recycled Files", | ||||||
|     "recycledFilesDeleted": "Recycled files deleted successfully", |     "recycledFilesDeleted": "Recycled files deleted successfully", | ||||||
|     "failedToDeleteRecycledFiles": "Failed to delete recycled files", |     "failedToDeleteRecycledFiles": "Failed to delete recycled files", | ||||||
|     "upload": "Upload" |     "upload": "Upload", | ||||||
|  |     "updateAvailable": "Update available", | ||||||
|  |     "noChangelogProvided": "No changelog provided.", | ||||||
|  |     "useSecondarySourceForDownload": "Use secondary source for download", | ||||||
|  |     "installUpdate": "Install update", | ||||||
|  |     "openReleasePage": "Open release page", | ||||||
|  |     "postCompose": "Compose Post", | ||||||
|  |     "postPublish": "Publish Post", | ||||||
|  |     "restoreDraftTitle": "Restore Draft", | ||||||
|  |     "restoreDraftMessage": "A draft was found. Do you want to restore it?", | ||||||
|  |     "draft": "Draft", | ||||||
|  |     "purchaseGift": "Purchase Gift", | ||||||
|  |     "selectRecipient": "Select Recipient", | ||||||
|  |     "changeRecipient": "Change Recipient", | ||||||
|  |     "addMessage": "Add Message", | ||||||
|  |     "skipRecipient": "Skip Recipient", | ||||||
|  |     "giftSubscriptions": "Gift Subscriptions", | ||||||
|  |     "purchaseAGift": "Purchase a Gift", | ||||||
|  |     "redeemAGift": "Redeem a Gift", | ||||||
|  |     "giftHistory": "Gift History", | ||||||
|  |     "sentGifts": "Sent Gifts", | ||||||
|  |     "receivedGifts": "Received Gifts", | ||||||
|  |     "noSentGifts": "No sent gifts", | ||||||
|  |     "noReceivedGifts": "No received gifts", | ||||||
|  |     "stellarGift": "Stellar Gift", | ||||||
|  |     "novaGift": "Nova Gift", | ||||||
|  |     "supernovaGift": "Supernova Gift", | ||||||
|  |     "sameAsMembership": "Same as membership", | ||||||
|  |     "enterGiftCodeToRedeem": "Enter gift code to redeem", | ||||||
|  |     "enterGiftCode": "Enter gift code", | ||||||
|  |     "giftPurchased": "Gift Purchased!", | ||||||
|  |     "shareCodeWithRecipient": "Share this code with the recipient to redeem the gift.", | ||||||
|  |     "openGiftAnyoneCanRedeem": "This is an open gift that anyone can redeem.", | ||||||
|  |     "ok": "OK", | ||||||
|  |     "selectedRecipient": "Selected recipient", | ||||||
|  |     "noRecipientSelected": "No recipient selected", | ||||||
|  |     "thisWillBeAnOpenGift": "This will be an open gift", | ||||||
|  |     "personalMessage": "Personal Message", | ||||||
|  |     "addPersonalMessageForRecipient": "Add a personal message for the recipient", | ||||||
|  |     "cancel": "Cancel", | ||||||
|  |     "giftStatusCreated": "Created", | ||||||
|  |     "giftStatusSent": "Sent", | ||||||
|  |     "giftStatusRedeemed": "Redeemed", | ||||||
|  |     "giftStatusCancelled": "Cancelled", | ||||||
|  |     "giftStatusExpired": "Expired", | ||||||
|  |     "giftStatusUnknown": "Unknown", | ||||||
|  |     "giftCodeCopiedToClipboard": "Gift code copied to clipboard", | ||||||
|  |     "codeLabel": "Code: ", | ||||||
|  |     "subscriptionLabel": "Subscription: ", | ||||||
|  |     "toLabel": "To: ", | ||||||
|  |     "fromLabel": "From: ", | ||||||
|  |     "messageLabel": "Message: ", | ||||||
|  |     "giftRedeemed": "Gift Redeemed!", | ||||||
|  |     "giftRedeemedSuccessfully": "You have successfully redeemed the gift. Your new subscription is now active.", | ||||||
|  |     "cancelGift": "Cancel Gift", | ||||||
|  |     "cancelGiftConfirm": "Are you sure you want to cancel this gift? This action cannot be undone.", | ||||||
|  |     "giftCancelledSuccessfully": "Gift cancelled successfully", | ||||||
|  |     "createFund": "Create Fund", | ||||||
|  |     "fundAmount": "Fund Amount", | ||||||
|  |     "enterAmount": "Enter Amount", | ||||||
|  |     "selectCurrency": "Select Currency", | ||||||
|  |     "splitType": "Split Type", | ||||||
|  |     "evenSplit": "Even Split", | ||||||
|  |     "equalAmountEach": "Equal amount for each recipient", | ||||||
|  |     "randomSplit": "Random Split", | ||||||
|  |     "randomAmountEach": "Random amount for each recipient", | ||||||
|  |     "recipientCount": "Recipient Count", | ||||||
|  |     "numberOfRecipients": "Number of Recipients", | ||||||
|  |     "addPersonalMessageForRecipients": "Add a personal message for recipients", | ||||||
|  |     "invalidAmount": "Invalid amount", | ||||||
|  |     "invalidRecipientCount": "Invalid recipient count", | ||||||
|  |     "fundOverview": "Fund Overview", | ||||||
|  |     "totalFundsSent": "Total Funds Sent", | ||||||
|  |     "totalFundsReceived": "Total Funds Received", | ||||||
|  |     "transactions": "Transactions", | ||||||
|  |     "myFunds": "My Funds", | ||||||
|  |     "availableFunds": "Available Funds", | ||||||
|  |     "fundStatusCreated": "Created", | ||||||
|  |     "fundStatusPartial": "Partially Claimed", | ||||||
|  |     "fundStatusCompleted": "Fully Claimed", | ||||||
|  |     "fundStatusExpired": "Expired", | ||||||
|  |     "fundStatusUnknown": "Unknown", | ||||||
|  |     "recipients": "Recipients", | ||||||
|  |     "fundClaimedSuccessfully": "Fund claimed successfully!", | ||||||
|  |     "claim": "Claim", | ||||||
|  |     "noFundsCreated": "No funds created yet", | ||||||
|  |     "createYourFirstFund": "Create your first fund to get started", | ||||||
|  |     "noAvailableFunds": "No available funds", | ||||||
|  |     "fundsWillAppearHere": "Funds you can claim will appear here", | ||||||
|  |     "fundCreatedSuccessfully": "Fund created successfully!", | ||||||
|  |     "selectRecipients": "Select Recipients", | ||||||
|  |     "noRecipientsSelected": "No recipients selected", | ||||||
|  |     "selectRecipientsToSendFund": "Select recipients to send the fund to", | ||||||
|  |     "addRecipient": "Add Recipient", | ||||||
|  |     "addMoreRecipients": "Add More Recipients", | ||||||
|  |     "transactionDetails": "Transaction Details", | ||||||
|  |     "remarks": "Remarks", | ||||||
|  |     "payer": "Payer", | ||||||
|  |     "payee": "Payee", | ||||||
|  |     "transactionType": "Transaction Type", | ||||||
|  |     "transfer": "Transfer", | ||||||
|  |     "payment": "Payment", | ||||||
|  |     "systemWallet": "System Wallet", | ||||||
|  |     "date": "Date", | ||||||
|  |     "createTransfer": "Create Transfer", | ||||||
|  |     "transferAmount": "Transfer Amount", | ||||||
|  |     "selectPayee": "Select Payee", | ||||||
|  |     "selectedPayee": "Selected Payee", | ||||||
|  |     "noPayeeSelected": "No payee selected", | ||||||
|  |     "selectPayeeToTransfer": "Select payee to transfer to", | ||||||
|  |     "addRemark": "Add Remark", | ||||||
|  |     "transferRemark": "Transfer Remark", | ||||||
|  |     "addRemarkForTransfer": "Add remark for transfer", | ||||||
|  |     "enterPinToConfirmTransfer": "Enter your 6-digit PIN to confirm transfer", | ||||||
|  |     "transferCreatedSuccessfully": "Transfer created successfully!", | ||||||
|  |     "postUpdate": "Update", | ||||||
|  |     "fileMetadata": "File Metadata", | ||||||
|  |     "resend": "Resend", | ||||||
|  |     "fileInfoTitle": "File Information", | ||||||
|  |     "download": "Download", | ||||||
|  |     "info": "Info", | ||||||
|  |     "noStickers": "No Stickers", | ||||||
|  |     "noStickersInPack": "This pack does not contains stickers", | ||||||
|  |     "noStickerPacks": "No Sticker Packs", | ||||||
|  |     "refresh": "Refresh", | ||||||
|  |     "spoiler": "Spoiler", | ||||||
|  |     "activityHeatmap": "Activity Heatmap", | ||||||
|  |     "custom": "Custom", | ||||||
|  |     "usernameColor": "Username Color", | ||||||
|  |     "colorType": "Color Type", | ||||||
|  |     "plain": "Plain", | ||||||
|  |     "gradient": "Gradient", | ||||||
|  |     "colorValue": "Color Value", | ||||||
|  |     "gradientDirection": "Gradient Direction", | ||||||
|  |     "gradientDirectionToRight": "To Right", | ||||||
|  |     "gradientDirectionToLeft": "To Left", | ||||||
|  |     "gradientDirectionToBottom": "To Bottom", | ||||||
|  |     "gradientDirectionToTop": "To Top", | ||||||
|  |     "gradientDirectionToBottomRight": "To Bottom Right", | ||||||
|  |     "gradientDirectionToBottomLeft": "To Bottom Left", | ||||||
|  |     "gradientDirectionToTopRight": "To Top Right", | ||||||
|  |     "gradientDirectionToTopLeft": "To Top Left", | ||||||
|  |     "gradientColors": "Gradient Colors", | ||||||
|  |     "color": "Color", | ||||||
|  |     "addColor": "Add Color", | ||||||
|  |     "preview": "Preview", | ||||||
|  |     "availableWithYourPlan": "Available with your plan", | ||||||
|  |     "upgradeRequired": "Upgrade required", | ||||||
|  |     "settingsDisableAnimation": "Disable Animation", | ||||||
|  |     "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" | ||||||
| } | } | ||||||
|   | |||||||
| @@ -391,10 +391,6 @@ | |||||||
|         "other": "{} 正在输入……" |         "other": "{} 正在输入……" | ||||||
|     }, |     }, | ||||||
|     "settingsAppearance": "外观", |     "settingsAppearance": "外观", | ||||||
|     "settingsThemeMode": "主题模式", |  | ||||||
|     "settingsThemeModeSystem": "跟随系统", |  | ||||||
|     "settingsThemeModeLight": "浅色", |  | ||||||
|     "settingsThemeModeDark": "深色", |  | ||||||
|     "settingsServer": "服务器", |     "settingsServer": "服务器", | ||||||
|     "settingsBehavior": "行为", |     "settingsBehavior": "行为", | ||||||
|     "settingsDesktop": "桌面", |     "settingsDesktop": "桌面", | ||||||
| @@ -944,7 +940,7 @@ | |||||||
|     "editBot": "编辑机器人", |     "editBot": "编辑机器人", | ||||||
|     "botAutomatedBy": "由 {} 自动化", |     "botAutomatedBy": "由 {} 自动化", | ||||||
|     "botDetails": "机器人详情", |     "botDetails": "机器人详情", | ||||||
|     "overview": "总揽", |     "overview": "总览", | ||||||
|     "keys": "密钥", |     "keys": "密钥", | ||||||
|     "botNotFound": "机器人未找到。", |     "botNotFound": "机器人未找到。", | ||||||
|     "newBotKey": "新建密钥", |     "newBotKey": "新建密钥", | ||||||
| @@ -1079,5 +1075,20 @@ | |||||||
|     "deleteRecycledFiles": "删除被回收的文件", |     "deleteRecycledFiles": "删除被回收的文件", | ||||||
|     "recycledFilesDeleted": "被回收文件成功删除", |     "recycledFilesDeleted": "被回收文件成功删除", | ||||||
|     "failedToDeleteRecycledFiles": "删除被回收文件失败", |     "failedToDeleteRecycledFiles": "删除被回收文件失败", | ||||||
|     "upload": "上传" |     "upload": "上传", | ||||||
|  |     "systemWallet": "中央统筹", | ||||||
|  |     "postCompose": "撰写帖子", | ||||||
|  |     "postPublish": "发布帖子", | ||||||
|  |     "restoreDraftTitle": "恢复草稿", | ||||||
|  |     "restoreDraftMessage": "发现了一个草稿。你想要恢复它吗?", | ||||||
|  |     "draft": "草稿", | ||||||
|  |     "thoughtDefaultTopic": "寻思", | ||||||
|  |     "thoughtAiName": "SN 酱", | ||||||
|  |     "thoughtUserName": "您", | ||||||
|  |     "thoughtStreamingHint": "SN 酱正在思考...", | ||||||
|  |     "thoughtInputHint": "问 SN 酱任何问题...", | ||||||
|  |     "thoughtNewConversation": "开始新对话", | ||||||
|  |     "thoughtParseError": "解析 AI 响应失败", | ||||||
|  |     "aiThought": "寻思", | ||||||
|  |     "aiThoughtTitle": "让 SN 酱寻思寻思" | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,169 +1,169 @@ | |||||||
| { | { | ||||||
|     "login": "Login", |     "login": "登入", | ||||||
|     "loginDescription": "Existing user? We're welcome you back!", |     "loginDescription": "常客乎?僅盼榮歸!", | ||||||
|     "forgotPassword": "Forgot password", |     "forgotPassword": "密語遺乎", | ||||||
|     "loginPickFactor": "Pick a factor", |     "loginPickFactor": "擇一信物以証", | ||||||
|     "loginMultiFactor": { |     "loginMultiFactor": { | ||||||
|         "one": "{} step left", |         "one": "尚餘 {} 步", | ||||||
|         "other": "{} steps left" |         "other": "尚餘 {} 步" | ||||||
|     }, |     }, | ||||||
|     "loginEnterPassword": "Enter the code", |     "loginEnterPassword": "請輸入驗證碼", | ||||||
|     "loginSuccess": "Logged in as {}", |     "loginSuccess": "{},恭迎尊駕", | ||||||
|     "loginGreeting": "Welcome back!", |     "loginGreeting": "欣見再臨!", | ||||||
|     "loginOr": "Or login with\nthird parties", |     "loginOr": "或借第三方登入", | ||||||
|     "loginInProgress": "Logging you in...", |     "loginInProgress": "引君入內……", | ||||||
|     "username": "Username", |     "username": "用戶名", | ||||||
|     "usernameCannotChangeHint": "Username cannot be updated after created.", |     "usernameCannotChangeHint": "用户名立,则如石刻,不可改也。", | ||||||
|     "usernameLookupHint": "We also take your email address.", |     "usernameLookupHint": "另需電郵地址,以便尺素往来。", | ||||||
|     "unknown": "Unknown", |     "unknown": "不詳", | ||||||
|     "termAcceptNextWithAgree": "By continuing, you agree to our terms of services and other terms and conditions.", |     "termAcceptNextWithAgree": "若續行,則示為閣下已允諾服務之約與諸般條件。", | ||||||
|     "termAcceptLink": "Check them out", |     "termAcceptLink": "敬請參閱", | ||||||
|     "loginResetPasswordHint": "Provide your username to receive a password reset link.", |     "loginResetPasswordHint": "請賜示尊號,當奉密鑰重置之途徑。", | ||||||
|     "password": "Password", |     "password": "密語", | ||||||
|     "next": "Next", |     "next": "進", | ||||||
|     "createAccount": "Create an Account", |     "createAccount": "開立新戶", | ||||||
|     "createAccountDescription": "New to here? We got you covered!", |     "createAccountDescription": "初臨寶地?無須多慮,自有安排!", | ||||||
|     "nickname": "Nickname", |     "nickname": "別號", | ||||||
|     "email": "Email", |     "email": "電郵地址", | ||||||
|     "bio": "Bio", |     "bio": "自述", | ||||||
|     "fieldCannotBeEmpty": "This field cannot be empty.", |     "fieldCannotBeEmpty": "此域空空如也,請填補之。", | ||||||
|     "fieldEmailAddressMustBeValid": "The email address must be valid.", |     "fieldEmailAddressMustBeValid": "電郵地址務必有效。", | ||||||
|     "logout": "Logout", |     "logout": "離", | ||||||
|     "updateYourProfile": "Profile Settings", |     "updateYourProfile": "個人檔案設置", | ||||||
|     "accountBasicInfo": "Basic Info", |     "accountBasicInfo": "基本資料", | ||||||
|     "accountProfile": "Your Profile", |     "accountProfile": "君之檔案", | ||||||
|     "saveChanges": "Save Changes", |     "saveChanges": "落定", | ||||||
|     "publishers": "Publishers", |     "publishers": "發布者", | ||||||
|     "managedPublisher": "Managed Publishers", |     "managedPublisher": "轄下發布者", | ||||||
|     "createPublisher": "Create a Publisher", |     "createPublisher": "創立發布者", | ||||||
|     "createPublisherHint": "To create posts, collections, etc.", |     "createPublisherHint": "司掌帖文、纂輯之務。", | ||||||
|     "editPublisher": "Edit Publisher", |     "editPublisher": "修訂發布者", | ||||||
|     "syncPublisher": "Use Account Data", |     "syncPublisher": "取資於戶", | ||||||
|     "syncPublisherRealm": "Use Realm Data", |     "syncPublisherRealm": "動用界域資料", | ||||||
|     "create": "Create", |     "create": "创建", | ||||||
|     "update": "Update", |     "update": "革", | ||||||
|     "edit": "Edit", |     "edit": "訂", | ||||||
|     "delete": "Delete", |     "delete": "革去", | ||||||
|     "deletePublisher": "Delete Publisher", |     "deletePublisher": "革除發布者", | ||||||
|     "deletePublisherHint": "Are you sure to delete this publisher? This will also deleted all the post and collections under this publisher.", |     "deletePublisherHint": "確乎?革除此發佈者,則其一切文翰結集,皆付之一炬。", | ||||||
|     "deletePost": "Delete Post", |     "deletePost": "焚稿", | ||||||
|     "deletePostHint": "Are you sure to delete this post?", |     "deletePostHint": "爾果欲焚此稿耶?", | ||||||
|     "copyLink": "Copy Link", |     "copyLink": "抄錄鏈接", | ||||||
|     "postCreateAccountTitle": "Thanks for joining!", |     "postCreateAccountTitle": "蒙君惠然肯來,不勝感激!", | ||||||
|     "postCreateAccountNext": "What's next?", |     "postCreateAccountNext": "其後欲行何事?", | ||||||
|     "postCreateAccountNext1": "Go to your email inbox and receive the account activation email.", |     "postCreateAccountNext1": "請歸於電郵信匣,取賬戶激活之尺素。", | ||||||
|     "postCreateAccountNext2": "Log in to your account and start exploring the Solar Network!", |     "postCreateAccountNext2": "請登入賬戶,暢遊 Solar Network之浩瀚!", | ||||||
|     "postPlaceholder": "What's on your mind?", |     "postPlaceholder": "心緒何方?", | ||||||
|     "publishersEmpty": "No publishers yet", |     "publishersEmpty": "尚無發布者", | ||||||
|     "publishersEmptyDescription": "You can need to create a publisher to start publishing your posts.", |     "publishersEmptyDescription": "君需先創立發布者,方能開始發表文章。", | ||||||
|     "authFactorPassword": "Password", |     "authFactorPassword": "密語", | ||||||
|     "authFactorPasswordDescription": "The password you set when you registered.", |     "authFactorPasswordDescription": "此乃君註冊時所設之密語。", | ||||||
|     "authFactorEmail": "Email verification code", |     "authFactorEmail": "電郵驗證符", | ||||||
|     "authFactorEmailDescription": "An one-time code sent to the email address you set when you registered.", |     "authFactorEmailDescription": "此一次性符令,已發於君註冊時所用之電郵地址之途。", | ||||||
|     "authFactorTOTP": "Time-based OTP", |     "authFactorTOTP": "動態一次性符令", | ||||||
|     "authFactorTOTPDescription": "A one-time code generated by a TOTP authenticator such as Google Authenticator or Authy.", |     "authFactorTOTPDescription": "此動態一次性符令,乃由 TOTP 信物如 Google Authenticator 或 Authy 所生成。", | ||||||
|     "authFactorInAppNotify": "In-app notification", |     "authFactorInAppNotify": "應用內通告", | ||||||
|     "authFactorInAppNotifyDescription": "A one-time code sent via in-app notification.", |     "authFactorInAppNotifyDescription": "此動態一次性符令,經由應用內通告發送。", | ||||||
|     "authFactorPin": "Pin Code", |     "authFactorPin": "定長密語", | ||||||
|     "authFactorPinDescription": "It consists of 6 digits. It cannot be used to log in. When performing some dangerous operations, the system will ask you to enter this PIN for confirmation.", |     "authFactorPinDescription": "此物凡六位數,不可用以登入。若行險要之舉,系統將請君輸入此定長數符以驗明正身。", | ||||||
|     "realms": "Realms", |     "realms": "界域", | ||||||
|     "createRealm": "Create a Realm", |     "createRealm": "始創一界域", | ||||||
|     "createRealmHint": "Meet friends with same interests, build communities, and more.", |     "createRealmHint": "結交同道,共建社羣,樂趣無窮。", | ||||||
|     "editRealm": "Edit Realm", |     "editRealm": "修訂界域訊息", | ||||||
|     "deleteRealm": "Delete Realm", |     "deleteRealm": "革去此界域", | ||||||
|     "deleteRealmHint": "Are you sure to delete this realm? This will also deleted all the channels, publishers, and posts under this realm.", |     "deleteRealmHint": "確定革去此界域乎?其下所有通道、發布者及文章亦將同歸於盡。", | ||||||
|     "explore": "Explore", |     "explore": "探索", | ||||||
|     "exploreFilterSubscriptions": "Subscriptions", |     "exploreFilterSubscriptions": "訂閱", | ||||||
|     "exploreFilterFriends": "Friends", |     "exploreFilterFriends": "知交", | ||||||
|     "account": "Account", |     "account": "賬戶", | ||||||
|     "name": "Name", |     "name": "名", | ||||||
|     "slug": "Slug", |     "slug": "別號", | ||||||
|     "slugHint": "The slug will be used in the URL to access this resource, it should be unique and URL safe.", |     "slugHint": "此別稱乃資源門徑之樞要,當為世間獨有,且不違網址安全之法度。", | ||||||
|     "createChatRoom": "Create a Room", |     "createChatRoom": "始創一談筵", | ||||||
|     "editChatRoom": "Edit Room", |     "editChatRoom": "修訂談筵", | ||||||
|     "deleteChatRoom": "Delete Room", |     "deleteChatRoom": "革去談筵", | ||||||
|     "deleteChatRoomHint": "Are you sure to delete this room? This action cannot be undone.", |     "deleteChatRoomHint": "確乎欲革去此聊天室?此舉萬劫不復。", | ||||||
|     "chat": "Chat", |     "chat": "暢談", | ||||||
|     "chatTabAll": "All", |     "chatTabAll": "總覽", | ||||||
|     "chatTabDirect": "Direct Messages", |     "chatTabDirect": "私信", | ||||||
|     "chatTabGroup": "Group Chats", |     "chatTabGroup": "群談", | ||||||
|     "chatMessageHint": "Message in {}", |     "chatMessageHint": "{} 中之訊息", | ||||||
|     "chatDirectMessageHint": "Message to {}", |     "chatDirectMessageHint": "致 {} 之訊", | ||||||
|     "directMessage": "Direct Message", |     "directMessage": "私信", | ||||||
|     "loading": "Loading...", |     "loading": "載入中……", | ||||||
|     "descriptionNone": "No description yet.", |     "descriptionNone": "未著一字。", | ||||||
|     "invites": "Invites", |     "invites": "邀函", | ||||||
|     "invitesEmpty": "No invites yet, such a lonely person...", |     "invitesEmpty": "空無邀函,形單影隻,何等寂寥……", | ||||||
|     "members": { |     "members": { | ||||||
|         "one": "{} member", |         "one": "{} 位成員", | ||||||
|         "other": "{} members" |         "other": "{} 位成員" | ||||||
|     }, |     }, | ||||||
|     "permissionOwner": "Owner", |     "permissionOwner": "宗主", | ||||||
|     "permissionModerator": "Moderator", |     "permissionModerator": "版正", | ||||||
|     "permissionMember": "Member", |     "permissionMember": "成員", | ||||||
|     "reply": "Reply", |     "reply": "回復", | ||||||
|     "repliesCount": { |     "repliesCount": { | ||||||
|         "zero": "No reply", |         "zero": "闃寂", | ||||||
|         "one": "{} reply", |         "one": "{} 個回復", | ||||||
|         "other": "{} replies" |         "other": "{} 個回復" | ||||||
|     }, |     }, | ||||||
|     "forward": "Forward", |     "forward": "傳檄", | ||||||
|     "repliedTo": "Replied to", |     "repliedTo": "已回覆", | ||||||
|     "forwarded": "Forwarded", |     "forwarded": "已轉", | ||||||
|     "hasAttachments": { |     "hasAttachments": { | ||||||
|         "one": "{} attachment", |         "one": "{} 個附件", | ||||||
|         "other": "{} attachments" |         "other": "{} 個附件" | ||||||
|     }, |     }, | ||||||
|     "postHasAttachments": { |     "postHasAttachments": { | ||||||
|         "one": "{} attachment", |         "one": "{} 個附件", | ||||||
|         "other": "{} attachments" |         "other": "{} 個附件" | ||||||
|     }, |     }, | ||||||
|     "edited": "Edited", |     "edited": "已修訂", | ||||||
|     "addVideo": "Add video", |     "addVideo": "附視訊", | ||||||
|     "addPhoto": "Add photo", |     "addPhoto": "附圖像", | ||||||
|     "addFile": "Add file", |     "addFile": "附卷宗", | ||||||
|     "createDirectMessage": "Send new DM", |     "createDirectMessage": "發新密訊", | ||||||
|     "gotoDirectMessage": "Go to DM", |     "gotoDirectMessage": "赴密訊", | ||||||
|     "react": "React", |     "react": "感應", | ||||||
|     "reactions": { |     "reactions": { | ||||||
|         "zero": "Reactions", |         "zero": "感應", | ||||||
|         "one": "{} reaction", |         "one": "{} 個感應", | ||||||
|         "other": "{} reactions" |         "other": "{} 個感應" | ||||||
|     }, |     }, | ||||||
|     "reactionPositive": "Postive", |     "reactionPositive": "嘉應", | ||||||
|     "reactionNegative": "Negative", |     "reactionNegative": "咎應", | ||||||
|     "reactionNeutral": "Neutral", |     "reactionNeutral": "中和", | ||||||
|     "connectionConnected": "Connected", |     "connectionConnected": "已聯", | ||||||
|     "connectionDisconnected": "Disconnected", |     "connectionDisconnected": "已絕", | ||||||
|     "connectionReconnecting": "Reconnecting", |     "connectionReconnecting": "復聯中", | ||||||
|     "accountConnections": "Account Connections", |     "accountConnections": "賬戶接續", | ||||||
|     "accountConnectionsDescription": "Manage your external account connections", |     "accountConnectionsDescription": "統御君之域外賬戶接續", | ||||||
|     "accountConnectionAdd": "Add Connection", |     "accountConnectionAdd": "始創一接續", | ||||||
|     "accountConnectionDelete": "Delete Connection", |     "accountConnectionDelete": "革去接續", | ||||||
|     "accountConnectionDeleteHint": "Are you sure you want to delete this connection? This action cannot be undone.", |     "accountConnectionDeleteHint": "確乎欲革去此接續?革去靈犀相接則永逝矣,不可挽回。", | ||||||
|     "accountConnectionsEmpty": "No connections found. Add a connection to get started.", |     "accountConnectionsEmpty": "未見接續。請始創一接續以啟用之。", | ||||||
|     "accountConnectionProvider": "Provider", |     "accountConnectionProvider": "供應者", | ||||||
|     "accountConnectionProviderHint": "Enter provider name", |     "accountConnectionProviderHint": "請輸入供應者名號", | ||||||
|     "accountConnectionIdentifier": "Identifier", |     "accountConnectionIdentifier": "標識符", | ||||||
|     "accountConnectionIdentifierHint": "Enter your identifier for this provider", |     "accountConnectionIdentifierHint": "請輸入君於此供應者之標識", | ||||||
|     "accountConnectionDescription": "Add a connection to link your account with external services.", |     "accountConnectionDescription": "締結靈契,以通聯君之戶牘與域外服務。", | ||||||
|     "accountConnectionAddSuccess": "Connection added successfully.", |     "accountConnectionAddSuccess": "靈犀已締。", | ||||||
|     "accountConnectionAddError": "Unable to setup connection.", |     "accountConnectionAddError": "靈犀難通。", | ||||||
|     "accountConnectionProviderApple": "Apple", |     "accountConnectionProviderApple": "蘋果", | ||||||
|     "accountConnectionProviderMicrosoft": "Microsoft", |     "accountConnectionProviderMicrosoft": "微軟", | ||||||
|     "accountConnectionProviderGoogle": "Google", |     "accountConnectionProviderGoogle": "谷歌", | ||||||
|     "accountConnectionProviderGithub": "Git Hub", |     "accountConnectionProviderGithub": "Git Hub", | ||||||
|     "accountConnectionProviderDiscord": "Discord", |     "accountConnectionProviderDiscord": "Discord", | ||||||
|     "accountConnectionProviderAfdian": "Afdian", |     "accountConnectionProviderAfdian": "愛發電", | ||||||
|     "checkIn": "Check In", |     "checkIn": "簽到", | ||||||
|     "checkInNone": "Not checked-in yet", |     "checkInNone": "尚未簽到", | ||||||
|     "checkInNoneHint": "Get your fortune tips and daily rewards by checking in.", |     "checkInNoneHint": "簽到以獲吉運籤文日祿。", | ||||||
|     "checkInResultLevel0": "Wrost Luck", |     "checkInResultLevel0": "大凶", | ||||||
|     "checkInResultLevel1": "Bad Luck", |     "checkInResultLevel1": "凶", | ||||||
|     "checkInResultLevel2": "A Normal Day", |     "checkInResultLevel2": "中平", | ||||||
|     "checkInResultLevel3": "Good Luck", |     "checkInResultLevel3": "吉", | ||||||
|     "checkInResultLevel4": "Best Luck", |     "checkInResultLevel4": "大吉", | ||||||
|     "checkInActivityTitle": "{} checked in on {} and got a {}", |     "checkInActivityTitle": "{} 於 {} 簽到,獲 {} 籤", | ||||||
|     "eventCalander": "Event Calander", |     "eventCalander": "Event Calander", | ||||||
|     "eventCalanderEmpty": "No events on that day.", |     "eventCalanderEmpty": "No events on that day.", | ||||||
|     "fortuneGraph": "Fortune Trend", |     "fortuneGraph": "Fortune Trend", | ||||||
|   | |||||||
| @@ -753,19 +753,19 @@ | |||||||
|     "markAsSensitive": "標記為敏感", |     "markAsSensitive": "標記為敏感", | ||||||
|     "fileName": "文件名", |     "fileName": "文件名", | ||||||
|     "sensitiveCategories": { |     "sensitiveCategories": { | ||||||
|         "language": "Language", |         "language": "語言", | ||||||
|         "sexualContent": "Sexual Content", |         "sexualContent": "色情內容", | ||||||
|         "violence": "Violence", |         "violence": "暴力", | ||||||
|         "profanity": "Profanity", |         "profanity": "褻瀆", | ||||||
|         "hateSpeech": "Hate Speech", |         "hateSpeech": "仇恨言論", | ||||||
|         "racism": "Racism", |         "racism": "種族主義", | ||||||
|         "adultContent": "Adult Content", |         "adultContent": "成人內容", | ||||||
|         "drugAbuse": "Drug Abuse", |         "drugAbuse": "藥物濫用", | ||||||
|         "alcoholAbuse": "Alcohol Abuse", |         "alcoholAbuse": "酗酒", | ||||||
|         "gambling": "Gambling", |         "gambling": "賭博", | ||||||
|         "selfHarm": "Self-harm", |         "selfHarm": "自殘", | ||||||
|         "childAbuse": "Child Abuse", |         "childAbuse": "虐待兒童", | ||||||
|         "other": "Other" |         "other": "其他" | ||||||
|     }, |     }, | ||||||
|     "poll": "投票", |     "poll": "投票", | ||||||
|     "pollsRecent": "最近投票", |     "pollsRecent": "最近投票", | ||||||
| @@ -809,159 +809,159 @@ | |||||||
|         "one": "+{} 個文件被摺疊", |         "one": "+{} 個文件被摺疊", | ||||||
|         "other": "+{} 個文件被摺疊" |         "other": "+{} 個文件被摺疊" | ||||||
|     }, |     }, | ||||||
|     "pollQuestions": "Questions", |     "pollQuestions": "問題", | ||||||
|     "pollAnswerSubmitted": "Poll answer has been submitted.", |     "pollAnswerSubmitted": "投票答案已提交。", | ||||||
|     "modifyAnswers": "Modify Answers", |     "modifyAnswers": "修改答案", | ||||||
|     "back": "Back", |     "back": "返回", | ||||||
|     "submit": "Submit", |     "submit": "提交", | ||||||
|     "pollOptionDefaultLabel": "Option 1", |     "pollOptionDefaultLabel": "選項1", | ||||||
|     "pollUpdated": "Poll updated.", |     "pollUpdated": "投票已更新。", | ||||||
|     "pollCreated": "Poll created.", |     "pollCreated": "投票已創建。", | ||||||
|     "pollCreate": "Create Poll", |     "pollCreate": "創建投票", | ||||||
|     "pollEdit": "Edit Poll", |     "pollEdit": "編輯投票", | ||||||
|     "pollPreviewJsonDebug": "Debug Preview", |     "pollPreviewJsonDebug": "調試預覽", | ||||||
|     "pollTitleRequired": "Title is required", |     "pollTitleRequired": "標題不可為空", | ||||||
|     "pollEndDateOptional": "End date & time (optional)", |     "pollEndDateOptional": "結束日期和時間 (可選)", | ||||||
|     "notSet": "Not set", |     "notSet": "未設定", | ||||||
|     "pick": "Pick", |     "pick": "選擇", | ||||||
|     "clear": "Clear", |     "clear": "清除", | ||||||
|     "questions": "Questions", |     "questions": "問題", | ||||||
|     "pollAddQuestion": "Add question", |     "pollAddQuestion": "添加問題", | ||||||
|     "pollQuestionTypeSingleChoice": "Single choice", |     "pollQuestionTypeSingleChoice": "單選框", | ||||||
|     "pollQuestionTypeMultipleChoice": "Multiple choice", |     "pollQuestionTypeMultipleChoice": "多選框", | ||||||
|     "pollQuestionTypeFreeText": "Free text", |     "pollQuestionTypeFreeText": "自由文本", | ||||||
|     "pollQuestionTypeYesNo": "Yes / No", |     "pollQuestionTypeYesNo": "是 / 不是", | ||||||
|     "pollQuestionTypeRating": "Rating", |     "pollQuestionTypeRating": "評分", | ||||||
|     "pollNoQuestionsYet": "No questions yet", |     "pollNoQuestionsYet": "尚未有問題", | ||||||
|     "pollNoQuestionsHint": "Use \"Add question\" to start building your poll.", |     "pollNoQuestionsHint": "使用「添加問題」開始建立您的投票。", | ||||||
|     "pollDebugPreview": "Debug Preview", |     "pollDebugPreview": "調試預覽", | ||||||
|     "pollUntitledQuestion": "Untitled question", |     "pollUntitledQuestion": "無標題問題", | ||||||
|     "moveUp": "Move up", |     "moveUp": "往上移動", | ||||||
|     "moveDown": "Move down", |     "moveDown": "往下移動", | ||||||
|     "required": "Required", |     "required": "必需的", | ||||||
|     "pollQuestionTitle": "Question title", |     "pollQuestionTitle": "問題標題", | ||||||
|     "pollQuestionTitleRequired": "Question title is required", |     "pollQuestionTitleRequired": "問題標題是必需的", | ||||||
|     "pollQuestionDescriptionOptional": "Question description (optional)", |     "pollQuestionDescriptionOptional": "問題描述(選填)", | ||||||
|     "options": "Options", |     "options": "選項", | ||||||
|     "pollAddOption": "Add option", |     "pollAddOption": "添加選項", | ||||||
|     "pollOptionLabel": "Option label", |     "pollOptionLabel": "選項標籤", | ||||||
|     "pollLongTextAnswerPreview": "Long text answer (preview)", |     "pollLongTextAnswerPreview": "長文本答案 (預覽)", | ||||||
|     "pollShortTextAnswerPreview": "Short text answer (preview)", |     "pollShortTextAnswerPreview": "短文本答案 (預覽)", | ||||||
|     "award": "Award", |     "award": "讚賞", | ||||||
|     "awardPost": "Award Post", |     "awardPost": "讚賞帖子", | ||||||
|     "awardMessage": "Message", |     "awardMessage": "消息", | ||||||
|     "awardMessageHint": "Enter your award message...", |     "awardMessageHint": "輸入您的讚賞消息...", | ||||||
|     "awardAttitude": "Attitude", |     "awardAttitude": "態度", | ||||||
|     "awardAttitudePositive": "Positive", |     "awardAttitudePositive": "積極", | ||||||
|     "awardAttitudeNegative": "Negative", |     "awardAttitudeNegative": "消极", | ||||||
|     "awardAmount": "Amount", |     "awardAmount": "金額", | ||||||
|     "awardAmountHint": "Enter amount...", |     "awardAmountHint": "輸入金額……", | ||||||
|     "awardAmountRequired": "Amount is required", |     "awardAmountRequired": "「金額」為必填字段", | ||||||
|     "awardAmountInvalid": "Please enter a valid amount", |     "awardAmountInvalid": "請輸入有效金額", | ||||||
|     "awardMessageTooLong": "Message is too long (max 4096 characters)", |     "awardMessageTooLong": "消息太長(最多4096個字符)", | ||||||
|     "awardSuccess": "Award sent successfully!", |     "awardSuccess": "獎勵已成功發送!", | ||||||
|     "awardSubmit": "Award", |     "awardSubmit": "讚賞", | ||||||
|     "awardPostPreview": "Post Preview", |     "awardPostPreview": "帖子預覽", | ||||||
|     "awardNoContent": "No content available", |     "awardNoContent": "暫無內容", | ||||||
|     "awardByPublisher": "By {}", |     "awardByPublisher": "由 {} 發表", | ||||||
|     "awardBenefits": "Award Benefits", |     "awardBenefits": "讚賞福利", | ||||||
|     "awardBenefitsDescription": "Awarding this post increases its value and visibility. Higher valued posts have a better chance of being featured and highlighted in the community.", |     "awardBenefitsDescription": "為該帖子授予獎勵可以提升其價值和曝光度。價值更高的帖子更有可能在社區中被推薦和突出顯示。", | ||||||
|     "checkInResultLevel5": "Happy Birthday 🥳", |     "checkInResultLevel5": "生日快樂 🥳", | ||||||
|     "region": "Region", |     "region": "區域", | ||||||
|     "accountRegionHint": "This region will be used for content delivery and localization.", |     "accountRegionHint": "這個區域將用於內容傳遞和本地化。", | ||||||
|     "settingsCustomFontsHelper": "Use comma to seprate.", |     "settingsCustomFontsHelper": "使用逗號分隔。", | ||||||
|     "settingsBackgroundImageEnable": "顯示背景圖片", |     "settingsBackgroundImageEnable": "顯示背景圖片", | ||||||
|     "settingsDataSavingMode": "低數據模式", |     "settingsDataSavingMode": "低數據模式", | ||||||
|     "dataSavingHint": "低數據模式", |     "dataSavingHint": "低數據模式", | ||||||
|     "postTypePost": "Post", |     "postTypePost": "帖子", | ||||||
|     "searchDrafts": "Search drafts...", |     "searchDrafts": "搜尋草稿……", | ||||||
|     "noSearchResults": "No search results", |     "noSearchResults": "無搜尋結果", | ||||||
|     "contactMethodMakePublic": "Make Public", |     "contactMethodMakePublic": "設為公開", | ||||||
|     "contactMethodMakePrivate": "Make Private", |     "contactMethodMakePrivate": "設定為僅自己可見", | ||||||
|     "contactMethodPublic": "Public", |     "contactMethodPublic": "公開", | ||||||
|     "contactMethodPrivate": "Private", |     "contactMethodPrivate": "私密", | ||||||
|     "discoverRealms": "發現領域", |     "discoverRealms": "發現領域", | ||||||
|     "discoverPublishers": "發現發佈者", |     "discoverPublishers": "發現發佈者", | ||||||
|     "discoverShuffledPost": "Random Posts", |     "discoverShuffledPost": "隨機帖子", | ||||||
|     "projects": "Projects", |     "projects": "項目", | ||||||
|     "noProjects": "No projects found.", |     "noProjects": "未找到項目。", | ||||||
|     "deleteProject": "Delete Project", |     "deleteProject": "刪除項目", | ||||||
|     "deleteProjectHint": "Are you sure you want to delete this project? This action cannot be undone.", |     "deleteProjectHint": "確定要刪除此項目嗎?此操作無法撤銷。", | ||||||
|     "createProject": "Create Project", |     "createProject": "新建專案", | ||||||
|     "editProject": "Edit Project", |     "editProject": "編輯項目", | ||||||
|     "projectDetails": "Project Details", |     "projectDetails": "專案描述", | ||||||
|     "createBot": "Create Bot", |     "createBot": "創建機器人", | ||||||
|     "bots": "Bots", |     "bots": "機器人", | ||||||
|     "noBots": "No bots yet.", |     "noBots": "還沒有機器人。", | ||||||
|     "deleteBotHint": "Are you sure you want to delete this bot? This action cannot be undone.", |     "deleteBotHint": "您確定要刪除這個機器人嗎?此操作無法撤銷。", | ||||||
|     "deleteBot": "Delete Bot", |     "deleteBot": "刪除機器人", | ||||||
|     "discoverWebArticles": "來自站外的文章", |     "discoverWebArticles": "來自站外的文章", | ||||||
|     "messageJumpNotLoaded": "The referenced message was not loaded, unable to jump to it.", |     "messageJumpNotLoaded": "引用的訊息未加載,無法跳轉到該訊息。", | ||||||
|     "postUnlinkRealm": "No linked realm", |     "postUnlinkRealm": "未連結到領域", | ||||||
|     "postSlug": "Slug", |     "postSlug": "別名", | ||||||
|     "postSlugHint": "The slug can be used to access your post via URL in the webpage, it should be publisher-wide unique.", |     "postSlugHint": "這個別名可以用於在網頁通過 URL 瀏覽到你的帖子,它應該在同一發布者中是唯一。", | ||||||
|     "attachmentOnDevice": "On-device", |     "attachmentOnDevice": "離線", | ||||||
|     "attachmentOnCloud": "On-cloud", |     "attachmentOnCloud": "在線", | ||||||
|     "attachments": "Attachments", |     "attachments": "附件", | ||||||
|     "publisherCollabInvitation": "Collabration invitations", |     "publisherCollabInvitation": "協作邀請", | ||||||
|     "publisherCollabInvitationCount": { |     "publisherCollabInvitationCount": { | ||||||
|         "zero": "No invitation", |         "zero": "無邀請", | ||||||
|         "one": "{} available invitation", |         "one": "{} 個可用邀請", | ||||||
|         "other": "{} available invitations" |         "other": "{} 個可用邀請" | ||||||
|     }, |     }, | ||||||
|     "failedToLoadUserInfo": "Failed to load user info", |     "failedToLoadUserInfo": "無法加載用戶資訊", | ||||||
|     "failedToLoadUserInfoNetwork": "It seems be network issue, you can tap the button below to try again.", |     "failedToLoadUserInfoNetwork": "看起來是網絡問題,您可以點擊下面的按鈕再試一次。", | ||||||
|     "failedToLoadUserInfoUnauthorized": "It seems your session has been logged out or not available anymore, you can still try agian to fetch the user info if you want.", |     "failedToLoadUserInfoUnauthorized": "看起來您的會話已經登出或不再可用,如果您想的話,您仍然可以嘗試再次獲取用戶資訊。", | ||||||
|     "okay": "Okay", |     "okay": "好的", | ||||||
|     "postDetail": "Post Detail", |     "postDetail": "帖子詳情", | ||||||
|     "postCount": { |     "postCount": { | ||||||
|         "zero": "No posts", |         "zero": "沒有帖子", | ||||||
|         "one": "{} post", |         "one": "{} 帖子", | ||||||
|         "other": "{} posts" |         "other": "{} 帖子" | ||||||
|     }, |     }, | ||||||
|     "mimeType": "MIME Type", |     "mimeType": "類型", | ||||||
|     "fileSize": "File Size", |     "fileSize": "文件大小", | ||||||
|     "fileHash": "File Hash", |     "fileHash": "文件哈希", | ||||||
|     "exifData": "EXIF Data", |     "exifData": "EXIF 數據", | ||||||
|     "postShuffle": "Shuffle Posts", |     "postShuffle": "隨機帖子", | ||||||
|     "leveling": "Leveling", |     "leveling": "等級", | ||||||
|     "levelingHistory": "Leveling History", |     "levelingHistory": "經驗記錄", | ||||||
|     "stellarProgram": "Stellar Program", |     "stellarProgram": "恆星計畫", | ||||||
|     "socialCredits": "Social Credits", |     "socialCredits": "社會信用點", | ||||||
|     "credits": "Credits", |     "credits": "信用", | ||||||
|     "creditsStatus": "Credits Status", |     "creditsStatus": "積分狀態", | ||||||
|     "socialCreditsDescription": "Social Credit is a way for Solar Network to evaluate users. It is calculated based on their behavior and interactions. With a base score of 100, higher scores indicate a user's credibility within the community. Scores change over time to reflect a user's recent behavior. Users with higher credit ratings enjoy more benefits, while users with lower credit ratings may have some functionality restricted.", |     "socialCreditsDescription": "社會信用是 Solar Network 評價用戶的一種方式。它基於用戶的行為和互動來計算。以 100 分為基準,分數越高表示用戶在社區中的信譽越好。分數會隨著時間的推移而變化,反映用戶的最新行為。信用等級高的用戶可以享受到更多的福利,反之的用戶部分功能可能受到限制。", | ||||||
|     "socialCreditsLevelPoor": "Poor", |     "socialCreditsLevelPoor": "糟糕", | ||||||
|     "socialCreditsLevelNormal": "Normal", |     "socialCreditsLevelNormal": "正常", | ||||||
|     "socialCreditsLevelGood": "Good", |     "socialCreditsLevelGood": "良好", | ||||||
|     "socialCreditsLevelExcellent": "Excellent", |     "socialCreditsLevelExcellent": "優秀", | ||||||
|     "orderByPopularity": "Sort by popularity", |     "orderByPopularity": "按熱度排序", | ||||||
|     "orderByReleaseDate": "Sort by release date", |     "orderByReleaseDate": "按發佈日期排序", | ||||||
|     "editBot": "Edit Bot", |     "editBot": "編輯機器人", | ||||||
|     "botAutomatedBy": "Automated by {}", |     "botAutomatedBy": "由 {} 自動化", | ||||||
|     "botDetails": "Bot Details", |     "botDetails": "機器人描述", | ||||||
|     "overview": "Overview", |     "overview": "概述", | ||||||
|     "keys": "Keys", |     "keys": "密鑰", | ||||||
|     "botNotFound": "Bot not found.", |     "botNotFound": "機器人未找到。", | ||||||
|     "newBotKey": "New Bot Key", |     "newBotKey": "新建密鑰", | ||||||
|     "newBotKeyHint": "Enter a name for your new key. The key will be shown only once.", |     "newBotKeyHint": "輸入新密鑰的名稱,密鑰只會顯示一次。", | ||||||
|     "revokeBotKey": "Revoke Bot Key", |     "revokeBotKey": "撤銷密鑰", | ||||||
|     "revokeBotKeyHint": "Are you sure you want to revoke this key? This action cannot be undone and any application using this key will stop working.", |     "revokeBotKeyHint": "你確定要撤銷這個密鑰?這個操作無法撤回,所有使用該密鑰的應用程式會停止工作。", | ||||||
|     "noBotKeys": "No bot keys yet.", |     "noBotKeys": "機器人未找到。", | ||||||
|     "revoke": "Revoke", |     "revoke": "撤銷", | ||||||
|     "keyName": "Key Name", |     "keyName": "密鑰名稱", | ||||||
|     "newKeyGenerated": "New Key Generated", |     "newKeyGenerated": "新密鑰已生成", | ||||||
|     "copyKeyHint": "Please copy this key and store it somewhere safe. You will not be able to see it again.", |     "copyKeyHint": "請安全地保存該密鑰,你不會再次看到它。", | ||||||
|     "rotateKey": "Rotate Key", |     "rotateKey": "旋轉密鑰", | ||||||
|     "rotateBotKey": "Rotate Bot Key", |     "rotateBotKey": "旋轉密鑰", | ||||||
|     "rotateBotKeyHint": "Are you sure you want to rotate this key? The old key will become invalid immediately. This action cannot be undone.", |     "rotateBotKeyHint": "你確認要旋轉這個密鑰?久的密鑰會立即失效,該操作無法撤銷。", | ||||||
|     "webFeedArticleCount": { |     "webFeedArticleCount": { | ||||||
|         "zero": "No articles", |         "zero": "無文章", | ||||||
|         "one": "{} article", |         "one": "{} 文章", | ||||||
|         "other": "{} articles" |         "other": "{} 文章" | ||||||
|     }, |     }, | ||||||
|     "webFeedSubscribed": "The feed has been subscribed", |     "webFeedSubscribed": "你已經訂閱了這個來源", | ||||||
|     "webFeedUnsubscribed": "The feed has been unsubscribed", |     "webFeedUnsubscribed": "你已經取消訂閱這個來源", | ||||||
|     "appDetails": "應用程式詳情", |     "appDetails": "應用程式詳情", | ||||||
|     "secrets": "密鑰", |     "secrets": "密鑰", | ||||||
|     "appNotFound": "找不到應用程式。", |     "appNotFound": "找不到應用程式。", | ||||||
| @@ -974,106 +974,108 @@ | |||||||
|     "copySecretHint": "請複製此密鑰並將其存放在安全的地方。您將無法再次看到它。", |     "copySecretHint": "請複製此密鑰並將其存放在安全的地方。您將無法再次看到它。", | ||||||
|     "expiresIn": "過期時間(秒)", |     "expiresIn": "過期時間(秒)", | ||||||
|     "isOidc": "OIDC 相容", |     "isOidc": "OIDC 相容", | ||||||
|     "pinPost": "Pin Post", |     "pinPost": "置頂帖子", | ||||||
|     "unpinPost": "Unpin Post", |     "unpinPost": "取消置頂", | ||||||
|     "pinnedPost": "Pinned", |     "pinnedPost": "已置顶", | ||||||
|     "publisherPage": "Publisher Page", |     "publisherPage": "發布者頁面", | ||||||
|     "realmPage": "Realm Page", |     "realmPage": "領域頁面", | ||||||
|     "replyPage": "Reply Page", |     "replyPage": "回覆頁面", | ||||||
|     "pinPostPublisherHint": "Pin this post to your publisher page", |     "pinPostPublisherHint": "將這篇文章置顶到您的發佈者頁面", | ||||||
|     "pinPostRealmHint": "Pin this post to the realm page", |     "pinPostRealmHint": "將這篇文章置顶到領域頁面", | ||||||
|     "pinPostRealmDisabledHint": "This post doesn't belong to any realm", |     "pinPostRealmDisabledHint": "這個帖子不屬於任何領域", | ||||||
|     "pinPostReplyHint": "Pin this post to the reply page", |     "pinPostReplyHint": "將這篇文章置顶到回覆頁面", | ||||||
|     "pinPostReplyDisabledHint": "This post is not a reply", |     "pinPostReplyDisabledHint": "這篇帖子不是回覆", | ||||||
|     "pin": "Pin", |     "pin": "置顶", | ||||||
|     "unpinPostHint": "Are you sure you want to unpin this post?", |     "unpinPostHint": "你確定要取消置顶這篇帖子嗎?", | ||||||
|     "all": "All", |     "all": "所有", | ||||||
|     "statusPresent": "Present", |     "statusPresent": "至今", | ||||||
|     "accountAutomated": "Automated", |     "accountAutomated": "機器人", | ||||||
|     "chatBreakClearButton": "Clear", |     "chatBreakClearButton": "清除", | ||||||
|     "chatBreak5m": "5m", |     "chatBreak5m": "5 分鐘", | ||||||
|     "chatBreak10m": "10m", |     "chatBreak10m": "10 分鐘", | ||||||
|     "chatBreak15m": "15m", |     "chatBreak15m": "15 分鐘", | ||||||
|     "chatBreak30m": "30m", |     "chatBreak30m": "30 分鐘", | ||||||
|     "chatBreakCustomMinutes": "Custom (minutes)", |     "chatBreakCustomMinutes": "自訂(分鐘)", | ||||||
|     "errorGeneric": "Error: {}", |     "errorGeneric": "錯誤:{}", | ||||||
|     "searchMessages": "Search Messages", |     "searchMessages": "搜尋消息", | ||||||
|     "messagesCount": "{} messages", |     "messagesCount": "{} 消息", | ||||||
|     "dotSeparator": "·", |     "dotSeparator": ".", | ||||||
|     "roleValidationHint": "Role must be between 0 and 100", |     "roleValidationHint": "成員角色必須設置在0到100之間", | ||||||
|     "searchMessagesHint": "Search messages...", |     "searchMessagesHint": "搜尋消息…", | ||||||
|     "searchLinks": "Links", |     "searchLinks": "連結", | ||||||
|     "searchAttachments": "Attachments", |     "searchAttachments": "附件", | ||||||
|     "noMessagesFound": "No messages found", |     "noMessagesFound": "未找到消息", | ||||||
|     "openInBrowser": "Open in Browser", |     "openInBrowser": "在瀏覽器打開", | ||||||
|     "highlightPost": "Highlight Post", |     "highlightPost": "精選帖子", | ||||||
|     "filters": "Filters", |     "filters": "過濾器", | ||||||
|     "apply": "Apply", |     "apply": "應用", | ||||||
|     "pubName": "Pub Name", |     "pubName": "題目名稱", | ||||||
|     "realm": "Realm", |     "realm": "領域", | ||||||
|     "shuffle": "Shuffle", |     "shuffle": "隨機", | ||||||
|     "pinned": "Pinned", |     "pinned": "已置顶", | ||||||
|     "noResultsFound": "No results found", |     "noResultsFound": "未找到結果", | ||||||
|     "toggleFilters": "Toggle filters", |     "toggleFilters": "切換篩檢器", | ||||||
|     "notableDayNext": "{} is in", |     "notableDayNext": "距離 {} 還有", | ||||||
|     "expandPoll": "Expand Poll", |     "expandPoll": "展開投票", | ||||||
|     "collapsePoll": "Collapse Poll", |     "collapsePoll": "摺叠投票", | ||||||
|     "embedView": "Embed View", |     "embedView": "嵌入視圖", | ||||||
|     "embedUri": "Embed URI", |     "embedUri": "嵌入URL", | ||||||
|     "aspectRatio": "Aspect Ratio", |     "aspectRatio": "縱橫比", | ||||||
|     "renderer": "Renderer", |     "renderer": "渲染器", | ||||||
|     "addEmbed": "Add Embed", |     "addEmbed": "添加嵌入", | ||||||
|     "editEmbed": "Edit Embed", |     "editEmbed": "編輯嵌入", | ||||||
|     "deleteEmbed": "Delete Embed", |     "deleteEmbed": "刪除嵌入", | ||||||
|     "deleteEmbedConfirm": "Are you sure you want to delete this embed?", |     "deleteEmbedConfirm": "您確定要刪除這個嵌入嗎?", | ||||||
|     "currentEmbed": "Current Embed", |     "currentEmbed": "當前嵌入", | ||||||
|     "noEmbed": "No embed yet", |     "noEmbed": "尚未嵌入", | ||||||
|     "save": "Save", |     "save": "保存", | ||||||
|     "webView": "Web View", |     "webView": "網頁視圖", | ||||||
|     "settingsDefaultPool": "Default file pool", |     "settingsDefaultPool": "預設檔案池", | ||||||
|     "settingsDefaultPoolHelper": "Select the default storage pool for file uploads", |     "settingsDefaultPoolHelper": "選擇文件上傳的默認儲存池", | ||||||
|     "uploadFile": "Upload File", |     "uploadFile": "上傳檔案", | ||||||
|     "authDeviceChallenges": "Device Usage", |     "authDeviceChallenges": "設備活動", | ||||||
|     "authDeviceHint": "Swipe left to edit label, swipe right to logout device.", |     "authDeviceHint": "向左滑動以編輯標籤,向右滑動以登出設備。", | ||||||
|     "settingsMessageDisplayStyle": "Message Display Style", |     "settingsMessageDisplayStyle": "訊息顯示樣式", | ||||||
|     "auto": "Auto", |     "auto": "自動", | ||||||
|     "manual": "Manual", |     "manual": "手動", | ||||||
|     "iframeCode": "Iframe Code", |     "iframeCode": "Iframe 代碼", | ||||||
|     "iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">", |     "iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">", | ||||||
|     "parseIframe": "Parse Iframe", |     "parseIframe": "解析 Iframe", | ||||||
|     "messageActions": "Message Actions", |     "messageActions": "消息選項", | ||||||
|     "viewEmbedLoadHint": "Tap to load", |     "viewEmbedLoadHint": "點擊以載入", | ||||||
|     "levelingStage1": "Novice", |     "levelingStage1": "新手", | ||||||
|     "levelingStage2": "Apprentice", |     "levelingStage2": "學徒", | ||||||
|     "levelingStage3": "Journeyman", |     "levelingStage3": "學徒工", | ||||||
|     "levelingStage4": "Adept", |     "levelingStage4": "熟練", | ||||||
|     "levelingStage5": "Expert", |     "levelingStage5": "專家", | ||||||
|     "levelingStage6": "Master", |     "levelingStage6": "大師", | ||||||
|     "levelingStage7": "Grandmaster", |     "levelingStage7": "宗師", | ||||||
|     "levelingStage8": "Legend", |     "levelingStage8": "傳說", | ||||||
|     "levelingStage9": "Myth", |     "levelingStage9": "神話", | ||||||
|     "levelingStage10": "Immortal", |     "levelingStage10": "不朽", | ||||||
|     "levelingStage11": "Divine", |     "levelingStage11": "神聖", | ||||||
|     "levelingStage12": "Transcendent", |     "levelingStage12": "超凡", | ||||||
|     "uploadAttachment": "Upload Attachment", |     "uploadAttachment": "上傳附件", | ||||||
|     "attachmentPreview": "Attachment Preview", |     "attachmentPreview": "附件預覽", | ||||||
|     "selectPool": "Select Pool", |     "selectPool": "選擇檔案池", | ||||||
|     "choosePool": "Choose a pool", |     "choosePool": "選擇一個檔案池", | ||||||
|     "errorLoadingPools": "Error loading pools", |     "errorLoadingPools": "加載池時出錯", | ||||||
|     "quotaCostInfo": "This upload will cost {} quota points", |     "quotaCostInfo": "這次上傳將消耗 {} 配額點", | ||||||
|     "uploadConstraints": "Upload Constraints", |     "uploadConstraints": "上傳限制", | ||||||
|     "fileSizeExceeded": "File size exceeds the maximum limit of {}", |     "fileSizeExceeded": "檔案大小超過了 {} 的最大限制", | ||||||
|     "fileTypeNotAccepted": "File type is not accepted by this pool", |     "fileTypeNotAccepted": "該文件類型不被此池接受", | ||||||
|     "files": "Files", |     "files": "附件", | ||||||
|     "confirmDeleteFile": "Are you sure you want to delete this file?", |     "confirmDeleteFile": "你確定要刪除這個文件嗎?", | ||||||
|     "deleteFile": "Delete File", |     "deleteFile": "刪除文件", | ||||||
|     "failedToDeleteFile": "Failed to delete file", |     "failedToDeleteFile": "刪除文件失敗", | ||||||
|     "drive": "Drive", |     "drive": "雲盤", | ||||||
|     "allPools": "All Pools", |     "allPools": "全部的池", | ||||||
|     "includeRecycled": "Include Recycled", |     "includeRecycled": "包含已回收文件", | ||||||
|     "confirmDeleteRecycledFiles": "Are you sure you want to delete all recycled files?", |     "confirmDeleteRecycledFiles": "您確定要刪除所有回收的檔案嗎?", | ||||||
|     "deleteRecycledFiles": "Delete Recycled Files", |     "deleteRecycledFiles": "刪除已回收檔案", | ||||||
|     "recycledFilesDeleted": "Recycled files deleted successfully", |     "recycledFilesDeleted": "已回收檔案刪除成功", | ||||||
|     "failedToDeleteRecycledFiles": "Failed to delete recycled files", |     "failedToDeleteRecycledFiles": "已回收檔案刪除失敗", | ||||||
|     "upload": "Upload" |     "upload": "上傳", | ||||||
|  |     "postCompose": "撰寫帖子", | ||||||
|  |     "postPublish": "發佈帖子" | ||||||
| } | } | ||||||
							
								
								
									
										15
									
								
								ios/Podfile
									
									
									
									
									
								
							
							
						
						| @@ -1,4 +1,3 @@ | |||||||
| # Uncomment this line to define a global platform for your project |  | ||||||
| platform :ios, '15.0' | platform :ios, '15.0' | ||||||
|  |  | ||||||
| # CocoaPods analytics sends network stats synchronously affecting flutter build latency. | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. | ||||||
| @@ -32,6 +31,8 @@ target 'Runner' do | |||||||
|   use_modular_headers! |   use_modular_headers! | ||||||
|  |  | ||||||
|   pod 'Alamofire' |   pod 'Alamofire' | ||||||
|  |   pod 'Kingfisher', '~> 8.0' | ||||||
|  |   pod 'KingfisherWebP' | ||||||
|  |  | ||||||
|   flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) |   flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) | ||||||
|  |  | ||||||
| @@ -41,8 +42,6 @@ target 'Runner' do | |||||||
|  |  | ||||||
|   target 'SolianNotificationService' do |   target 'SolianNotificationService' do | ||||||
|     inherit! :search_paths |     inherit! :search_paths | ||||||
|     pod 'Kingfisher', '~> 8.0' |  | ||||||
|     pod 'Alamofire' |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   target 'SolianShareExtension' do |   target 'SolianShareExtension' do | ||||||
| @@ -50,6 +49,16 @@ target 'Runner' do | |||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | target 'WatchRunner Watch App' do | ||||||
|  |   platform :watchos, '11.0' | ||||||
|  |  | ||||||
|  |   use_frameworks! | ||||||
|  |   use_modular_headers! | ||||||
|  |  | ||||||
|  |   pod 'Kingfisher', '~> 8.0' | ||||||
|  |   pod 'KingfisherWebP' | ||||||
|  | end | ||||||
|  |  | ||||||
| post_install do |installer| | post_install do |installer| | ||||||
|   installer.pods_project.targets.each do |target| |   installer.pods_project.targets.each do |target| | ||||||
|     flutter_additional_ios_build_settings(target) |     flutter_additional_ios_build_settings(target) | ||||||
|   | |||||||
							
								
								
									
										193
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							
							
						
						| @@ -1,5 +1,7 @@ | |||||||
| PODS: | PODS: | ||||||
|   - Alamofire (5.10.2) |   - Alamofire (5.10.2) | ||||||
|  |   - app_links (6.4.1): | ||||||
|  |     - Flutter | ||||||
|   - connectivity_plus (0.0.1): |   - connectivity_plus (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - croppy (0.0.1): |   - croppy (0.0.1): | ||||||
| @@ -42,83 +44,83 @@ PODS: | |||||||
|     - Flutter |     - Flutter | ||||||
|   - file_saver (0.0.1): |   - file_saver (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - Firebase/CoreOnly (12.2.0): |   - Firebase/CoreOnly (12.4.0): | ||||||
|     - FirebaseCore (~> 12.2.0) |     - FirebaseCore (~> 12.4.0) | ||||||
|   - Firebase/Crashlytics (12.2.0): |   - Firebase/Crashlytics (12.4.0): | ||||||
|     - Firebase/CoreOnly |     - Firebase/CoreOnly | ||||||
|     - FirebaseCrashlytics (~> 12.2.0) |     - FirebaseCrashlytics (~> 12.4.0) | ||||||
|   - Firebase/Messaging (12.2.0): |   - Firebase/Messaging (12.4.0): | ||||||
|     - Firebase/CoreOnly |     - Firebase/CoreOnly | ||||||
|     - FirebaseMessaging (~> 12.2.0) |     - FirebaseMessaging (~> 12.4.0) | ||||||
|   - firebase_analytics (12.0.2): |   - firebase_analytics (12.0.3): | ||||||
|     - firebase_core |     - firebase_core | ||||||
|     - FirebaseAnalytics (= 12.2.0) |     - FirebaseAnalytics (= 12.4.0) | ||||||
|     - Flutter |     - Flutter | ||||||
|   - firebase_core (4.1.1): |   - firebase_core (4.2.0): | ||||||
|     - Firebase/CoreOnly (= 12.2.0) |     - Firebase/CoreOnly (= 12.4.0) | ||||||
|     - Flutter |     - Flutter | ||||||
|   - firebase_crashlytics (5.0.2): |   - firebase_crashlytics (5.0.3): | ||||||
|     - Firebase/Crashlytics (= 12.2.0) |     - Firebase/Crashlytics (= 12.4.0) | ||||||
|     - firebase_core |     - firebase_core | ||||||
|     - Flutter |     - Flutter | ||||||
|   - firebase_messaging (16.0.2): |   - firebase_messaging (16.0.3): | ||||||
|     - Firebase/Messaging (= 12.2.0) |     - Firebase/Messaging (= 12.4.0) | ||||||
|     - firebase_core |     - firebase_core | ||||||
|     - Flutter |     - Flutter | ||||||
|   - FirebaseAnalytics (12.2.0): |   - FirebaseAnalytics (12.4.0): | ||||||
|     - FirebaseAnalytics/Default (= 12.2.0) |     - FirebaseAnalytics/Default (= 12.4.0) | ||||||
|     - FirebaseCore (~> 12.2.0) |     - FirebaseCore (~> 12.4.0) | ||||||
|     - FirebaseInstallations (~> 12.2.0) |     - FirebaseInstallations (~> 12.4.0) | ||||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) |     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) | ||||||
|     - GoogleUtilities/MethodSwizzler (~> 8.1) |     - GoogleUtilities/MethodSwizzler (~> 8.1) | ||||||
|     - GoogleUtilities/Network (~> 8.1) |     - GoogleUtilities/Network (~> 8.1) | ||||||
|     - "GoogleUtilities/NSData+zlib (~> 8.1)" |     - "GoogleUtilities/NSData+zlib (~> 8.1)" | ||||||
|     - nanopb (~> 3.30910.0) |     - nanopb (~> 3.30910.0) | ||||||
|   - FirebaseAnalytics/Default (12.2.0): |   - FirebaseAnalytics/Default (12.4.0): | ||||||
|     - FirebaseCore (~> 12.2.0) |     - FirebaseCore (~> 12.4.0) | ||||||
|     - FirebaseInstallations (~> 12.2.0) |     - FirebaseInstallations (~> 12.4.0) | ||||||
|     - GoogleAppMeasurement/Default (= 12.2.0) |     - GoogleAppMeasurement/Default (= 12.4.0) | ||||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) |     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) | ||||||
|     - GoogleUtilities/MethodSwizzler (~> 8.1) |     - GoogleUtilities/MethodSwizzler (~> 8.1) | ||||||
|     - GoogleUtilities/Network (~> 8.1) |     - GoogleUtilities/Network (~> 8.1) | ||||||
|     - "GoogleUtilities/NSData+zlib (~> 8.1)" |     - "GoogleUtilities/NSData+zlib (~> 8.1)" | ||||||
|     - nanopb (~> 3.30910.0) |     - nanopb (~> 3.30910.0) | ||||||
|   - FirebaseCore (12.2.0): |   - FirebaseCore (12.4.0): | ||||||
|     - FirebaseCoreInternal (~> 12.2.0) |     - FirebaseCoreInternal (~> 12.4.0) | ||||||
|     - GoogleUtilities/Environment (~> 8.1) |     - GoogleUtilities/Environment (~> 8.1) | ||||||
|     - GoogleUtilities/Logger (~> 8.1) |     - GoogleUtilities/Logger (~> 8.1) | ||||||
|   - FirebaseCoreExtension (12.2.0): |   - FirebaseCoreExtension (12.4.0): | ||||||
|     - FirebaseCore (~> 12.2.0) |     - FirebaseCore (~> 12.4.0) | ||||||
|   - FirebaseCoreInternal (12.2.0): |   - FirebaseCoreInternal (12.4.0): | ||||||
|     - "GoogleUtilities/NSData+zlib (~> 8.1)" |     - "GoogleUtilities/NSData+zlib (~> 8.1)" | ||||||
|   - FirebaseCrashlytics (12.2.0): |   - FirebaseCrashlytics (12.4.0): | ||||||
|     - FirebaseCore (~> 12.2.0) |     - FirebaseCore (~> 12.4.0) | ||||||
|     - FirebaseInstallations (~> 12.2.0) |     - FirebaseInstallations (~> 12.4.0) | ||||||
|     - FirebaseRemoteConfigInterop (~> 12.2.0) |     - FirebaseRemoteConfigInterop (~> 12.4.0) | ||||||
|     - FirebaseSessions (~> 12.2.0) |     - FirebaseSessions (~> 12.4.0) | ||||||
|     - GoogleDataTransport (~> 10.1) |     - GoogleDataTransport (~> 10.1) | ||||||
|     - GoogleUtilities/Environment (~> 8.1) |     - GoogleUtilities/Environment (~> 8.1) | ||||||
|     - nanopb (~> 3.30910.0) |     - nanopb (~> 3.30910.0) | ||||||
|     - PromisesObjC (~> 2.4) |     - PromisesObjC (~> 2.4) | ||||||
|   - FirebaseInstallations (12.2.0): |   - FirebaseInstallations (12.4.0): | ||||||
|     - FirebaseCore (~> 12.2.0) |     - FirebaseCore (~> 12.4.0) | ||||||
|     - GoogleUtilities/Environment (~> 8.1) |     - GoogleUtilities/Environment (~> 8.1) | ||||||
|     - GoogleUtilities/UserDefaults (~> 8.1) |     - GoogleUtilities/UserDefaults (~> 8.1) | ||||||
|     - PromisesObjC (~> 2.4) |     - PromisesObjC (~> 2.4) | ||||||
|   - FirebaseMessaging (12.2.0): |   - FirebaseMessaging (12.4.0): | ||||||
|     - FirebaseCore (~> 12.2.0) |     - FirebaseCore (~> 12.4.0) | ||||||
|     - FirebaseInstallations (~> 12.2.0) |     - FirebaseInstallations (~> 12.4.0) | ||||||
|     - GoogleDataTransport (~> 10.1) |     - GoogleDataTransport (~> 10.1) | ||||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) |     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) | ||||||
|     - GoogleUtilities/Environment (~> 8.1) |     - GoogleUtilities/Environment (~> 8.1) | ||||||
|     - GoogleUtilities/Reachability (~> 8.1) |     - GoogleUtilities/Reachability (~> 8.1) | ||||||
|     - GoogleUtilities/UserDefaults (~> 8.1) |     - GoogleUtilities/UserDefaults (~> 8.1) | ||||||
|     - nanopb (~> 3.30910.0) |     - nanopb (~> 3.30910.0) | ||||||
|   - FirebaseRemoteConfigInterop (12.2.0) |   - FirebaseRemoteConfigInterop (12.4.0) | ||||||
|   - FirebaseSessions (12.2.0): |   - FirebaseSessions (12.4.0): | ||||||
|     - FirebaseCore (~> 12.2.0) |     - FirebaseCore (~> 12.4.0) | ||||||
|     - FirebaseCoreExtension (~> 12.2.0) |     - FirebaseCoreExtension (~> 12.4.0) | ||||||
|     - FirebaseInstallations (~> 12.2.0) |     - FirebaseInstallations (~> 12.4.0) | ||||||
|     - GoogleDataTransport (~> 10.1) |     - GoogleDataTransport (~> 10.1) | ||||||
|     - GoogleUtilities/Environment (~> 8.1) |     - GoogleUtilities/Environment (~> 8.1) | ||||||
|     - GoogleUtilities/UserDefaults (~> 8.1) |     - GoogleUtilities/UserDefaults (~> 8.1) | ||||||
| @@ -155,27 +157,28 @@ PODS: | |||||||
|   - gal (1.0.0): |   - gal (1.0.0): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - FlutterMacOS |     - FlutterMacOS | ||||||
|   - GoogleAdsOnDeviceConversion (2.3.0): |   - GoogleAdsOnDeviceConversion (3.1.0): | ||||||
|  |     - GoogleUtilities/Environment (~> 8.1) | ||||||
|     - GoogleUtilities/Logger (~> 8.1) |     - GoogleUtilities/Logger (~> 8.1) | ||||||
|     - GoogleUtilities/Network (~> 8.1) |     - GoogleUtilities/Network (~> 8.1) | ||||||
|     - nanopb (~> 3.30910.0) |     - nanopb (~> 3.30910.0) | ||||||
|   - GoogleAppMeasurement/Core (12.2.0): |   - GoogleAppMeasurement/Core (12.4.0): | ||||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) |     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) | ||||||
|     - GoogleUtilities/MethodSwizzler (~> 8.1) |     - GoogleUtilities/MethodSwizzler (~> 8.1) | ||||||
|     - GoogleUtilities/Network (~> 8.1) |     - GoogleUtilities/Network (~> 8.1) | ||||||
|     - "GoogleUtilities/NSData+zlib (~> 8.1)" |     - "GoogleUtilities/NSData+zlib (~> 8.1)" | ||||||
|     - nanopb (~> 3.30910.0) |     - nanopb (~> 3.30910.0) | ||||||
|   - GoogleAppMeasurement/Default (12.2.0): |   - GoogleAppMeasurement/Default (12.4.0): | ||||||
|     - GoogleAdsOnDeviceConversion (= 2.3.0) |     - GoogleAdsOnDeviceConversion (~> 3.1.0) | ||||||
|     - GoogleAppMeasurement/Core (= 12.2.0) |     - GoogleAppMeasurement/Core (= 12.4.0) | ||||||
|     - GoogleAppMeasurement/IdentitySupport (= 12.2.0) |     - GoogleAppMeasurement/IdentitySupport (= 12.4.0) | ||||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) |     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) | ||||||
|     - GoogleUtilities/MethodSwizzler (~> 8.1) |     - GoogleUtilities/MethodSwizzler (~> 8.1) | ||||||
|     - GoogleUtilities/Network (~> 8.1) |     - GoogleUtilities/Network (~> 8.1) | ||||||
|     - "GoogleUtilities/NSData+zlib (~> 8.1)" |     - "GoogleUtilities/NSData+zlib (~> 8.1)" | ||||||
|     - nanopb (~> 3.30910.0) |     - nanopb (~> 3.30910.0) | ||||||
|   - GoogleAppMeasurement/IdentitySupport (12.2.0): |   - GoogleAppMeasurement/IdentitySupport (12.4.0): | ||||||
|     - GoogleAppMeasurement/Core (= 12.2.0) |     - GoogleAppMeasurement/Core (= 12.4.0) | ||||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) |     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) | ||||||
|     - GoogleUtilities/MethodSwizzler (~> 8.1) |     - GoogleUtilities/MethodSwizzler (~> 8.1) | ||||||
|     - GoogleUtilities/Network (~> 8.1) |     - GoogleUtilities/Network (~> 8.1) | ||||||
| @@ -215,8 +218,23 @@ PODS: | |||||||
|     - Flutter |     - Flutter | ||||||
|   - irondash_engine_context (0.0.1): |   - irondash_engine_context (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - Kingfisher (8.5.0) |   - Kingfisher (8.6.1) | ||||||
|   - livekit_client (2.5.0): |   - 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 | ||||||
|     - flutter_webrtc |     - flutter_webrtc | ||||||
|     - WebRTC-SDK (= 137.7151.04) |     - WebRTC-SDK (= 137.7151.04) | ||||||
| @@ -252,9 +270,9 @@ PODS: | |||||||
|   - record_ios (1.1.0): |   - record_ios (1.1.0): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - SAMKeychain (1.5.3) |   - SAMKeychain (1.5.3) | ||||||
|   - SDWebImage (5.21.2): |   - SDWebImage (5.21.3): | ||||||
|     - SDWebImage/Core (= 5.21.2) |     - SDWebImage/Core (= 5.21.3) | ||||||
|   - SDWebImage/Core (5.21.2) |   - SDWebImage/Core (5.21.3) | ||||||
|   - share_plus (0.0.1): |   - share_plus (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - shared_preferences_foundation (0.0.1): |   - shared_preferences_foundation (0.0.1): | ||||||
| @@ -293,6 +311,8 @@ PODS: | |||||||
|   - super_native_extensions (0.0.1): |   - super_native_extensions (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - SwiftyGif (5.4.5) |   - SwiftyGif (5.4.5) | ||||||
|  |   - syncfusion_flutter_pdfviewer (0.0.1): | ||||||
|  |     - Flutter | ||||||
|   - url_launcher_ios (0.0.1): |   - url_launcher_ios (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - volume_controller (0.0.1): |   - volume_controller (0.0.1): | ||||||
| @@ -303,6 +323,7 @@ PODS: | |||||||
|  |  | ||||||
| DEPENDENCIES: | DEPENDENCIES: | ||||||
|   - Alamofire |   - Alamofire | ||||||
|  |   - app_links (from `.symlinks/plugins/app_links/ios`) | ||||||
|   - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) |   - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) | ||||||
|   - croppy (from `.symlinks/plugins/croppy/ios`) |   - croppy (from `.symlinks/plugins/croppy/ios`) | ||||||
|   - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) |   - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) | ||||||
| @@ -327,6 +348,7 @@ DEPENDENCIES: | |||||||
|   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) |   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) | ||||||
|   - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) |   - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) | ||||||
|   - Kingfisher (~> 8.0) |   - Kingfisher (~> 8.0) | ||||||
|  |   - KingfisherWebP | ||||||
|   - livekit_client (from `.symlinks/plugins/livekit_client/ios`) |   - livekit_client (from `.symlinks/plugins/livekit_client/ios`) | ||||||
|   - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) |   - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) | ||||||
|   - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) |   - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) | ||||||
| @@ -344,6 +366,7 @@ DEPENDENCIES: | |||||||
|   - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) |   - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) | ||||||
|   - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) |   - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) | ||||||
|   - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) |   - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) | ||||||
|  |   - syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`) | ||||||
|   - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) |   - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) | ||||||
|   - volume_controller (from `.symlinks/plugins/volume_controller/ios`) |   - volume_controller (from `.symlinks/plugins/volume_controller/ios`) | ||||||
|   - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) |   - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) | ||||||
| @@ -368,6 +391,8 @@ SPEC REPOS: | |||||||
|     - GoogleDataTransport |     - GoogleDataTransport | ||||||
|     - GoogleUtilities |     - GoogleUtilities | ||||||
|     - Kingfisher |     - Kingfisher | ||||||
|  |     - KingfisherWebP | ||||||
|  |     - libwebp | ||||||
|     - nanopb |     - nanopb | ||||||
|     - OrderedSet |     - OrderedSet | ||||||
|     - PromisesObjC |     - PromisesObjC | ||||||
| @@ -379,6 +404,8 @@ SPEC REPOS: | |||||||
|     - WebRTC-SDK |     - WebRTC-SDK | ||||||
|  |  | ||||||
| EXTERNAL SOURCES: | EXTERNAL SOURCES: | ||||||
|  |   app_links: | ||||||
|  |     :path: ".symlinks/plugins/app_links/ios" | ||||||
|   connectivity_plus: |   connectivity_plus: | ||||||
|     :path: ".symlinks/plugins/connectivity_plus/ios" |     :path: ".symlinks/plugins/connectivity_plus/ios" | ||||||
|   croppy: |   croppy: | ||||||
| @@ -459,6 +486,8 @@ EXTERNAL SOURCES: | |||||||
|     :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" |     :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" | ||||||
|   super_native_extensions: |   super_native_extensions: | ||||||
|     :path: ".symlinks/plugins/super_native_extensions/ios" |     :path: ".symlinks/plugins/super_native_extensions/ios" | ||||||
|  |   syncfusion_flutter_pdfviewer: | ||||||
|  |     :path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios" | ||||||
|   url_launcher_ios: |   url_launcher_ios: | ||||||
|     :path: ".symlinks/plugins/url_launcher_ios/ios" |     :path: ".symlinks/plugins/url_launcher_ios/ios" | ||||||
|   volume_controller: |   volume_controller: | ||||||
| @@ -468,6 +497,7 @@ EXTERNAL SOURCES: | |||||||
|  |  | ||||||
| SPEC CHECKSUMS: | SPEC CHECKSUMS: | ||||||
|   Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 |   Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 | ||||||
|  |   app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a | ||||||
|   connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd |   connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd | ||||||
|   croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30 |   croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30 | ||||||
|   device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe |   device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe | ||||||
| @@ -475,20 +505,20 @@ SPEC CHECKSUMS: | |||||||
|   DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 |   DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 | ||||||
|   file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be |   file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be | ||||||
|   file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 |   file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 | ||||||
|   Firebase: 26f6f8d460603af3df970ad505b16b15f5e2e9a1 |   Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e | ||||||
|   firebase_analytics: 8c78ce6224e0623152379d6cc7ef3d9098477b7e |   firebase_analytics: 1d024068b1d4707d5ba7a42a12976ddf3316d835 | ||||||
|   firebase_core: dfc4bd142bee4bc53a5d482397ca322c2dd3165d |   firebase_core: 744984dbbed8b3036abf34f0b98d80f130a7e464 | ||||||
|   firebase_crashlytics: e55dcf895eed0dd87c447dd5aff8db7f1bb8bbdb |   firebase_crashlytics: f3a9a4338ab99b67042f64e9e22e1bf349cb44ed | ||||||
|   firebase_messaging: 38c66c1184695b0c87abe51d40fc590718abed1a |   firebase_messaging: 82c70650c426a0a14873e1acdb9ec2b443c4e8b4 | ||||||
|   FirebaseAnalytics: e04e23bc070e3014aa5cf4980f9df7ce5cd79ec8 |   FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f | ||||||
|   FirebaseCore: 311c48a147ad4a0ab7febbaed89e8025c67510cd |   FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3 | ||||||
|   FirebaseCoreExtension: 73af080c22a2f7b44cefa391dc08f7e4ee162cb5 |   FirebaseCoreExtension: 7e1f7118ee970e001a8013719fb90950ee5e0018 | ||||||
|   FirebaseCoreInternal: 56ea29f3dad2894f81b060f706f9d53509b6ed3b |   FirebaseCoreInternal: d7f5a043c2cd01a08103ab586587c1468047bca6 | ||||||
|   FirebaseCrashlytics: f83cbf176d5c637ade108c0aacf1ccbd5ec499bf |   FirebaseCrashlytics: a6ece278a837c7e88de2d9b5da0a3542f2342395 | ||||||
|   FirebaseInstallations: 3e884b01feabdf67582a80f3250425a00979b4ed |   FirebaseInstallations: ae9f4902cb5bf1d0c5eaa31ec1f4e5495a0714e2 | ||||||
|   FirebaseMessaging: 43ec73bbfedd0c385a849bb91593ab4ad4b9e48e |   FirebaseMessaging: d33971b7bb252745ea6cd31ab190d1a1df4b8ed5 | ||||||
|   FirebaseRemoteConfigInterop: 0896fd52ab72586a355c8f389ff85aaa9e5375e1 |   FirebaseRemoteConfigInterop: 1e31ec72b89c9924367c59bfb5ec9ab60d1d6766 | ||||||
|   FirebaseSessions: f4692789e770bec66ce17d772c0e9561c4f11737 |   FirebaseSessions: ba7c7a7ca8696a8d540eb3fe3800fbe98c79786d | ||||||
|   Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 |   Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 | ||||||
|   flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9 |   flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9 | ||||||
|   flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 |   flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 | ||||||
| @@ -501,14 +531,16 @@ SPEC CHECKSUMS: | |||||||
|   flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 |   flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 | ||||||
|   flutter_webrtc: c3e21fc0dcd9d8eb246ae4d5256fcbeb2f5ecd22 |   flutter_webrtc: c3e21fc0dcd9d8eb246ae4d5256fcbeb2f5ecd22 | ||||||
|   gal: baecd024ebfd13c441269ca7404792a7152fde89 |   gal: baecd024ebfd13c441269ca7404792a7152fde89 | ||||||
|   GoogleAdsOnDeviceConversion: 9090c435cde08903e8dd1ba2c77fbec9e46d9afe |   GoogleAdsOnDeviceConversion: e03a386840803ea7eef3fd22a061930142c039c1 | ||||||
|   GoogleAppMeasurement: 09f341dfa8527d1612a09cbfe809a242c0b737af |   GoogleAppMeasurement: 1e718274b7e015cefd846ac1fcf7820c70dc017d | ||||||
|   GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 |   GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 | ||||||
|   GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 |   GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 | ||||||
|   image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a |   image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 | ||||||
|   irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 |   irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 | ||||||
|   Kingfisher: ff0d31a1f07bdff6a1ebb3ba08b8e6e567b6500c |   Kingfisher: 7ac7a7288653787a54206b11a3c74f49ab650f1f | ||||||
|   livekit_client: a6f5fa86ac28ccd7ded53626a5379961db311ab4 |   KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402 | ||||||
|  |   libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 | ||||||
|  |   livekit_client: 86c8af579274e4b7a215185a8080db2d4e176f40 | ||||||
|   local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb |   local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb | ||||||
|   media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 |   media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 | ||||||
|   media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 |   media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 | ||||||
| @@ -517,27 +549,28 @@ SPEC CHECKSUMS: | |||||||
|   OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 |   OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 | ||||||
|   package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 |   package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 | ||||||
|   pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c |   pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c | ||||||
|   path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 |   path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 | ||||||
|   pointer_interceptor_ios: ec847ef8b0915778bed2b2cef636f4d177fa8eed |   pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0 | ||||||
|   PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 |   PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 | ||||||
|   PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 |   PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 | ||||||
|   receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 |   receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 | ||||||
|   record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374 |   record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374 | ||||||
|   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c |   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c | ||||||
|   SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a |   SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a | ||||||
|   share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a |   share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a | ||||||
|   shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 |   shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb | ||||||
|   sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 |   sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 | ||||||
|   sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 |   sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 | ||||||
|   sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b |   sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b | ||||||
|   sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1 |   sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1 | ||||||
|   super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 |   super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 | ||||||
|   SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 |   SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 | ||||||
|   url_launcher_ios: 694010445543906933d732453a59da0a173ae33d |   syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14 | ||||||
|  |   url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b | ||||||
|   volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 |   volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 | ||||||
|   wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 |   wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 | ||||||
|   WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e |   WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e | ||||||
|  |  | ||||||
| PODFILE CHECKSUM: c818292390b02fa379036ea099713a332bd7193f | PODFILE CHECKSUM: fa173dbf2c15b3248d7fd65204fa9d6c4a6f13d6 | ||||||
|  |  | ||||||
| COCOAPODS: 1.16.2 | COCOAPODS: 1.16.2 | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ | |||||||
| 		1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; | 		1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; | ||||||
| 		331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; | 		331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; | ||||||
| 		3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; | 		3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; | ||||||
|  | 		7310A7DF2EB10963002C0FD3 /* WatchRunner Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | ||||||
| 		73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */; }; | 		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, ); }; }; | 		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, ); }; }; | 		73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | ||||||
| @@ -20,6 +21,7 @@ | |||||||
| 		97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; | 		97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; | ||||||
| 		97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; | 		97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; | ||||||
| 		97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; | 		97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; | ||||||
|  | 		A1D34487886D362AC8B99B2E /* Pods_WatchRunner_Watch_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 802C1CFCA7F1E069AAEFB454 /* Pods_WatchRunner_Watch_App.framework */; }; | ||||||
| 		B87C0E607033790E71B54D73 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F6D834CA86410B09796B312B /* Pods_Runner.framework */; }; | 		B87C0E607033790E71B54D73 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F6D834CA86410B09796B312B /* Pods_Runner.framework */; }; | ||||||
| 		D174D53FF3E8EA943491A5CC /* Pods_SolianShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */; }; | 		D174D53FF3E8EA943491A5CC /* Pods_SolianShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */; }; | ||||||
| 		D1772CE196985AE8E8C9F2E5 /* Pods_SolianNotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */; }; | 		D1772CE196985AE8E8C9F2E5 /* Pods_SolianNotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */; }; | ||||||
| @@ -58,6 +60,17 @@ | |||||||
| /* End PBXContainerItemProxy section */ | /* End PBXContainerItemProxy section */ | ||||||
|  |  | ||||||
| /* Begin PBXCopyFilesBuildPhase section */ | /* Begin PBXCopyFilesBuildPhase section */ | ||||||
|  | 		7310A7DE2EB10963002C0FD3 /* Embed Watch Content */ = { | ||||||
|  | 			isa = PBXCopyFilesBuildPhase; | ||||||
|  | 			buildActionMask = 12; | ||||||
|  | 			dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; | ||||||
|  | 			dstSubfolderSpec = 16; | ||||||
|  | 			files = ( | ||||||
|  | 				7310A7DF2EB10963002C0FD3 /* WatchRunner Watch App.app in Embed Watch Content */, | ||||||
|  | 			); | ||||||
|  | 			name = "Embed Watch Content"; | ||||||
|  | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
|  | 		}; | ||||||
| 		73268D1D2DEAFD670076E970 /* Embed Foundation Extensions */ = { | 		73268D1D2DEAFD670076E970 /* Embed Foundation Extensions */ = { | ||||||
| 			isa = PBXCopyFilesBuildPhase; | 			isa = PBXCopyFilesBuildPhase; | ||||||
| 			buildActionMask = 2147483647; | 			buildActionMask = 2147483647; | ||||||
| @@ -84,6 +97,7 @@ | |||||||
| /* End PBXCopyFilesBuildPhase section */ | /* End PBXCopyFilesBuildPhase section */ | ||||||
|  |  | ||||||
| /* Begin PBXFileReference section */ | /* Begin PBXFileReference section */ | ||||||
|  | 		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>"; }; | 		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>"; }; | 		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>"; }; | 		1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; | ||||||
| @@ -100,6 +114,7 @@ | |||||||
| 		39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SolianNotificationService.framework; 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>"; }; | 		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>"; }; | 		3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; | ||||||
|  | 		7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "WatchRunner Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||||
| 		737E920B2DB6A9FF00BE9CDB /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; }; | 		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; }; | 		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; }; | 		73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReplayKit.framework; path = System/Library/Frameworks/ReplayKit.framework; sourceTree = SDKROOT; }; | ||||||
| @@ -111,6 +126,8 @@ | |||||||
| 		74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; | 		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>"; }; | 		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; }; | 		7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SolianShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||||
|  | 		802C1CFCA7F1E069AAEFB454 /* Pods_WatchRunner_Watch_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WatchRunner_Watch_App.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>"; }; | 		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>"; }; | 		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>"; }; | 		9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; | ||||||
| @@ -120,6 +137,7 @@ | |||||||
| 		97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; | 		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>"; }; | 		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>"; }; | 		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>"; }; | 		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>"; }; | 		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; }; | 		AA0CA8A3E15DEE023BB27438 /* Pods_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||||
| @@ -162,6 +180,13 @@ | |||||||
| /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ | ||||||
|  |  | ||||||
| /* Begin PBXFileSystemSynchronizedRootGroup section */ | /* Begin PBXFileSystemSynchronizedRootGroup section */ | ||||||
|  | 		7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */ = { | ||||||
|  | 			isa = PBXFileSystemSynchronizedRootGroup; | ||||||
|  | 			exceptions = ( | ||||||
|  | 			); | ||||||
|  | 			path = "WatchRunner Watch App"; | ||||||
|  | 			sourceTree = "<group>"; | ||||||
|  | 		}; | ||||||
| 		73268D272DEB012A0076E970 /* Services */ = { | 		73268D272DEB012A0076E970 /* Services */ = { | ||||||
| 			isa = PBXFileSystemSynchronizedRootGroup; | 			isa = PBXFileSystemSynchronizedRootGroup; | ||||||
| 			exceptions = ( | 			exceptions = ( | ||||||
| @@ -205,6 +230,14 @@ | |||||||
| 			); | 			); | ||||||
| 			runOnlyForDeploymentPostprocessing = 0; | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
| 		}; | 		}; | ||||||
|  | 		7310A7D12EB10962002C0FD3 /* Frameworks */ = { | ||||||
|  | 			isa = PBXFrameworksBuildPhase; | ||||||
|  | 			buildActionMask = 2147483647; | ||||||
|  | 			files = ( | ||||||
|  | 				A1D34487886D362AC8B99B2E /* Pods_WatchRunner_Watch_App.framework in Frameworks */, | ||||||
|  | 			); | ||||||
|  | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
|  | 		}; | ||||||
| 		73ACDFA82E3D0E6100B63535 /* Frameworks */ = { | 		73ACDFA82E3D0E6100B63535 /* Frameworks */ = { | ||||||
| 			isa = PBXFrameworksBuildPhase; | 			isa = PBXFrameworksBuildPhase; | ||||||
| 			buildActionMask = 2147483647; | 			buildActionMask = 2147483647; | ||||||
| @@ -258,6 +291,7 @@ | |||||||
| 				7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */, | 				7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */, | ||||||
| 				73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */, | 				73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */, | ||||||
| 				73ACDFB82E3D0E6100B63535 /* UIKit.framework */, | 				73ACDFB82E3D0E6100B63535 /* UIKit.framework */, | ||||||
|  | 				802C1CFCA7F1E069AAEFB454 /* Pods_WatchRunner_Watch_App.framework */, | ||||||
| 			); | 			); | ||||||
| 			name = Frameworks; | 			name = Frameworks; | ||||||
| 			sourceTree = "<group>"; | 			sourceTree = "<group>"; | ||||||
| @@ -280,6 +314,9 @@ | |||||||
| 				17FAB080A9C53193ABD9C15B /* Pods-SolianShareExtension.debug.xcconfig */, | 				17FAB080A9C53193ABD9C15B /* Pods-SolianShareExtension.debug.xcconfig */, | ||||||
| 				27C66EFB5A705F1A822C3EB0 /* Pods-SolianShareExtension.release.xcconfig */, | 				27C66EFB5A705F1A822C3EB0 /* Pods-SolianShareExtension.release.xcconfig */, | ||||||
| 				A85FF612AE7623A9934E57CE /* Pods-SolianShareExtension.profile.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 */, | ||||||
| 			); | 			); | ||||||
| 			path = Pods; | 			path = Pods; | ||||||
| 			sourceTree = "<group>"; | 			sourceTree = "<group>"; | ||||||
| @@ -303,6 +340,7 @@ | |||||||
| 				73CDD67B2DEC00480059D95D /* SolianNotificationService */, | 				73CDD67B2DEC00480059D95D /* SolianNotificationService */, | ||||||
| 				73C305CF2E0BE878009035B9 /* SolianShareExtension */, | 				73C305CF2E0BE878009035B9 /* SolianShareExtension */, | ||||||
| 				73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */, | 				73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */, | ||||||
|  | 				7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */, | ||||||
| 				97C146EF1CF9000F007C117D /* Products */, | 				97C146EF1CF9000F007C117D /* Products */, | ||||||
| 				331C8082294A63A400263BE5 /* RunnerTests */, | 				331C8082294A63A400263BE5 /* RunnerTests */, | ||||||
| 				91E124CE95BCB4DCD890160D /* Pods */, | 				91E124CE95BCB4DCD890160D /* Pods */, | ||||||
| @@ -319,6 +357,7 @@ | |||||||
| 				73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */, | 				73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */, | ||||||
| 				73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */, | 				73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */, | ||||||
| 				73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */, | 				73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */, | ||||||
|  | 				7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */, | ||||||
| 			); | 			); | ||||||
| 			name = Products; | 			name = Products; | ||||||
| 			sourceTree = "<group>"; | 			sourceTree = "<group>"; | ||||||
| @@ -363,6 +402,28 @@ | |||||||
| 			productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; | 			productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; | ||||||
| 			productType = "com.apple.product-type.bundle.unit-test"; | 			productType = "com.apple.product-type.bundle.unit-test"; | ||||||
| 		}; | 		}; | ||||||
|  | 		7310A7D32EB10962002C0FD3 /* WatchRunner Watch App */ = { | ||||||
|  | 			isa = PBXNativeTarget; | ||||||
|  | 			buildConfigurationList = 7310A7E32EB10963002C0FD3 /* Build configuration list for PBXNativeTarget "WatchRunner Watch App" */; | ||||||
|  | 			buildPhases = ( | ||||||
|  | 				DDEDA1BA6278B94F0F7B9B61 /* [CP] Check Pods Manifest.lock */, | ||||||
|  | 				7310A7D02EB10962002C0FD3 /* Sources */, | ||||||
|  | 				7310A7D12EB10962002C0FD3 /* Frameworks */, | ||||||
|  | 				7310A7D22EB10962002C0FD3 /* Resources */, | ||||||
|  | 				E29ECA5954168075BDB000DC /* [CP] Embed Pods Frameworks */, | ||||||
|  | 			); | ||||||
|  | 			buildRules = ( | ||||||
|  | 			); | ||||||
|  | 			dependencies = ( | ||||||
|  | 			); | ||||||
|  | 			fileSystemSynchronizedGroups = ( | ||||||
|  | 				7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */, | ||||||
|  | 			); | ||||||
|  | 			name = "WatchRunner Watch App"; | ||||||
|  | 			productName = "WatchRunner Watch App"; | ||||||
|  | 			productReference = 7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */; | ||||||
|  | 			productType = "com.apple.product-type.application"; | ||||||
|  | 		}; | ||||||
| 		73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */ = { | 		73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */ = { | ||||||
| 			isa = PBXNativeTarget; | 			isa = PBXNativeTarget; | ||||||
| 			buildConfigurationList = 73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */; | 			buildConfigurationList = 73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */; | ||||||
| @@ -434,6 +495,7 @@ | |||||||
| 				97C146EA1CF9000F007C117D /* Sources */, | 				97C146EA1CF9000F007C117D /* Sources */, | ||||||
| 				97C146EB1CF9000F007C117D /* Frameworks */, | 				97C146EB1CF9000F007C117D /* Frameworks */, | ||||||
| 				73268D1D2DEAFD670076E970 /* Embed Foundation Extensions */, | 				73268D1D2DEAFD670076E970 /* Embed Foundation Extensions */, | ||||||
|  | 				7310A7DE2EB10963002C0FD3 /* Embed Watch Content */, | ||||||
| 				97C146EC1CF9000F007C117D /* Resources */, | 				97C146EC1CF9000F007C117D /* Resources */, | ||||||
| 				9705A1C41CF9048500538489 /* Embed Frameworks */, | 				9705A1C41CF9048500538489 /* Embed Frameworks */, | ||||||
| 				3B06AD1E1E4923F5004D2608 /* Thin Binary */, | 				3B06AD1E1E4923F5004D2608 /* Thin Binary */, | ||||||
| @@ -463,7 +525,7 @@ | |||||||
| 			isa = PBXProject; | 			isa = PBXProject; | ||||||
| 			attributes = { | 			attributes = { | ||||||
| 				BuildIndependentTargetsInParallel = YES; | 				BuildIndependentTargetsInParallel = YES; | ||||||
| 				LastSwiftUpdateCheck = 1640; | 				LastSwiftUpdateCheck = 2600; | ||||||
| 				LastUpgradeCheck = 1510; | 				LastUpgradeCheck = 1510; | ||||||
| 				ORGANIZATIONNAME = ""; | 				ORGANIZATIONNAME = ""; | ||||||
| 				TargetAttributes = { | 				TargetAttributes = { | ||||||
| @@ -471,6 +533,9 @@ | |||||||
| 						CreatedOnToolsVersion = 14.0; | 						CreatedOnToolsVersion = 14.0; | ||||||
| 						TestTargetID = 97C146ED1CF9000F007C117D; | 						TestTargetID = 97C146ED1CF9000F007C117D; | ||||||
| 					}; | 					}; | ||||||
|  | 					7310A7D32EB10962002C0FD3 = { | ||||||
|  | 						CreatedOnToolsVersion = 26.0.1; | ||||||
|  | 					}; | ||||||
| 					73ACDFAA2E3D0E6100B63535 = { | 					73ACDFAA2E3D0E6100B63535 = { | ||||||
| 						CreatedOnToolsVersion = 16.4; | 						CreatedOnToolsVersion = 16.4; | ||||||
| 					}; | 					}; | ||||||
| @@ -504,6 +569,7 @@ | |||||||
| 				73CDD6792DEC00480059D95D /* SolianNotificationService */, | 				73CDD6792DEC00480059D95D /* SolianNotificationService */, | ||||||
| 				73C305CD2E0BE878009035B9 /* SolianShareExtension */, | 				73C305CD2E0BE878009035B9 /* SolianShareExtension */, | ||||||
| 				73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */, | 				73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */, | ||||||
|  | 				7310A7D32EB10962002C0FD3 /* WatchRunner Watch App */, | ||||||
| 			); | 			); | ||||||
| 		}; | 		}; | ||||||
| /* End PBXProject section */ | /* End PBXProject section */ | ||||||
| @@ -516,6 +582,13 @@ | |||||||
| 			); | 			); | ||||||
| 			runOnlyForDeploymentPostprocessing = 0; | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
| 		}; | 		}; | ||||||
|  | 		7310A7D22EB10962002C0FD3 /* Resources */ = { | ||||||
|  | 			isa = PBXResourcesBuildPhase; | ||||||
|  | 			buildActionMask = 2147483647; | ||||||
|  | 			files = ( | ||||||
|  | 			); | ||||||
|  | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
|  | 		}; | ||||||
| 		73ACDFA92E3D0E6100B63535 /* Resources */ = { | 		73ACDFA92E3D0E6100B63535 /* Resources */ = { | ||||||
| 			isa = PBXResourcesBuildPhase; | 			isa = PBXResourcesBuildPhase; | ||||||
| 			buildActionMask = 2147483647; | 			buildActionMask = 2147483647; | ||||||
| @@ -683,6 +756,45 @@ | |||||||
| 			shellPath = /bin/sh; | 			shellPath = /bin/sh; | ||||||
| 			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; | 			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-WatchRunner 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-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks-${CONFIGURATION}-input-files.xcfilelist", | ||||||
|  | 			); | ||||||
|  | 			name = "[CP] Embed Pods Frameworks"; | ||||||
|  | 			outputFileListPaths = ( | ||||||
|  | 				"${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist", | ||||||
|  | 			); | ||||||
|  | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
|  | 			shellPath = /bin/sh; | ||||||
|  | 			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks.sh\"\n"; | ||||||
|  | 			showEnvVarsInLog = 0; | ||||||
|  | 		}; | ||||||
| 		E86CDE9D6464F4F52B910856 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = { | 		E86CDE9D6464F4F52B910856 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = { | ||||||
| 			isa = PBXShellScriptBuildPhase; | 			isa = PBXShellScriptBuildPhase; | ||||||
| 			buildActionMask = 2147483647; | 			buildActionMask = 2147483647; | ||||||
| @@ -734,6 +846,13 @@ | |||||||
| 			); | 			); | ||||||
| 			runOnlyForDeploymentPostprocessing = 0; | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
| 		}; | 		}; | ||||||
|  | 		7310A7D02EB10962002C0FD3 /* Sources */ = { | ||||||
|  | 			isa = PBXSourcesBuildPhase; | ||||||
|  | 			buildActionMask = 2147483647; | ||||||
|  | 			files = ( | ||||||
|  | 			); | ||||||
|  | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
|  | 		}; | ||||||
| 		73ACDFA72E3D0E6100B63535 /* Sources */ = { | 		73ACDFA72E3D0E6100B63535 /* Sources */ = { | ||||||
| 			isa = PBXSourcesBuildPhase; | 			isa = PBXSourcesBuildPhase; | ||||||
| 			buildActionMask = 2147483647; | 			buildActionMask = 2147483647; | ||||||
| @@ -873,6 +992,7 @@ | |||||||
| 				CUSTOM_GROUP_ID = group.solsynth.solian; | 				CUSTOM_GROUP_ID = group.solsynth.solian; | ||||||
| 				DEVELOPMENT_TEAM = W7HPZ53V6B; | 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||||
| 				ENABLE_BITCODE = NO; | 				ENABLE_BITCODE = NO; | ||||||
|  | 				EXCLUDED_SOURCE_FILE_NAMES = ""; | ||||||
| 				INFOPLIST_FILE = Runner/Info.plist; | 				INFOPLIST_FILE = Runner/Info.plist; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||||
| @@ -883,10 +1003,12 @@ | |||||||
| 				); | 				); | ||||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian; | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian; | ||||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
|  | 				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; | ||||||
| 				SWIFT_ENABLE_EXPLICIT_MODULES = "$(SWIFT_USE_INTEGRATED_DRIVER)"; | 				SWIFT_ENABLE_EXPLICIT_MODULES = "$(SWIFT_USE_INTEGRATED_DRIVER)"; | ||||||
| 				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; | 				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; | ||||||
| 				SWIFT_VERSION = 5.0; | 				SWIFT_VERSION = 5.0; | ||||||
| 				VERSIONING_SYSTEM = "apple-generic"; | 				VERSIONING_SYSTEM = "apple-generic"; | ||||||
|  | 				WATCHOS_DEPLOYMENT_TARGET = 11.6; | ||||||
| 			}; | 			}; | ||||||
| 			name = Profile; | 			name = Profile; | ||||||
| 		}; | 		}; | ||||||
| @@ -894,6 +1016,7 @@ | |||||||
| 			isa = XCBuildConfiguration; | 			isa = XCBuildConfiguration; | ||||||
| 			baseConfigurationReference = 14DFD79BE7C26E51B117583C /* Pods-RunnerTests.debug.xcconfig */; | 			baseConfigurationReference = 14DFD79BE7C26E51B117583C /* Pods-RunnerTests.debug.xcconfig */; | ||||||
| 			buildSettings = { | 			buildSettings = { | ||||||
|  | 				ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; | ||||||
| 				BUNDLE_LOADER = "$(TEST_HOST)"; | 				BUNDLE_LOADER = "$(TEST_HOST)"; | ||||||
| 				CODE_SIGN_STYLE = Automatic; | 				CODE_SIGN_STYLE = Automatic; | ||||||
| 				CURRENT_PROJECT_VERSION = 1; | 				CURRENT_PROJECT_VERSION = 1; | ||||||
| @@ -902,6 +1025,8 @@ | |||||||
| 				MARKETING_VERSION = 1.0; | 				MARKETING_VERSION = 1.0; | ||||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests; | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests; | ||||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
|  | 				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; | ||||||
|  | 				SUPPORTS_MACCATALYST = YES; | ||||||
| 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; | 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; | ||||||
| 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | ||||||
| 				SWIFT_VERSION = 5.0; | 				SWIFT_VERSION = 5.0; | ||||||
| @@ -913,6 +1038,7 @@ | |||||||
| 			isa = XCBuildConfiguration; | 			isa = XCBuildConfiguration; | ||||||
| 			baseConfigurationReference = 14118AC858B441AB16B7309E /* Pods-RunnerTests.release.xcconfig */; | 			baseConfigurationReference = 14118AC858B441AB16B7309E /* Pods-RunnerTests.release.xcconfig */; | ||||||
| 			buildSettings = { | 			buildSettings = { | ||||||
|  | 				ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; | ||||||
| 				BUNDLE_LOADER = "$(TEST_HOST)"; | 				BUNDLE_LOADER = "$(TEST_HOST)"; | ||||||
| 				CODE_SIGN_STYLE = Automatic; | 				CODE_SIGN_STYLE = Automatic; | ||||||
| 				CURRENT_PROJECT_VERSION = 1; | 				CURRENT_PROJECT_VERSION = 1; | ||||||
| @@ -921,6 +1047,8 @@ | |||||||
| 				MARKETING_VERSION = 1.0; | 				MARKETING_VERSION = 1.0; | ||||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests; | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests; | ||||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
|  | 				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; | ||||||
|  | 				SUPPORTS_MACCATALYST = YES; | ||||||
| 				SWIFT_VERSION = 5.0; | 				SWIFT_VERSION = 5.0; | ||||||
| 				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; | 				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; | ||||||
| 			}; | 			}; | ||||||
| @@ -930,6 +1058,7 @@ | |||||||
| 			isa = XCBuildConfiguration; | 			isa = XCBuildConfiguration; | ||||||
| 			baseConfigurationReference = E6B10A9A85BECA2E576C91FF /* Pods-RunnerTests.profile.xcconfig */; | 			baseConfigurationReference = E6B10A9A85BECA2E576C91FF /* Pods-RunnerTests.profile.xcconfig */; | ||||||
| 			buildSettings = { | 			buildSettings = { | ||||||
|  | 				ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; | ||||||
| 				BUNDLE_LOADER = "$(TEST_HOST)"; | 				BUNDLE_LOADER = "$(TEST_HOST)"; | ||||||
| 				CODE_SIGN_STYLE = Automatic; | 				CODE_SIGN_STYLE = Automatic; | ||||||
| 				CURRENT_PROJECT_VERSION = 1; | 				CURRENT_PROJECT_VERSION = 1; | ||||||
| @@ -938,11 +1067,162 @@ | |||||||
| 				MARKETING_VERSION = 1.0; | 				MARKETING_VERSION = 1.0; | ||||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests; | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests; | ||||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
|  | 				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; | ||||||
|  | 				SUPPORTS_MACCATALYST = YES; | ||||||
| 				SWIFT_VERSION = 5.0; | 				SWIFT_VERSION = 5.0; | ||||||
| 				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; | 				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; | ||||||
| 			}; | 			}; | ||||||
| 			name = Profile; | 			name = Profile; | ||||||
| 		}; | 		}; | ||||||
|  | 		7310A7E02EB10963002C0FD3 /* Debug */ = { | ||||||
|  | 			isa = XCBuildConfiguration; | ||||||
|  | 			baseConfigurationReference = 86D60BA96DA647E1B11AA7F0 /* Pods-WatchRunner 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 = A2EB1DAFDE9B8E6D88BBF7A3 /* Pods-WatchRunner 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 = 103EA2362B9E9F127016A1F1 /* Pods-WatchRunner 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 */ = { | 		73ACDFC42E3D0E6100B63535 /* Debug */ = { | ||||||
| 			isa = XCBuildConfiguration; | 			isa = XCBuildConfiguration; | ||||||
| 			buildSettings = { | 			buildSettings = { | ||||||
| @@ -976,6 +1256,7 @@ | |||||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension; | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension; | ||||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
| 				SKIP_INSTALL = YES; | 				SKIP_INSTALL = YES; | ||||||
|  | 				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; | ||||||
| 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; | 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; | ||||||
| 				SWIFT_EMIT_LOC_STRINGS = YES; | 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||||
| 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | ||||||
| @@ -1016,6 +1297,7 @@ | |||||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension; | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension; | ||||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
| 				SKIP_INSTALL = YES; | 				SKIP_INSTALL = YES; | ||||||
|  | 				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; | ||||||
| 				SWIFT_EMIT_LOC_STRINGS = YES; | 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||||
| 				SWIFT_VERSION = 5.0; | 				SWIFT_VERSION = 5.0; | ||||||
| 				TARGETED_DEVICE_FAMILY = "1,2"; | 				TARGETED_DEVICE_FAMILY = "1,2"; | ||||||
| @@ -1054,6 +1336,7 @@ | |||||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension; | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension; | ||||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
| 				SKIP_INSTALL = YES; | 				SKIP_INSTALL = YES; | ||||||
|  | 				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; | ||||||
| 				SWIFT_EMIT_LOC_STRINGS = YES; | 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||||
| 				SWIFT_VERSION = 5.0; | 				SWIFT_VERSION = 5.0; | ||||||
| 				TARGETED_DEVICE_FAMILY = "1,2"; | 				TARGETED_DEVICE_FAMILY = "1,2"; | ||||||
| @@ -1095,6 +1378,7 @@ | |||||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianShareExtension; | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianShareExtension; | ||||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
| 				SKIP_INSTALL = YES; | 				SKIP_INSTALL = YES; | ||||||
|  | 				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; | ||||||
| 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; | 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; | ||||||
| 				SWIFT_EMIT_LOC_STRINGS = YES; | 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||||
| 				SWIFT_ENABLE_EXPLICIT_MODULES = NO; | 				SWIFT_ENABLE_EXPLICIT_MODULES = NO; | ||||||
| @@ -1138,6 +1422,7 @@ | |||||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianShareExtension; | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianShareExtension; | ||||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
| 				SKIP_INSTALL = YES; | 				SKIP_INSTALL = YES; | ||||||
|  | 				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; | ||||||
| 				SWIFT_EMIT_LOC_STRINGS = YES; | 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||||
| 				SWIFT_ENABLE_EXPLICIT_MODULES = NO; | 				SWIFT_ENABLE_EXPLICIT_MODULES = NO; | ||||||
| 				SWIFT_VERSION = 5.0; | 				SWIFT_VERSION = 5.0; | ||||||
| @@ -1179,6 +1464,7 @@ | |||||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianShareExtension; | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianShareExtension; | ||||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
| 				SKIP_INSTALL = YES; | 				SKIP_INSTALL = YES; | ||||||
|  | 				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; | ||||||
| 				SWIFT_EMIT_LOC_STRINGS = YES; | 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||||
| 				SWIFT_ENABLE_EXPLICIT_MODULES = NO; | 				SWIFT_ENABLE_EXPLICIT_MODULES = NO; | ||||||
| 				SWIFT_VERSION = 5.0; | 				SWIFT_VERSION = 5.0; | ||||||
| @@ -1428,6 +1714,7 @@ | |||||||
| 				CUSTOM_GROUP_ID = group.solsynth.solian; | 				CUSTOM_GROUP_ID = group.solsynth.solian; | ||||||
| 				DEVELOPMENT_TEAM = W7HPZ53V6B; | 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||||
| 				ENABLE_BITCODE = NO; | 				ENABLE_BITCODE = NO; | ||||||
|  | 				EXCLUDED_SOURCE_FILE_NAMES = ""; | ||||||
| 				INFOPLIST_FILE = Runner/Info.plist; | 				INFOPLIST_FILE = Runner/Info.plist; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||||
| @@ -1443,6 +1730,7 @@ | |||||||
| 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | ||||||
| 				SWIFT_VERSION = 5.0; | 				SWIFT_VERSION = 5.0; | ||||||
| 				VERSIONING_SYSTEM = "apple-generic"; | 				VERSIONING_SYSTEM = "apple-generic"; | ||||||
|  | 				WATCHOS_DEPLOYMENT_TARGET = 11.6; | ||||||
| 			}; | 			}; | ||||||
| 			name = Debug; | 			name = Debug; | ||||||
| 		}; | 		}; | ||||||
| @@ -1457,6 +1745,7 @@ | |||||||
| 				CUSTOM_GROUP_ID = group.solsynth.solian; | 				CUSTOM_GROUP_ID = group.solsynth.solian; | ||||||
| 				DEVELOPMENT_TEAM = W7HPZ53V6B; | 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||||
| 				ENABLE_BITCODE = NO; | 				ENABLE_BITCODE = NO; | ||||||
|  | 				EXCLUDED_SOURCE_FILE_NAMES = ""; | ||||||
| 				INFOPLIST_FILE = Runner/Info.plist; | 				INFOPLIST_FILE = Runner/Info.plist; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||||
| @@ -1465,12 +1754,15 @@ | |||||||
| 					"$(inherited)", | 					"$(inherited)", | ||||||
| 					"@executable_path/Frameworks", | 					"@executable_path/Frameworks", | ||||||
| 				); | 				); | ||||||
|  | 				ONLY_ACTIVE_ARCH = NO; | ||||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian; | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian; | ||||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
|  | 				SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; | ||||||
| 				SWIFT_ENABLE_EXPLICIT_MODULES = "$(SWIFT_USE_INTEGRATED_DRIVER)"; | 				SWIFT_ENABLE_EXPLICIT_MODULES = "$(SWIFT_USE_INTEGRATED_DRIVER)"; | ||||||
| 				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; | 				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; | ||||||
| 				SWIFT_VERSION = 5.0; | 				SWIFT_VERSION = 5.0; | ||||||
| 				VERSIONING_SYSTEM = "apple-generic"; | 				VERSIONING_SYSTEM = "apple-generic"; | ||||||
|  | 				WATCHOS_DEPLOYMENT_TARGET = 11.6; | ||||||
| 			}; | 			}; | ||||||
| 			name = Release; | 			name = Release; | ||||||
| 		}; | 		}; | ||||||
| @@ -1487,6 +1779,16 @@ | |||||||
| 			defaultConfigurationIsVisible = 0; | 			defaultConfigurationIsVisible = 0; | ||||||
| 			defaultConfigurationName = Release; | 			defaultConfigurationName = Release; | ||||||
| 		}; | 		}; | ||||||
|  | 		7310A7E32EB10963002C0FD3 /* Build configuration list for PBXNativeTarget "WatchRunner Watch App" */ = { | ||||||
|  | 			isa = XCConfigurationList; | ||||||
|  | 			buildConfigurations = ( | ||||||
|  | 				7310A7E02EB10963002C0FD3 /* Debug */, | ||||||
|  | 				7310A7E12EB10963002C0FD3 /* Release */, | ||||||
|  | 				7310A7E22EB10963002C0FD3 /* Profile */, | ||||||
|  | 			); | ||||||
|  | 			defaultConfigurationIsVisible = 0; | ||||||
|  | 			defaultConfigurationName = Release; | ||||||
|  | 		}; | ||||||
| 		73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */ = { | 		73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */ = { | ||||||
| 			isa = XCConfigurationList; | 			isa = XCConfigurationList; | ||||||
| 			buildConfigurations = ( | 			buildConfigurations = ( | ||||||
|   | |||||||
| @@ -20,6 +20,20 @@ | |||||||
|                ReferencedContainer = "container:Runner.xcodeproj"> |                ReferencedContainer = "container:Runner.xcodeproj"> | ||||||
|             </BuildableReference> |             </BuildableReference> | ||||||
|          </BuildActionEntry> |          </BuildActionEntry> | ||||||
|  |          <BuildActionEntry | ||||||
|  |             buildForTesting = "YES" | ||||||
|  |             buildForRunning = "YES" | ||||||
|  |             buildForProfiling = "YES" | ||||||
|  |             buildForArchiving = "YES" | ||||||
|  |             buildForAnalyzing = "YES"> | ||||||
|  |             <BuildableReference | ||||||
|  |                BuildableIdentifier = "primary" | ||||||
|  |                BlueprintIdentifier = "7310A7D32EB10962002C0FD3" | ||||||
|  |                BuildableName = "WatchRunner Watch App.app" | ||||||
|  |                BlueprintName = "WatchRunner Watch App" | ||||||
|  |                ReferencedContainer = "container:Runner.xcodeproj"> | ||||||
|  |             </BuildableReference> | ||||||
|  |          </BuildActionEntry> | ||||||
|       </BuildActionEntries> |       </BuildActionEntries> | ||||||
|    </BuildAction> |    </BuildAction> | ||||||
|    <TestAction |    <TestAction | ||||||
|   | |||||||
| @@ -1,9 +1,11 @@ | |||||||
| import Flutter | import Flutter | ||||||
| import UIKit | import UIKit | ||||||
|  | import WatchConnectivity | ||||||
|  |  | ||||||
| @main | @main | ||||||
| @objc class AppDelegate: FlutterAppDelegate { | @objc class AppDelegate: FlutterAppDelegate { | ||||||
|     let notifyDelegate = NotifyDelegate() |     let notifyDelegate = NotifyDelegate() | ||||||
|  |     private static var sharedWatchConnectivityService: WatchConnectivityService? | ||||||
|  |  | ||||||
|     override func application( |     override func application( | ||||||
|         _ application: UIApplication, |         _ application: UIApplication, | ||||||
| @@ -12,7 +14,7 @@ import UIKit | |||||||
|         UNUserNotificationCenter.current().delegate = notifyDelegate |         UNUserNotificationCenter.current().delegate = notifyDelegate | ||||||
|  |  | ||||||
|         let replyableMessageCategory = UNNotificationCategory( |         let replyableMessageCategory = UNNotificationCategory( | ||||||
|             identifier: "REPLYABLE_MESSAGE", |             identifier: "CHAT_MESSAGE", | ||||||
|             actions: [ |             actions: [ | ||||||
|                 UNTextInputNotificationAction( |                 UNTextInputNotificationAction( | ||||||
|                     identifier: "reply_action", |                     identifier: "reply_action", | ||||||
| @@ -23,11 +25,85 @@ import UIKit | |||||||
|             intentIdentifiers: [], |             intentIdentifiers: [], | ||||||
|             options: [] |             options: [] | ||||||
|         ) |         ) | ||||||
|          |  | ||||||
|         UNUserNotificationCenter.current().setNotificationCategories([replyableMessageCategory]) |         UNUserNotificationCenter.current().setNotificationCategories([replyableMessageCategory]) | ||||||
|          |          | ||||||
|         GeneratedPluginRegistrant.register(with: self) |         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) |         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 | 
| @@ -36,6 +36,14 @@ | |||||||
| 				<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string> | 				<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string> | ||||||
| 			</array> | 			</array> | ||||||
| 		</dict> | 		</dict> | ||||||
|  | 		<dict> | ||||||
|  | 			<key>CFBundleTypeRole</key> | ||||||
|  | 			<string>Viewer</string> | ||||||
|  | 			<key>CFBundleURLSchemes</key> | ||||||
|  | 			<array> | ||||||
|  | 				<string>solian</string> | ||||||
|  | 			</array> | ||||||
|  | 		</dict> | ||||||
| 	</array> | 	</array> | ||||||
| 	<key>CFBundleVersion</key> | 	<key>CFBundleVersion</key> | ||||||
| 	<string>$(FLUTTER_BUILD_NUMBER)</string> | 	<string>$(FLUTTER_BUILD_NUMBER)</string> | ||||||
| @@ -87,6 +95,8 @@ | |||||||
| 		<string>UIInterfaceOrientationLandscapeRight</string> | 		<string>UIInterfaceOrientationLandscapeRight</string> | ||||||
| 		<string>UIInterfaceOrientationPortrait</string> | 		<string>UIInterfaceOrientationPortrait</string> | ||||||
| 	</array> | 	</array> | ||||||
|  | 	<key>WKCompanionAppBundleIdentifier</key> | ||||||
|  | 	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> | ||||||
| 	<key>UISupportedInterfaceOrientations~ipad</key> | 	<key>UISupportedInterfaceOrientations~ipad</key> | ||||||
| 	<array> | 	<array> | ||||||
| 		<string>UIInterfaceOrientationLandscapeLeft</string> | 		<string>UIInterfaceOrientationLandscapeLeft</string> | ||||||
|   | |||||||
| @@ -8,7 +8,7 @@ | |||||||
| import Foundation | import Foundation | ||||||
|  |  | ||||||
| func getAttachmentUrl(for identifier: String) -> String { | 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)" |     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 { |     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" | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -47,7 +47,6 @@ class NotificationService: UNNotificationServiceExtension { | |||||||
|     private func processNotification(request: UNNotificationRequest, content: UNMutableNotificationContent) throws { |     private func processNotification(request: UNNotificationRequest, content: UNMutableNotificationContent) throws { | ||||||
|         switch content.userInfo["type"] as? String { |         switch content.userInfo["type"] as? String { | ||||||
|         case "messages.new": |         case "messages.new": | ||||||
|             content.categoryIdentifier = "REPLYABLE_MESSAGE" |  | ||||||
|             try handleMessagingNotification(request: request, content: content) |             try handleMessagingNotification(request: request, content: content) | ||||||
|         default: |         default: | ||||||
|             try handleDefaultNotification(content: content) |             try handleDefaultNotification(content: content) | ||||||
| @@ -60,14 +59,16 @@ class NotificationService: UNNotificationServiceExtension { | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         let pfpIdentifier = meta["pfp"] as? String |         let pfpIdentifier = meta["pfp"] as? String | ||||||
|          |  | ||||||
|         let metaCopy = meta as? [String: Any] ?? [:] |         let metaCopy = meta as? [String: Any] ?? [:] | ||||||
|         let pfpUrl = pfpIdentifier != nil ? getAttachmentUrl(for: pfpIdentifier!) : nil |         let pfpUrl = pfpIdentifier != nil ? getAttachmentUrl(for: pfpIdentifier!) : nil | ||||||
|  |  | ||||||
|  |         let handle = INPersonHandle(value: "\(metaCopy["user_id"] ?? "")", type: .unknown) | ||||||
|  |  | ||||||
|  |         if let pfpUrl = pfpUrl, let url = URL(string: pfpUrl) { | ||||||
|             let targetSize = 512 |             let targetSize = 512 | ||||||
|             let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit) |             let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit) | ||||||
|  |  | ||||||
|         KingfisherManager.shared.retrieveImage(with: URL(string: pfpUrl!)!, options: [.processor(scaleProcessor)], completionHandler: { result in |             KingfisherManager.shared.retrieveImage(with: url, options: [.processor(scaleProcessor)], completionHandler: { result in | ||||||
|                 var image: Data? |                 var image: Data? | ||||||
|                 switch result { |                 switch result { | ||||||
|                 case .success(let value): |                 case .success(let value): | ||||||
| @@ -76,7 +77,6 @@ class NotificationService: UNNotificationServiceExtension { | |||||||
|                     print("Unable to get pfp url: \(error)") |                     print("Unable to get pfp url: \(error)") | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|             let handle = INPersonHandle(value: "\(metaCopy["user_id"] ?? "")", type: .unknown) |  | ||||||
|                 let sender = INPerson( |                 let sender = INPerson( | ||||||
|                     personHandle: handle, |                     personHandle: handle, | ||||||
|                     nameComponents: PersonNameComponents(nickname: "\(metaCopy["sender_name"] ?? "")"), |                     nameComponents: PersonNameComponents(nickname: "\(metaCopy["sender_name"] ?? "")"), | ||||||
| @@ -88,9 +88,26 @@ class NotificationService: UNNotificationServiceExtension { | |||||||
|  |  | ||||||
|                 let intent = self.createMessageIntent(with: sender, meta: metaCopy, body: content.body) |                 let intent = self.createMessageIntent(with: sender, meta: metaCopy, body: content.body) | ||||||
|                 self.donateInteraction(for: intent) |                 self.donateInteraction(for: intent) | ||||||
|             let updatedContent = try? request.content.updating(from: intent) |  | ||||||
|             self.contentHandler?(updatedContent ?? content) |                 content.categoryIdentifier = "CHAT_MESSAGE" | ||||||
|  |                 self.contentHandler?(content) | ||||||
|             }) |             }) | ||||||
|  |         } else { | ||||||
|  |             let sender = INPerson( | ||||||
|  |                 personHandle: handle, | ||||||
|  |                 nameComponents: PersonNameComponents(nickname: "\(metaCopy["sender_name"] ?? "")"), | ||||||
|  |                 displayName: content.title, | ||||||
|  |                 image: nil, | ||||||
|  |                 contactIdentifier: nil, | ||||||
|  |                 customIdentifier: nil | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             let intent = self.createMessageIntent(with: sender, meta: metaCopy, body: content.body) | ||||||
|  |             self.donateInteraction(for: intent) | ||||||
|  |  | ||||||
|  |             content.categoryIdentifier = "CHAT_MESSAGE" | ||||||
|  |             self.contentHandler?(content) | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     private func handleDefaultNotification(content: UNMutableNotificationContent) throws { |     private func handleDefaultNotification(content: UNMutableNotificationContent) throws { | ||||||
|   | |||||||
| @@ -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/WatchRunner Watch App/Assets.xcassets/Contents.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,6 @@ | |||||||
|  | { | ||||||
|  |   "info" : { | ||||||
|  |     "author" : "xcode", | ||||||
|  |     "version" : 1 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										21
									
								
								ios/WatchRunner 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/WatchRunner Watch App/Assets.xcassets/Logo.imageset/icon.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 70 KiB | 
							
								
								
									
										50
									
								
								ios/WatchRunner 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/WatchRunner 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/WatchRunner 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/WatchRunner 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() | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										637
									
								
								ios/WatchRunner Watch App/Services/NetworkService.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,637 @@ | |||||||
|  | // | ||||||
|  | //  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.shared | ||||||
|  |      | ||||||
|  |     // 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/WatchRunner 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/WatchRunner 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/WatchRunner 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/WatchRunner 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 | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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/WatchRunner 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/WatchRunner 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/WatchRunner 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/WatchRunner 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/WatchRunner 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/WatchRunner 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/WatchRunner 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/WatchRunner 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/WatchRunner 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/WatchRunner 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/WatchRunner 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/WatchRunner 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/WatchRunner 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/WatchRunner 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/WatchRunner 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() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										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> | ||||||
| @@ -1,6 +1,7 @@ | |||||||
| import 'package:drift/drift.dart'; | import 'package:drift/drift.dart'; | ||||||
| import 'package:drift/wasm.dart'; | import 'package:drift/wasm.dart'; | ||||||
| import 'package:island/database/drift_db.dart'; | import 'package:island/database/drift_db.dart'; | ||||||
|  | import 'package:island/talker.dart'; | ||||||
|  |  | ||||||
| AppDatabase constructDb() { | AppDatabase constructDb() { | ||||||
|   return AppDatabase(connectOnWeb()); |   return AppDatabase(connectOnWeb()); | ||||||
| @@ -9,12 +10,17 @@ AppDatabase constructDb() { | |||||||
| DatabaseConnection connectOnWeb() { | DatabaseConnection connectOnWeb() { | ||||||
|   return DatabaseConnection.delayed( |   return DatabaseConnection.delayed( | ||||||
|     Future(() async { |     Future(() async { | ||||||
|  |       try { | ||||||
|         final result = await WasmDatabase.open( |         final result = await WasmDatabase.open( | ||||||
|           databaseName: 'solar_network_data', |           databaseName: 'solar_network_data', | ||||||
|           sqlite3Uri: Uri.parse('sqlite3.wasm'), |           sqlite3Uri: Uri.parse('sqlite3.wasm'), | ||||||
|           driftWorkerUri: Uri.parse('drift_worker.dart.js'), |           driftWorkerUri: Uri.parse('drift_worker.dart.js'), | ||||||
|         ); |         ); | ||||||
|         return result.resolvedExecutor; |         return result.resolvedExecutor; | ||||||
|  |       } catch (e, stackTrace) { | ||||||
|  |         talker.error('Failed to open WASM database...', e, stackTrace); | ||||||
|  |         rethrow; | ||||||
|  |       } | ||||||
|     }), |     }), | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,6 +1,4 @@ | |||||||
| import 'dart:developer'; |  | ||||||
| import 'dart:io'; | import 'dart:io'; | ||||||
|  |  | ||||||
| import 'package:croppy/croppy.dart'; | import 'package:croppy/croppy.dart'; | ||||||
| import 'package:easy_localization/easy_localization.dart' hide TextDirection; | import 'package:easy_localization/easy_localization.dart' hide TextDirection; | ||||||
| import 'package:firebase_core/firebase_core.dart'; | import 'package:firebase_core/firebase_core.dart'; | ||||||
| @@ -12,11 +10,11 @@ import 'package:flutter_hooks/flutter_hooks.dart'; | |||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:image_picker_android/image_picker_android.dart'; | import 'package:image_picker_android/image_picker_android.dart'; | ||||||
|  | import 'package:island/talker.dart'; | ||||||
| import 'package:island/firebase_options.dart'; | import 'package:island/firebase_options.dart'; | ||||||
| import 'package:island/pods/config.dart'; | import 'package:island/pods/config.dart'; | ||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
| import 'package:island/pods/theme.dart'; | import 'package:island/pods/theme.dart'; | ||||||
|  |  | ||||||
| import 'package:island/pods/userinfo.dart'; | import 'package:island/pods/userinfo.dart'; | ||||||
| import 'package:island/pods/websocket.dart'; | import 'package:island/pods/websocket.dart'; | ||||||
| import 'package:island/route.dart'; | import 'package:island/route.dart'; | ||||||
| @@ -28,19 +26,21 @@ import 'package:relative_time/relative_time.dart'; | |||||||
| import 'package:shared_preferences/shared_preferences.dart'; | import 'package:shared_preferences/shared_preferences.dart'; | ||||||
| import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; | import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; | ||||||
| import 'package:flutter_native_splash/flutter_native_splash.dart'; | import 'package:flutter_native_splash/flutter_native_splash.dart'; | ||||||
|  | import 'package:talker_flutter/talker_flutter.dart'; | ||||||
|  | import 'package:talker_riverpod_logger/talker_riverpod_logger.dart'; | ||||||
| import 'package:url_launcher/url_launcher_string.dart'; | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
| import 'package:window_manager/window_manager.dart'; | import 'package:window_manager/window_manager.dart'; | ||||||
|  |  | ||||||
| @pragma('vm:entry-point') | @pragma('vm:entry-point') | ||||||
| Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { | Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { | ||||||
|   await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); |   await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); | ||||||
|   log('Handling a background message: ${message.messageId}'); |   talker.info('Handling a background message: ${message.messageId}'); | ||||||
| } | } | ||||||
|  |  | ||||||
| void main() async { | void main() async { | ||||||
|   final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); |   final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); | ||||||
|   if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { |   if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { | ||||||
|     log( |     talker.info( | ||||||
|       "[SplashScreen] Keeping the flash screen to loading other resources...", |       "[SplashScreen] Keeping the flash screen to loading other resources...", | ||||||
|     ); |     ); | ||||||
|     FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); |     FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); | ||||||
| @@ -73,17 +73,17 @@ void main() async { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     log("[SplashScreen] Firebase is ready!"); |     talker.info("[SplashScreen] Firebase is ready!"); | ||||||
|   } catch (err) { |   } catch (err) { | ||||||
|     showErrorAlert(err); |     showErrorAlert(err); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   try { |   try { | ||||||
|     log("[SplashScreen] Loading timezone database..."); |     talker.info("[SplashScreen] Loading timezone database..."); | ||||||
|     await initializeTzdb(); |     await initializeTzdb(); | ||||||
|     log("[SplashScreen] Time zone database was loaded!"); |     talker.info("[SplashScreen] Time zone database was loaded!"); | ||||||
|   } catch (err) { |   } catch (err) { | ||||||
|     log("[SplashScreen] Failed to load timezone database... $err"); |     talker.error("[SplashScreen] Failed to load timezone database... $err"); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   final prefs = await SharedPreferences.getInstance(); |   final prefs = await SharedPreferences.getInstance(); | ||||||
| @@ -106,7 +106,7 @@ void main() async { | |||||||
|           initialSize = Size(width, height); |           initialSize = Size(width, height); | ||||||
|         } |         } | ||||||
|       } catch (e) { |       } catch (e) { | ||||||
|         log("[SplashScreen] Failed to parse saved window size: $e"); |         talker.error("[SplashScreen] Failed to parse saved window size: $e"); | ||||||
|         initialSize = defaultSize; |         initialSize = defaultSize; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| @@ -120,13 +120,24 @@ void main() async { | |||||||
|       windowButtonVisibility: true, |       windowButtonVisibility: true, | ||||||
|     ); |     ); | ||||||
|     windowManager.waitUntilReadyToShow(windowOptions, () async { |     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.setMinimumSize(defaultSize); | ||||||
|       await windowManager.show(); |       await windowManager.show(); | ||||||
|       await windowManager.focus(); |       await windowManager.focus(); | ||||||
|       final opacity = prefs.getDouble(kAppWindowOpacity) ?? 1.0; |       final opacity = prefs.getDouble(kAppWindowOpacity) ?? 1.0; | ||||||
|       await windowManager.setOpacity(opacity); |       await windowManager.setOpacity(opacity); | ||||||
|       log( |       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)" : ""}", | ||||||
|       ); |       ); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| @@ -137,16 +148,27 @@ void main() async { | |||||||
|     if (imagePickerImplementation is ImagePickerAndroid) { |     if (imagePickerImplementation is ImagePickerAndroid) { | ||||||
|       imagePickerImplementation.useAndroidPhotoPicker = true; |       imagePickerImplementation.useAndroidPhotoPicker = true; | ||||||
|     } |     } | ||||||
|     log("[SplashScreen] Android image picker is ready!"); |     talker.info("[SplashScreen] Android image picker is ready!"); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { |   if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { | ||||||
|     FlutterNativeSplash.remove(); |     FlutterNativeSplash.remove(); | ||||||
|     log("[SplashScreen] Now hiding the splash screen..."); |     talker.info("[SplashScreen] Now hiding the splash screen..."); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   runApp( |   runApp( | ||||||
|     ProviderScope( |     ProviderScope( | ||||||
|  |       observers: [ | ||||||
|  |         TalkerRiverpodObserver( | ||||||
|  |           talker: talker, | ||||||
|  |           settings: TalkerRiverpodLoggerSettings( | ||||||
|  |             printProviderAdded: false, | ||||||
|  |             printProviderDisposed: false, | ||||||
|  |             printProviderUpdated: false, | ||||||
|  |             printStateFullData: false, | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|       overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], |       overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], | ||||||
|       child: Directionality( |       child: Directionality( | ||||||
|         textDirection: TextDirection.ltr, |         textDirection: TextDirection.ltr, | ||||||
| @@ -155,6 +177,10 @@ void main() async { | |||||||
|             Locale('en', 'US'), |             Locale('en', 'US'), | ||||||
|             Locale('zh', 'CN'), |             Locale('zh', 'CN'), | ||||||
|             Locale('zh', 'TW'), |             Locale('zh', 'TW'), | ||||||
|  |             Locale('zh', 'OG'), | ||||||
|  |             Locale('ja', 'JP'), | ||||||
|  |             Locale('ko', 'KR'), | ||||||
|  |             Locale('es', 'ES'), | ||||||
|           ], |           ], | ||||||
|           path: 'assets/i18n', |           path: 'assets/i18n', | ||||||
|           fallbackLocale: Locale('en', 'US'), |           fallbackLocale: Locale('en', 'US'), | ||||||
| @@ -227,7 +253,9 @@ class IslandApp extends HookConsumerWidget { | |||||||
|       final onMessageSubscription = FirebaseMessaging.onMessage.listen(( |       final onMessageSubscription = FirebaseMessaging.onMessage.listen(( | ||||||
|         message, |         message, | ||||||
|       ) { |       ) { | ||||||
|         log('Foreground message received: ${message.messageId}'); |         talker.info( | ||||||
|  |           '[Notification] foreground message received: ${message.messageId}', | ||||||
|  |         ); | ||||||
|         handleMessage(message); |         handleMessage(message); | ||||||
|       }); |       }); | ||||||
|  |  | ||||||
| @@ -241,7 +269,7 @@ class IslandApp extends HookConsumerWidget { | |||||||
|       // Load userinfo |       // Load userinfo | ||||||
|       final userNotifier = ref.read(userInfoProvider.notifier); |       final userNotifier = ref.read(userInfoProvider.notifier); | ||||||
|       ref.listen(websocketStateProvider, (_, state) { |       ref.listen(websocketStateProvider, (_, state) { | ||||||
|         log('[WebSocket] $state'); |         talker.info('[WebSocket] $state'); | ||||||
|       }); |       }); | ||||||
|       Future(() { |       Future(() { | ||||||
|         userNotifier.fetchUser().then((_) { |         userNotifier.fetchUser().then((_) { | ||||||
| @@ -262,8 +290,8 @@ class IslandApp extends HookConsumerWidget { | |||||||
|  |  | ||||||
|     return MaterialApp.router( |     return MaterialApp.router( | ||||||
|       color: Colors.transparent, |       color: Colors.transparent, | ||||||
|       theme: theme?.light, |       theme: theme.light, | ||||||
|       darkTheme: theme?.dark, |       darkTheme: theme.dark, | ||||||
|       themeMode: getThemeMode(), |       themeMode: getThemeMode(), | ||||||
|       routerConfig: router, |       routerConfig: router, | ||||||
|       supportedLocales: context.supportedLocales, |       supportedLocales: context.supportedLocales, | ||||||
| @@ -279,9 +307,15 @@ class IslandApp extends HookConsumerWidget { | |||||||
|           key: globalOverlay, |           key: globalOverlay, | ||||||
|           initialEntries: [ |           initialEntries: [ | ||||||
|             OverlayEntry( |             OverlayEntry( | ||||||
|               builder: |               builder: (_) { | ||||||
|                   (_) => |                 return TalkerWrapper( | ||||||
|                       WindowScaffold(child: child ?? const SizedBox.shrink()), |                   talker: talker, | ||||||
|  |                   options: const TalkerWrapperOptions(enableErrorAlerts: true), | ||||||
|  |                   child: WindowScaffold( | ||||||
|  |                     child: child ?? const SizedBox.shrink(), | ||||||
|  |                   ), | ||||||
|  |                 ); | ||||||
|  |               }, | ||||||
|             ), |             ), | ||||||
|           ], |           ], | ||||||
|         ); |         ); | ||||||
|   | |||||||