Compare commits
	
		
			68 Commits
		
	
	
		
			3.1.0+118
			...
			67d130dc34
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 67d130dc34 | |||
| 7e923c77fe | |||
| a593b52812 | |||
|  | 520dc80303 | ||
| 001897bbcd | |||
|  | bab29c23e3 | ||
| 76b39f2df3 | |||
| 509b3e145b | |||
| 2b80ebc2d0 | |||
| 0ab908dd2a | |||
| 6007467e7a | |||
| 3745157c42 | |||
| 94481ec7bd | |||
| fbfe8cbdee | |||
| fbbab0a981 | |||
| ae2fb3b303 | |||
| 3d7a4666ed | |||
| 5d3e0fb800 | |||
| 85ff52a661 | |||
| da7fd64a43 | |||
| 3902633217 | |||
| f478ea8b84 | |||
| 0f481aff5b | |||
| 7a31663310 | |||
| 0239c53c04 | |||
| 16987c758e | |||
| 3a36915140 | |||
| 4bde708878 | |||
| 2f0cf560f8 | |||
| cf355a95fd | |||
| 2f43073172 | |||
| 8236d31ecc | |||
| 459a7dade0 | |||
| e6000a660a | |||
| 75abaac205 | |||
| 603d5c3f73 | |||
| 4e4bd99598 | |||
| d1fbe5f15e | |||
| c061ef2132 | |||
| c378309bdd | |||
| b2c5d64fc5 | |||
|  | 5371637b16 | ||
| c5cbf0af37 | |||
| 1a31e22450 | |||
|  | 49db54529d | ||
| 8e0c0c6054 | |||
| f3d1183076 | |||
| a9f7f0cce0 | |||
| f2943f8411 | |||
| 808e7dcffa | |||
| 9bed4fa6fb | |||
| e6255a340b | |||
| 78bf319fb7 | |||
| 36a966d582 | |||
| f72b268d36 | |||
| 44ef31034e | |||
| 229dc2186f | |||
| a2f9a1efb4 | |||
|  | 823e3c5de6 | ||
|  | faac7bac35 | ||
| 1fac1bfe02 | |||
| 9394b1d9c8 | |||
| 43dd13bac4 | |||
| 65bc372103 | |||
| 6558854a7a | |||
| 892035ab27 | |||
| 87ae8d2ff4 | |||
| 15c2dbaa0d | 
							
								
								
									
										9
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
								
							| @@ -41,6 +41,15 @@ jobs: | ||||
|         with: | ||||
|           name: build-output-windows | ||||
|           path: build/windows/x64/runner/Release | ||||
|       - name: Compile Installer | ||||
|         uses: Minionguyjpro/Inno-Setup-Action@v1.2.2 | ||||
|         with: | ||||
|           path: setup.iss | ||||
|       - name: Archive installer artifacts | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: build-output-windows-installer | ||||
|           path: Installer/windows-x86_64-setup.exe | ||||
|   build-linux: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|   | ||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -12,6 +12,9 @@ | ||||
| .swiftpm/ | ||||
| migrate_working_dir/ | ||||
|  | ||||
| # Inno Setup | ||||
| Installer/ | ||||
|  | ||||
| # IntelliJ related | ||||
| *.iml | ||||
| *.ipr | ||||
|   | ||||
| @@ -5,6 +5,7 @@ plugins { | ||||
|     id("com.android.application") | ||||
|     // START: FlutterFire Configuration | ||||
|     id("com.google.gms.google-services") | ||||
|     id("com.google.firebase.crashlytics") | ||||
|     // END: FlutterFire Configuration | ||||
|     id("kotlin-android") | ||||
|     // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. | ||||
| @@ -51,6 +52,12 @@ android { | ||||
|     buildTypes { | ||||
|         release { | ||||
|             signingConfig = signingConfigs.getByName("release") | ||||
|  | ||||
|             isMinifyEnabled = true | ||||
|             proguardFiles( | ||||
|                 getDefaultProguardFile("proguard-android-optimize.txt"), | ||||
|                 "proguard-rules.pro" | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -58,7 +65,7 @@ android { | ||||
| dependencies { | ||||
|     implementation("com.google.android.material:material:1.12.0") | ||||
|     implementation("com.github.bumptech.glide:glide:4.16.0") | ||||
|     implementation("com.squareup.okhttp3:okhttp:4.12.0") | ||||
|     implementation("com.squareup.okhttp3:okhttp:5.1.0") | ||||
| } | ||||
|  | ||||
| flutter { | ||||
|   | ||||
							
								
								
									
										5
									
								
								android/app/proguard-rules.pro
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								android/app/proguard-rules.pro
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| # JNI Zero initialization (required for WebRTC native method registration) | ||||
| -keep class livekit.org.jni_zero.JniInit { | ||||
|     # Keep the init method un-obfuscated for native code callback | ||||
|     private static java.lang.Object[] init(); | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable/ic_notification.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable/ic_notification.png
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 70 KiB | 
| @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME | ||||
| distributionPath=wrapper/dists | ||||
| zipStoreBase=GRADLE_USER_HOME | ||||
| zipStorePath=wrapper/dists | ||||
| distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip | ||||
| distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip | ||||
|   | ||||
| @@ -18,11 +18,12 @@ pluginManagement { | ||||
|  | ||||
| plugins { | ||||
|     id("dev.flutter.flutter-plugin-loader") version "1.0.0" | ||||
|     id("com.android.application") version "8.10.1" apply false | ||||
|     id("com.android.application") version "8.12.0" apply false | ||||
|     // START: FlutterFire Configuration | ||||
|     id("com.google.gms.google-services") version("4.3.15") apply false | ||||
|     id("com.google.firebase.crashlytics") version("2.8.1") apply false | ||||
|     // END: FlutterFire Configuration | ||||
|     id("org.jetbrains.kotlin.android") version "1.8.22" apply false | ||||
|     id("org.jetbrains.kotlin.android") version("2.2.0") apply false | ||||
| } | ||||
|  | ||||
| include(":app") | ||||
|   | ||||
| @@ -334,6 +334,7 @@ | ||||
|   "walletCreate": "Create a Wallet", | ||||
|   "settingsServerUrl": "Server URL", | ||||
|   "settingsApplied": "The settings has been applied.", | ||||
|   "settingsCustomFontsHelper": "Use comma to seprate.", | ||||
|   "notifications": "Notifications", | ||||
|   "posts": "Posts", | ||||
|   "settingsBackgroundImage": "Background Image", | ||||
| @@ -573,6 +574,7 @@ | ||||
|   "keyboardShortcuts": "Keyboard Shortcuts", | ||||
|   "share": "Share", | ||||
|   "sharePost": "Share Post", | ||||
|   "sharePostPhoto": "Share Post as Photo", | ||||
|   "quickActions": "Quick Actions", | ||||
|   "post": "Post", | ||||
|   "copy": "Copy", | ||||
| @@ -706,6 +708,7 @@ | ||||
|   "copyToClipboardTooltip": "Copy to clipboard", | ||||
|   "postForwardingTo": "Forwarding to", | ||||
|   "postReplyingTo": "Replying to", | ||||
|   "postReplyPlaceholder": "Post your reply", | ||||
|   "postEditing": "You are editing an existing post", | ||||
|   "postArticle": "Article", | ||||
|   "aboutDeviceName": "Device Name", | ||||
| @@ -759,6 +762,7 @@ | ||||
|   "pollsRecent": "Recent Polls", | ||||
|   "pollCreateNew": "Create New", | ||||
|   "pollCreateNewHint": "Create a new poll for your post. Pick a publisher and continue.", | ||||
|   "pollQuestions": "Questions", | ||||
|   "publisher": "Publisher", | ||||
|   "publisherHint": "Enter the publisher name", | ||||
|   "publisherCannotBeEmpty": "Publisher cannot be empty", | ||||
| @@ -787,5 +791,51 @@ | ||||
|   "addLink": "Add link", | ||||
|   "linkKey": "Link Name", | ||||
|   "linkValue": "URL", | ||||
|   "debugOptions": "Debug Options" | ||||
|   "debugOptions": "Debug Options", | ||||
|   "joinedAt": "Joined at {}", | ||||
|   "searchAccounts": "Search accounts...", | ||||
|   "webFeeds": "Web Feeds", | ||||
|   "polls": "Polls", | ||||
|   "sharePostSlogan": "Explore more on the Solar Network", | ||||
|   "filesListAdditional": { | ||||
|     "one": "+{} file remaining", | ||||
|     "other": "+{} files remaining" | ||||
|   }, | ||||
|   "pollAnswerSubmitted": "Poll answer has been submitted.", | ||||
|   "modifyAnswers": "Modify Answers", | ||||
|   "back": "Back", | ||||
|   "submit": "Submit", | ||||
|   "pollOptionDefaultLabel": "Option 1", | ||||
|   "pollUpdated": "Poll updated.", | ||||
|   "pollCreated": "Poll created.", | ||||
|   "pollCreate": "Create Poll", | ||||
|   "pollEdit": "Edit Poll", | ||||
|   "pollPreviewJsonDebug": "Debug Preview", | ||||
|   "pollTitleRequired": "Title is required", | ||||
|   "pollEndDateOptional": "End date & time (optional)", | ||||
|   "notSet": "Not set", | ||||
|   "pick": "Pick", | ||||
|   "clear": "Clear", | ||||
|   "questions": "Questions", | ||||
|   "pollAddQuestion": "Add question", | ||||
|   "pollQuestionTypeSingleChoice": "Single choice", | ||||
|   "pollQuestionTypeMultipleChoice": "Multiple choice", | ||||
|   "pollQuestionTypeFreeText": "Free text", | ||||
|   "pollQuestionTypeYesNo": "Yes / No", | ||||
|   "pollQuestionTypeRating": "Rating", | ||||
|   "pollNoQuestionsYet": "No questions yet", | ||||
|   "pollNoQuestionsHint": "Use \"Add question\" to start building your poll.", | ||||
|   "pollDebugPreview": "Debug Preview", | ||||
|   "pollUntitledQuestion": "Untitled question", | ||||
|   "moveUp": "Move up", | ||||
|   "moveDown": "Move down", | ||||
|   "required": "Required", | ||||
|   "pollQuestionTitle": "Question title", | ||||
|   "pollQuestionTitleRequired": "Question title is required", | ||||
|   "pollQuestionDescriptionOptional": "Question description (optional)", | ||||
|   "options": "Options", | ||||
|   "pollAddOption": "Add option", | ||||
|   "pollOptionLabel": "Option label", | ||||
|   "pollLongTextAnswerPreview": "Long text answer (preview)", | ||||
|   "pollShortTextAnswerPreview": "Short text answer (preview)" | ||||
| } | ||||
| @@ -46,7 +46,6 @@ | ||||
|   "delete": "删除", | ||||
|   "deletePublisher": "删除发布者", | ||||
|   "deletePublisherHint": "确定要删除此发布者吗?这也会删除此发布者下的所有帖子和收藏。", | ||||
|   "somethingWentWrong": "发生了一些错误", | ||||
|   "deletePost": "删除帖子", | ||||
|   "deletePostHint": "确定要删除这篇帖子吗?", | ||||
|   "copyLink": "复制链接", | ||||
| @@ -120,14 +119,9 @@ | ||||
|     "other": "{}个附件" | ||||
|   }, | ||||
|   "edited": "已编辑", | ||||
|   "editedAt": "编辑于 {}", | ||||
|   "addVideo": "添加视频", | ||||
|   "addPhoto": "添加照片", | ||||
|   "addFile": "添加文件", | ||||
|   "addAttachmentById": "通过 ID 添加附件", | ||||
|   "enterFileId": "输入文件 ID", | ||||
|   "fileIdCannotBeEmpty": "文件 ID 不能为空", | ||||
|   "failedToFetchFile": "获取文件失败: {}", | ||||
|   "createDirectMessage": "创建新私人消息", | ||||
|   "gotoDirectMessage": "前往私信", | ||||
|   "react": "反应", | ||||
| @@ -306,6 +300,7 @@ | ||||
|   "walletCreate": "创建钱包", | ||||
|   "settingsServerUrl": "服务器 URL", | ||||
|   "settingsApplied": "设置已应用。", | ||||
|   "settingsCustomFontsHelper": "用逗号分隔。", | ||||
|   "notifications": "通知", | ||||
|   "posts": "帖子", | ||||
|   "settingsBackgroundImage": "背景图片", | ||||
| @@ -350,11 +345,10 @@ | ||||
|   "accountSettingsHelpContent": "此页面允许您管理您的帐户安全性、隐私和其他设置。如果您需要帮助,请联系管理员。", | ||||
|   "unauthorized": "未授权", | ||||
|   "unauthorizedHint": "您未登录或会话已过期,请重新登录。", | ||||
|   "publisherBelongsTo": "属于 {}", | ||||
|   "publisherBelongsTo": "属于", | ||||
|   "postContent": "内容", | ||||
|   "postSettings": "设置", | ||||
|   "postPublisherUnselected": "未指定发布者", | ||||
|   "postVisibility": "可见性", | ||||
|   "postVisibilityPublic": "公开", | ||||
|   "postVisibilityFriends": "仅好友可见", | ||||
|   "postVisibilityUnlisted": "不公开", | ||||
| @@ -478,7 +472,7 @@ | ||||
|   "description": "描述", | ||||
|   "pinCode": "PIN 码", | ||||
|   "biometric": "生物识别", | ||||
|   "enterPinToConfirm": "请输入您的 6 位数字 PIN 以确认付款", | ||||
|   "enterPinToConfirm": "请输入您的6位数字 PIN 以确认付款", | ||||
|   "clearPin": "清除 PIN 码", | ||||
|   "useBiometricToConfirm": "使用生物特征认证来确认付款", | ||||
|   "touchSensorToAuthenticate": "触摸传感器进行身份验证", | ||||
| @@ -495,20 +489,26 @@ | ||||
|   "paymentError": "付款失败: {error}", | ||||
|   "usePinInstead": "使用 PIN 码", | ||||
|   "levelProgress": "等级进度", | ||||
|   "unlockedFeatures": "已解锁的功能", | ||||
|   "unlockedFeaturesDescription": "在您当前级别上解锁的功能将显示在这里。", | ||||
|   "stellarMembership": "恒星计划", | ||||
|   "upgradeYourPlan": "升级您的计划", | ||||
|   "chooseYourPlan": "选择你的方案", | ||||
|   "currentMembership": "当前:{}", | ||||
|   "currentMembershipMember": "恒星计划「{}」级会员", | ||||
|   "membershipExpires": "过期于:{}", | ||||
|   "membershipTierStellar": "恒星", | ||||
|   "membershipTierNova": "新星", | ||||
|   "membershipTierSupernova": "超新星", | ||||
|   "membershipTierUnknown": "未知", | ||||
|   "membershipPriceStellar": "每月 1200 源点,至少需要 3 级", | ||||
|   "membershipPriceNova": "每月 2400 源点,至少需要 6 级", | ||||
|   "membershipPriceSupernova": "每月 3600 源点,至少需要 9 级", | ||||
|   "membershipFeatureBasic": "基础功能", | ||||
|   "membershipFeaturePrioritySupport": "优先支持", | ||||
|   "membershipFeatureAdFree": "无广告", | ||||
|   "membershipFeatureAllPrimary": "所有主要功能", | ||||
|   "membershipFeatureAdvancedCustomization": "高级自定义", | ||||
|   "membershipFeatureEarlyAccess": "抢先体验", | ||||
|   "membershipFeatureAllNova": "所有「新星」功能", | ||||
|   "membershipFeatureExclusiveContent": "限定内容", | ||||
|   "membershipFeatureVipSupport": "VIP 支持", | ||||
|   "membershipCurrentBadge": "当前", | ||||
|   "restorePurchase": "恢复购买", | ||||
|   "restorePurchaseDescription": "输入您付款的提供商和订单 ID 以恢复您的购买。", | ||||
| @@ -518,11 +518,186 @@ | ||||
|   "enterOrderId": "输入您的订单 ID", | ||||
|   "restore": "恢复", | ||||
|   "keyboardShortcuts": "键盘快捷键", | ||||
|   "safetyReport": "举报", | ||||
|   "safetyReportTitle": "举报", | ||||
|   "safetyReportDescription": "通过举报不合适的内容和行为来维护我们社区的稳定。", | ||||
|   "safetyReportType": "举报类型", | ||||
|   "safetyReportReason": "更多证据", | ||||
|   "safetyReportReasonHint": "请提供更多证据……", | ||||
|   "safetyReportSubmit": "提交举报", | ||||
|   "safetyReportSubmitting": "提交中……", | ||||
|   "safetyReportSuccess": "举报成功,感谢您参与维护社区健康发展。", | ||||
|   "safetyReportError": "举报失败,请稍后重试。", | ||||
|   "safetyReportReasonRequired": "请提供举报证据", | ||||
|   "safetyReportTypeSpam": "垃圾或导向错误", | ||||
|   "safetyReportTypeHarassment": "骚扰或暴力行为", | ||||
|   "safetyReportTypeHateSpeech": "歧视言论", | ||||
|   "safetyReportTypeViolence": "威胁或暴力内容", | ||||
|   "safetyReportTypeAdultContent": "成人内容", | ||||
|   "safetyReportTypeIntellectualProperty": "抄袭", | ||||
|   "safetyReportTypeOther": "其它", | ||||
|   "safetyReportTypeInappropriate": "不良内容", | ||||
|   "safetyReportTypeCopyright": "版权侵害", | ||||
|   "safetyReportSuccessTitle": "举报成功", | ||||
|   "safetyReportErrorTitle": "错误", | ||||
|   "discover": "发现", | ||||
|   "joinRealm": "加入领域", | ||||
|   "removePublisherMember": "移除发布者", | ||||
|   "removePublisherMemberHint": "你确定要将这个成员从发布者中移除?", | ||||
|   "drafts": "草稿箱", | ||||
|   "noDrafts": "无草稿", | ||||
|   "articleDrafts": "文章草稿", | ||||
|   "postDrafts": "帖子草稿", | ||||
|   "saveDraft": "保存草稿", | ||||
|   "draftSaved": "草稿已保存", | ||||
|   "draftSaveFailed": "保存草稿失败", | ||||
|   "clearAllDrafts": "清除全部草稿", | ||||
|   "clearAllDraftsConfirm": "你确定要清除全部草稿?这一操作无法撤销。", | ||||
|   "clearAll": "清除所有", | ||||
|   "untitled": "未命名", | ||||
|   "noContent": "内容为空", | ||||
|   "justNow": "刚刚", | ||||
|   "minutesAgo": "{} 分钟以前", | ||||
|   "hoursAgo": "{} 小时以前", | ||||
|   "daysAgo": "{} 天以前", | ||||
|   "public": "公开的", | ||||
|   "unlisted": "不列出", | ||||
|   "friends": "朋友", | ||||
|   "selected": "选择的", | ||||
|   "private": "私密的", | ||||
|   "postContentEmpty": "发布的内容不能为空", | ||||
|   "share": "分享", | ||||
|   "sharePost": "分享帖子", | ||||
|   "quickActions": "快捷操作", | ||||
|   "post": "发帖", | ||||
|   "copy": "复制", | ||||
|   "sendToChat": "发送到聊天", | ||||
|   "failedToShareToPost": "分享到帖子失败:{}", | ||||
|   "shareToChatComingSoon": "分享到聊天功能即将推出", | ||||
|   "failedToShareToChat": "分享到聊天失败:{}", | ||||
|   "shareToSpecificChatComingSoon": "分享到 {} 功能即将推出", | ||||
|   "directChat": "私信", | ||||
|   "systemShareComingSoon": "系统分享功能即将推出", | ||||
|   "failedToShareToSystem": "分享到系统失败:{}", | ||||
|   "failedToCopy": "复制失败:{}", | ||||
|   "noChatRoomsAvailable": "无可用聊天室", | ||||
|   "failedToLoadChats": "加载聊天失败", | ||||
|   "contentToShare": "分享内容:", | ||||
|   "unknownChat": "未知聊天", | ||||
|   "addAdditionalMessage": "添加附加消息……", | ||||
|   "uploadingFiles": "上传文件中……", | ||||
|   "sharedSuccessfully": "分享成功!", | ||||
|   "shareSuccess": "分享成功!", | ||||
|   "shareToSpecificChatSuccess": "成功分享至 {}!", | ||||
|   "wouldYouLikeToGoToChat": "是否前往该聊天?", | ||||
|   "no": "否", | ||||
|   "yes": "是", | ||||
|   "navigateToChat": "前往聊天", | ||||
|   "abuseReport": "举报", | ||||
|   "abuseReportTitle": "举报内容", | ||||
|   "abuseReportDescription": "举报不当内容或行为,协助维护社区安全。", | ||||
|   "abuseReportType": "举报类型", | ||||
|   "abuseReportReason": "补充详情", | ||||
|   "abuseReportReasonHint": "请提供更多详情……", | ||||
|   "abuseReportSubmit": "提交举报", | ||||
|   "abuseReportSuccess": "举报提交成功,感谢你为社区维护作出贡献。", | ||||
|   "abuseReportError": "无法提交举报,请稍后再试。", | ||||
|   "abuseReportReasonRequired": "请提供关于此事件的细节", | ||||
|   "abuseReportSuccessTitle": "举报已提交", | ||||
|   "abuseReportErrorTitle": "错误", | ||||
|   "abuseReportTypeSpam": "垃圾或错误信息", | ||||
|   "abuseReportTypeHarassment": "骚扰或滥用", | ||||
|   "abuseReportTypeInappropriate": "不合适的内容", | ||||
|   "abuseReportTypeViolence": "暴力或人身威胁", | ||||
|   "abuseReportTypeCopyright": "版权侵犯", | ||||
|   "abuseReportTypeImpersonation": "冒充", | ||||
|   "abuseReportTypeOffensiveContent": "冒犯性内容", | ||||
|   "abuseReportTypePrivacyViolation": "隐私侵犯", | ||||
|   "abuseReportTypeIllegalContent": "违法内容", | ||||
|   "abuseReportTypeOther": "其他", | ||||
|   "tags": "标签", | ||||
|   "tagsHint": "输入标签,用英文逗号分隔", | ||||
|   "categories": "分类", | ||||
|   "categoriesHint": "输入分类,由逗号隔开", | ||||
|   "chatNotJoined": "你还没有加入这个聊天。", | ||||
|   "chatUnableJoin": "由于该聊天的访问设置使你无法加入。", | ||||
|   "chatJoin": "加入聊天", | ||||
|   "realmJoin": "加入领域", | ||||
|   "realmJoinSuccess": "成功加入领域。", | ||||
|   "search": "搜索", | ||||
|   "publisherMembers": "合作者", | ||||
|   "developerHub": "开发者中心", | ||||
|   "developerHubUnselectedHint": "选择一名开发者查看总结数据或成为一名。", | ||||
|   "enrollDeveloper": "成为一名开发者", | ||||
|   "enrollDeveloperHint": "让你的一个发布者成为开发者。", | ||||
|   "noPublishersToEnroll": "你没有可以成为开发者的发布者。", | ||||
|   "totalCustomApps": "所有应用套件", | ||||
|   "customApps": "应用套件", | ||||
|   "noCustomApps": "还没有应用套件。", | ||||
|   "createCustomApp": "创建应用套件", | ||||
|   "editCustomApp": "编辑应用套件", | ||||
|   "deleteCustomApp": "删除应用套件", | ||||
|   "deleteCustomAppHint": "你确定要删除这个应用套件吗?这一步无法撤销。", | ||||
|   "publicRealm": "公开领域", | ||||
|   "publicRealmDescription": "所有人都可以预览这个领域的内容。", | ||||
|   "communityRealm": "领域", | ||||
|   "communityRealmDescription": "所有人都可以加入该领域并参与讨论,并将在发现和反馈页面显示。", | ||||
|   "publicChat": "公开聊天", | ||||
|   "publicChatDescription": "任何人都可以预览此聊天的内容。包括未加入的机器人。", | ||||
|   "communityChat": "社区聊天", | ||||
|   "communityChatDescription": "所有人都可以加入该聊天并参与参与讨论。", | ||||
|   "appLinks": "应用链接", | ||||
|   "homePageUrl": "主页链接", | ||||
|   "privacyPolicyUrl": "隐私政策链接", | ||||
|   "termsOfServiceUrl": "用户协议链接", | ||||
|   "oauthConfig": "OAuth 配置", | ||||
|   "clientUri": "客户端 URI", | ||||
|   "redirectUris": "重定向 URIs", | ||||
|   "addRedirectUri": "添加重定向 URI", | ||||
|   "allowedScopes": "允许的范围", | ||||
|   "requirePkce": "需要 PKCE", | ||||
|   "allowOfflineAccess": "允许离线访问", | ||||
|   "redirectUri": "重定向 URI", | ||||
|   "redirectUriHint": "重定向 URI 用于 OAuth 认证,但您的项目状态转为线上时我们会验证请求中的重定向 URI 是否符合此配置。", | ||||
|   "uriRequired": "这个 URI 是必须填写的。", | ||||
|   "uriInvalid": "无效 URI。", | ||||
|   "add": "添加", | ||||
|   "addScope": "添加范围", | ||||
|   "scope": "范围", | ||||
|   "publisherFeatures": "功能", | ||||
|   "publisherFeatureDevelop": "开发者计划", | ||||
|   "publisherFeatureDevelopDescription": "为你的开发者解锁包括应用套件,API 及更多开发功能。", | ||||
|   "publisherFeatureDevelopHint": "目前该功能还在开发中,你需要邀请才可解锁。", | ||||
|   "learnMore": "了解更多", | ||||
|   "discoverWebArticles": "来自站外的文章", | ||||
|   "webArticlesStand": "文章亭", | ||||
|   "about": "关于", | ||||
|   "somethingWentWrong": "发生了一些错误", | ||||
|   "editedAt": "编辑于 {}", | ||||
|   "addAudio": "添加音频", | ||||
|   "recordAudio": "录制音频", | ||||
|   "linkAttachment": "链接附件", | ||||
|   "fileIdCannotBeEmpty": "文件 ID 不能为空", | ||||
|   "fileIdLinkHint": "还没有上传到 Solar Network?点击此处打开 Solar Network Drive,自定义您的上传内容。", | ||||
|   "failedToFetchFile": "获取文件失败:{}", | ||||
|   "callLeave": "离开", | ||||
|   "callEnd": "挂断通话", | ||||
|   "postType": "帖子类型", | ||||
|   "articleAttachmentHint": "附件必须上传并插入到文章主体中才能显示出来。", | ||||
|   "postVisibility": "可见性", | ||||
|   "currentMembershipMember": "恒星计划成员 · {}", | ||||
|   "membershipPriceStellar": "需要用户等级 3+,每月价格 1200 NSP", | ||||
|   "membershipPriceNova": "需要用户等级 6+,每月价格 2400 NSP", | ||||
|   "membershipPriceSupernova": "需要用户等级 9+,每月价格 3600 NSP", | ||||
|   "sharePostPhoto": "通过图片分享帖子", | ||||
|   "wouldYouLikeToNavigateToChat": "你想要前往聊天页面吗?", | ||||
|   "abuseReports": "举报", | ||||
|   "discoverRealms": "发现领域", | ||||
|   "discoverPublishers": "发现发布者", | ||||
|   "membershipCancel": "取消会员订阅", | ||||
|   "membershipCancelConfirm": "您确定要取消您的会员订阅?", | ||||
|   "membershipCancelHint": "您确定要取消您的会员订阅吗?您将不会再被收费。您的会员资格将在当前计费周期结束前保持有效。并且您在当前订阅结束之前无法重新订阅。", | ||||
|   "membershipCancelSuccess": "您的会员订阅已成功取消。", | ||||
|   "membershipCancelConfirm": "你确定要取消会员订阅吗?", | ||||
|   "membershipCancelHint": "你确定要取消会员订阅吗?你将不会再次被扣费。你的会员资格将在当前计费周期结束前保持有效。并且你将无法重新订阅,直到当前订阅结束。", | ||||
|   "membershipCancelSuccess": "你的会员订阅已成功取消。", | ||||
|   "aboutScreenTitle": "关于", | ||||
|   "aboutScreenVersionInfo": "版本 {} ({})", | ||||
|   "aboutScreenAppInfoSectionTitle": "应用信息", | ||||
| @@ -532,18 +707,110 @@ | ||||
|   "aboutScreenLinksSectionTitle": "链接", | ||||
|   "aboutScreenPrivacyPolicyTitle": "隐私政策", | ||||
|   "aboutScreenTermsOfServiceTitle": "服务条款", | ||||
|   "aboutScreenOpenSourceLicensesTitle": "开源许可证", | ||||
|   "aboutScreenOpenSourceLicensesTitle": "开源许可", | ||||
|   "aboutScreenDeveloperSectionTitle": "开发者", | ||||
|   "aboutScreenContactUsTitle": "联系我们", | ||||
|   "aboutScreenLicenseTitle": "许可证", | ||||
|   "aboutScreenLicenseContent": "GNU Affero General Public License v3.0", | ||||
|   "aboutScreenCopyright": "版权所有 © 索尔辛茨 {}", | ||||
|   "aboutScreenMadeWith": "由 Solar Network Team 用 ❤︎️ 制作", | ||||
|   "aboutScreenFailedToLoadPackageInfo": "加载包信息失败:{error}", | ||||
|   "aboutScreenLicenseTitle": "许可", | ||||
|   "aboutScreenLicenseContent": "无法翻译", | ||||
|   "aboutScreenCopyright": "版权所有 © Solsynth {}", | ||||
|   "aboutScreenMadeWith": "由 Solar Network 团队用 ❤︎️ 制作", | ||||
|   "aboutScreenFailedToLoadPackageInfo": "无法加载包信息:{error}", | ||||
|   "copiedToClipboard": "已复制到剪贴板", | ||||
|   "copyToClipboardTooltip": "复制到剪贴板", | ||||
|   "postForwardingTo": "转发给", | ||||
|   "postReplyingTo": "回复给", | ||||
|   "postEditing": "您正在编辑现有帖子", | ||||
|   "postArticle": "文章" | ||||
|   "postForwardingTo": "正在转发到", | ||||
|   "postReplyingTo": "正在回复", | ||||
|   "postReplyPlaceholder": "发表你的回复", | ||||
|   "postEditing": "你正在编辑一个现有的帖子", | ||||
|   "postArticle": "文章", | ||||
|   "aboutDeviceName": "设备名称", | ||||
|   "aboutDeviceIdentifier": "设备标识符", | ||||
|   "donate": "捐赠", | ||||
|   "donateDescription": "支持我们继续开发 Solar Network,并维持服务器运行。", | ||||
|   "fileId": "文件 ID", | ||||
|   "fileIdHint": "文件 ID 是你通过 Solar Network Drive 上传文件后获得的 ID。", | ||||
|   "translate": "翻译", | ||||
|   "translating": "正在翻译", | ||||
|   "translated": "已翻译", | ||||
|   "reactionThumbUp": "赞", | ||||
|   "reactionThumbDown": "踩", | ||||
|   "reactionJustOkay": "还行", | ||||
|   "reactionCry": "哭", | ||||
|   "reactionConfuse": "困惑", | ||||
|   "reactionClap": "鼓掌", | ||||
|   "reactionLaugh": "笑", | ||||
|   "reactionAngry": "生气", | ||||
|   "reactionParty": "派对", | ||||
|   "reactionPray": "祈祷", | ||||
|   "reactionHeart": "爱心", | ||||
|   "selectMicrophone": "选择麦克风", | ||||
|   "selectCamera": "选择摄像头", | ||||
|   "switchedTo": "已切换到 {}", | ||||
|   "connecting": "正在连接", | ||||
|   "reconnecting": "正在重新连接", | ||||
|   "disconnected": "已断开连接", | ||||
|   "connected": "已连接", | ||||
|   "repliesLoadMore": "加载更多回复", | ||||
|   "attachmentsRecentUploads": "最近上传", | ||||
|   "attachmentsManualInput": "手动输入", | ||||
|   "crop": "裁剪", | ||||
|   "rename": "重命名", | ||||
|   "markAsSensitive": "标记为敏感", | ||||
|   "fileName": "文件名", | ||||
|   "sensitiveCategories": { | ||||
|     "language": "语言", | ||||
|     "sexualContent": "色情内容", | ||||
|     "violence": "暴力", | ||||
|     "profanity": "亵渎", | ||||
|     "hateSpeech": "仇恨言论", | ||||
|     "racism": "种族主义", | ||||
|     "adultContent": "成人内容", | ||||
|     "drugAbuse": "药物滥用", | ||||
|     "alcoholAbuse": "酗酒", | ||||
|     "gambling": "赌博", | ||||
|     "selfHarm": "自残", | ||||
|     "childAbuse": "虐待儿童", | ||||
|     "other": "其他" | ||||
|   }, | ||||
|   "poll": "投票", | ||||
|   "pollsRecent": "最近投票", | ||||
|   "pollCreateNew": "创建新投票", | ||||
|   "pollCreateNewHint": "为你的帖子创建一个新投票。选择一个发布者然后继续。", | ||||
|   "publisher": "发布者", | ||||
|   "publisherHint": "输入发布者名称", | ||||
|   "publisherCannotBeEmpty": "发布者不能为空", | ||||
|   "operationFailed": "操作失败:{}", | ||||
|   "stickerMarketplace": "贴纸市场", | ||||
|   "stickerPackAdded": "贴纸包已添加到你的收藏", | ||||
|   "stickerPackRemoved": "贴纸包已从你的收藏中移除", | ||||
|   "addPack": "添加贴纸包", | ||||
|   "removePack": "移除贴纸包", | ||||
|   "browseAndAddStickers": "浏览并添加贴纸包", | ||||
|   "stickerPack": "贴纸包", | ||||
|   "postCategoryTechnology": "科技", | ||||
|   "postCategoryTravel": "旅行", | ||||
|   "postCategoryFood": "美食", | ||||
|   "postCategoryHealth": "健康", | ||||
|   "postCategoryScience": "科学", | ||||
|   "postCategorySports": "体育", | ||||
|   "postCategoryFinance": "金融", | ||||
|   "postCategoryLife": "生活", | ||||
|   "postCategoryArt": "艺术", | ||||
|   "postCategoryStudy": "学习", | ||||
|   "postCategoryGaming": "游戏", | ||||
|   "postCategoryProgramming": "编程", | ||||
|   "postCategoryMusic": "音乐", | ||||
|   "links": "链接", | ||||
|   "addLink": "添加链接", | ||||
|   "linkKey": "链接名称", | ||||
|   "linkValue": "链接", | ||||
|   "debugOptions": "调试选项", | ||||
|   "joinedAt": "加入于 {}", | ||||
|   "searchAccounts": "搜索帐号……", | ||||
|   "webFeeds": "订阅源", | ||||
|   "polls": "投票", | ||||
|   "sharePostSlogan": "加入 Solar Network 以便探索更多", | ||||
|   "filesListAdditional": { | ||||
|     "one": "+{} 个文件被折叠", | ||||
|     "other": "+{} 个文件被折叠" | ||||
|   } | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								assets/icons/icon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/icons/icon.ico
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 108 KiB | 
							
								
								
									
										139
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							
							
						
						
									
										139
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							| @@ -42,22 +42,62 @@ PODS: | ||||
|     - Flutter | ||||
|   - Firebase/CoreOnly (12.0.0): | ||||
|     - FirebaseCore (~> 12.0.0) | ||||
|   - Firebase/Crashlytics (12.0.0): | ||||
|     - Firebase/CoreOnly | ||||
|     - FirebaseCrashlytics (~> 12.0.0) | ||||
|   - Firebase/Messaging (12.0.0): | ||||
|     - Firebase/CoreOnly | ||||
|     - FirebaseMessaging (~> 12.0.0) | ||||
|   - firebase_analytics (12.0.0): | ||||
|     - firebase_core | ||||
|     - FirebaseAnalytics (= 12.0.0) | ||||
|     - Flutter | ||||
|   - firebase_core (4.0.0): | ||||
|     - Firebase/CoreOnly (= 12.0.0) | ||||
|     - Flutter | ||||
|   - firebase_crashlytics (5.0.0): | ||||
|     - Firebase/Crashlytics (= 12.0.0) | ||||
|     - firebase_core | ||||
|     - Flutter | ||||
|   - firebase_messaging (16.0.0): | ||||
|     - Firebase/Messaging (= 12.0.0) | ||||
|     - firebase_core | ||||
|     - Flutter | ||||
|   - FirebaseAnalytics (12.0.0): | ||||
|     - FirebaseAnalytics/Default (= 12.0.0) | ||||
|     - FirebaseCore (~> 12.0.0) | ||||
|     - FirebaseInstallations (~> 12.0.0) | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) | ||||
|     - GoogleUtilities/MethodSwizzler (~> 8.1) | ||||
|     - GoogleUtilities/Network (~> 8.1) | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.1)" | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - FirebaseAnalytics/Default (12.0.0): | ||||
|     - FirebaseCore (~> 12.0.0) | ||||
|     - FirebaseInstallations (~> 12.0.0) | ||||
|     - GoogleAppMeasurement/Default (= 12.0.0) | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) | ||||
|     - GoogleUtilities/MethodSwizzler (~> 8.1) | ||||
|     - GoogleUtilities/Network (~> 8.1) | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.1)" | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - FirebaseCore (12.0.0): | ||||
|     - FirebaseCoreInternal (~> 12.0.0) | ||||
|     - GoogleUtilities/Environment (~> 8.1) | ||||
|     - GoogleUtilities/Logger (~> 8.1) | ||||
|   - FirebaseCoreExtension (12.0.0): | ||||
|     - FirebaseCore (~> 12.0.0) | ||||
|   - FirebaseCoreInternal (12.0.0): | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.1)" | ||||
|   - FirebaseCrashlytics (12.0.0): | ||||
|     - FirebaseCore (~> 12.0.0) | ||||
|     - FirebaseInstallations (~> 12.0.0) | ||||
|     - FirebaseRemoteConfigInterop (~> 12.0.0) | ||||
|     - FirebaseSessions (~> 12.0.0) | ||||
|     - GoogleDataTransport (~> 10.1) | ||||
|     - GoogleUtilities/Environment (~> 8.1) | ||||
|     - nanopb (~> 3.30910.0) | ||||
|     - PromisesObjC (~> 2.4) | ||||
|   - FirebaseInstallations (12.0.0): | ||||
|     - FirebaseCore (~> 12.0.0) | ||||
|     - GoogleUtilities/Environment (~> 8.1) | ||||
| @@ -72,7 +112,19 @@ PODS: | ||||
|     - GoogleUtilities/Reachability (~> 8.1) | ||||
|     - GoogleUtilities/UserDefaults (~> 8.1) | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - FirebaseRemoteConfigInterop (12.0.0) | ||||
|   - FirebaseSessions (12.0.0): | ||||
|     - FirebaseCore (~> 12.0.0) | ||||
|     - FirebaseCoreExtension (~> 12.0.0) | ||||
|     - FirebaseInstallations (~> 12.0.0) | ||||
|     - GoogleDataTransport (~> 10.1) | ||||
|     - GoogleUtilities/Environment (~> 8.1) | ||||
|     - GoogleUtilities/UserDefaults (~> 8.1) | ||||
|     - nanopb (~> 3.30910.0) | ||||
|     - PromisesSwift (~> 2.1) | ||||
|   - Flutter (1.0.0) | ||||
|   - flutter_app_update (0.0.1): | ||||
|     - Flutter | ||||
|   - flutter_inappwebview_ios (0.0.1): | ||||
|     - Flutter | ||||
|     - flutter_inappwebview_ios/Core (= 0.0.1) | ||||
| @@ -99,6 +151,32 @@ PODS: | ||||
|   - gal (1.0.0): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
|   - GoogleAdsOnDeviceConversion (2.1.0): | ||||
|     - GoogleUtilities/Logger (~> 8.1) | ||||
|     - GoogleUtilities/Network (~> 8.1) | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - GoogleAppMeasurement/Core (12.0.0): | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) | ||||
|     - GoogleUtilities/MethodSwizzler (~> 8.1) | ||||
|     - GoogleUtilities/Network (~> 8.1) | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.1)" | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - GoogleAppMeasurement/Default (12.0.0): | ||||
|     - GoogleAdsOnDeviceConversion (= 2.1.0) | ||||
|     - GoogleAppMeasurement/Core (= 12.0.0) | ||||
|     - GoogleAppMeasurement/IdentitySupport (= 12.0.0) | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) | ||||
|     - GoogleUtilities/MethodSwizzler (~> 8.1) | ||||
|     - GoogleUtilities/Network (~> 8.1) | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.1)" | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - GoogleAppMeasurement/IdentitySupport (12.0.0): | ||||
|     - GoogleAppMeasurement/Core (= 12.0.0) | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.1) | ||||
|     - GoogleUtilities/MethodSwizzler (~> 8.1) | ||||
|     - GoogleUtilities/Network (~> 8.1) | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.1)" | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - GoogleDataTransport (10.1.0): | ||||
|     - nanopb (~> 3.30910.0) | ||||
|     - PromisesObjC (~> 2.4) | ||||
| @@ -112,6 +190,9 @@ PODS: | ||||
|   - GoogleUtilities/Logger (8.1.0): | ||||
|     - GoogleUtilities/Environment | ||||
|     - GoogleUtilities/Privacy | ||||
|   - GoogleUtilities/MethodSwizzler (8.1.0): | ||||
|     - GoogleUtilities/Logger | ||||
|     - GoogleUtilities/Privacy | ||||
|   - GoogleUtilities/Network (8.1.0): | ||||
|     - GoogleUtilities/Logger | ||||
|     - "GoogleUtilities/NSData+zlib" | ||||
| @@ -160,9 +241,11 @@ PODS: | ||||
|   - pointer_interceptor_ios (0.0.1): | ||||
|     - Flutter | ||||
|   - PromisesObjC (2.4.0) | ||||
|   - PromisesSwift (2.4.0): | ||||
|     - PromisesObjC (= 2.4.0) | ||||
|   - receive_sharing_intent (1.8.1): | ||||
|     - Flutter | ||||
|   - record_ios (1.0.0): | ||||
|   - record_ios (1.1.0): | ||||
|     - Flutter | ||||
|   - SAMKeychain (1.5.3) | ||||
|   - SDWebImage (5.21.1): | ||||
| @@ -178,25 +261,25 @@ PODS: | ||||
|   - sqflite_darwin (0.0.4): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
|   - sqlite3 (3.50.3): | ||||
|     - sqlite3/common (= 3.50.3) | ||||
|   - sqlite3/common (3.50.3) | ||||
|   - sqlite3/dbstatvtab (3.50.3): | ||||
|   - sqlite3 (3.50.4): | ||||
|     - sqlite3/common (= 3.50.4) | ||||
|   - sqlite3/common (3.50.4) | ||||
|   - sqlite3/dbstatvtab (3.50.4): | ||||
|     - sqlite3/common | ||||
|   - sqlite3/fts5 (3.50.3): | ||||
|   - sqlite3/fts5 (3.50.4): | ||||
|     - sqlite3/common | ||||
|   - sqlite3/math (3.50.3): | ||||
|   - sqlite3/math (3.50.4): | ||||
|     - sqlite3/common | ||||
|   - sqlite3/perf-threadsafe (3.50.3): | ||||
|   - sqlite3/perf-threadsafe (3.50.4): | ||||
|     - sqlite3/common | ||||
|   - sqlite3/rtree (3.50.3): | ||||
|   - sqlite3/rtree (3.50.4): | ||||
|     - sqlite3/common | ||||
|   - sqlite3/session (3.50.3): | ||||
|   - sqlite3/session (3.50.4): | ||||
|     - sqlite3/common | ||||
|   - sqlite3_flutter_libs (0.0.1): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
|     - sqlite3 (~> 3.50.3) | ||||
|     - sqlite3 (~> 3.50.4) | ||||
|     - sqlite3/dbstatvtab | ||||
|     - sqlite3/fts5 | ||||
|     - sqlite3/math | ||||
| @@ -220,9 +303,12 @@ DEPENDENCIES: | ||||
|   - croppy (from `.symlinks/plugins/croppy/ios`) | ||||
|   - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) | ||||
|   - file_picker (from `.symlinks/plugins/file_picker/ios`) | ||||
|   - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) | ||||
|   - firebase_core (from `.symlinks/plugins/firebase_core/ios`) | ||||
|   - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) | ||||
|   - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) | ||||
|   - Flutter (from `Flutter`) | ||||
|   - flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`) | ||||
|   - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) | ||||
|   - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) | ||||
|   - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) | ||||
| @@ -262,16 +348,24 @@ SPEC REPOS: | ||||
|     - DKImagePickerController | ||||
|     - DKPhotoGallery | ||||
|     - Firebase | ||||
|     - FirebaseAnalytics | ||||
|     - FirebaseCore | ||||
|     - FirebaseCoreExtension | ||||
|     - FirebaseCoreInternal | ||||
|     - FirebaseCrashlytics | ||||
|     - FirebaseInstallations | ||||
|     - FirebaseMessaging | ||||
|     - FirebaseRemoteConfigInterop | ||||
|     - FirebaseSessions | ||||
|     - GoogleAdsOnDeviceConversion | ||||
|     - GoogleAppMeasurement | ||||
|     - GoogleDataTransport | ||||
|     - GoogleUtilities | ||||
|     - Kingfisher | ||||
|     - nanopb | ||||
|     - OrderedSet | ||||
|     - PromisesObjC | ||||
|     - PromisesSwift | ||||
|     - SAMKeychain | ||||
|     - SDWebImage | ||||
|     - sqlite3 | ||||
| @@ -287,12 +381,18 @@ EXTERNAL SOURCES: | ||||
|     :path: ".symlinks/plugins/device_info_plus/ios" | ||||
|   file_picker: | ||||
|     :path: ".symlinks/plugins/file_picker/ios" | ||||
|   firebase_analytics: | ||||
|     :path: ".symlinks/plugins/firebase_analytics/ios" | ||||
|   firebase_core: | ||||
|     :path: ".symlinks/plugins/firebase_core/ios" | ||||
|   firebase_crashlytics: | ||||
|     :path: ".symlinks/plugins/firebase_crashlytics/ios" | ||||
|   firebase_messaging: | ||||
|     :path: ".symlinks/plugins/firebase_messaging/ios" | ||||
|   Flutter: | ||||
|     :path: Flutter | ||||
|   flutter_app_update: | ||||
|     :path: ".symlinks/plugins/flutter_app_update/ios" | ||||
|   flutter_inappwebview_ios: | ||||
|     :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" | ||||
|   flutter_keyboard_visibility: | ||||
| @@ -365,13 +465,21 @@ SPEC CHECKSUMS: | ||||
|   DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 | ||||
|   file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be | ||||
|   Firebase: 800d487043c0557d9faed71477a38d9aafb08a41 | ||||
|   firebase_analytics: cd56fc56f75c1df30a6ff5290cd56e230996a76d | ||||
|   firebase_core: 633e1851ffe1b9ab875f6467a4f574c79cef02e4 | ||||
|   firebase_crashlytics: 2c6c1a17900a38081d938330e9f48e60ec5b255d | ||||
|   firebase_messaging: d17feef781edc84ebefe62624fb384358ad96361 | ||||
|   FirebaseAnalytics: 6d790cd1b159b4eb61a99948df0934ce505a34f7 | ||||
|   FirebaseCore: 055f4ab117d5964158c833f3d5e7ec6d91648d4a | ||||
|   FirebaseCoreExtension: 639afb3de6abd611952be78a794c54a47fa0f361 | ||||
|   FirebaseCoreInternal: dedc28e569a4be85f38f3d6af1070a2e12018d55 | ||||
|   FirebaseCrashlytics: db75aa0cab8d00f68406fa247c32fe17ade884d7 | ||||
|   FirebaseInstallations: d4c7c958f99c8860d7fcece786314ae790e2f988 | ||||
|   FirebaseMessaging: af49f8d7c0a3d2a017d9302c80946f45a7777dde | ||||
|   FirebaseRemoteConfigInterop: bfa0ea72ba3dc5af739777296424e46bd6f42613 | ||||
|   FirebaseSessions: 4e784acda213108aafef536535cdfc03504acc42 | ||||
|   Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 | ||||
|   flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9 | ||||
|   flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 | ||||
|   flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619 | ||||
|   flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf | ||||
| @@ -381,6 +489,8 @@ SPEC CHECKSUMS: | ||||
|   flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 | ||||
|   flutter_webrtc: 6f7da106613d52ade777d5b4875a43f48c28b457 | ||||
|   gal: baecd024ebfd13c441269ca7404792a7152fde89 | ||||
|   GoogleAdsOnDeviceConversion: 2be6297a4f048459e0ae17fad9bfd2844e10cf64 | ||||
|   GoogleAppMeasurement: 8f6ab04ad6ae493b53fcf56bd26323fb2f1384f3 | ||||
|   GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 | ||||
|   GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 | ||||
|   image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a | ||||
| @@ -398,16 +508,17 @@ SPEC CHECKSUMS: | ||||
|   path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 | ||||
|   pointer_interceptor_ios: ec847ef8b0915778bed2b2cef636f4d177fa8eed | ||||
|   PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 | ||||
|   PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 | ||||
|   receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 | ||||
|   record_ios: fee1c924aa4879b882ebca2b4bce6011bcfc3d8b | ||||
|   record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374 | ||||
|   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c | ||||
|   SDWebImage: f29024626962457f3470184232766516dee8dfea | ||||
|   share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a | ||||
|   shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 | ||||
|   sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 | ||||
|   sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 | ||||
|   sqlite3: 83105acd294c9137c026e2da1931c30b4588ab81 | ||||
|   sqlite3_flutter_libs: 616267f2fca40e9c6af8c5d82324e05667040b6e | ||||
|   sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b | ||||
|   sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1 | ||||
|   super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 | ||||
|   SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 | ||||
|   url_launcher_ios: 694010445543906933d732453a59da0a173ae33d | ||||
|   | ||||
| @@ -439,6 +439,7 @@ | ||||
| 				3B06AD1E1E4923F5004D2608 /* Thin Binary */, | ||||
| 				8C0351B03869BBF493808288 /* [CP] Embed Pods Frameworks */, | ||||
| 				5E7D6EF29B671AC7EDBA5649 /* [CP] Copy Pods Resources */, | ||||
| 				E86CDE9D6464F4F52B910856 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */, | ||||
| 			); | ||||
| 			buildRules = ( | ||||
| 			); | ||||
| @@ -682,6 +683,24 @@ | ||||
| 			shellPath = /bin/sh; | ||||
| 			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; | ||||
| 		}; | ||||
| 		E86CDE9D6464F4F52B910856 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = { | ||||
| 			isa = PBXShellScriptBuildPhase; | ||||
| 			buildActionMask = 2147483647; | ||||
| 			files = ( | ||||
| 			); | ||||
| 			inputFileListPaths = ( | ||||
| 			); | ||||
| 			inputPaths = ( | ||||
| 			); | ||||
| 			name = "FlutterFire: \"flutterfire upload-crashlytics-symbols\""; | ||||
| 			outputFileListPaths = ( | ||||
| 			); | ||||
| 			outputPaths = ( | ||||
| 			); | ||||
| 			runOnlyForDeploymentPostprocessing = 0; | ||||
| 			shellPath = /bin/sh; | ||||
| 			shellScript = "\n#!/bin/bash\nPATH=\"${PATH}:$FLUTTER_ROOT/bin:${PUB_CACHE}/bin:$HOME/.pub-cache/bin\"\n\nif [ -z \"$PODS_ROOT\" ] || [ ! -d \"$PODS_ROOT/FirebaseCrashlytics\" ]; then\n  # Cannot use \"BUILD_DIR%/Build/*\" as per Firebase documentation, it points to \"flutter-project/build/ios/*\" path which doesn't have run script\n  DERIVED_DATA_PATH=$(echo \"$BUILD_ROOT\" | sed -E 's|(.*DerivedData/[^/]+).*|\\1|')\n  PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT=\"${DERIVED_DATA_PATH}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\nelse\n  PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT=\"$PODS_ROOT/FirebaseCrashlytics/run\"\nfi\n\n# Command to upload symbols script used to upload symbols to Firebase server\nflutterfire upload-crashlytics-symbols --upload-symbols-script-path=\"$PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT\" --platform=ios --apple-project-path=\"${SRCROOT}\" --env-platform-name=\"${PLATFORM_NAME}\" --env-configuration=\"${CONFIGURATION}\" --env-project-dir=\"${PROJECT_DIR}\" --env-built-products-dir=\"${BUILT_PRODUCTS_DIR}\" --env-dwarf-dsym-folder-path=\"${DWARF_DSYM_FOLDER_PATH}\" --env-dwarf-dsym-file-name=\"${DWARF_DSYM_FILE_NAME}\" --env-infoplist-path=\"${INFOPLIST_PATH}\" --default-config=default\n"; | ||||
| 		}; | ||||
| 		E947029FCA058878F9B63890 /* [CP] Check Pods Manifest.lock */ = { | ||||
| 			isa = PBXShellScriptBuildPhase; | ||||
| 			buildActionMask = 2147483647; | ||||
|   | ||||
| @@ -34,7 +34,7 @@ class NotifyDelegate: UIResponder, UNUserNotificationCenterDelegate { | ||||
|         } | ||||
|          | ||||
|         let serverUrl = UserDefaults.standard.getServerUrl() | ||||
|         let url = "\(serverUrl)/chat/\(metadata["room_id"] ?? "")/messages" | ||||
|         let url = "\(serverUrl)/sphere/chat/\(metadata["room_id"] ?? "")/messages" | ||||
|          | ||||
|         let parameters: [String: Any?] = [ | ||||
|             "content": textResponse.userText, | ||||
|   | ||||
| @@ -8,7 +8,7 @@ | ||||
| import Foundation | ||||
|  | ||||
| func getAttachmentUrl(for identifier: String) -> String { | ||||
|     let serverBaseUrl = "https://nt.solian.app" | ||||
|     let serverBaseUrl = "https://api.solian.app" | ||||
|      | ||||
|     return identifier.starts(with: "http") ? identifier : "\(serverBaseUrl)/files/\(identifier)" | ||||
|     return identifier.starts(with: "http") ? identifier : "\(serverBaseUrl)/drive/files/\(identifier)" | ||||
| } | ||||
|   | ||||
| @@ -61,10 +61,8 @@ class DefaultFirebaseOptions { | ||||
|     messagingSenderId: '961776991058', | ||||
|     projectId: 'solian-0x001', | ||||
|     storageBucket: 'solian-0x001.firebasestorage.app', | ||||
|     androidClientId: | ||||
|         '961776991058-r4iv9qoio57ul7utbfpgfrda2etvtch8.apps.googleusercontent.com', | ||||
|     iosClientId: | ||||
|         '961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig.apps.googleusercontent.com', | ||||
|     androidClientId: '961776991058-r4iv9qoio57ul7utbfpgfrda2etvtch8.apps.googleusercontent.com', | ||||
|     iosClientId: '961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig.apps.googleusercontent.com', | ||||
|     iosBundleId: 'dev.solsynth.solian', | ||||
|   ); | ||||
|  | ||||
| @@ -74,10 +72,8 @@ class DefaultFirebaseOptions { | ||||
|     messagingSenderId: '961776991058', | ||||
|     projectId: 'solian-0x001', | ||||
|     storageBucket: 'solian-0x001.firebasestorage.app', | ||||
|     androidClientId: | ||||
|         '961776991058-r4iv9qoio57ul7utbfpgfrda2etvtch8.apps.googleusercontent.com', | ||||
|     iosClientId: | ||||
|         '961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig.apps.googleusercontent.com', | ||||
|     androidClientId: '961776991058-r4iv9qoio57ul7utbfpgfrda2etvtch8.apps.googleusercontent.com', | ||||
|     iosClientId: '961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig.apps.googleusercontent.com', | ||||
|     iosBundleId: 'dev.solsynth.solian', | ||||
|   ); | ||||
|  | ||||
| @@ -90,4 +86,5 @@ class DefaultFirebaseOptions { | ||||
|     storageBucket: 'solian-0x001.firebasestorage.app', | ||||
|     measurementId: 'G-JD1YEG9D6F', | ||||
|   ); | ||||
|  | ||||
| } | ||||
| @@ -4,6 +4,7 @@ import 'dart:io'; | ||||
| import 'package:croppy/croppy.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart' hide TextDirection; | ||||
| import 'package:firebase_core/firebase_core.dart'; | ||||
| import 'package:firebase_crashlytics/firebase_crashlytics.dart'; | ||||
| import 'package:firebase_messaging/firebase_messaging.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| @@ -30,7 +31,6 @@ import 'package:image_picker_platform_interface/image_picker_platform_interface. | ||||
| import 'package:flutter_native_splash/flutter_native_splash.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
| import 'package:flutter_langdetect/flutter_langdetect.dart' as langdetect; | ||||
| import 'package:island/services/update_service.dart'; | ||||
|  | ||||
| @pragma('vm:entry-point') | ||||
| Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { | ||||
| @@ -62,6 +62,17 @@ void main() async { | ||||
|       FirebaseMessaging.onBackgroundMessage( | ||||
|         _firebaseMessagingBackgroundHandler, | ||||
|       ); | ||||
|       // Although previous if case checked this. Still check is web or not | ||||
|       // Otherwise the web platform will broke due to there is no Platform api on the web | ||||
|       // Skip crashlytics setup on debug mode to prevent unexpected report to firebase | ||||
|       if ((kIsWeb || !Platform.isWindows) && !kDebugMode) { | ||||
|         FlutterError.onError = | ||||
|             FirebaseCrashlytics.instance.recordFlutterFatalError; | ||||
|         PlatformDispatcher.instance.onError = (error, stack) { | ||||
|           FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); | ||||
|           return true; | ||||
|         }; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     log("[SplashScreen] Firebase is ready!"); | ||||
| @@ -144,15 +155,6 @@ void main() async { | ||||
|       ), | ||||
|     ), | ||||
|   ); | ||||
|  | ||||
|   // Schedule update check shortly after startup, when a context is available. | ||||
|   // Uses the global overlay key to obtain a BuildContext safely. | ||||
|   WidgetsBinding.instance.addPostFrameCallback((_) { | ||||
|     final ctx = globalOverlay.currentContext; | ||||
|     if (ctx != null) { | ||||
|       UpdateService().checkForUpdates(ctx); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  | ||||
| // Router will be provided through Riverpod | ||||
| @@ -181,6 +183,9 @@ class IslandApp extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       if (!kIsWeb && Platform.isLinux) { | ||||
|         return null; | ||||
|       } | ||||
|       const channel = MethodChannel('dev.solsynth.solian/notifications'); | ||||
|  | ||||
|       Future<void> handleInitialLink() async { | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/auth.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/models/wallet.dart'; | ||||
| 
 | ||||
| part 'user.freezed.dart'; | ||||
| part 'user.g.dart'; | ||||
| part 'account.freezed.dart'; | ||||
| part 'account.g.dart'; | ||||
| 
 | ||||
| @freezed | ||||
| sealed class SnAccount with _$SnAccount { | ||||
| @@ -25,6 +26,32 @@ sealed class SnAccount with _$SnAccount { | ||||
|       _$SnAccountFromJson(json); | ||||
| } | ||||
| 
 | ||||
| @freezed | ||||
| sealed class ProfileLink with _$ProfileLink { | ||||
|   const factory ProfileLink({required String name, required String url}) = | ||||
|       _ProfileLink; | ||||
| 
 | ||||
|   factory ProfileLink.fromJson(Map<String, dynamic> json) => | ||||
|       _$ProfileLinkFromJson(json); | ||||
| } | ||||
| 
 | ||||
| class ProfileLinkConverter | ||||
|     implements JsonConverter<List<ProfileLink>, dynamic> { | ||||
|   const ProfileLinkConverter(); | ||||
| 
 | ||||
|   @override | ||||
|   List<ProfileLink> fromJson(dynamic json) { | ||||
|     return json is List<dynamic> | ||||
|         ? json.map((e) => ProfileLink.fromJson(e)).cast<ProfileLink>().toList() | ||||
|         : <ProfileLink>[]; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   List<dynamic> toJson(List<ProfileLink> object) { | ||||
|     return object.map((e) => e.toJson()).toList(); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @freezed | ||||
| sealed class SnAccountProfile with _$SnAccountProfile { | ||||
|   const factory SnAccountProfile({ | ||||
| @@ -38,7 +65,7 @@ sealed class SnAccountProfile with _$SnAccountProfile { | ||||
|     @Default('') String location, | ||||
|     @Default('') String timeZone, | ||||
|     DateTime? birthday, | ||||
|     @Default({}) Map<String, String> links, | ||||
|     @ProfileLinkConverter() @Default([]) List<ProfileLink> links, | ||||
|     DateTime? lastSeenAt, | ||||
|     SnAccountBadge? activeBadge, | ||||
|     required int experience, | ||||
| @@ -148,3 +175,36 @@ sealed class SnVerificationMark with _$SnVerificationMark { | ||||
|   factory SnVerificationMark.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnVerificationMarkFromJson(json); | ||||
| } | ||||
| 
 | ||||
| @freezed | ||||
| sealed class SnAuthDevice with _$SnAuthDevice { | ||||
|   const factory SnAuthDevice({ | ||||
|     required String id, | ||||
|     required String deviceId, | ||||
|     required String deviceName, | ||||
|     required String? deviceLabel, | ||||
|     required String accountId, | ||||
|     required int platform, | ||||
|     @Default(false) bool isCurrent, | ||||
|   }) = _SnAuthDevice; | ||||
| 
 | ||||
|   factory SnAuthDevice.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnAuthDeviceFromJson(json); | ||||
| } | ||||
| 
 | ||||
| @freezed | ||||
| sealed class SnAuthDeviceWithChallenge with _$SnAuthDeviceWithChallenge { | ||||
|   const factory SnAuthDeviceWithChallenge({ | ||||
|     required String id, | ||||
|     required String deviceId, | ||||
|     required String deviceName, | ||||
|     required String? deviceLabel, | ||||
|     required String accountId, | ||||
|     required int platform, | ||||
|     required List<SnAuthChallenge> challenges, | ||||
|     @Default(false) bool isCurrent, | ||||
|   }) = _SnAuthDeviceWithChallengee; | ||||
| 
 | ||||
|   factory SnAuthDeviceWithChallenge.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnAuthDeviceWithChallengeFromJson(json); | ||||
| } | ||||
| @@ -3,7 +3,7 @@ | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark | ||||
| 
 | ||||
| part of 'user.dart'; | ||||
| part of 'account.dart'; | ||||
| 
 | ||||
| // ************************************************************************** | ||||
| // FreezedGenerator | ||||
| @@ -347,10 +347,270 @@ $SnWalletSubscriptionRefCopyWith<$Res>? get perkSubscription { | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /// @nodoc | ||||
| mixin _$ProfileLink { | ||||
| 
 | ||||
|  String get name; String get url; | ||||
| /// Create a copy of ProfileLink | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $ProfileLinkCopyWith<ProfileLink> get copyWith => _$ProfileLinkCopyWithImpl<ProfileLink>(this as ProfileLink, _$identity); | ||||
| 
 | ||||
|   /// Serializes this ProfileLink to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
| 
 | ||||
| 
 | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is ProfileLink&&(identical(other.name, name) || other.name == name)&&(identical(other.url, url) || other.url == url)); | ||||
| } | ||||
| 
 | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,name,url); | ||||
| 
 | ||||
| @override | ||||
| String toString() { | ||||
|   return 'ProfileLink(name: $name, url: $url)'; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /// @nodoc | ||||
| abstract mixin class $ProfileLinkCopyWith<$Res>  { | ||||
|   factory $ProfileLinkCopyWith(ProfileLink value, $Res Function(ProfileLink) _then) = _$ProfileLinkCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String name, String url | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| /// @nodoc | ||||
| class _$ProfileLinkCopyWithImpl<$Res> | ||||
|     implements $ProfileLinkCopyWith<$Res> { | ||||
|   _$ProfileLinkCopyWithImpl(this._self, this._then); | ||||
| 
 | ||||
|   final ProfileLink _self; | ||||
|   final $Res Function(ProfileLink) _then; | ||||
| 
 | ||||
| /// Create a copy of ProfileLink | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? url = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||
| as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable | ||||
| as String, | ||||
|   )); | ||||
| } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /// Adds pattern-matching-related methods to [ProfileLink]. | ||||
| extension ProfileLinkPatterns on ProfileLink { | ||||
| /// A variant of `map` that fallback to returning `orElse`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ProfileLink value)?  $default,{required TResult orElse(),}){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _ProfileLink() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return orElse(); | ||||
| 
 | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// Callbacks receives the raw object, upcasted. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case final Subclass2 value: | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ProfileLink value)  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _ProfileLink(): | ||||
| return $default(_that);} | ||||
| } | ||||
| /// A variant of `map` that fallback to returning `null`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ProfileLink value)?  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _ProfileLink() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return null; | ||||
| 
 | ||||
| } | ||||
| } | ||||
| /// A variant of `when` that fallback to an `orElse` callback. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name,  String url)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _ProfileLink() when $default != null: | ||||
| return $default(_that.name,_that.url);case _: | ||||
|   return orElse(); | ||||
| 
 | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// As opposed to `map`, this offers destructuring. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case Subclass2(:final field2): | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name,  String url)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _ProfileLink(): | ||||
| return $default(_that.name,_that.url);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name,  String url)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _ProfileLink() when $default != null: | ||||
| return $default(_that.name,_that.url);case _: | ||||
|   return null; | ||||
| 
 | ||||
| } | ||||
| } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
| 
 | ||||
| class _ProfileLink implements ProfileLink { | ||||
|   const _ProfileLink({required this.name, required this.url}); | ||||
|   factory _ProfileLink.fromJson(Map<String, dynamic> json) => _$ProfileLinkFromJson(json); | ||||
| 
 | ||||
| @override final  String name; | ||||
| @override final  String url; | ||||
| 
 | ||||
| /// Create a copy of ProfileLink | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$ProfileLinkCopyWith<_ProfileLink> get copyWith => __$ProfileLinkCopyWithImpl<_ProfileLink>(this, _$identity); | ||||
| 
 | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$ProfileLinkToJson(this, ); | ||||
| } | ||||
| 
 | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProfileLink&&(identical(other.name, name) || other.name == name)&&(identical(other.url, url) || other.url == url)); | ||||
| } | ||||
| 
 | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,name,url); | ||||
| 
 | ||||
| @override | ||||
| String toString() { | ||||
|   return 'ProfileLink(name: $name, url: $url)'; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /// @nodoc | ||||
| abstract mixin class _$ProfileLinkCopyWith<$Res> implements $ProfileLinkCopyWith<$Res> { | ||||
|   factory _$ProfileLinkCopyWith(_ProfileLink value, $Res Function(_ProfileLink) _then) = __$ProfileLinkCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String name, String url | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| /// @nodoc | ||||
| class __$ProfileLinkCopyWithImpl<$Res> | ||||
|     implements _$ProfileLinkCopyWith<$Res> { | ||||
|   __$ProfileLinkCopyWithImpl(this._self, this._then); | ||||
| 
 | ||||
|   final _ProfileLink _self; | ||||
|   final $Res Function(_ProfileLink) _then; | ||||
| 
 | ||||
| /// Create a copy of ProfileLink | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? url = null,}) { | ||||
|   return _then(_ProfileLink( | ||||
| name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||
| as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable | ||||
| as String, | ||||
|   )); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /// @nodoc | ||||
| mixin _$SnAccountProfile { | ||||
| 
 | ||||
|  String get id; String get firstName; String get middleName; String get lastName; String get bio; String get gender; String get pronouns; String get location; String get timeZone; DateTime? get birthday; Map<String, String> get links; DateTime? get lastSeenAt; SnAccountBadge? get activeBadge; int get experience; int get level; double get levelingProgress; SnCloudFile? get picture; SnCloudFile? get background; SnVerificationMark? get verification; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
|  String get id; String get firstName; String get middleName; String get lastName; String get bio; String get gender; String get pronouns; String get location; String get timeZone; DateTime? get birthday;@ProfileLinkConverter() List<ProfileLink> get links; DateTime? get lastSeenAt; SnAccountBadge? get activeBadge; int get experience; int get level; double get levelingProgress; SnCloudFile? get picture; SnCloudFile? get background; SnVerificationMark? get verification; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
| /// Create a copy of SnAccountProfile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -383,7 +643,7 @@ abstract mixin class $SnAccountProfileCopyWith<$Res>  { | ||||
|   factory $SnAccountProfileCopyWith(SnAccountProfile value, $Res Function(SnAccountProfile) _then) = _$SnAccountProfileCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, Map<String, String> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday,@ProfileLinkConverter() List<ProfileLink> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
| @@ -413,7 +673,7 @@ as String,location: null == location ? _self.location : location // ignore: cast | ||||
| as String,timeZone: null == timeZone ? _self.timeZone : timeZone // ignore: cast_nullable_to_non_nullable | ||||
| as String,birthday: freezed == birthday ? _self.birthday : birthday // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,links: null == links ? _self.links : links // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, String>,lastSeenAt: freezed == lastSeenAt ? _self.lastSeenAt : lastSeenAt // ignore: cast_nullable_to_non_nullable | ||||
| as List<ProfileLink>,lastSeenAt: freezed == lastSeenAt ? _self.lastSeenAt : lastSeenAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,activeBadge: freezed == activeBadge ? _self.activeBadge : activeBadge // ignore: cast_nullable_to_non_nullable | ||||
| as SnAccountBadge?,experience: null == experience ? _self.experience : experience // ignore: cast_nullable_to_non_nullable | ||||
| as int,level: null == level ? _self.level : level // ignore: cast_nullable_to_non_nullable | ||||
| @@ -554,7 +814,7 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String firstName,  String middleName,  String lastName,  String bio,  String gender,  String pronouns,  String location,  String timeZone,  DateTime? birthday,  Map<String, String> links,  DateTime? lastSeenAt,  SnAccountBadge? activeBadge,  int experience,  int level,  double levelingProgress,  SnCloudFile? picture,  SnCloudFile? background,  SnVerificationMark? verification,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String firstName,  String middleName,  String lastName,  String bio,  String gender,  String pronouns,  String location,  String timeZone,  DateTime? birthday, @ProfileLinkConverter()  List<ProfileLink> links,  DateTime? lastSeenAt,  SnAccountBadge? activeBadge,  int experience,  int level,  double levelingProgress,  SnCloudFile? picture,  SnCloudFile? background,  SnVerificationMark? verification,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAccountProfile() when $default != null: | ||||
| return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.links,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
| @@ -575,7 +835,7 @@ return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.b | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String firstName,  String middleName,  String lastName,  String bio,  String gender,  String pronouns,  String location,  String timeZone,  DateTime? birthday,  Map<String, String> links,  DateTime? lastSeenAt,  SnAccountBadge? activeBadge,  int experience,  int level,  double levelingProgress,  SnCloudFile? picture,  SnCloudFile? background,  SnVerificationMark? verification,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String firstName,  String middleName,  String lastName,  String bio,  String gender,  String pronouns,  String location,  String timeZone,  DateTime? birthday, @ProfileLinkConverter()  List<ProfileLink> links,  DateTime? lastSeenAt,  SnAccountBadge? activeBadge,  int experience,  int level,  double levelingProgress,  SnCloudFile? picture,  SnCloudFile? background,  SnVerificationMark? verification,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAccountProfile(): | ||||
| return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.links,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.createdAt,_that.updatedAt,_that.deletedAt);} | ||||
| @@ -592,7 +852,7 @@ return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.b | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String firstName,  String middleName,  String lastName,  String bio,  String gender,  String pronouns,  String location,  String timeZone,  DateTime? birthday,  Map<String, String> links,  DateTime? lastSeenAt,  SnAccountBadge? activeBadge,  int experience,  int level,  double levelingProgress,  SnCloudFile? picture,  SnCloudFile? background,  SnVerificationMark? verification,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String firstName,  String middleName,  String lastName,  String bio,  String gender,  String pronouns,  String location,  String timeZone,  DateTime? birthday, @ProfileLinkConverter()  List<ProfileLink> links,  DateTime? lastSeenAt,  SnAccountBadge? activeBadge,  int experience,  int level,  double levelingProgress,  SnCloudFile? picture,  SnCloudFile? background,  SnVerificationMark? verification,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAccountProfile() when $default != null: | ||||
| return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.links,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
| @@ -607,7 +867,7 @@ return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.b | ||||
| @JsonSerializable() | ||||
| 
 | ||||
| class _SnAccountProfile implements SnAccountProfile { | ||||
|   const _SnAccountProfile({required this.id, this.firstName = '', this.middleName = '', this.lastName = '', this.bio = '', this.gender = '', this.pronouns = '', this.location = '', this.timeZone = '', this.birthday, final  Map<String, String> links = const {}, this.lastSeenAt, this.activeBadge, required this.experience, required this.level, required this.levelingProgress, required this.picture, required this.background, required this.verification, required this.createdAt, required this.updatedAt, required this.deletedAt}): _links = links; | ||||
|   const _SnAccountProfile({required this.id, this.firstName = '', this.middleName = '', this.lastName = '', this.bio = '', this.gender = '', this.pronouns = '', this.location = '', this.timeZone = '', this.birthday, @ProfileLinkConverter() final  List<ProfileLink> links = const [], this.lastSeenAt, this.activeBadge, required this.experience, required this.level, required this.levelingProgress, required this.picture, required this.background, required this.verification, required this.createdAt, required this.updatedAt, required this.deletedAt}): _links = links; | ||||
|   factory _SnAccountProfile.fromJson(Map<String, dynamic> json) => _$SnAccountProfileFromJson(json); | ||||
| 
 | ||||
| @override final  String id; | ||||
| @@ -620,11 +880,11 @@ class _SnAccountProfile implements SnAccountProfile { | ||||
| @override@JsonKey() final  String location; | ||||
| @override@JsonKey() final  String timeZone; | ||||
| @override final  DateTime? birthday; | ||||
|  final  Map<String, String> _links; | ||||
| @override@JsonKey() Map<String, String> get links { | ||||
|   if (_links is EqualUnmodifiableMapView) return _links; | ||||
|  final  List<ProfileLink> _links; | ||||
| @override@JsonKey()@ProfileLinkConverter() List<ProfileLink> get links { | ||||
|   if (_links is EqualUnmodifiableListView) return _links; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableMapView(_links); | ||||
|   return EqualUnmodifiableListView(_links); | ||||
| } | ||||
| 
 | ||||
| @override final  DateTime? lastSeenAt; | ||||
| @@ -672,7 +932,7 @@ abstract mixin class _$SnAccountProfileCopyWith<$Res> implements $SnAccountProfi | ||||
|   factory _$SnAccountProfileCopyWith(_SnAccountProfile value, $Res Function(_SnAccountProfile) _then) = __$SnAccountProfileCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, Map<String, String> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday,@ProfileLinkConverter() List<ProfileLink> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
| @@ -702,7 +962,7 @@ as String,location: null == location ? _self.location : location // ignore: cast | ||||
| as String,timeZone: null == timeZone ? _self.timeZone : timeZone // ignore: cast_nullable_to_non_nullable | ||||
| as String,birthday: freezed == birthday ? _self.birthday : birthday // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,links: null == links ? _self._links : links // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, String>,lastSeenAt: freezed == lastSeenAt ? _self.lastSeenAt : lastSeenAt // ignore: cast_nullable_to_non_nullable | ||||
| as List<ProfileLink>,lastSeenAt: freezed == lastSeenAt ? _self.lastSeenAt : lastSeenAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,activeBadge: freezed == activeBadge ? _self.activeBadge : activeBadge // ignore: cast_nullable_to_non_nullable | ||||
| as SnAccountBadge?,experience: null == experience ? _self.experience : experience // ignore: cast_nullable_to_non_nullable | ||||
| as int,level: null == level ? _self.level : level // ignore: cast_nullable_to_non_nullable | ||||
| @@ -2192,6 +2452,572 @@ as String?, | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /// @nodoc | ||||
| mixin _$SnAuthDevice { | ||||
| 
 | ||||
|  String get id; String get deviceId; String get deviceName; String? get deviceLabel; String get accountId; int get platform; bool get isCurrent; | ||||
| /// Create a copy of SnAuthDevice | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnAuthDeviceCopyWith<SnAuthDevice> get copyWith => _$SnAuthDeviceCopyWithImpl<SnAuthDevice>(this as SnAuthDevice, _$identity); | ||||
| 
 | ||||
|   /// Serializes this SnAuthDevice to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
| 
 | ||||
| 
 | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthDevice&&(identical(other.id, id) || other.id == id)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.deviceName, deviceName) || other.deviceName == deviceName)&&(identical(other.deviceLabel, deviceLabel) || other.deviceLabel == deviceLabel)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.platform, platform) || other.platform == platform)&&(identical(other.isCurrent, isCurrent) || other.isCurrent == isCurrent)); | ||||
| } | ||||
| 
 | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,deviceId,deviceName,deviceLabel,accountId,platform,isCurrent); | ||||
| 
 | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAuthDevice(id: $id, deviceId: $deviceId, deviceName: $deviceName, deviceLabel: $deviceLabel, accountId: $accountId, platform: $platform, isCurrent: $isCurrent)'; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /// @nodoc | ||||
| abstract mixin class $SnAuthDeviceCopyWith<$Res>  { | ||||
|   factory $SnAuthDeviceCopyWith(SnAuthDevice value, $Res Function(SnAuthDevice) _then) = _$SnAuthDeviceCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String deviceId, String deviceName, String? deviceLabel, String accountId, int platform, bool isCurrent | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| /// @nodoc | ||||
| class _$SnAuthDeviceCopyWithImpl<$Res> | ||||
|     implements $SnAuthDeviceCopyWith<$Res> { | ||||
|   _$SnAuthDeviceCopyWithImpl(this._self, this._then); | ||||
| 
 | ||||
|   final SnAuthDevice _self; | ||||
|   final $Res Function(SnAuthDevice) _then; | ||||
| 
 | ||||
| /// Create a copy of SnAuthDevice | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? deviceId = null,Object? deviceName = null,Object? deviceLabel = freezed,Object? accountId = null,Object? platform = null,Object? isCurrent = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceName: null == deviceName ? _self.deviceName : deviceName // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceLabel: freezed == deviceLabel ? _self.deviceLabel : deviceLabel // ignore: cast_nullable_to_non_nullable | ||||
| as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable | ||||
| as int,isCurrent: null == isCurrent ? _self.isCurrent : isCurrent // ignore: cast_nullable_to_non_nullable | ||||
| as bool, | ||||
|   )); | ||||
| } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /// Adds pattern-matching-related methods to [SnAuthDevice]. | ||||
| extension SnAuthDevicePatterns on SnAuthDevice { | ||||
| /// A variant of `map` that fallback to returning `orElse`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnAuthDevice value)?  $default,{required TResult orElse(),}){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDevice() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return orElse(); | ||||
| 
 | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// Callbacks receives the raw object, upcasted. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case final Subclass2 value: | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnAuthDevice value)  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDevice(): | ||||
| return $default(_that);} | ||||
| } | ||||
| /// A variant of `map` that fallback to returning `null`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnAuthDevice value)?  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDevice() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return null; | ||||
| 
 | ||||
| } | ||||
| } | ||||
| /// A variant of `when` that fallback to an `orElse` callback. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String deviceId,  String deviceName,  String? deviceLabel,  String accountId,  int platform,  bool isCurrent)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDevice() when $default != null: | ||||
| return $default(_that.id,_that.deviceId,_that.deviceName,_that.deviceLabel,_that.accountId,_that.platform,_that.isCurrent);case _: | ||||
|   return orElse(); | ||||
| 
 | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// As opposed to `map`, this offers destructuring. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case Subclass2(:final field2): | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String deviceId,  String deviceName,  String? deviceLabel,  String accountId,  int platform,  bool isCurrent)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDevice(): | ||||
| return $default(_that.id,_that.deviceId,_that.deviceName,_that.deviceLabel,_that.accountId,_that.platform,_that.isCurrent);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String deviceId,  String deviceName,  String? deviceLabel,  String accountId,  int platform,  bool isCurrent)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDevice() when $default != null: | ||||
| return $default(_that.id,_that.deviceId,_that.deviceName,_that.deviceLabel,_that.accountId,_that.platform,_that.isCurrent);case _: | ||||
|   return null; | ||||
| 
 | ||||
| } | ||||
| } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
| 
 | ||||
| class _SnAuthDevice implements SnAuthDevice { | ||||
|   const _SnAuthDevice({required this.id, required this.deviceId, required this.deviceName, required this.deviceLabel, required this.accountId, required this.platform, this.isCurrent = false}); | ||||
|   factory _SnAuthDevice.fromJson(Map<String, dynamic> json) => _$SnAuthDeviceFromJson(json); | ||||
| 
 | ||||
| @override final  String id; | ||||
| @override final  String deviceId; | ||||
| @override final  String deviceName; | ||||
| @override final  String? deviceLabel; | ||||
| @override final  String accountId; | ||||
| @override final  int platform; | ||||
| @override@JsonKey() final  bool isCurrent; | ||||
| 
 | ||||
| /// Create a copy of SnAuthDevice | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$SnAuthDeviceCopyWith<_SnAuthDevice> get copyWith => __$SnAuthDeviceCopyWithImpl<_SnAuthDevice>(this, _$identity); | ||||
| 
 | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$SnAuthDeviceToJson(this, ); | ||||
| } | ||||
| 
 | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthDevice&&(identical(other.id, id) || other.id == id)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.deviceName, deviceName) || other.deviceName == deviceName)&&(identical(other.deviceLabel, deviceLabel) || other.deviceLabel == deviceLabel)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.platform, platform) || other.platform == platform)&&(identical(other.isCurrent, isCurrent) || other.isCurrent == isCurrent)); | ||||
| } | ||||
| 
 | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,deviceId,deviceName,deviceLabel,accountId,platform,isCurrent); | ||||
| 
 | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAuthDevice(id: $id, deviceId: $deviceId, deviceName: $deviceName, deviceLabel: $deviceLabel, accountId: $accountId, platform: $platform, isCurrent: $isCurrent)'; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /// @nodoc | ||||
| abstract mixin class _$SnAuthDeviceCopyWith<$Res> implements $SnAuthDeviceCopyWith<$Res> { | ||||
|   factory _$SnAuthDeviceCopyWith(_SnAuthDevice value, $Res Function(_SnAuthDevice) _then) = __$SnAuthDeviceCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String deviceId, String deviceName, String? deviceLabel, String accountId, int platform, bool isCurrent | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| /// @nodoc | ||||
| class __$SnAuthDeviceCopyWithImpl<$Res> | ||||
|     implements _$SnAuthDeviceCopyWith<$Res> { | ||||
|   __$SnAuthDeviceCopyWithImpl(this._self, this._then); | ||||
| 
 | ||||
|   final _SnAuthDevice _self; | ||||
|   final $Res Function(_SnAuthDevice) _then; | ||||
| 
 | ||||
| /// Create a copy of SnAuthDevice | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? deviceId = null,Object? deviceName = null,Object? deviceLabel = freezed,Object? accountId = null,Object? platform = null,Object? isCurrent = null,}) { | ||||
|   return _then(_SnAuthDevice( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceName: null == deviceName ? _self.deviceName : deviceName // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceLabel: freezed == deviceLabel ? _self.deviceLabel : deviceLabel // ignore: cast_nullable_to_non_nullable | ||||
| as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable | ||||
| as int,isCurrent: null == isCurrent ? _self.isCurrent : isCurrent // ignore: cast_nullable_to_non_nullable | ||||
| as bool, | ||||
|   )); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| SnAuthDeviceWithChallenge _$SnAuthDeviceWithChallengeFromJson( | ||||
|   Map<String, dynamic> json | ||||
| ) { | ||||
|     return _SnAuthDeviceWithChallengee.fromJson( | ||||
|       json | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| /// @nodoc | ||||
| mixin _$SnAuthDeviceWithChallenge { | ||||
| 
 | ||||
|  String get id; String get deviceId; String get deviceName; String? get deviceLabel; String get accountId; int get platform; List<SnAuthChallenge> get challenges; bool get isCurrent; | ||||
| /// Create a copy of SnAuthDeviceWithChallenge | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnAuthDeviceWithChallengeCopyWith<SnAuthDeviceWithChallenge> get copyWith => _$SnAuthDeviceWithChallengeCopyWithImpl<SnAuthDeviceWithChallenge>(this as SnAuthDeviceWithChallenge, _$identity); | ||||
| 
 | ||||
|   /// Serializes this SnAuthDeviceWithChallenge to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
| 
 | ||||
| 
 | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthDeviceWithChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.deviceName, deviceName) || other.deviceName == deviceName)&&(identical(other.deviceLabel, deviceLabel) || other.deviceLabel == deviceLabel)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.platform, platform) || other.platform == platform)&&const DeepCollectionEquality().equals(other.challenges, challenges)&&(identical(other.isCurrent, isCurrent) || other.isCurrent == isCurrent)); | ||||
| } | ||||
| 
 | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,deviceId,deviceName,deviceLabel,accountId,platform,const DeepCollectionEquality().hash(challenges),isCurrent); | ||||
| 
 | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAuthDeviceWithChallenge(id: $id, deviceId: $deviceId, deviceName: $deviceName, deviceLabel: $deviceLabel, accountId: $accountId, platform: $platform, challenges: $challenges, isCurrent: $isCurrent)'; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /// @nodoc | ||||
| abstract mixin class $SnAuthDeviceWithChallengeCopyWith<$Res>  { | ||||
|   factory $SnAuthDeviceWithChallengeCopyWith(SnAuthDeviceWithChallenge value, $Res Function(SnAuthDeviceWithChallenge) _then) = _$SnAuthDeviceWithChallengeCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String deviceId, String deviceName, String? deviceLabel, String accountId, int platform, List<SnAuthChallenge> challenges, bool isCurrent | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| /// @nodoc | ||||
| class _$SnAuthDeviceWithChallengeCopyWithImpl<$Res> | ||||
|     implements $SnAuthDeviceWithChallengeCopyWith<$Res> { | ||||
|   _$SnAuthDeviceWithChallengeCopyWithImpl(this._self, this._then); | ||||
| 
 | ||||
|   final SnAuthDeviceWithChallenge _self; | ||||
|   final $Res Function(SnAuthDeviceWithChallenge) _then; | ||||
| 
 | ||||
| /// Create a copy of SnAuthDeviceWithChallenge | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? deviceId = null,Object? deviceName = null,Object? deviceLabel = freezed,Object? accountId = null,Object? platform = null,Object? challenges = null,Object? isCurrent = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceName: null == deviceName ? _self.deviceName : deviceName // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceLabel: freezed == deviceLabel ? _self.deviceLabel : deviceLabel // ignore: cast_nullable_to_non_nullable | ||||
| as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable | ||||
| as int,challenges: null == challenges ? _self.challenges : challenges // ignore: cast_nullable_to_non_nullable | ||||
| as List<SnAuthChallenge>,isCurrent: null == isCurrent ? _self.isCurrent : isCurrent // ignore: cast_nullable_to_non_nullable | ||||
| as bool, | ||||
|   )); | ||||
| } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /// Adds pattern-matching-related methods to [SnAuthDeviceWithChallenge]. | ||||
| extension SnAuthDeviceWithChallengePatterns on SnAuthDeviceWithChallenge { | ||||
| /// A variant of `map` that fallback to returning `orElse`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnAuthDeviceWithChallengee value)?  $default,{required TResult orElse(),}){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDeviceWithChallengee() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return orElse(); | ||||
| 
 | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// Callbacks receives the raw object, upcasted. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case final Subclass2 value: | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnAuthDeviceWithChallengee value)  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDeviceWithChallengee(): | ||||
| return $default(_that);} | ||||
| } | ||||
| /// A variant of `map` that fallback to returning `null`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnAuthDeviceWithChallengee value)?  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDeviceWithChallengee() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return null; | ||||
| 
 | ||||
| } | ||||
| } | ||||
| /// A variant of `when` that fallback to an `orElse` callback. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String deviceId,  String deviceName,  String? deviceLabel,  String accountId,  int platform,  List<SnAuthChallenge> challenges,  bool isCurrent)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDeviceWithChallengee() when $default != null: | ||||
| return $default(_that.id,_that.deviceId,_that.deviceName,_that.deviceLabel,_that.accountId,_that.platform,_that.challenges,_that.isCurrent);case _: | ||||
|   return orElse(); | ||||
| 
 | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// As opposed to `map`, this offers destructuring. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case Subclass2(:final field2): | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String deviceId,  String deviceName,  String? deviceLabel,  String accountId,  int platform,  List<SnAuthChallenge> challenges,  bool isCurrent)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDeviceWithChallengee(): | ||||
| return $default(_that.id,_that.deviceId,_that.deviceName,_that.deviceLabel,_that.accountId,_that.platform,_that.challenges,_that.isCurrent);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
| 
 | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String deviceId,  String deviceName,  String? deviceLabel,  String accountId,  int platform,  List<SnAuthChallenge> challenges,  bool isCurrent)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDeviceWithChallengee() when $default != null: | ||||
| return $default(_that.id,_that.deviceId,_that.deviceName,_that.deviceLabel,_that.accountId,_that.platform,_that.challenges,_that.isCurrent);case _: | ||||
|   return null; | ||||
| 
 | ||||
| } | ||||
| } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
| 
 | ||||
| class _SnAuthDeviceWithChallengee implements SnAuthDeviceWithChallenge { | ||||
|   const _SnAuthDeviceWithChallengee({required this.id, required this.deviceId, required this.deviceName, required this.deviceLabel, required this.accountId, required this.platform, required final  List<SnAuthChallenge> challenges, this.isCurrent = false}): _challenges = challenges; | ||||
|   factory _SnAuthDeviceWithChallengee.fromJson(Map<String, dynamic> json) => _$SnAuthDeviceWithChallengeeFromJson(json); | ||||
| 
 | ||||
| @override final  String id; | ||||
| @override final  String deviceId; | ||||
| @override final  String deviceName; | ||||
| @override final  String? deviceLabel; | ||||
| @override final  String accountId; | ||||
| @override final  int platform; | ||||
|  final  List<SnAuthChallenge> _challenges; | ||||
| @override List<SnAuthChallenge> get challenges { | ||||
|   if (_challenges is EqualUnmodifiableListView) return _challenges; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableListView(_challenges); | ||||
| } | ||||
| 
 | ||||
| @override@JsonKey() final  bool isCurrent; | ||||
| 
 | ||||
| /// Create a copy of SnAuthDeviceWithChallenge | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$SnAuthDeviceWithChallengeeCopyWith<_SnAuthDeviceWithChallengee> get copyWith => __$SnAuthDeviceWithChallengeeCopyWithImpl<_SnAuthDeviceWithChallengee>(this, _$identity); | ||||
| 
 | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$SnAuthDeviceWithChallengeeToJson(this, ); | ||||
| } | ||||
| 
 | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthDeviceWithChallengee&&(identical(other.id, id) || other.id == id)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.deviceName, deviceName) || other.deviceName == deviceName)&&(identical(other.deviceLabel, deviceLabel) || other.deviceLabel == deviceLabel)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.platform, platform) || other.platform == platform)&&const DeepCollectionEquality().equals(other._challenges, _challenges)&&(identical(other.isCurrent, isCurrent) || other.isCurrent == isCurrent)); | ||||
| } | ||||
| 
 | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,deviceId,deviceName,deviceLabel,accountId,platform,const DeepCollectionEquality().hash(_challenges),isCurrent); | ||||
| 
 | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAuthDeviceWithChallenge(id: $id, deviceId: $deviceId, deviceName: $deviceName, deviceLabel: $deviceLabel, accountId: $accountId, platform: $platform, challenges: $challenges, isCurrent: $isCurrent)'; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /// @nodoc | ||||
| abstract mixin class _$SnAuthDeviceWithChallengeeCopyWith<$Res> implements $SnAuthDeviceWithChallengeCopyWith<$Res> { | ||||
|   factory _$SnAuthDeviceWithChallengeeCopyWith(_SnAuthDeviceWithChallengee value, $Res Function(_SnAuthDeviceWithChallengee) _then) = __$SnAuthDeviceWithChallengeeCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String deviceId, String deviceName, String? deviceLabel, String accountId, int platform, List<SnAuthChallenge> challenges, bool isCurrent | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| /// @nodoc | ||||
| class __$SnAuthDeviceWithChallengeeCopyWithImpl<$Res> | ||||
|     implements _$SnAuthDeviceWithChallengeeCopyWith<$Res> { | ||||
|   __$SnAuthDeviceWithChallengeeCopyWithImpl(this._self, this._then); | ||||
| 
 | ||||
|   final _SnAuthDeviceWithChallengee _self; | ||||
|   final $Res Function(_SnAuthDeviceWithChallengee) _then; | ||||
| 
 | ||||
| /// Create a copy of SnAuthDeviceWithChallenge | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? deviceId = null,Object? deviceName = null,Object? deviceLabel = freezed,Object? accountId = null,Object? platform = null,Object? challenges = null,Object? isCurrent = null,}) { | ||||
|   return _then(_SnAuthDeviceWithChallengee( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceName: null == deviceName ? _self.deviceName : deviceName // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceLabel: freezed == deviceLabel ? _self.deviceLabel : deviceLabel // ignore: cast_nullable_to_non_nullable | ||||
| as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable | ||||
| as int,challenges: null == challenges ? _self._challenges : challenges // ignore: cast_nullable_to_non_nullable | ||||
| as List<SnAuthChallenge>,isCurrent: null == isCurrent ? _self.isCurrent : isCurrent // ignore: cast_nullable_to_non_nullable | ||||
| as bool, | ||||
|   )); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| // dart format on | ||||
| @@ -1,6 +1,6 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| 
 | ||||
| part of 'user.dart'; | ||||
| part of 'account.dart'; | ||||
| 
 | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| @@ -47,6 +47,12 @@ Map<String, dynamic> _$SnAccountToJson(_SnAccount instance) => | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
|     }; | ||||
| 
 | ||||
| _ProfileLink _$ProfileLinkFromJson(Map<String, dynamic> json) => | ||||
|     _ProfileLink(name: json['name'] as String, url: json['url'] as String); | ||||
| 
 | ||||
| Map<String, dynamic> _$ProfileLinkToJson(_ProfileLink instance) => | ||||
|     <String, dynamic>{'name': instance.name, 'url': instance.url}; | ||||
| 
 | ||||
| _SnAccountProfile _$SnAccountProfileFromJson(Map<String, dynamic> json) => | ||||
|     _SnAccountProfile( | ||||
|       id: json['id'] as String, | ||||
| @@ -63,10 +69,9 @@ _SnAccountProfile _$SnAccountProfileFromJson(Map<String, dynamic> json) => | ||||
|               ? null | ||||
|               : DateTime.parse(json['birthday'] as String), | ||||
|       links: | ||||
|           (json['links'] as Map<String, dynamic>?)?.map( | ||||
|             (k, e) => MapEntry(k, e as String), | ||||
|           ) ?? | ||||
|           const {}, | ||||
|           json['links'] == null | ||||
|               ? const [] | ||||
|               : const ProfileLinkConverter().fromJson(json['links']), | ||||
|       lastSeenAt: | ||||
|           json['last_seen_at'] == null | ||||
|               ? null | ||||
| @@ -116,7 +121,7 @@ Map<String, dynamic> _$SnAccountProfileToJson(_SnAccountProfile instance) => | ||||
|       'location': instance.location, | ||||
|       'time_zone': instance.timeZone, | ||||
|       'birthday': instance.birthday?.toIso8601String(), | ||||
|       'links': instance.links, | ||||
|       'links': const ProfileLinkConverter().toJson(instance.links), | ||||
|       'last_seen_at': instance.lastSeenAt?.toIso8601String(), | ||||
|       'active_badge': instance.activeBadge?.toJson(), | ||||
|       'experience': instance.experience, | ||||
| @@ -292,3 +297,54 @@ Map<String, dynamic> _$SnVerificationMarkToJson(_SnVerificationMark instance) => | ||||
|       'description': instance.description, | ||||
|       'verified_by': instance.verifiedBy, | ||||
|     }; | ||||
| 
 | ||||
| _SnAuthDevice _$SnAuthDeviceFromJson(Map<String, dynamic> json) => | ||||
|     _SnAuthDevice( | ||||
|       id: json['id'] as String, | ||||
|       deviceId: json['device_id'] as String, | ||||
|       deviceName: json['device_name'] as String, | ||||
|       deviceLabel: json['device_label'] as String?, | ||||
|       accountId: json['account_id'] as String, | ||||
|       platform: (json['platform'] as num).toInt(), | ||||
|       isCurrent: json['is_current'] as bool? ?? false, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$SnAuthDeviceToJson(_SnAuthDevice instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'device_id': instance.deviceId, | ||||
|       'device_name': instance.deviceName, | ||||
|       'device_label': instance.deviceLabel, | ||||
|       'account_id': instance.accountId, | ||||
|       'platform': instance.platform, | ||||
|       'is_current': instance.isCurrent, | ||||
|     }; | ||||
| 
 | ||||
| _SnAuthDeviceWithChallengee _$SnAuthDeviceWithChallengeeFromJson( | ||||
|   Map<String, dynamic> json, | ||||
| ) => _SnAuthDeviceWithChallengee( | ||||
|   id: json['id'] as String, | ||||
|   deviceId: json['device_id'] as String, | ||||
|   deviceName: json['device_name'] as String, | ||||
|   deviceLabel: json['device_label'] as String?, | ||||
|   accountId: json['account_id'] as String, | ||||
|   platform: (json['platform'] as num).toInt(), | ||||
|   challenges: | ||||
|       (json['challenges'] as List<dynamic>) | ||||
|           .map((e) => SnAuthChallenge.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList(), | ||||
|   isCurrent: json['is_current'] as bool? ?? false, | ||||
| ); | ||||
| 
 | ||||
| Map<String, dynamic> _$SnAuthDeviceWithChallengeeToJson( | ||||
|   _SnAuthDeviceWithChallengee instance, | ||||
| ) => <String, dynamic>{ | ||||
|   'id': instance.id, | ||||
|   'device_id': instance.deviceId, | ||||
|   'device_name': instance.deviceName, | ||||
|   'device_label': instance.deviceLabel, | ||||
|   'account_id': instance.accountId, | ||||
|   'platform': instance.platform, | ||||
|   'challenges': instance.challenges.map((e) => e.toJson()).toList(), | ||||
|   'is_current': instance.isCurrent, | ||||
| }; | ||||
| @@ -1,5 +1,5 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
|  | ||||
| part 'activity.freezed.dart'; | ||||
| part 'activity.g.dart'; | ||||
|   | ||||
| @@ -19,14 +19,12 @@ sealed class SnAuthChallenge with _$SnAuthChallenge { | ||||
|     required int stepRemain, | ||||
|     required int stepTotal, | ||||
|     required int failedAttempts, | ||||
|     required int platform, | ||||
|     required int type, | ||||
|     required List<String> blacklistFactors, | ||||
|     required List<dynamic> audiences, | ||||
|     required List<dynamic> scopes, | ||||
|     required String ipAddress, | ||||
|     required String userAgent, | ||||
|     required String deviceId, | ||||
|     required String? nonce, | ||||
|     required String? location, | ||||
|     required String accountId, | ||||
| @@ -76,22 +74,6 @@ sealed class SnAuthFactor with _$SnAuthFactor { | ||||
|       _$SnAuthFactorFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| sealed class SnAuthDevice with _$SnAuthDevice { | ||||
|   const factory SnAuthDevice({ | ||||
|     required dynamic label, | ||||
|     required String userAgent, | ||||
|     required String deviceId, | ||||
|     required int platform, | ||||
|     required List<SnAuthSession> sessions, | ||||
|     // Not from backend, used for UI | ||||
|     @Default(false) bool isCurrent, | ||||
|   }) = _SnAuthDevice; | ||||
|  | ||||
|   factory SnAuthDevice.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnAuthDeviceFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| sealed class SnAccountConnection with _$SnAccountConnection { | ||||
|   const factory SnAccountConnection({ | ||||
|   | ||||
| @@ -272,7 +272,7 @@ as String, | ||||
| /// @nodoc | ||||
| mixin _$SnAuthChallenge { | ||||
|  | ||||
|  String get id; DateTime get expiredAt; int get stepRemain; int get stepTotal; int get failedAttempts; int get platform; int get type; List<String> get blacklistFactors; List<dynamic> get audiences; List<dynamic> get scopes; String get ipAddress; String get userAgent; String get deviceId; String? get nonce; String? get location; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
|  String get id; DateTime get expiredAt; int get stepRemain; int get stepTotal; int get failedAttempts; int get type; List<String> get blacklistFactors; List<dynamic> get audiences; List<dynamic> get scopes; String get ipAddress; String get userAgent; String? get nonce; String? get location; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
| /// Create a copy of SnAuthChallenge | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -285,16 +285,16 @@ $SnAuthChallengeCopyWith<SnAuthChallenge> get copyWith => _$SnAuthChallengeCopyW | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.stepRemain, stepRemain) || other.stepRemain == stepRemain)&&(identical(other.stepTotal, stepTotal) || other.stepTotal == stepTotal)&&(identical(other.failedAttempts, failedAttempts) || other.failedAttempts == failedAttempts)&&(identical(other.platform, platform) || other.platform == platform)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.blacklistFactors, blacklistFactors)&&const DeepCollectionEquality().equals(other.audiences, audiences)&&const DeepCollectionEquality().equals(other.scopes, scopes)&&(identical(other.ipAddress, ipAddress) || other.ipAddress == ipAddress)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&(identical(other.location, location) || other.location == location)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.stepRemain, stepRemain) || other.stepRemain == stepRemain)&&(identical(other.stepTotal, stepTotal) || other.stepTotal == stepTotal)&&(identical(other.failedAttempts, failedAttempts) || other.failedAttempts == failedAttempts)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.blacklistFactors, blacklistFactors)&&const DeepCollectionEquality().equals(other.audiences, audiences)&&const DeepCollectionEquality().equals(other.scopes, scopes)&&(identical(other.ipAddress, ipAddress) || other.ipAddress == ipAddress)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&(identical(other.location, location) || other.location == location)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hashAll([runtimeType,id,expiredAt,stepRemain,stepTotal,failedAttempts,platform,type,const DeepCollectionEquality().hash(blacklistFactors),const DeepCollectionEquality().hash(audiences),const DeepCollectionEquality().hash(scopes),ipAddress,userAgent,deviceId,nonce,location,accountId,createdAt,updatedAt,deletedAt]); | ||||
| int get hashCode => Object.hash(runtimeType,id,expiredAt,stepRemain,stepTotal,failedAttempts,type,const DeepCollectionEquality().hash(blacklistFactors),const DeepCollectionEquality().hash(audiences),const DeepCollectionEquality().hash(scopes),ipAddress,userAgent,nonce,location,accountId,createdAt,updatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAuthChallenge(id: $id, expiredAt: $expiredAt, stepRemain: $stepRemain, stepTotal: $stepTotal, failedAttempts: $failedAttempts, platform: $platform, type: $type, blacklistFactors: $blacklistFactors, audiences: $audiences, scopes: $scopes, ipAddress: $ipAddress, userAgent: $userAgent, deviceId: $deviceId, nonce: $nonce, location: $location, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
|   return 'SnAuthChallenge(id: $id, expiredAt: $expiredAt, stepRemain: $stepRemain, stepTotal: $stepTotal, failedAttempts: $failedAttempts, type: $type, blacklistFactors: $blacklistFactors, audiences: $audiences, scopes: $scopes, ipAddress: $ipAddress, userAgent: $userAgent, nonce: $nonce, location: $location, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -305,7 +305,7 @@ abstract mixin class $SnAuthChallengeCopyWith<$Res>  { | ||||
|   factory $SnAuthChallengeCopyWith(SnAuthChallenge value, $Res Function(SnAuthChallenge) _then) = _$SnAuthChallengeCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int platform, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String deviceId, String? nonce, String? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, String? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -322,21 +322,19 @@ class _$SnAuthChallengeCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnAuthChallenge | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? platform = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? deviceId = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable | ||||
| as int,stepTotal: null == stepTotal ? _self.stepTotal : stepTotal // ignore: cast_nullable_to_non_nullable | ||||
| as int,failedAttempts: null == failedAttempts ? _self.failedAttempts : failedAttempts // ignore: cast_nullable_to_non_nullable | ||||
| as int,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable | ||||
| as int,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| as int,blacklistFactors: null == blacklistFactors ? _self.blacklistFactors : blacklistFactors // ignore: cast_nullable_to_non_nullable | ||||
| as List<String>,audiences: null == audiences ? _self.audiences : audiences // ignore: cast_nullable_to_non_nullable | ||||
| as List<dynamic>,scopes: null == scopes ? _self.scopes : scopes // ignore: cast_nullable_to_non_nullable | ||||
| as List<dynamic>,ipAddress: null == ipAddress ? _self.ipAddress : ipAddress // ignore: cast_nullable_to_non_nullable | ||||
| as String,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable | ||||
| as String,nonce: freezed == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable | ||||
| as String?,location: freezed == location ? _self.location : location // ignore: cast_nullable_to_non_nullable | ||||
| as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| @@ -425,10 +423,10 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  DateTime expiredAt,  int stepRemain,  int stepTotal,  int failedAttempts,  int platform,  int type,  List<String> blacklistFactors,  List<dynamic> audiences,  List<dynamic> scopes,  String ipAddress,  String userAgent,  String deviceId,  String? nonce,  String? location,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  DateTime expiredAt,  int stepRemain,  int stepTotal,  int failedAttempts,  int type,  List<String> blacklistFactors,  List<dynamic> audiences,  List<dynamic> scopes,  String ipAddress,  String userAgent,  String? nonce,  String? location,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthChallenge() when $default != null: | ||||
| return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that.failedAttempts,_that.platform,_that.type,_that.blacklistFactors,_that.audiences,_that.scopes,_that.ipAddress,_that.userAgent,_that.deviceId,_that.nonce,_that.location,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
| return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that.failedAttempts,_that.type,_that.blacklistFactors,_that.audiences,_that.scopes,_that.ipAddress,_that.userAgent,_that.nonce,_that.location,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| @@ -446,10 +444,10 @@ return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that. | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  DateTime expiredAt,  int stepRemain,  int stepTotal,  int failedAttempts,  int platform,  int type,  List<String> blacklistFactors,  List<dynamic> audiences,  List<dynamic> scopes,  String ipAddress,  String userAgent,  String deviceId,  String? nonce,  String? location,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  DateTime expiredAt,  int stepRemain,  int stepTotal,  int failedAttempts,  int type,  List<String> blacklistFactors,  List<dynamic> audiences,  List<dynamic> scopes,  String ipAddress,  String userAgent,  String? nonce,  String? location,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthChallenge(): | ||||
| return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that.failedAttempts,_that.platform,_that.type,_that.blacklistFactors,_that.audiences,_that.scopes,_that.ipAddress,_that.userAgent,_that.deviceId,_that.nonce,_that.location,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);} | ||||
| return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that.failedAttempts,_that.type,_that.blacklistFactors,_that.audiences,_that.scopes,_that.ipAddress,_that.userAgent,_that.nonce,_that.location,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| @@ -463,10 +461,10 @@ return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that. | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  DateTime expiredAt,  int stepRemain,  int stepTotal,  int failedAttempts,  int platform,  int type,  List<String> blacklistFactors,  List<dynamic> audiences,  List<dynamic> scopes,  String ipAddress,  String userAgent,  String deviceId,  String? nonce,  String? location,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  DateTime expiredAt,  int stepRemain,  int stepTotal,  int failedAttempts,  int type,  List<String> blacklistFactors,  List<dynamic> audiences,  List<dynamic> scopes,  String ipAddress,  String userAgent,  String? nonce,  String? location,  String accountId,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthChallenge() when $default != null: | ||||
| return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that.failedAttempts,_that.platform,_that.type,_that.blacklistFactors,_that.audiences,_that.scopes,_that.ipAddress,_that.userAgent,_that.deviceId,_that.nonce,_that.location,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
| return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that.failedAttempts,_that.type,_that.blacklistFactors,_that.audiences,_that.scopes,_that.ipAddress,_that.userAgent,_that.nonce,_that.location,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| @@ -478,7 +476,7 @@ return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that. | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnAuthChallenge implements SnAuthChallenge { | ||||
|   const _SnAuthChallenge({required this.id, required this.expiredAt, required this.stepRemain, required this.stepTotal, required this.failedAttempts, required this.platform, required this.type, required final  List<String> blacklistFactors, required final  List<dynamic> audiences, required final  List<dynamic> scopes, required this.ipAddress, required this.userAgent, required this.deviceId, required this.nonce, required this.location, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}): _blacklistFactors = blacklistFactors,_audiences = audiences,_scopes = scopes; | ||||
|   const _SnAuthChallenge({required this.id, required this.expiredAt, required this.stepRemain, required this.stepTotal, required this.failedAttempts, required this.type, required final  List<String> blacklistFactors, required final  List<dynamic> audiences, required final  List<dynamic> scopes, required this.ipAddress, required this.userAgent, required this.nonce, required this.location, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}): _blacklistFactors = blacklistFactors,_audiences = audiences,_scopes = scopes; | ||||
|   factory _SnAuthChallenge.fromJson(Map<String, dynamic> json) => _$SnAuthChallengeFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @@ -486,7 +484,6 @@ class _SnAuthChallenge implements SnAuthChallenge { | ||||
| @override final  int stepRemain; | ||||
| @override final  int stepTotal; | ||||
| @override final  int failedAttempts; | ||||
| @override final  int platform; | ||||
| @override final  int type; | ||||
|  final  List<String> _blacklistFactors; | ||||
| @override List<String> get blacklistFactors { | ||||
| @@ -511,7 +508,6 @@ class _SnAuthChallenge implements SnAuthChallenge { | ||||
|  | ||||
| @override final  String ipAddress; | ||||
| @override final  String userAgent; | ||||
| @override final  String deviceId; | ||||
| @override final  String? nonce; | ||||
| @override final  String? location; | ||||
| @override final  String accountId; | ||||
| @@ -532,16 +528,16 @@ Map<String, dynamic> toJson() { | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.stepRemain, stepRemain) || other.stepRemain == stepRemain)&&(identical(other.stepTotal, stepTotal) || other.stepTotal == stepTotal)&&(identical(other.failedAttempts, failedAttempts) || other.failedAttempts == failedAttempts)&&(identical(other.platform, platform) || other.platform == platform)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._blacklistFactors, _blacklistFactors)&&const DeepCollectionEquality().equals(other._audiences, _audiences)&&const DeepCollectionEquality().equals(other._scopes, _scopes)&&(identical(other.ipAddress, ipAddress) || other.ipAddress == ipAddress)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&(identical(other.location, location) || other.location == location)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.stepRemain, stepRemain) || other.stepRemain == stepRemain)&&(identical(other.stepTotal, stepTotal) || other.stepTotal == stepTotal)&&(identical(other.failedAttempts, failedAttempts) || other.failedAttempts == failedAttempts)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._blacklistFactors, _blacklistFactors)&&const DeepCollectionEquality().equals(other._audiences, _audiences)&&const DeepCollectionEquality().equals(other._scopes, _scopes)&&(identical(other.ipAddress, ipAddress) || other.ipAddress == ipAddress)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&(identical(other.location, location) || other.location == location)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hashAll([runtimeType,id,expiredAt,stepRemain,stepTotal,failedAttempts,platform,type,const DeepCollectionEquality().hash(_blacklistFactors),const DeepCollectionEquality().hash(_audiences),const DeepCollectionEquality().hash(_scopes),ipAddress,userAgent,deviceId,nonce,location,accountId,createdAt,updatedAt,deletedAt]); | ||||
| int get hashCode => Object.hash(runtimeType,id,expiredAt,stepRemain,stepTotal,failedAttempts,type,const DeepCollectionEquality().hash(_blacklistFactors),const DeepCollectionEquality().hash(_audiences),const DeepCollectionEquality().hash(_scopes),ipAddress,userAgent,nonce,location,accountId,createdAt,updatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAuthChallenge(id: $id, expiredAt: $expiredAt, stepRemain: $stepRemain, stepTotal: $stepTotal, failedAttempts: $failedAttempts, platform: $platform, type: $type, blacklistFactors: $blacklistFactors, audiences: $audiences, scopes: $scopes, ipAddress: $ipAddress, userAgent: $userAgent, deviceId: $deviceId, nonce: $nonce, location: $location, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
|   return 'SnAuthChallenge(id: $id, expiredAt: $expiredAt, stepRemain: $stepRemain, stepTotal: $stepTotal, failedAttempts: $failedAttempts, type: $type, blacklistFactors: $blacklistFactors, audiences: $audiences, scopes: $scopes, ipAddress: $ipAddress, userAgent: $userAgent, nonce: $nonce, location: $location, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -552,7 +548,7 @@ abstract mixin class _$SnAuthChallengeCopyWith<$Res> implements $SnAuthChallenge | ||||
|   factory _$SnAuthChallengeCopyWith(_SnAuthChallenge value, $Res Function(_SnAuthChallenge) _then) = __$SnAuthChallengeCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int platform, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String deviceId, String? nonce, String? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, String? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -569,21 +565,19 @@ class __$SnAuthChallengeCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnAuthChallenge | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? platform = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? deviceId = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_SnAuthChallenge( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable | ||||
| as int,stepTotal: null == stepTotal ? _self.stepTotal : stepTotal // ignore: cast_nullable_to_non_nullable | ||||
| as int,failedAttempts: null == failedAttempts ? _self.failedAttempts : failedAttempts // ignore: cast_nullable_to_non_nullable | ||||
| as int,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable | ||||
| as int,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| as int,blacklistFactors: null == blacklistFactors ? _self._blacklistFactors : blacklistFactors // ignore: cast_nullable_to_non_nullable | ||||
| as List<String>,audiences: null == audiences ? _self._audiences : audiences // ignore: cast_nullable_to_non_nullable | ||||
| as List<dynamic>,scopes: null == scopes ? _self._scopes : scopes // ignore: cast_nullable_to_non_nullable | ||||
| as List<dynamic>,ipAddress: null == ipAddress ? _self.ipAddress : ipAddress // ignore: cast_nullable_to_non_nullable | ||||
| as String,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable | ||||
| as String,nonce: freezed == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable | ||||
| as String?,location: freezed == location ? _self.location : location // ignore: cast_nullable_to_non_nullable | ||||
| as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| @@ -1189,286 +1183,6 @@ as Map<String, dynamic>?, | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SnAuthDevice { | ||||
|  | ||||
|  dynamic get label; String get userAgent; String get deviceId; int get platform; List<SnAuthSession> get sessions;// Not from backend, used for UI | ||||
|  bool get isCurrent; | ||||
| /// Create a copy of SnAuthDevice | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnAuthDeviceCopyWith<SnAuthDevice> get copyWith => _$SnAuthDeviceCopyWithImpl<SnAuthDevice>(this as SnAuthDevice, _$identity); | ||||
|  | ||||
|   /// Serializes this SnAuthDevice to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
|  | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthDevice&&const DeepCollectionEquality().equals(other.label, label)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.platform, platform) || other.platform == platform)&&const DeepCollectionEquality().equals(other.sessions, sessions)&&(identical(other.isCurrent, isCurrent) || other.isCurrent == isCurrent)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(label),userAgent,deviceId,platform,const DeepCollectionEquality().hash(sessions),isCurrent); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAuthDevice(label: $label, userAgent: $userAgent, deviceId: $deviceId, platform: $platform, sessions: $sessions, isCurrent: $isCurrent)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class $SnAuthDeviceCopyWith<$Res>  { | ||||
|   factory $SnAuthDeviceCopyWith(SnAuthDevice value, $Res Function(SnAuthDevice) _then) = _$SnAuthDeviceCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  dynamic label, String userAgent, String deviceId, int platform, List<SnAuthSession> sessions, bool isCurrent | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class _$SnAuthDeviceCopyWithImpl<$Res> | ||||
|     implements $SnAuthDeviceCopyWith<$Res> { | ||||
|   _$SnAuthDeviceCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final SnAuthDevice _self; | ||||
|   final $Res Function(SnAuthDevice) _then; | ||||
|  | ||||
| /// Create a copy of SnAuthDevice | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? label = freezed,Object? userAgent = null,Object? deviceId = null,Object? platform = null,Object? sessions = null,Object? isCurrent = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable | ||||
| as dynamic,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable | ||||
| as String,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable | ||||
| as int,sessions: null == sessions ? _self.sessions : sessions // ignore: cast_nullable_to_non_nullable | ||||
| as List<SnAuthSession>,isCurrent: null == isCurrent ? _self.isCurrent : isCurrent // ignore: cast_nullable_to_non_nullable | ||||
| as bool, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| /// Adds pattern-matching-related methods to [SnAuthDevice]. | ||||
| extension SnAuthDevicePatterns on SnAuthDevice { | ||||
| /// A variant of `map` that fallback to returning `orElse`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnAuthDevice value)?  $default,{required TResult orElse(),}){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDevice() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// Callbacks receives the raw object, upcasted. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case final Subclass2 value: | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnAuthDevice value)  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDevice(): | ||||
| return $default(_that);} | ||||
| } | ||||
| /// A variant of `map` that fallback to returning `null`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnAuthDevice value)?  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDevice() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A variant of `when` that fallback to an `orElse` callback. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( dynamic label,  String userAgent,  String deviceId,  int platform,  List<SnAuthSession> sessions,  bool isCurrent)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDevice() when $default != null: | ||||
| return $default(_that.label,_that.userAgent,_that.deviceId,_that.platform,_that.sessions,_that.isCurrent);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// As opposed to `map`, this offers destructuring. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case Subclass2(:final field2): | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( dynamic label,  String userAgent,  String deviceId,  int platform,  List<SnAuthSession> sessions,  bool isCurrent)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDevice(): | ||||
| return $default(_that.label,_that.userAgent,_that.deviceId,_that.platform,_that.sessions,_that.isCurrent);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( dynamic label,  String userAgent,  String deviceId,  int platform,  List<SnAuthSession> sessions,  bool isCurrent)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnAuthDevice() when $default != null: | ||||
| return $default(_that.label,_that.userAgent,_that.deviceId,_that.platform,_that.sessions,_that.isCurrent);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnAuthDevice implements SnAuthDevice { | ||||
|   const _SnAuthDevice({required this.label, required this.userAgent, required this.deviceId, required this.platform, required final  List<SnAuthSession> sessions, this.isCurrent = false}): _sessions = sessions; | ||||
|   factory _SnAuthDevice.fromJson(Map<String, dynamic> json) => _$SnAuthDeviceFromJson(json); | ||||
|  | ||||
| @override final  dynamic label; | ||||
| @override final  String userAgent; | ||||
| @override final  String deviceId; | ||||
| @override final  int platform; | ||||
|  final  List<SnAuthSession> _sessions; | ||||
| @override List<SnAuthSession> get sessions { | ||||
|   if (_sessions is EqualUnmodifiableListView) return _sessions; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableListView(_sessions); | ||||
| } | ||||
|  | ||||
| // Not from backend, used for UI | ||||
| @override@JsonKey() final  bool isCurrent; | ||||
|  | ||||
| /// Create a copy of SnAuthDevice | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$SnAuthDeviceCopyWith<_SnAuthDevice> get copyWith => __$SnAuthDeviceCopyWithImpl<_SnAuthDevice>(this, _$identity); | ||||
|  | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$SnAuthDeviceToJson(this, ); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthDevice&&const DeepCollectionEquality().equals(other.label, label)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.platform, platform) || other.platform == platform)&&const DeepCollectionEquality().equals(other._sessions, _sessions)&&(identical(other.isCurrent, isCurrent) || other.isCurrent == isCurrent)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(label),userAgent,deviceId,platform,const DeepCollectionEquality().hash(_sessions),isCurrent); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAuthDevice(label: $label, userAgent: $userAgent, deviceId: $deviceId, platform: $platform, sessions: $sessions, isCurrent: $isCurrent)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class _$SnAuthDeviceCopyWith<$Res> implements $SnAuthDeviceCopyWith<$Res> { | ||||
|   factory _$SnAuthDeviceCopyWith(_SnAuthDevice value, $Res Function(_SnAuthDevice) _then) = __$SnAuthDeviceCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  dynamic label, String userAgent, String deviceId, int platform, List<SnAuthSession> sessions, bool isCurrent | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class __$SnAuthDeviceCopyWithImpl<$Res> | ||||
|     implements _$SnAuthDeviceCopyWith<$Res> { | ||||
|   __$SnAuthDeviceCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final _SnAuthDevice _self; | ||||
|   final $Res Function(_SnAuthDevice) _then; | ||||
|  | ||||
| /// Create a copy of SnAuthDevice | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? label = freezed,Object? userAgent = null,Object? deviceId = null,Object? platform = null,Object? sessions = null,Object? isCurrent = null,}) { | ||||
|   return _then(_SnAuthDevice( | ||||
| label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable | ||||
| as dynamic,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable | ||||
| as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable | ||||
| as String,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable | ||||
| as int,sessions: null == sessions ? _self._sessions : sessions // ignore: cast_nullable_to_non_nullable | ||||
| as List<SnAuthSession>,isCurrent: null == isCurrent ? _self.isCurrent : isCurrent // ignore: cast_nullable_to_non_nullable | ||||
| as bool, | ||||
|   )); | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SnAccountConnection { | ||||
|  | ||||
|   | ||||
| @@ -20,7 +20,6 @@ _SnAuthChallenge _$SnAuthChallengeFromJson(Map<String, dynamic> json) => | ||||
|       stepRemain: (json['step_remain'] as num).toInt(), | ||||
|       stepTotal: (json['step_total'] as num).toInt(), | ||||
|       failedAttempts: (json['failed_attempts'] as num).toInt(), | ||||
|       platform: (json['platform'] as num).toInt(), | ||||
|       type: (json['type'] as num).toInt(), | ||||
|       blacklistFactors: | ||||
|           (json['blacklist_factors'] as List<dynamic>) | ||||
| @@ -30,7 +29,6 @@ _SnAuthChallenge _$SnAuthChallengeFromJson(Map<String, dynamic> json) => | ||||
|       scopes: json['scopes'] as List<dynamic>, | ||||
|       ipAddress: json['ip_address'] as String, | ||||
|       userAgent: json['user_agent'] as String, | ||||
|       deviceId: json['device_id'] as String, | ||||
|       nonce: json['nonce'] as String?, | ||||
|       location: json['location'] as String?, | ||||
|       accountId: json['account_id'] as String, | ||||
| @@ -49,14 +47,12 @@ Map<String, dynamic> _$SnAuthChallengeToJson(_SnAuthChallenge instance) => | ||||
|       'step_remain': instance.stepRemain, | ||||
|       'step_total': instance.stepTotal, | ||||
|       'failed_attempts': instance.failedAttempts, | ||||
|       'platform': instance.platform, | ||||
|       'type': instance.type, | ||||
|       'blacklist_factors': instance.blacklistFactors, | ||||
|       'audiences': instance.audiences, | ||||
|       'scopes': instance.scopes, | ||||
|       'ip_address': instance.ipAddress, | ||||
|       'user_agent': instance.userAgent, | ||||
|       'device_id': instance.deviceId, | ||||
|       'nonce': instance.nonce, | ||||
|       'location': instance.location, | ||||
|       'account_id': instance.accountId, | ||||
| @@ -133,29 +129,6 @@ Map<String, dynamic> _$SnAuthFactorToJson(_SnAuthFactor instance) => | ||||
|       'created_response': instance.createdResponse, | ||||
|     }; | ||||
|  | ||||
| _SnAuthDevice _$SnAuthDeviceFromJson(Map<String, dynamic> json) => | ||||
|     _SnAuthDevice( | ||||
|       label: json['label'], | ||||
|       userAgent: json['user_agent'] as String, | ||||
|       deviceId: json['device_id'] as String, | ||||
|       platform: (json['platform'] as num).toInt(), | ||||
|       sessions: | ||||
|           (json['sessions'] as List<dynamic>) | ||||
|               .map((e) => SnAuthSession.fromJson(e as Map<String, dynamic>)) | ||||
|               .toList(), | ||||
|       isCurrent: json['is_current'] as bool? ?? false, | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$SnAuthDeviceToJson(_SnAuthDevice instance) => | ||||
|     <String, dynamic>{ | ||||
|       'label': instance.label, | ||||
|       'user_agent': instance.userAgent, | ||||
|       'device_id': instance.deviceId, | ||||
|       'platform': instance.platform, | ||||
|       'sessions': instance.sessions.map((e) => e.toJson()).toList(), | ||||
|       'is_current': instance.isCurrent, | ||||
|     }; | ||||
|  | ||||
| _SnAccountConnection _$SnAccountConnectionFromJson(Map<String, dynamic> json) => | ||||
|     _SnAccountConnection( | ||||
|       id: json['id'] as String, | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/models/realm.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
|  | ||||
| part 'chat.freezed.dart'; | ||||
| part 'chat.g.dart'; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
|  | ||||
| part 'custom_app.freezed.dart'; | ||||
| part 'custom_app.g.dart'; | ||||
|   | ||||
| @@ -1,13 +1,25 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/publisher.dart'; | ||||
|  | ||||
| part 'developer.freezed.dart'; | ||||
| part 'developer.g.dart'; | ||||
|  | ||||
| @freezed | ||||
| sealed class SnDeveloper with _$SnDeveloper { | ||||
|   const factory SnDeveloper({ | ||||
|     required String id, | ||||
|     required String publisherId, | ||||
|     SnPublisher? publisher, | ||||
|   }) = _SnDeveloper; | ||||
|  | ||||
|   factory SnDeveloper.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnDeveloperFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| sealed class DeveloperStats with _$DeveloperStats { | ||||
|   const factory DeveloperStats({ | ||||
|     @Default(0) int totalCustomApps, | ||||
|   }) = _DeveloperStats; | ||||
|   const factory DeveloperStats({@Default(0) int totalCustomApps}) = | ||||
|       _DeveloperStats; | ||||
|  | ||||
|   factory DeveloperStats.fromJson(Map<String, dynamic> json) => | ||||
|       _$DeveloperStatsFromJson(json); | ||||
|   | ||||
| @@ -12,6 +12,293 @@ part of 'developer.dart'; | ||||
| // dart format off | ||||
| T _$identity<T>(T value) => value; | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SnDeveloper { | ||||
|  | ||||
|  String get id; String get publisherId; SnPublisher? get publisher; | ||||
| /// Create a copy of SnDeveloper | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnDeveloperCopyWith<SnDeveloper> get copyWith => _$SnDeveloperCopyWithImpl<SnDeveloper>(this as SnDeveloper, _$identity); | ||||
|  | ||||
|   /// Serializes this SnDeveloper to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
|  | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnDeveloper&&(identical(other.id, id) || other.id == id)&&(identical(other.publisherId, publisherId) || other.publisherId == publisherId)&&(identical(other.publisher, publisher) || other.publisher == publisher)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,publisherId,publisher); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnDeveloper(id: $id, publisherId: $publisherId, publisher: $publisher)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class $SnDeveloperCopyWith<$Res>  { | ||||
|   factory $SnDeveloperCopyWith(SnDeveloper value, $Res Function(SnDeveloper) _then) = _$SnDeveloperCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String publisherId, SnPublisher? publisher | ||||
| }); | ||||
|  | ||||
|  | ||||
| $SnPublisherCopyWith<$Res>? get publisher; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class _$SnDeveloperCopyWithImpl<$Res> | ||||
|     implements $SnDeveloperCopyWith<$Res> { | ||||
|   _$SnDeveloperCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final SnDeveloper _self; | ||||
|   final $Res Function(SnDeveloper) _then; | ||||
|  | ||||
| /// Create a copy of SnDeveloper | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? publisherId = null,Object? publisher = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,publisherId: null == publisherId ? _self.publisherId : publisherId // ignore: cast_nullable_to_non_nullable | ||||
| as String,publisher: freezed == publisher ? _self.publisher : publisher // ignore: cast_nullable_to_non_nullable | ||||
| as SnPublisher?, | ||||
|   )); | ||||
| } | ||||
| /// Create a copy of SnDeveloper | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnPublisherCopyWith<$Res>? get publisher { | ||||
|     if (_self.publisher == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnPublisherCopyWith<$Res>(_self.publisher!, (value) { | ||||
|     return _then(_self.copyWith(publisher: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
|  | ||||
| /// Adds pattern-matching-related methods to [SnDeveloper]. | ||||
| extension SnDeveloperPatterns on SnDeveloper { | ||||
| /// A variant of `map` that fallback to returning `orElse`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnDeveloper value)?  $default,{required TResult orElse(),}){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnDeveloper() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// Callbacks receives the raw object, upcasted. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case final Subclass2 value: | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnDeveloper value)  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnDeveloper(): | ||||
| return $default(_that);} | ||||
| } | ||||
| /// A variant of `map` that fallback to returning `null`. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case final Subclass value: | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnDeveloper value)?  $default,){ | ||||
| final _that = this; | ||||
| switch (_that) { | ||||
| case _SnDeveloper() when $default != null: | ||||
| return $default(_that);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A variant of `when` that fallback to an `orElse` callback. | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return orElse(); | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String publisherId,  SnPublisher? publisher)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnDeveloper() when $default != null: | ||||
| return $default(_that.id,_that.publisherId,_that.publisher);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| } | ||||
| /// A `switch`-like method, using callbacks. | ||||
| /// | ||||
| /// As opposed to `map`, this offers destructuring. | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case Subclass2(:final field2): | ||||
| ///     return ...; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String publisherId,  SnPublisher? publisher)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnDeveloper(): | ||||
| return $default(_that.id,_that.publisherId,_that.publisher);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| /// It is equivalent to doing: | ||||
| /// ```dart | ||||
| /// switch (sealedClass) { | ||||
| ///   case Subclass(:final field): | ||||
| ///     return ...; | ||||
| ///   case _: | ||||
| ///     return null; | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String publisherId,  SnPublisher? publisher)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnDeveloper() when $default != null: | ||||
| return $default(_that.id,_that.publisherId,_that.publisher);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnDeveloper implements SnDeveloper { | ||||
|   const _SnDeveloper({required this.id, required this.publisherId, this.publisher}); | ||||
|   factory _SnDeveloper.fromJson(Map<String, dynamic> json) => _$SnDeveloperFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @override final  String publisherId; | ||||
| @override final  SnPublisher? publisher; | ||||
|  | ||||
| /// Create a copy of SnDeveloper | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$SnDeveloperCopyWith<_SnDeveloper> get copyWith => __$SnDeveloperCopyWithImpl<_SnDeveloper>(this, _$identity); | ||||
|  | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$SnDeveloperToJson(this, ); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnDeveloper&&(identical(other.id, id) || other.id == id)&&(identical(other.publisherId, publisherId) || other.publisherId == publisherId)&&(identical(other.publisher, publisher) || other.publisher == publisher)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,publisherId,publisher); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnDeveloper(id: $id, publisherId: $publisherId, publisher: $publisher)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class _$SnDeveloperCopyWith<$Res> implements $SnDeveloperCopyWith<$Res> { | ||||
|   factory _$SnDeveloperCopyWith(_SnDeveloper value, $Res Function(_SnDeveloper) _then) = __$SnDeveloperCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String publisherId, SnPublisher? publisher | ||||
| }); | ||||
|  | ||||
|  | ||||
| @override $SnPublisherCopyWith<$Res>? get publisher; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class __$SnDeveloperCopyWithImpl<$Res> | ||||
|     implements _$SnDeveloperCopyWith<$Res> { | ||||
|   __$SnDeveloperCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final _SnDeveloper _self; | ||||
|   final $Res Function(_SnDeveloper) _then; | ||||
|  | ||||
| /// Create a copy of SnDeveloper | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? publisherId = null,Object? publisher = freezed,}) { | ||||
|   return _then(_SnDeveloper( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,publisherId: null == publisherId ? _self.publisherId : publisherId // ignore: cast_nullable_to_non_nullable | ||||
| as String,publisher: freezed == publisher ? _self.publisher : publisher // ignore: cast_nullable_to_non_nullable | ||||
| as SnPublisher?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| /// Create a copy of SnDeveloper | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnPublisherCopyWith<$Res>? get publisher { | ||||
|     if (_self.publisher == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnPublisherCopyWith<$Res>(_self.publisher!, (value) { | ||||
|     return _then(_self.copyWith(publisher: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$DeveloperStats { | ||||
|  | ||||
|   | ||||
| @@ -6,6 +6,22 @@ part of 'developer.dart'; | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| _SnDeveloper _$SnDeveloperFromJson(Map<String, dynamic> json) => _SnDeveloper( | ||||
|   id: json['id'] as String, | ||||
|   publisherId: json['publisher_id'] as String, | ||||
|   publisher: | ||||
|       json['publisher'] == null | ||||
|           ? null | ||||
|           : SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>), | ||||
| ); | ||||
|  | ||||
| Map<String, dynamic> _$SnDeveloperToJson(_SnDeveloper instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'publisher_id': instance.publisherId, | ||||
|       'publisher': instance.publisher?.toJson(), | ||||
|     }; | ||||
|  | ||||
| _DeveloperStats _$DeveloperStatsFromJson(Map<String, dynamic> json) => | ||||
|     _DeveloperStats( | ||||
|       totalCustomApps: (json['total_custom_apps'] as num?)?.toInt() ?? 0, | ||||
|   | ||||
| @@ -11,8 +11,8 @@ sealed class SnScrappedLink with _$SnScrappedLink { | ||||
|     required String title, | ||||
|     required String? description, | ||||
|     required String? imageUrl, | ||||
|     required String faviconUrl, | ||||
|     required String siteName, | ||||
|     required String? faviconUrl, | ||||
|     required String? siteName, | ||||
|     required String? contentType, | ||||
|     required String? author, | ||||
|     required DateTime? publishedDate, | ||||
|   | ||||
| @@ -15,7 +15,7 @@ T _$identity<T>(T value) => value; | ||||
| /// @nodoc | ||||
| mixin _$SnScrappedLink { | ||||
|  | ||||
|  String get type; String get url; String get title; String? get description; String? get imageUrl; String get faviconUrl; String get siteName; String? get contentType; String? get author; DateTime? get publishedDate; | ||||
|  String get type; String get url; String get title; String? get description; String? get imageUrl; String? get faviconUrl; String? get siteName; String? get contentType; String? get author; DateTime? get publishedDate; | ||||
| /// Create a copy of SnScrappedLink | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -48,7 +48,7 @@ abstract mixin class $SnScrappedLinkCopyWith<$Res>  { | ||||
|   factory $SnScrappedLinkCopyWith(SnScrappedLink value, $Res Function(SnScrappedLink) _then) = _$SnScrappedLinkCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String type, String url, String title, String? description, String? imageUrl, String faviconUrl, String siteName, String? contentType, String? author, DateTime? publishedDate | ||||
|  String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -65,16 +65,16 @@ class _$SnScrappedLinkCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnScrappedLink | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = null,Object? siteName = null,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = freezed,Object? siteName = freezed,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable | ||||
| as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable | ||||
| as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | ||||
| as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable | ||||
| as String?,faviconUrl: null == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable | ||||
| as String,siteName: null == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable | ||||
| as String,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable | ||||
| as String?,faviconUrl: freezed == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable | ||||
| as String?,siteName: freezed == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable | ||||
| as String?,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable | ||||
| as String?,author: freezed == author ? _self.author : author // ignore: cast_nullable_to_non_nullable | ||||
| as String?,publishedDate: freezed == publishedDate ? _self.publishedDate : publishedDate // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?, | ||||
| @@ -159,7 +159,7 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String type,  String url,  String title,  String? description,  String? imageUrl,  String faviconUrl,  String siteName,  String? contentType,  String? author,  DateTime? publishedDate)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String type,  String url,  String title,  String? description,  String? imageUrl,  String? faviconUrl,  String? siteName,  String? contentType,  String? author,  DateTime? publishedDate)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnScrappedLink() when $default != null: | ||||
| return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);case _: | ||||
| @@ -180,7 +180,7 @@ return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUr | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String type,  String url,  String title,  String? description,  String? imageUrl,  String faviconUrl,  String siteName,  String? contentType,  String? author,  DateTime? publishedDate)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String type,  String url,  String title,  String? description,  String? imageUrl,  String? faviconUrl,  String? siteName,  String? contentType,  String? author,  DateTime? publishedDate)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnScrappedLink(): | ||||
| return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);} | ||||
| @@ -197,7 +197,7 @@ return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUr | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String type,  String url,  String title,  String? description,  String? imageUrl,  String faviconUrl,  String siteName,  String? contentType,  String? author,  DateTime? publishedDate)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String type,  String url,  String title,  String? description,  String? imageUrl,  String? faviconUrl,  String? siteName,  String? contentType,  String? author,  DateTime? publishedDate)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnScrappedLink() when $default != null: | ||||
| return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);case _: | ||||
| @@ -220,8 +220,8 @@ class _SnScrappedLink implements SnScrappedLink { | ||||
| @override final  String title; | ||||
| @override final  String? description; | ||||
| @override final  String? imageUrl; | ||||
| @override final  String faviconUrl; | ||||
| @override final  String siteName; | ||||
| @override final  String? faviconUrl; | ||||
| @override final  String? siteName; | ||||
| @override final  String? contentType; | ||||
| @override final  String? author; | ||||
| @override final  DateTime? publishedDate; | ||||
| @@ -259,7 +259,7 @@ abstract mixin class _$SnScrappedLinkCopyWith<$Res> implements $SnScrappedLinkCo | ||||
|   factory _$SnScrappedLinkCopyWith(_SnScrappedLink value, $Res Function(_SnScrappedLink) _then) = __$SnScrappedLinkCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String type, String url, String title, String? description, String? imageUrl, String faviconUrl, String siteName, String? contentType, String? author, DateTime? publishedDate | ||||
|  String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -276,16 +276,16 @@ class __$SnScrappedLinkCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnScrappedLink | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = null,Object? siteName = null,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = freezed,Object? siteName = freezed,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) { | ||||
|   return _then(_SnScrappedLink( | ||||
| type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable | ||||
| as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable | ||||
| as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | ||||
| as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable | ||||
| as String?,faviconUrl: null == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable | ||||
| as String,siteName: null == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable | ||||
| as String,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable | ||||
| as String?,faviconUrl: freezed == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable | ||||
| as String?,siteName: freezed == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable | ||||
| as String?,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable | ||||
| as String?,author: freezed == author ? _self.author : author // ignore: cast_nullable_to_non_nullable | ||||
| as String?,publishedDate: freezed == publishedDate ? _self.publishedDate : publishedDate // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?, | ||||
|   | ||||
| @@ -13,8 +13,8 @@ _SnScrappedLink _$SnScrappedLinkFromJson(Map<String, dynamic> json) => | ||||
|       title: json['title'] as String, | ||||
|       description: json['description'] as String?, | ||||
|       imageUrl: json['image_url'] as String?, | ||||
|       faviconUrl: json['favicon_url'] as String, | ||||
|       siteName: json['site_name'] as String, | ||||
|       faviconUrl: json['favicon_url'] as String?, | ||||
|       siteName: json['site_name'] as String?, | ||||
|       contentType: json['content_type'] as String?, | ||||
|       author: json['author'] as String?, | ||||
|       publishedDate: | ||||
|   | ||||
| @@ -8,7 +8,7 @@ part 'poll.g.dart'; | ||||
| sealed class SnPollWithStats with _$SnPollWithStats { | ||||
|   const factory SnPollWithStats({ | ||||
|     required Map<String, dynamic>? userAnswer, | ||||
|     required Map<String, dynamic> stats, | ||||
|     @Default({}) Map<String, dynamic> stats, | ||||
|     required String id, | ||||
|     required List<SnPollQuestion> questions, | ||||
|     String? title, | ||||
|   | ||||
| @@ -213,7 +213,7 @@ return $default(_that.userAnswer,_that.stats,_that.id,_that.questions,_that.titl | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnPollWithStats implements SnPollWithStats { | ||||
|   const _SnPollWithStats({required final  Map<String, dynamic>? userAnswer, required final  Map<String, dynamic> stats, required this.id, required final  List<SnPollQuestion> questions, this.title, this.description, this.endedAt, required this.publisherId, required this.createdAt, required this.updatedAt, this.deletedAt}): _userAnswer = userAnswer,_stats = stats,_questions = questions; | ||||
|   const _SnPollWithStats({required final  Map<String, dynamic>? userAnswer, final  Map<String, dynamic> stats = const {}, required this.id, required final  List<SnPollQuestion> questions, this.title, this.description, this.endedAt, required this.publisherId, required this.createdAt, required this.updatedAt, this.deletedAt}): _userAnswer = userAnswer,_stats = stats,_questions = questions; | ||||
|   factory _SnPollWithStats.fromJson(Map<String, dynamic> json) => _$SnPollWithStatsFromJson(json); | ||||
|  | ||||
|  final  Map<String, dynamic>? _userAnswer; | ||||
| @@ -226,7 +226,7 @@ class _SnPollWithStats implements SnPollWithStats { | ||||
| } | ||||
|  | ||||
|  final  Map<String, dynamic> _stats; | ||||
| @override Map<String, dynamic> get stats { | ||||
| @override@JsonKey() Map<String, dynamic> get stats { | ||||
|   if (_stats is EqualUnmodifiableMapView) return _stats; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableMapView(_stats); | ||||
|   | ||||
| @@ -9,7 +9,7 @@ part of 'poll.dart'; | ||||
| _SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) => | ||||
|     _SnPollWithStats( | ||||
|       userAnswer: json['user_answer'] as Map<String, dynamic>?, | ||||
|       stats: json['stats'] as Map<String, dynamic>, | ||||
|       stats: json['stats'] as Map<String, dynamic>? ?? const {}, | ||||
|       id: json['id'] as String, | ||||
|       questions: | ||||
|           (json['questions'] as List<dynamic>) | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
|  | ||||
| part 'publisher.freezed.dart'; | ||||
| part 'publisher.g.dart'; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
|  | ||||
| part 'realm.freezed.dart'; | ||||
| part 'realm.g.dart'; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
|  | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
|  | ||||
| part 'relationship.freezed.dart'; | ||||
| part 'relationship.g.dart'; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
|  | ||||
| part 'wallet.freezed.dart'; | ||||
| part 'wallet.g.dart'; | ||||
|   | ||||
| @@ -23,6 +23,8 @@ const kAppSoundEffects = 'app_sound_effects'; | ||||
| const kAppAprilFoolFeatures = 'app_april_fool_features'; | ||||
| const kAppWindowSize = 'app_window_size'; | ||||
| const kAppEnterToSend = 'app_enter_to_send'; | ||||
| const kFeaturedPostsCollapsedId = | ||||
|     'featured_posts_collapsed_id'; // Key for storing the ID of the collapsed featured post | ||||
|  | ||||
| const Map<String, FilterQuality> kImageQualityLevel = { | ||||
|   'settingsImageQualityLowest': FilterQuality.none, | ||||
|   | ||||
| @@ -1,8 +1,9 @@ | ||||
| import 'dart:developer'; | ||||
|  | ||||
| import 'package:firebase_analytics/firebase_analytics.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
|  | ||||
| @@ -17,6 +18,7 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> { | ||||
|       final response = await client.get('/id/accounts/me'); | ||||
|       final user = SnAccount.fromJson(response.data); | ||||
|       state = AsyncValue.data(user); | ||||
|       FirebaseAnalytics.instance.setUserId(id: user.id); | ||||
|     } catch (error, stackTrace) { | ||||
|       log( | ||||
|         "[UserInfo] Failed to fetch user info...", | ||||
| @@ -33,6 +35,7 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> { | ||||
|     final prefs = _ref.read(sharedPreferencesProvider); | ||||
|     await prefs.remove(kTokenPairStoreKey); | ||||
|     _ref.invalidate(tokenProvider); | ||||
|     FirebaseAnalytics.instance.setUserId(id: null); | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,8 @@ | ||||
| import 'dart:io' show Platform; | ||||
| import 'package:animations/animations.dart'; | ||||
| import 'package:firebase_analytics/firebase_analytics.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/foundation.dart' show kIsWeb; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/screens/about.dart'; | ||||
| @@ -54,11 +58,36 @@ final rootNavigatorKey = GlobalKey<NavigatorState>(); | ||||
| final _shellNavigatorKey = GlobalKey<NavigatorState>(); | ||||
| final _tabsShellKey = GlobalKey<NavigatorState>(); | ||||
|  | ||||
| Widget _tabPagesTransitionBuilder( | ||||
|   BuildContext context, | ||||
|   Animation<double> animation, | ||||
|   Animation<double> secondaryAnimation, | ||||
|   Widget child, | ||||
| ) { | ||||
|   return FadeThroughTransition( | ||||
|     animation: animation, | ||||
|     secondaryAnimation: secondaryAnimation, | ||||
|     fillColor: Theme.of(context).colorScheme.surface, | ||||
|     child: child, | ||||
|   ); | ||||
| } | ||||
|  | ||||
| bool get _supportsAnalytics => | ||||
|     kIsWeb || | ||||
|     Platform.isAndroid || | ||||
|     Platform.isIOS || | ||||
|     Platform.isMacOS || | ||||
|     Platform.isWindows; | ||||
|  | ||||
| // Provider for the router | ||||
| final routerProvider = Provider<GoRouter>((ref) { | ||||
|   return GoRouter( | ||||
|     navigatorKey: rootNavigatorKey, | ||||
|     initialLocation: '/', | ||||
|     observers: [ | ||||
|       if (_supportsAnalytics) | ||||
|         FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance), | ||||
|     ], | ||||
|     routes: [ | ||||
|       ShellRoute( | ||||
|         navigatorKey: _shellNavigatorKey, | ||||
| @@ -334,7 +363,12 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|               GoRoute( | ||||
|                 name: 'explore', | ||||
|                 path: '/', | ||||
|                 builder: (context, state) => const ExploreScreen(), | ||||
|                 pageBuilder: | ||||
|                     (context, state) => CustomTransitionPage( | ||||
|                       key: const ValueKey('explore'), | ||||
|                       child: const ExploreScreen(), | ||||
|                       transitionsBuilder: _tabPagesTransitionBuilder, | ||||
|                     ), | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 name: 'postSearch', | ||||
| @@ -384,8 +418,12 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|  | ||||
|               // Chat tab | ||||
|               ShellRoute( | ||||
|                 builder: | ||||
|                     (context, state, child) => ChatShellScreen(child: child), | ||||
|                 pageBuilder: | ||||
|                     (context, state, child) => CustomTransitionPage( | ||||
|                       key: const ValueKey('chat'), | ||||
|                       child: ChatShellScreen(child: child), | ||||
|                       transitionsBuilder: _tabPagesTransitionBuilder, | ||||
|                     ), | ||||
|                 routes: [ | ||||
|                   GoRoute( | ||||
|                     name: 'chatList', | ||||
| @@ -428,7 +466,12 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|               GoRoute( | ||||
|                 name: 'realmList', | ||||
|                 path: '/realms', | ||||
|                 builder: (context, state) => const RealmListScreen(), | ||||
|                 pageBuilder: | ||||
|                     (context, state) => CustomTransitionPage( | ||||
|                       key: const ValueKey('realms'), | ||||
|                       child: const RealmListScreen(), | ||||
|                       transitionsBuilder: _tabPagesTransitionBuilder, | ||||
|                     ), | ||||
|                 routes: [ | ||||
|                   GoRoute( | ||||
|                     name: 'realmNew', | ||||
| @@ -456,8 +499,12 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|  | ||||
|               // Account tab | ||||
|               ShellRoute( | ||||
|                 builder: | ||||
|                     (context, state, child) => AccountShellScreen(child: child), | ||||
|                 pageBuilder: | ||||
|                     (context, state, child) => CustomTransitionPage( | ||||
|                       key: const ValueKey('account'), | ||||
|                       child: AccountShellScreen(child: child), | ||||
|                       transitionsBuilder: _tabPagesTransitionBuilder, | ||||
|                     ), | ||||
|                 routes: [ | ||||
|                   GoRoute( | ||||
|                     name: 'account', | ||||
|   | ||||
| @@ -7,12 +7,12 @@ import 'package:flutter/services.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/services/udid.native.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:package_info_plus/package_info_plus.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:island/services/update_service.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:url_launcher/url_launcher.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
| @@ -178,7 +178,8 @@ class _AboutScreenState extends ConsumerState<AboutScreen> { | ||||
|                                 context, | ||||
|                                 icon: Symbols.label, | ||||
|                                 label: 'aboutDeviceName'.tr(), | ||||
|                                 value: _deviceInfo?.data['name'], | ||||
|                                 value: | ||||
|                                     _deviceInfo?.data['name'] ?? 'unknown'.tr(), | ||||
|                               ), | ||||
|                               _buildInfoItem( | ||||
|                                 context, | ||||
| @@ -205,33 +206,16 @@ class _AboutScreenState extends ConsumerState<AboutScreen> { | ||||
|                                 // Fetch latest release and show the unified sheet | ||||
|                                 final svc = UpdateService(); | ||||
|                                 // Reuse service fetch + compare to decide content | ||||
|                                 showLoadingModal(context); | ||||
|                                 final release = await svc.fetchLatestRelease(); | ||||
|                                 if (!context.mounted) return; | ||||
|                                 hideLoadingModal(context); | ||||
|                                 if (release != null) { | ||||
|                                   await svc.showUpdateSheet(context, release); | ||||
|                                 } else { | ||||
|                                   // Fallback: show a simple sheet indicating no info | ||||
|                                   // Use your SheetScaffold for consistent styling | ||||
|                                   // Show a minimal message | ||||
|                                   // ignore: use_build_context_synchronously | ||||
|                                   showModalBottomSheet( | ||||
|                                     context: context, | ||||
|                                     isScrollControlled: true, | ||||
|                                     useSafeArea: true, | ||||
|                                     showDragHandle: true, | ||||
|                                     backgroundColor: | ||||
|                                         Theme.of(context).colorScheme.surface, | ||||
|                                     builder: | ||||
|                                         (_) => const SheetScaffold( | ||||
|                                           titleText: 'Update', | ||||
|                                           child: Center( | ||||
|                                             child: Padding( | ||||
|                                               padding: EdgeInsets.all(24), | ||||
|                                               child: Text( | ||||
|                                                 'Unable to fetch release info at this time.', | ||||
|                                               ), | ||||
|                                             ), | ||||
|                                           ), | ||||
|                                         ), | ||||
|                                   showInfoAlert( | ||||
|                                     'Currently cannot get update from the GitHub.', | ||||
|                                     'Unable to check for updates', | ||||
|                                   ); | ||||
|                                 } | ||||
|                               }, | ||||
|   | ||||
| @@ -3,12 +3,14 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/screens/notification.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/account/account_name.dart'; | ||||
| import 'package:island/widgets/account/status.dart'; | ||||
| import 'package:island/widgets/account/leveling_progress.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/debug_sheet.dart'; | ||||
| @@ -236,7 +238,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|             ), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
|               title: Text('abuseReports').tr(), | ||||
|               title: Text('abuseReport').tr(), | ||||
|               contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|               leading: const Icon(Symbols.gavel), | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
| @@ -303,7 +305,12 @@ class AccountScreen extends HookConsumerWidget { | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('logout').tr(), | ||||
|               onTap: () { | ||||
|               onTap: () async { | ||||
|                 final apiClient = ref.watch(apiClientProvider); | ||||
|                 showLoadingModal(context); | ||||
|                 await apiClient.delete('/id/accounts/me/sessions/current'); | ||||
|                 if (!context.mounted) return; | ||||
|                 hideLoadingModal(context); | ||||
|                 final userNotifier = ref.read(userInfoProvider.notifier); | ||||
|                 userNotifier.logOut(); | ||||
|               }, | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/auth.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/screens/account/me/settings_auth_factors.dart'; | ||||
| @@ -15,7 +15,7 @@ import 'package:island/screens/account/me/settings_contacts.dart'; | ||||
| import 'package:island/screens/auth/captcha.dart'; | ||||
| import 'package:island/screens/auth/login.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/account/account_session_sheet.dart'; | ||||
| import 'package:island/widgets/account/account_devices.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/response.dart'; | ||||
|   | ||||
| @@ -166,7 +166,7 @@ class AccountConnectionNewSheet extends HookConsumerWidget { | ||||
|               webAuthenticationOptions: WebAuthenticationOptions( | ||||
|                 clientId: 'dev.solsynth.solarpass', | ||||
|                 redirectUri: Uri.parse( | ||||
|                   'https://nt.solian.app/auth/callback/apple', | ||||
|                   'https://id.solian.app/auth/callback/apple', | ||||
|                 ), | ||||
|               ), | ||||
|             ); | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:image_picker/image_picker.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| @@ -95,11 +96,7 @@ class UpdateProfileScreen extends HookConsumerWidget { | ||||
|     final usernameController = useTextEditingController(text: user.value!.name); | ||||
|     final nicknameController = useTextEditingController(text: user.value!.nick); | ||||
|     final language = useState(user.value!.language); | ||||
|     final links = useState<List<Map<String, String>>>( | ||||
|       user.value!.profile.links.entries | ||||
|           .map((e) => {'key': e.key, 'value': e.value}) | ||||
|           .toList(), | ||||
|     ); | ||||
|     final links = useState<List<ProfileLink>>(user.value!.profile.links); | ||||
|  | ||||
|     void updateBasicInfo() async { | ||||
|       if (!formKeyBasicInfo.currentState!.validate()) return; | ||||
| @@ -171,7 +168,7 @@ class UpdateProfileScreen extends HookConsumerWidget { | ||||
|             'location': locationController.text, | ||||
|             'time_zone': timeZoneController.text, | ||||
|             'birthday': birthday.value?.toUtc().toIso8601String(), | ||||
|             'links': {for (var e in links.value) e['key']!: e['value']!}, | ||||
|             'links': links.value, | ||||
|           }, | ||||
|         ); | ||||
|         final userNotifier = ref.read(userInfoProvider.notifier); | ||||
| @@ -575,13 +572,15 @@ class UpdateProfileScreen extends HookConsumerWidget { | ||||
|                           children: [ | ||||
|                             Expanded( | ||||
|                               child: TextFormField( | ||||
|                                 initialValue: links.value[i]['key'], | ||||
|                                 initialValue: links.value[i].name, | ||||
|                                 decoration: InputDecoration( | ||||
|                                   labelText: 'linkKey'.tr(), | ||||
|                                   isDense: true, | ||||
|                                 ), | ||||
|                                 onChanged: (value) { | ||||
|                                   links.value[i]['key'] = value; | ||||
|                                   links.value[i] = links.value[i].copyWith( | ||||
|                                     name: value, | ||||
|                                   ); | ||||
|                                 }, | ||||
|                                 onTapOutside: | ||||
|                                     (_) => | ||||
| @@ -592,13 +591,15 @@ class UpdateProfileScreen extends HookConsumerWidget { | ||||
|                             const Gap(8), | ||||
|                             Expanded( | ||||
|                               child: TextFormField( | ||||
|                                 initialValue: links.value[i]['value'], | ||||
|                                 initialValue: links.value[i].url, | ||||
|                                 decoration: InputDecoration( | ||||
|                                   labelText: 'linkValue'.tr(), | ||||
|                                   isDense: true, | ||||
|                                 ), | ||||
|                                 onChanged: (value) { | ||||
|                                   links.value[i]['value'] = value; | ||||
|                                   links.value[i] = links.value[i].copyWith( | ||||
|                                     url: value, | ||||
|                                   ); | ||||
|                                 }, | ||||
|                                 onTapOutside: | ||||
|                                     (_) => | ||||
| @@ -620,7 +621,7 @@ class UpdateProfileScreen extends HookConsumerWidget { | ||||
|                         child: FilledButton.icon( | ||||
|                           onPressed: () { | ||||
|                             links.value = List.from(links.value) | ||||
|                               ..add({'key': '', 'value': ''}); | ||||
|                               ..add(ProfileLink(name: '', url: '')); | ||||
|                           }, | ||||
|                           label: Text('addLink').tr(), | ||||
|                           icon: const Icon(Symbols.add), | ||||
|   | ||||
| @@ -1,12 +1,14 @@ | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/chat.dart'; | ||||
| import 'package:island/models/relationship.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/event_calendar.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| @@ -196,6 +198,15 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|  | ||||
|     List<Widget> buildSubcolumn(SnAccount data) { | ||||
|       return [ | ||||
|         Row( | ||||
|           spacing: 6, | ||||
|           children: [ | ||||
|             const Icon(Symbols.join, size: 17, fill: 1), | ||||
|             Text( | ||||
|               'joinedAt'.tr(args: [data.createdAt.formatCustom('yyyy-MM-dd')]), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|         if (data.profile.birthday != null) | ||||
|           Row( | ||||
|             spacing: 6, | ||||
| @@ -252,6 +263,10 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     final user = ref.watch(userInfoProvider); | ||||
|     final isCurrentUser = useMemoized( | ||||
|       () => user.value?.id == account.value?.id, | ||||
|       [user, account], | ||||
|     ); | ||||
|  | ||||
|     Widget accountBasicInfo(SnAccount data) => Padding( | ||||
|       padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), | ||||
| @@ -322,7 +337,7 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|               spacing: 2, | ||||
|               children: buildSubcolumn(data), | ||||
|             ), | ||||
|           if (data.profile.timeZone.isNotEmpty) | ||||
|           if (data.profile.timeZone.isNotEmpty && !kIsWeb) | ||||
|             Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
| @@ -357,17 +372,21 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           Text('links').tr().bold().padding(horizontal: 24, top: 12, bottom: 4), | ||||
|           for (final link in data.profile.links.entries) | ||||
|           for (final link in data.profile.links) | ||||
|             ListTile( | ||||
|               title: Text(link.key.capitalizeEachWord()), | ||||
|               subtitle: Text(link.value), | ||||
|               title: Text(link.name.capitalizeEachWord()), | ||||
|               subtitle: Text(link.url), | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
|               shape: RoundedRectangleBorder( | ||||
|                 borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|               ), | ||||
|               onTap: () { | ||||
|                 launchUrlString(link.value); | ||||
|                 if (!link.url.startsWith('http') && !link.url.contains('://')) { | ||||
|                   launchUrlString('https://${link.url}'); | ||||
|                 } else { | ||||
|                   launchUrlString(link.url); | ||||
|                 } | ||||
|               }, | ||||
|             ), | ||||
|         ], | ||||
| @@ -561,9 +580,10 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|                               SliverToBoxAdapter( | ||||
|                                 child: accountProfileBio(data).padding(top: 4), | ||||
|                               ), | ||||
|                               SliverToBoxAdapter( | ||||
|                                 child: accountProfileLinks(data), | ||||
|                               ), | ||||
|                               if (data.profile.links.isNotEmpty) | ||||
|                                 SliverToBoxAdapter( | ||||
|                                   child: accountProfileLinks(data), | ||||
|                                 ), | ||||
|                               SliverToBoxAdapter( | ||||
|                                 child: accountProfileDetail(data), | ||||
|                               ), | ||||
| @@ -574,7 +594,7 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|                           child: CustomScrollView( | ||||
|                             slivers: [ | ||||
|                               SliverGap(24), | ||||
|                               if (user.value != null) | ||||
|                               if (user.value != null && !isCurrentUser) | ||||
|                                 SliverToBoxAdapter(child: accountAction(data)), | ||||
|                               SliverToBoxAdapter( | ||||
|                                 child: Card( | ||||
| @@ -660,17 +680,18 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|                         SliverToBoxAdapter( | ||||
|                           child: accountProfileBio(data).padding(horizontal: 4), | ||||
|                         ), | ||||
|                         SliverToBoxAdapter( | ||||
|                           child: accountProfileLinks( | ||||
|                             data, | ||||
|                           ).padding(horizontal: 4), | ||||
|                         ), | ||||
|                         if (data.profile.links.isNotEmpty) | ||||
|                           SliverToBoxAdapter( | ||||
|                             child: accountProfileLinks( | ||||
|                               data, | ||||
|                             ).padding(horizontal: 4), | ||||
|                           ), | ||||
|                         SliverToBoxAdapter( | ||||
|                           child: accountProfileDetail( | ||||
|                             data, | ||||
|                           ).padding(horizontal: 4), | ||||
|                         ), | ||||
|                         if (user.value != null) | ||||
|                         if (user.value != null && !isCurrentUser) | ||||
|                           SliverToBoxAdapter( | ||||
|                             child: accountAction(data).padding(horizontal: 4), | ||||
|                           ), | ||||
|   | ||||
| @@ -216,6 +216,7 @@ class RelationshipScreen extends HookConsumerWidget { | ||||
|       final result = await showModalBottomSheet( | ||||
|         context: context, | ||||
|         useRootNavigator: true, | ||||
|         isScrollControlled: true, | ||||
|         builder: (context) => AccountPickerSheet(), | ||||
|       ); | ||||
|       if (result == null) return; | ||||
|   | ||||
| @@ -42,6 +42,22 @@ final Map<int, (String, String, IconData)> kFactorTypes = { | ||||
|   4: ('authFactorPin', 'authFactorPinDescription', Symbols.nest_secure_alarm), | ||||
| }; | ||||
|  | ||||
| Future<String?> getDeviceName() async { | ||||
|   if (kIsWeb) return null; | ||||
|   String? name; | ||||
|   if (Platform.isIOS) { | ||||
|     final deviceInfo = await DeviceInfoPlugin().iosInfo; | ||||
|     name = deviceInfo.name; | ||||
|   } else if (Platform.isAndroid) { | ||||
|     final deviceInfo = await DeviceInfoPlugin().androidInfo; | ||||
|     name = deviceInfo.name; | ||||
|   } else if (Platform.isWindows) { | ||||
|     final deviceInfo = await DeviceInfoPlugin().windowsInfo; | ||||
|     name = deviceInfo.computerName; | ||||
|   } | ||||
|   return name; | ||||
| } | ||||
|  | ||||
| class LoginScreen extends HookConsumerWidget { | ||||
|   const LoginScreen({super.key}); | ||||
|  | ||||
| @@ -198,28 +214,6 @@ class _LoginCheckScreen extends HookConsumerWidget { | ||||
|         wsNotifier.connect(); | ||||
|         if (context.mounted) Navigator.pop(context, true); | ||||
|       }); | ||||
|  | ||||
|       // Update the sessions' device name is available | ||||
|       if (!kIsWeb) { | ||||
|         String? name; | ||||
|         if (Platform.isIOS) { | ||||
|           final deviceInfo = await DeviceInfoPlugin().iosInfo; | ||||
|           name = deviceInfo.name; | ||||
|         } else if (Platform.isAndroid) { | ||||
|           final deviceInfo = await DeviceInfoPlugin().androidInfo; | ||||
|           name = deviceInfo.name; | ||||
|         } else if (Platform.isWindows) { | ||||
|           final deviceInfo = await DeviceInfoPlugin().windowsInfo; | ||||
|           name = deviceInfo.computerName; | ||||
|         } | ||||
|         if (name != null) { | ||||
|           final client = ref.watch(apiClientProvider); | ||||
|           await client.patch( | ||||
|             '/id/accounts/me/sessions/current/label', | ||||
|             data: jsonEncode(name), | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
| @@ -578,6 +572,7 @@ class _LoginLookupScreen extends HookConsumerWidget { | ||||
|           data: { | ||||
|             'account': uname, | ||||
|             'device_id': await getUdid(), | ||||
|             'device_name': await getDeviceName(), | ||||
|             'platform': | ||||
|                 kIsWeb | ||||
|                     ? 1 | ||||
| @@ -628,6 +623,7 @@ class _LoginLookupScreen extends HookConsumerWidget { | ||||
|             'identity_token': credential.identityToken!, | ||||
|             'authorization_code': credential.authorizationCode, | ||||
|             'device_id': await getUdid(), | ||||
|             'device_name': await getDeviceName(), | ||||
|           }, | ||||
|         ); | ||||
|  | ||||
|   | ||||
| @@ -227,6 +227,7 @@ class ChatListScreen extends HookConsumerWidget { | ||||
|       final result = await showModalBottomSheet( | ||||
|         context: context, | ||||
|         useRootNavigator: true, | ||||
|         isScrollControlled: true, | ||||
|         builder: (context) => const AccountPickerSheet(), | ||||
|       ); | ||||
|       if (result == null) return; | ||||
|   | ||||
| @@ -339,7 +339,7 @@ class ChatRoomScreen extends HookConsumerWidget { | ||||
|                             } | ||||
|  | ||||
|                             await apiClient.post( | ||||
|                               '/chat/${chatRoom.value!.id}/members/me', | ||||
|                               '/sphere/chat/${chatRoom.value!.id}/members/me', | ||||
|                             ); | ||||
|                             ref.invalidate(chatroomIdentityProvider(id)); | ||||
|                           } catch (err) { | ||||
| @@ -929,7 +929,7 @@ class ChatRoomScreen extends HookConsumerWidget { | ||||
|                             if (attachment.isOnCloud) { | ||||
|                               final client = ref.watch(apiClientProvider); | ||||
|                               await client.delete( | ||||
|                                 '/files/${attachment.data.id}', | ||||
|                                 '/drive/files/${attachment.data.id}', | ||||
|                               ); | ||||
|                             } | ||||
|                             final clone = List.of(attachments.value); | ||||
|   | ||||
| @@ -589,6 +589,7 @@ class _ChatMemberListSheet extends HookConsumerWidget { | ||||
|       final result = await showModalBottomSheet( | ||||
|         context: context, | ||||
|         useRootNavigator: true, | ||||
|         isScrollControlled: true, | ||||
|         builder: (context) => const AccountPickerSheet(), | ||||
|       ); | ||||
|       if (result == null) return; | ||||
| @@ -727,7 +728,7 @@ class _ChatMemberListSheet extends HookConsumerWidget { | ||||
|                                       apiClientProvider, | ||||
|                                     ); | ||||
|                                     await apiClient.delete( | ||||
|                                       '/chat/$roomId/members/${member.accountId}', | ||||
|                                       '/sphere/chat/$roomId/members/${member.accountId}', | ||||
|                                     ); | ||||
|                                     // Refresh both providers | ||||
|                                     memberNotifier.reset(); | ||||
|   | ||||
| @@ -382,7 +382,7 @@ class CreatorHubScreen extends HookConsumerWidget { | ||||
|                           ), | ||||
|                           ListTile( | ||||
|                             minTileHeight: 48, | ||||
|                             title: const Text('Polls'), | ||||
|                             title: Text('polls').tr(), | ||||
|                             trailing: const Icon(Symbols.chevron_right), | ||||
|                             leading: const Icon(Symbols.poll), | ||||
|                             contentPadding: const EdgeInsets.symmetric( | ||||
| @@ -419,7 +419,7 @@ class CreatorHubScreen extends HookConsumerWidget { | ||||
|                           ), | ||||
|                           ListTile( | ||||
|                             minTileHeight: 48, | ||||
|                             title: const Text('Web Feeds').tr(), | ||||
|                             title: const Text('webFeeds').tr(), | ||||
|                             trailing: const Icon(Symbols.chevron_right), | ||||
|                             leading: const Icon(Symbols.rss_feed), | ||||
|                             contentPadding: const EdgeInsets.symmetric( | ||||
| @@ -659,7 +659,7 @@ class PublisherMemberNotifier extends StateNotifier<PublisherMemberState> { | ||||
|  | ||||
|     try { | ||||
|       final response = await _apiClient.get( | ||||
|         '/publishers/$publisherUname/members', | ||||
|         '/sphere/publishers/$publisherUname/members', | ||||
|         queryParameters: {'offset': offset, 'take': take}, | ||||
|       ); | ||||
|  | ||||
| @@ -708,6 +708,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget { | ||||
|  | ||||
|     Future<void> invitePerson() async { | ||||
|       final result = await showModalBottomSheet( | ||||
|         useRootNavigator: true, | ||||
|         isScrollControlled: true, | ||||
|         context: context, | ||||
|         builder: (context) => const AccountPickerSheet(), | ||||
| @@ -719,6 +720,9 @@ class _PublisherMemberListSheet extends HookConsumerWidget { | ||||
|           '/publishers/$publisherUname/invites', | ||||
|           data: {'related_user_id': result.id, 'role': 0}, | ||||
|         ); | ||||
|         // Refresh both providers | ||||
|         memberNotifier.reset(); | ||||
|         await memberNotifier.loadMore(); | ||||
|         ref.invalidate(memberListProvider); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
| @@ -822,6 +826,9 @@ class _PublisherMemberListSheet extends HookConsumerWidget { | ||||
|                                       ), | ||||
|                                 ).then((value) { | ||||
|                                   if (value != null) { | ||||
|                                     // Refresh both providers | ||||
|                                     memberNotifier.reset(); | ||||
|                                     memberNotifier.loadMore(); | ||||
|                                     ref.invalidate(memberListProvider); | ||||
|                                   } | ||||
|                                 }); | ||||
| @@ -843,6 +850,9 @@ class _PublisherMemberListSheet extends HookConsumerWidget { | ||||
|                                     await apiClient.delete( | ||||
|                                       '/publishers/$publisherUname/members/${member.accountId}', | ||||
|                                     ); | ||||
|                                     // Refresh both providers | ||||
|                                     memberNotifier.reset(); | ||||
|                                     memberNotifier.loadMore(); | ||||
|                                     ref.invalidate(memberListProvider); | ||||
|                                   } catch (err) { | ||||
|                                     showErrorAlert(err); | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/poll.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/poll/poll_feedback.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| @@ -13,17 +14,19 @@ part 'poll_list.g.dart'; | ||||
|  | ||||
| @riverpod | ||||
| class PollListNotifier extends _$PollListNotifier | ||||
|     with CursorPagingNotifierMixin<SnPoll> { | ||||
|     with CursorPagingNotifierMixin<SnPollWithStats> { | ||||
|   static const int _pageSize = 20; | ||||
|  | ||||
|   @override | ||||
|   Future<CursorPagingData<SnPoll>> build(String? pubName) { | ||||
|   Future<CursorPagingData<SnPollWithStats>> build(String? pubName) { | ||||
|     // immediately load first page | ||||
|     return fetch(cursor: null); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<CursorPagingData<SnPoll>> fetch({required String? cursor}) async { | ||||
|   Future<CursorPagingData<SnPollWithStats>> fetch({ | ||||
|     required String? cursor, | ||||
|   }) async { | ||||
|     final client = ref.read(apiClientProvider); | ||||
|     final offset = cursor == null ? 0 : int.parse(cursor); | ||||
|  | ||||
| @@ -41,7 +44,7 @@ class PollListNotifier extends _$PollListNotifier | ||||
|     ); | ||||
|     final total = int.parse(response.headers.value('X-Total') ?? '0'); | ||||
|     final List<dynamic> data = response.data; | ||||
|     final items = data.map((json) => SnPoll.fromJson(json)).toList(); | ||||
|     final items = data.map((json) => SnPollWithStats.fromJson(json)).toList(); | ||||
|  | ||||
|     final hasMore = offset + items.length < total; | ||||
|     final nextCursor = hasMore ? (offset + items.length).toString() : null; | ||||
| @@ -54,6 +57,13 @@ class PollListNotifier extends _$PollListNotifier | ||||
|   } | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
| Future<SnPollWithStats> pollWithStats(Ref ref, String id) async { | ||||
|   final apiClient = ref.watch(apiClientProvider); | ||||
|   final resp = await apiClient.get('/sphere/polls/$id'); | ||||
|   return SnPollWithStats.fromJson(resp.data); | ||||
| } | ||||
|  | ||||
| class CreatorPollListScreen extends HookConsumerWidget { | ||||
|   const CreatorPollListScreen({super.key, required this.pubName}); | ||||
|  | ||||
| @@ -63,14 +73,14 @@ class CreatorPollListScreen extends HookConsumerWidget { | ||||
|     final result = await GoRouter.of( | ||||
|       context, | ||||
|     ).pushNamed('creatorPollNew', pathParameters: {'name': pubName}); | ||||
|     if (result is SnPoll && context.mounted) { | ||||
|     if (result is SnPollWithStats && context.mounted) { | ||||
|       Navigator.of(context).maybePop(result); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     return Scaffold( | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar(title: const Text('Polls')), | ||||
|       floatingActionButton: FloatingActionButton( | ||||
|         onPressed: () => _createPoll(context), | ||||
| @@ -91,8 +101,11 @@ class CreatorPollListScreen extends HookConsumerWidget { | ||||
|                       if (index == widgetCount - 1) { | ||||
|                         return endItemView; | ||||
|                       } | ||||
|                       final poll = data.items[index]; | ||||
|                       return _CreatorPollItem(poll: poll, pubName: pubName); | ||||
|                       final pollWithStats = data.items[index]; | ||||
|                       return _CreatorPollItem( | ||||
|                         pollWithStats: pollWithStats, | ||||
|                         pubName: pubName, | ||||
|                       ); | ||||
|                     }, | ||||
|                   ), | ||||
|             ), | ||||
| @@ -105,14 +118,14 @@ class CreatorPollListScreen extends HookConsumerWidget { | ||||
|  | ||||
| class _CreatorPollItem extends StatelessWidget { | ||||
|   final String pubName; | ||||
|   const _CreatorPollItem({required this.poll, required this.pubName}); | ||||
|   const _CreatorPollItem({required this.pollWithStats, required this.pubName}); | ||||
|  | ||||
|   final SnPoll poll; | ||||
|   final SnPollWithStats pollWithStats; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final theme = Theme.of(context); | ||||
|     final ended = poll.endedAt; | ||||
|     final ended = pollWithStats.endedAt; | ||||
|     final endedText = | ||||
|         ended == null | ||||
|             ? 'No end' | ||||
| @@ -122,15 +135,16 @@ class _CreatorPollItem extends StatelessWidget { | ||||
|       margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), | ||||
|       clipBehavior: Clip.antiAlias, | ||||
|       child: ListTile( | ||||
|         title: Text(poll.title ?? 'Untitled poll'), | ||||
|         title: Text(pollWithStats.title ?? 'Untitled poll'), | ||||
|         subtitle: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             if (poll.description != null && poll.description!.isNotEmpty) | ||||
|             if (pollWithStats.description != null && | ||||
|                 pollWithStats.description!.isNotEmpty) | ||||
|               Padding( | ||||
|                 padding: const EdgeInsets.only(top: 4), | ||||
|                 child: Text( | ||||
|                   poll.description!, | ||||
|                   pollWithStats.description!, | ||||
|                   maxLines: 2, | ||||
|                   overflow: TextOverflow.ellipsis, | ||||
|                 ), | ||||
| @@ -138,7 +152,7 @@ class _CreatorPollItem extends StatelessWidget { | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.only(top: 4), | ||||
|               child: Text( | ||||
|                 'Questions: ${poll.questions.length} · Ends: $endedText', | ||||
|                 'Questions: ${pollWithStats.questions.length} · Ends: $endedText', | ||||
|                 style: theme.textTheme.bodySmall, | ||||
|               ), | ||||
|             ), | ||||
| @@ -158,7 +172,7 @@ class _CreatorPollItem extends StatelessWidget { | ||||
|                   onTap: () { | ||||
|                     GoRouter.of(context).pushNamed( | ||||
|                       'creatorPollEdit', | ||||
|                       pathParameters: {'name': pubName, 'id': poll.id}, | ||||
|                       pathParameters: {'name': pubName, 'id': pollWithStats.id}, | ||||
|                     ); | ||||
|                   }, | ||||
|                 ), | ||||
| @@ -169,8 +183,7 @@ class _CreatorPollItem extends StatelessWidget { | ||||
|             context: context, | ||||
|             useRootNavigator: true, | ||||
|             isScrollControlled: true, | ||||
|             builder: | ||||
|                 (context) => PollFeedbackSheet(pollId: poll.id, poll: poll), | ||||
|             builder: (context) => PollFeedbackSheet(pollId: pollWithStats.id), | ||||
|           ); | ||||
|         }, | ||||
|       ), | ||||
|   | ||||
| @@ -6,7 +6,7 @@ part of 'poll_list.dart'; | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$pollListNotifierHash() => r'd3da24ff6bbb8f35b06d57fc41625dc0312508e4'; | ||||
| String _$pollWithStatsHash() => r'6bb910046ce1e09368f9922dbec52fdc2cc86740'; | ||||
|  | ||||
| /// Copied from Dart SDK | ||||
| class _SystemHash { | ||||
| @@ -29,11 +29,133 @@ class _SystemHash { | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// See also [pollWithStats]. | ||||
| @ProviderFor(pollWithStats) | ||||
| const pollWithStatsProvider = PollWithStatsFamily(); | ||||
|  | ||||
| /// See also [pollWithStats]. | ||||
| class PollWithStatsFamily extends Family<AsyncValue<SnPollWithStats>> { | ||||
|   /// See also [pollWithStats]. | ||||
|   const PollWithStatsFamily(); | ||||
|  | ||||
|   /// See also [pollWithStats]. | ||||
|   PollWithStatsProvider call(String id) { | ||||
|     return PollWithStatsProvider(id); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   PollWithStatsProvider getProviderOverride( | ||||
|     covariant PollWithStatsProvider provider, | ||||
|   ) { | ||||
|     return call(provider.id); | ||||
|   } | ||||
|  | ||||
|   static const Iterable<ProviderOrFamily>? _dependencies = null; | ||||
|  | ||||
|   @override | ||||
|   Iterable<ProviderOrFamily>? get dependencies => _dependencies; | ||||
|  | ||||
|   static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null; | ||||
|  | ||||
|   @override | ||||
|   Iterable<ProviderOrFamily>? get allTransitiveDependencies => | ||||
|       _allTransitiveDependencies; | ||||
|  | ||||
|   @override | ||||
|   String? get name => r'pollWithStatsProvider'; | ||||
| } | ||||
|  | ||||
| /// See also [pollWithStats]. | ||||
| class PollWithStatsProvider extends AutoDisposeFutureProvider<SnPollWithStats> { | ||||
|   /// See also [pollWithStats]. | ||||
|   PollWithStatsProvider(String id) | ||||
|     : this._internal( | ||||
|         (ref) => pollWithStats(ref as PollWithStatsRef, id), | ||||
|         from: pollWithStatsProvider, | ||||
|         name: r'pollWithStatsProvider', | ||||
|         debugGetCreateSourceHash: | ||||
|             const bool.fromEnvironment('dart.vm.product') | ||||
|                 ? null | ||||
|                 : _$pollWithStatsHash, | ||||
|         dependencies: PollWithStatsFamily._dependencies, | ||||
|         allTransitiveDependencies: | ||||
|             PollWithStatsFamily._allTransitiveDependencies, | ||||
|         id: id, | ||||
|       ); | ||||
|  | ||||
|   PollWithStatsProvider._internal( | ||||
|     super._createNotifier, { | ||||
|     required super.name, | ||||
|     required super.dependencies, | ||||
|     required super.allTransitiveDependencies, | ||||
|     required super.debugGetCreateSourceHash, | ||||
|     required super.from, | ||||
|     required this.id, | ||||
|   }) : super.internal(); | ||||
|  | ||||
|   final String id; | ||||
|  | ||||
|   @override | ||||
|   Override overrideWith( | ||||
|     FutureOr<SnPollWithStats> Function(PollWithStatsRef provider) create, | ||||
|   ) { | ||||
|     return ProviderOverride( | ||||
|       origin: this, | ||||
|       override: PollWithStatsProvider._internal( | ||||
|         (ref) => create(ref as PollWithStatsRef), | ||||
|         from: from, | ||||
|         name: null, | ||||
|         dependencies: null, | ||||
|         allTransitiveDependencies: null, | ||||
|         debugGetCreateSourceHash: null, | ||||
|         id: id, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AutoDisposeFutureProviderElement<SnPollWithStats> createElement() { | ||||
|     return _PollWithStatsProviderElement(this); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return other is PollWithStatsProvider && other.id == id; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||
|     hash = _SystemHash.combine(hash, id.hashCode); | ||||
|  | ||||
|     return _SystemHash.finish(hash); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| mixin PollWithStatsRef on AutoDisposeFutureProviderRef<SnPollWithStats> { | ||||
|   /// The parameter `id` of this provider. | ||||
|   String get id; | ||||
| } | ||||
|  | ||||
| class _PollWithStatsProviderElement | ||||
|     extends AutoDisposeFutureProviderElement<SnPollWithStats> | ||||
|     with PollWithStatsRef { | ||||
|   _PollWithStatsProviderElement(super.provider); | ||||
|  | ||||
|   @override | ||||
|   String get id => (origin as PollWithStatsProvider).id; | ||||
| } | ||||
|  | ||||
| String _$pollListNotifierHash() => r'd5b822e737788be8982f5cb3b501d460441930c1'; | ||||
|  | ||||
| abstract class _$PollListNotifier | ||||
|     extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnPoll>> { | ||||
|     extends | ||||
|         BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnPollWithStats>> { | ||||
|   late final String? pubName; | ||||
|  | ||||
|   FutureOr<CursorPagingData<SnPoll>> build(String? pubName); | ||||
|   FutureOr<CursorPagingData<SnPollWithStats>> build(String? pubName); | ||||
| } | ||||
|  | ||||
| /// See also [PollListNotifier]. | ||||
| @@ -42,7 +164,7 @@ const pollListNotifierProvider = PollListNotifierFamily(); | ||||
|  | ||||
| /// See also [PollListNotifier]. | ||||
| class PollListNotifierFamily | ||||
|     extends Family<AsyncValue<CursorPagingData<SnPoll>>> { | ||||
|     extends Family<AsyncValue<CursorPagingData<SnPollWithStats>>> { | ||||
|   /// See also [PollListNotifier]. | ||||
|   const PollListNotifierFamily(); | ||||
|  | ||||
| @@ -78,7 +200,7 @@ class PollListNotifierProvider | ||||
|     extends | ||||
|         AutoDisposeAsyncNotifierProviderImpl< | ||||
|           PollListNotifier, | ||||
|           CursorPagingData<SnPoll> | ||||
|           CursorPagingData<SnPollWithStats> | ||||
|         > { | ||||
|   /// See also [PollListNotifier]. | ||||
|   PollListNotifierProvider(String? pubName) | ||||
| @@ -109,7 +231,7 @@ class PollListNotifierProvider | ||||
|   final String? pubName; | ||||
|  | ||||
|   @override | ||||
|   FutureOr<CursorPagingData<SnPoll>> runNotifierBuild( | ||||
|   FutureOr<CursorPagingData<SnPollWithStats>> runNotifierBuild( | ||||
|     covariant PollListNotifier notifier, | ||||
|   ) { | ||||
|     return notifier.build(pubName); | ||||
| @@ -134,7 +256,7 @@ class PollListNotifierProvider | ||||
|   @override | ||||
|   AutoDisposeAsyncNotifierProviderElement< | ||||
|     PollListNotifier, | ||||
|     CursorPagingData<SnPoll> | ||||
|     CursorPagingData<SnPollWithStats> | ||||
|   > | ||||
|   createElement() { | ||||
|     return _PollListNotifierProviderElement(this); | ||||
| @@ -157,7 +279,7 @@ class PollListNotifierProvider | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| mixin PollListNotifierRef | ||||
|     on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnPoll>> { | ||||
|     on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnPollWithStats>> { | ||||
|   /// The parameter `pubName` of this provider. | ||||
|   String? get pubName; | ||||
| } | ||||
| @@ -166,7 +288,7 @@ class _PollListNotifierProviderElement | ||||
|     extends | ||||
|         AutoDisposeAsyncNotifierProviderElement< | ||||
|           PollListNotifier, | ||||
|           CursorPagingData<SnPoll> | ||||
|           CursorPagingData<SnPollWithStats> | ||||
|         > | ||||
|     with PollListNotifierRef { | ||||
|   _PollListNotifierProviderElement(super.provider); | ||||
|   | ||||
| @@ -58,7 +58,7 @@ class StickerPackDetailScreen extends HookConsumerWidget { | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final apiClient = ref.watch(apiClientProvider); | ||||
|         await apiClient.delete('/stickers/$id/content/${sticker.id}'); | ||||
|         await apiClient.delete('/sphere/stickers/$id/content/${sticker.id}'); | ||||
|         ref.invalidate(stickerPackContentProvider(id)); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
| @@ -180,6 +180,7 @@ class StickerPackDetailScreen extends HookConsumerWidget { | ||||
|                                               .pushNamed( | ||||
|                                                 'creatorStickerEdit', | ||||
|                                                 pathParameters: { | ||||
|                                                   'name': pubName, | ||||
|                                                   'packId': id, | ||||
|                                                   'id': sticker.id, | ||||
|                                                 }, | ||||
| @@ -297,7 +298,7 @@ class _StickerPackActionMenu extends HookConsumerWidget { | ||||
|                 ).then((confirm) { | ||||
|                   if (confirm) { | ||||
|                     final client = ref.watch(apiClientProvider); | ||||
|                     client.delete('/stickers/$packId'); | ||||
|                     client.delete('/sphere/stickers/$packId'); | ||||
|                     ref.invalidate(stickerPacksNotifierProvider); | ||||
|                     if (context.mounted) context.pop(true); | ||||
|                   } | ||||
| @@ -325,7 +326,7 @@ Future<SnSticker?> stickerPackSticker( | ||||
|   if (query == null) return null; | ||||
|   final apiClient = ref.watch(apiClientProvider); | ||||
|   final resp = await apiClient.get( | ||||
|     '/stickers/${query.packId}/content/${query.id}', | ||||
|     '/sphere/stickers/${query.packId}/content/${query.id}', | ||||
|   ); | ||||
|   if (resp.data == null) return null; | ||||
|   return SnSticker.fromJson(resp.data); | ||||
| @@ -379,8 +380,8 @@ class EditStickersScreen extends HookConsumerWidget { | ||||
|       try { | ||||
|         final resp = await apiClient.request( | ||||
|           id == null | ||||
|               ? '/stickers/$packId/content' | ||||
|               : '/stickers/$packId/content/$id', | ||||
|               ? '/sphere/stickers/$packId/content' | ||||
|               : '/sphere/stickers/$packId/content/$id', | ||||
|           data: {'slug': slugController.text, 'image_id': imageController.text}, | ||||
|           options: Options(method: id == null ? 'POST' : 'PATCH'), | ||||
|         ); | ||||
|   | ||||
| @@ -151,7 +151,7 @@ class _StickerPackContentProviderElement | ||||
| } | ||||
|  | ||||
| String _$stickerPackStickerHash() => | ||||
|     r'36f524c047e632236d5597aaaa8678ed86599602'; | ||||
|     r'5c553666b3a63530bdebae4b7cd52f303c5ab3a0'; | ||||
|  | ||||
| /// See also [stickerPackSticker]. | ||||
| @ProviderFor(stickerPackSticker) | ||||
|   | ||||
| @@ -31,7 +31,7 @@ class StickersScreen extends HookConsumerWidget { | ||||
|               context | ||||
|                   .pushNamed( | ||||
|                     'creatorStickerPackNew', | ||||
|                     queryParameters: {'name': pubName}, | ||||
|                     pathParameters: {'name': pubName}, | ||||
|                   ) | ||||
|                   .then((value) { | ||||
|                     if (value != null) { | ||||
| @@ -187,10 +187,8 @@ class EditStickerPacksScreen extends HookConsumerWidget { | ||||
|             'description': descriptionController.text, | ||||
|             'prefix': prefixController.text, | ||||
|           }, | ||||
|           options: Options( | ||||
|             method: packId == null ? 'POST' : 'PATCH', | ||||
|             headers: {'X-Pub': pubName}, | ||||
|           ), | ||||
|           queryParameters: {'pub': pubName}, | ||||
|           options: Options(method: packId == null ? 'POST' : 'PATCH'), | ||||
|         ); | ||||
|         if (!context.mounted) return; | ||||
|         context.pop(SnStickerPack.fromJson(resp.data)); | ||||
|   | ||||
| @@ -114,10 +114,11 @@ class WebFeedEditScreen extends HookConsumerWidget { | ||||
|  | ||||
|     return feedAsync.when( | ||||
|       loading: | ||||
|           () => | ||||
|               const Scaffold(body: Center(child: CircularProgressIndicator())), | ||||
|           () => const AppScaffold( | ||||
|             body: Center(child: CircularProgressIndicator()), | ||||
|           ), | ||||
|       error: | ||||
|           (error, stack) => Scaffold( | ||||
|           (error, stack) => AppScaffold( | ||||
|             appBar: AppBar(title: const Text('Error')), | ||||
|             body: Center(child: Text('Error: $error')), | ||||
|           ), | ||||
|   | ||||
| @@ -30,12 +30,12 @@ Future<DeveloperStats?> developerStats(Ref ref, String? uname) async { | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
| Future<List<SnPublisher>> developers(Ref ref) async { | ||||
| Future<List<SnDeveloper>> developers(Ref ref) async { | ||||
|   final client = ref.watch(apiClientProvider); | ||||
|   final resp = await client.get('/develop/developers'); | ||||
|   return resp.data | ||||
|       .map((e) => SnPublisher.fromJson(e)) | ||||
|       .cast<SnPublisher>() | ||||
|       .map((e) => SnDeveloper.fromJson(e)) | ||||
|       .cast<SnDeveloper>() | ||||
|       .toList(); | ||||
| } | ||||
|  | ||||
| @@ -74,25 +74,25 @@ class DeveloperHubScreen extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     final developers = ref.watch(developersProvider); | ||||
|     final currentDeveloper = useState<SnPublisher?>( | ||||
|     final currentDeveloper = useState<SnDeveloper?>( | ||||
|       developers.value?.firstOrNull, | ||||
|     ); | ||||
|  | ||||
|     final List<DropdownMenuItem<SnPublisher>> developersMenu = developers.when( | ||||
|     final List<DropdownMenuItem<SnDeveloper>> developersMenu = developers.when( | ||||
|       data: | ||||
|           (data) => | ||||
|               data | ||||
|                   .map( | ||||
|                     (item) => DropdownMenuItem<SnPublisher>( | ||||
|                     (item) => DropdownMenuItem<SnDeveloper>( | ||||
|                       value: item, | ||||
|                       child: ListTile( | ||||
|                         minTileHeight: 48, | ||||
|                         leading: ProfilePictureWidget( | ||||
|                           radius: 16, | ||||
|                           fileId: item.picture?.id, | ||||
|                           fileId: item.publisher?.picture?.id, | ||||
|                         ), | ||||
|                         title: Text(item.nick), | ||||
|                         subtitle: Text('@${item.name}'), | ||||
|                         title: Text(item.publisher!.nick), | ||||
|                         subtitle: Text('@${item.publisher!.name}'), | ||||
|                         trailing: | ||||
|                             currentDeveloper.value?.id == item.id | ||||
|                                 ? const Icon(Icons.check) | ||||
| @@ -107,7 +107,7 @@ class DeveloperHubScreen extends HookConsumerWidget { | ||||
|     ); | ||||
|  | ||||
|     final developerStats = ref.watch( | ||||
|       developerStatsProvider(currentDeveloper.value?.name), | ||||
|       developerStatsProvider(currentDeveloper.value?.publisher?.name), | ||||
|     ); | ||||
|  | ||||
|     return AppScaffold( | ||||
| @@ -117,7 +117,7 @@ class DeveloperHubScreen extends HookConsumerWidget { | ||||
|         title: Text('developerHub').tr(), | ||||
|         actions: [ | ||||
|           DropdownButtonHideUnderline( | ||||
|             child: DropdownButton2<SnPublisher>( | ||||
|             child: DropdownButton2<SnDeveloper>( | ||||
|               alignment: Alignment.centerRight, | ||||
|               value: currentDeveloper.value, | ||||
|               hint: CircleAvatar( | ||||
| @@ -139,7 +139,7 @@ class DeveloperHubScreen extends HookConsumerWidget { | ||||
|                   ...developersMenu.map( | ||||
|                     (e) => ProfilePictureWidget( | ||||
|                       radius: 16, | ||||
|                       fileId: e.value?.picture?.id, | ||||
|                       fileId: e.value?.publisher?.picture?.id, | ||||
|                     ).center().padding(right: 8), | ||||
|                   ), | ||||
|                 ]; | ||||
| @@ -193,10 +193,12 @@ class DeveloperHubScreen extends HookConsumerWidget { | ||||
|                           ...(developers.value?.map( | ||||
|                                 (developer) => ListTile( | ||||
|                                   leading: ProfilePictureWidget( | ||||
|                                     file: developer.picture, | ||||
|                                     file: developer.publisher?.picture, | ||||
|                                   ), | ||||
|                                   title: Text(developer.publisher!.nick), | ||||
|                                   subtitle: Text( | ||||
|                                     '@${developer.publisher!.name}', | ||||
|                                   ), | ||||
|                                   title: Text(developer.nick), | ||||
|                                   subtitle: Text('@${developer.name}'), | ||||
|                                   onTap: () { | ||||
|                                     currentDeveloper.value = developer; | ||||
|                                   }, | ||||
| @@ -243,7 +245,8 @@ class DeveloperHubScreen extends HookConsumerWidget { | ||||
|                               context.pushNamed( | ||||
|                                 'developerApps', | ||||
|                                 pathParameters: { | ||||
|                                   'name': currentDeveloper.value!.name, | ||||
|                                   'name': | ||||
|                                       currentDeveloper.value!.publisher!.name, | ||||
|                                 }, | ||||
|                               ); | ||||
|                             }, | ||||
| @@ -257,7 +260,9 @@ class DeveloperHubScreen extends HookConsumerWidget { | ||||
|               error: err, | ||||
|               onRetry: () { | ||||
|                 ref.invalidate( | ||||
|                   developerStatsProvider(currentDeveloper.value?.name), | ||||
|                   developerStatsProvider( | ||||
|                     currentDeveloper.value?.publisher!.name, | ||||
|                   ), | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
| @@ -354,7 +359,7 @@ class _DeveloperEnrollmentSheet extends HookConsumerWidget { | ||||
|                     ? Center( | ||||
|                       child: | ||||
|                           Text( | ||||
|                             'noPublishersToEnroll', | ||||
|                             'noDevelopersToEnroll', | ||||
|                             textAlign: TextAlign.center, | ||||
|                           ).tr(), | ||||
|                     ) | ||||
|   | ||||
| @@ -149,12 +149,12 @@ class _DeveloperStatsProviderElement | ||||
|   String? get uname => (origin as DeveloperStatsProvider).uname; | ||||
| } | ||||
|  | ||||
| String _$developersHash() => r'04f25db31f511f651a5add128d56631236ed0b39'; | ||||
| String _$developersHash() => r'252341098617ac398ce133994453f318dd3edbd2'; | ||||
|  | ||||
| /// See also [developers]. | ||||
| @ProviderFor(developers) | ||||
| final developersProvider = | ||||
|     AutoDisposeFutureProvider<List<SnPublisher>>.internal( | ||||
|     AutoDisposeFutureProvider<List<SnDeveloper>>.internal( | ||||
|       developers, | ||||
|       name: r'developersProvider', | ||||
|       debugGetCreateSourceHash: | ||||
| @@ -167,6 +167,6 @@ final developersProvider = | ||||
|  | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| typedef DevelopersRef = AutoDisposeFutureProviderRef<List<SnPublisher>>; | ||||
| typedef DevelopersRef = AutoDisposeFutureProviderRef<List<SnDeveloper>>; | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import 'package:island/models/realm.dart'; | ||||
| import 'package:island/models/webfeed.dart'; | ||||
| import 'package:island/pods/event_calendar.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/screens/notification.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/account/fortune_graph.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| @@ -30,6 +31,33 @@ import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| part 'explore.g.dart'; | ||||
|  | ||||
| Widget notificationIndicatorWidget( | ||||
|   BuildContext context, { | ||||
|   required int count, | ||||
|   EdgeInsets? margin, | ||||
| }) => Card( | ||||
|   margin: margin, | ||||
|   child: ListTile( | ||||
|     shape: const RoundedRectangleBorder( | ||||
|       borderRadius: BorderRadius.all(Radius.circular(8)), | ||||
|     ), | ||||
|     leading: const Icon(Symbols.notifications), | ||||
|     title: Row( | ||||
|       children: [ | ||||
|         Text('notifications').tr().fontSize(14), | ||||
|         const Gap(8), | ||||
|         Badge(label: Text(count.toString())), | ||||
|       ], | ||||
|     ), | ||||
|     trailing: const Icon(Symbols.chevron_right), | ||||
|     minTileHeight: 40, | ||||
|     contentPadding: EdgeInsets.only(left: 16, right: 15), | ||||
|     onTap: () { | ||||
|       GoRouter.of(context).pushNamed('notifications'); | ||||
|     }, | ||||
|   ), | ||||
| ); | ||||
|  | ||||
| class ExploreScreen extends HookConsumerWidget { | ||||
|   const ExploreScreen({super.key}); | ||||
|  | ||||
| @@ -77,6 +105,10 @@ class ExploreScreen extends HookConsumerWidget { | ||||
|  | ||||
|     final user = ref.watch(userInfoProvider); | ||||
|  | ||||
|     final notificationCount = ref.watch( | ||||
|       notificationUnreadCountNotifierProvider, | ||||
|     ); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       isNoBackground: false, | ||||
|       appBar: AppBar( | ||||
| @@ -185,7 +217,7 @@ class ExploreScreen extends HookConsumerWidget { | ||||
|       floatingActionButtonLocation: TabbedFabLocation(context), | ||||
|       body: Builder( | ||||
|         builder: (context) { | ||||
|           final isWider = isWiderScreen(context); | ||||
|           final isWide = isWideScreen(context); | ||||
|  | ||||
|           final bodyView = _buildActivityList( | ||||
|             context, | ||||
| @@ -193,40 +225,58 @@ class ExploreScreen extends HookConsumerWidget { | ||||
|             currentFilter.value, | ||||
|           ); | ||||
|  | ||||
|           if (isWider) { | ||||
|           if (isWide) { | ||||
|             return Row( | ||||
|               children: [ | ||||
|                 Flexible(flex: 3, child: bodyView.padding(left: 8)), | ||||
|                 if (user.value != null) | ||||
|                   Flexible( | ||||
|                     flex: 2, | ||||
|                     child: SingleChildScrollView( | ||||
|                       child: Column( | ||||
|                         children: [ | ||||
|                           CheckInWidget( | ||||
|                             margin: EdgeInsets.only( | ||||
|                     child: Align( | ||||
|                       alignment: Alignment.topCenter, | ||||
|                       child: SingleChildScrollView( | ||||
|                         child: Column( | ||||
|                           children: [ | ||||
|                             CheckInWidget( | ||||
|                               margin: EdgeInsets.only( | ||||
|                                 left: 8, | ||||
|                                 right: 12, | ||||
|                                 top: 16, | ||||
|                               ), | ||||
|                               onChecked: () { | ||||
|                                 ref.invalidate( | ||||
|                                   eventCalendarProvider(query.value), | ||||
|                                 ); | ||||
|                               }, | ||||
|                             ), | ||||
|                             if (notificationCount.value != null && | ||||
|                                 notificationCount.value! > 0) | ||||
|                               notificationIndicatorWidget( | ||||
|                                 context, | ||||
|                                 count: notificationCount.value ?? 0, | ||||
|                                 margin: EdgeInsets.only( | ||||
|                                   left: 8, | ||||
|                                   right: 12, | ||||
|                                   top: 8, | ||||
|                                 ), | ||||
|                               ), | ||||
|                             PostFeaturedList().padding( | ||||
|                               left: 8, | ||||
|                               right: 12, | ||||
|                               top: 16, | ||||
|                               top: 8, | ||||
|                             ), | ||||
|                             onChecked: () { | ||||
|                               ref.invalidate( | ||||
|                                 eventCalendarProvider(query.value), | ||||
|                               ); | ||||
|                             }, | ||||
|                           ), | ||||
|                           PostFeaturedList().padding( | ||||
|                             left: 8, | ||||
|                             right: 12, | ||||
|                             top: 8, | ||||
|                           ), | ||||
|                           FortuneGraphWidget( | ||||
|                             margin: EdgeInsets.only(left: 8, right: 12, top: 8), | ||||
|                             events: events, | ||||
|                             constrainWidth: true, | ||||
|                             onPointSelected: onDaySelected, | ||||
|                           ), | ||||
|                         ], | ||||
|                             FortuneGraphWidget( | ||||
|                               margin: EdgeInsets.only( | ||||
|                                 left: 8, | ||||
|                                 right: 12, | ||||
|                                 top: 8, | ||||
|                               ), | ||||
|                               events: events, | ||||
|                               constrainWidth: true, | ||||
|                               onPointSelected: onDaySelected, | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ) | ||||
| @@ -268,7 +318,7 @@ class ExploreScreen extends HookConsumerWidget { | ||||
|       activityListNotifierProvider(filter).notifier, | ||||
|     ); | ||||
|  | ||||
|     final isWider = isWiderScreen(context); | ||||
|     final isWide = isWideScreen(context); | ||||
|  | ||||
|     return RefreshIndicator( | ||||
|       onRefresh: () => Future.sync(activitiesNotifier.forceRefresh), | ||||
| @@ -283,7 +333,7 @@ class ExploreScreen extends HookConsumerWidget { | ||||
|                 widgetCount: widgetCount, | ||||
|                 endItemView: endItemView, | ||||
|                 activitiesNotifier: activitiesNotifier, | ||||
|                 contentOnly: isWider || filter != null, | ||||
|                 contentOnly: isWide || filter != null, | ||||
|               ), | ||||
|             ), | ||||
|       ), | ||||
| @@ -380,6 +430,10 @@ class _ActivityListView extends HookConsumerWidget { | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final user = ref.watch(userInfoProvider); | ||||
|  | ||||
|     final notificationCount = ref.watch( | ||||
|       notificationUnreadCountNotifierProvider, | ||||
|     ); | ||||
|  | ||||
|     return CustomScrollView( | ||||
|       slivers: [ | ||||
|         SliverGap(12), | ||||
| @@ -393,6 +447,14 @@ class _ActivityListView extends HookConsumerWidget { | ||||
|           SliverToBoxAdapter( | ||||
|             child: PostFeaturedList().padding(horizontal: 8, bottom: 4, top: 4), | ||||
|           ), | ||||
|         if (!contentOnly) | ||||
|           SliverToBoxAdapter( | ||||
|             child: notificationIndicatorWidget( | ||||
|               context, | ||||
|               count: notificationCount.value ?? 0, | ||||
|               margin: EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 4), | ||||
|             ), | ||||
|           ), | ||||
|         SliverList.builder( | ||||
|           itemCount: widgetCount, | ||||
|           itemBuilder: (context, index) { | ||||
|   | ||||
| @@ -3,14 +3,17 @@ import 'dart:math' as math; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/route.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||
| import 'package:relative_time/relative_time.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||
| @@ -62,6 +65,10 @@ class NotificationUnreadCountNotifier | ||||
|     final current = await future; | ||||
|     state = AsyncData(math.max(current - count, 0)); | ||||
|   } | ||||
|  | ||||
|   void clear() async { | ||||
|     state = AsyncData(0); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
| @@ -111,8 +118,27 @@ class NotificationScreen extends HookConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     Future<void> markAllRead() async { | ||||
|       showLoadingModal(context); | ||||
|       final apiClient = ref.watch(apiClientProvider); | ||||
|       await apiClient.post('/pusher/notifications/all/read'); | ||||
|       if (!context.mounted) return; | ||||
|       hideLoadingModal(context); | ||||
|       ref.invalidate(notificationListNotifierProvider); | ||||
|       ref.watch(notificationUnreadCountNotifierProvider.notifier).clear(); | ||||
|     } | ||||
|  | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar(title: const Text('notifications').tr()), | ||||
|       appBar: AppBar( | ||||
|         title: const Text('notifications').tr(), | ||||
|         actions: [ | ||||
|           IconButton( | ||||
|             onPressed: markAllRead, | ||||
|             icon: const Icon(Symbols.mark_as_unread), | ||||
|           ), | ||||
|           const Gap(8), | ||||
|         ], | ||||
|       ), | ||||
|       body: PagingHelperView( | ||||
|         provider: notificationListNotifierProvider, | ||||
|         futureRefreshable: notificationListNotifierProvider.future, | ||||
|   | ||||
| @@ -9,7 +9,9 @@ import 'package:gap/gap.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/models/poll.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:uuid/uuid.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
|  | ||||
| class PollEditorState { | ||||
|   String? id; // for editing | ||||
| @@ -109,7 +111,7 @@ class PollEditor extends Notifier<PollEditorState> { | ||||
|               ? [ | ||||
|                 SnPollOption( | ||||
|                   id: const Uuid().v4(), | ||||
|                   label: 'Option 1', | ||||
|                   label: 'pollOptionDefaultLabel'.tr(), | ||||
|                   order: 0, | ||||
|                 ), | ||||
|               ] | ||||
| @@ -190,7 +192,7 @@ class PollEditor extends Notifier<PollEditorState> { | ||||
|                 : [ | ||||
|                   SnPollOption( | ||||
|                     id: const Uuid().v4(), | ||||
|                     label: 'Option 1', | ||||
|                     label: 'pollOptionDefaultLabel'.tr(), | ||||
|                     order: 0, | ||||
|                   ), | ||||
|                 ]) | ||||
| @@ -388,7 +390,7 @@ class PollEditorScreen extends ConsumerWidget { | ||||
|                 data: body, | ||||
|               )); | ||||
|  | ||||
|       showSnackBar(isUpdate ? 'Poll updated.' : 'Poll created.'); | ||||
|       showSnackBar(isUpdate ? 'pollUpdated'.tr() : 'pollCreated'.tr()); | ||||
|  | ||||
|       if (!context.mounted) return; | ||||
|       Navigator.of(context).maybePop(res.data); | ||||
| @@ -413,13 +415,13 @@ class PollEditorScreen extends ConsumerWidget { | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar( | ||||
|         title: Text(model.id == null ? 'Create Poll' : 'Edit Poll'), | ||||
|         title: Text(model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr()), | ||||
|         actions: [ | ||||
|           if (kDebugMode) | ||||
|             IconButton( | ||||
|               tooltip: 'Preview JSON (debug)', | ||||
|               tooltip: 'pollPreviewJsonDebug'.tr(), | ||||
|               onPressed: () { | ||||
|                 _showDebugPreview(context, model); | ||||
|               }, | ||||
| @@ -428,175 +430,175 @@ class PollEditorScreen extends ConsumerWidget { | ||||
|           const Gap(8), | ||||
|         ], | ||||
|       ), | ||||
|       body: SafeArea( | ||||
|         child: Form( | ||||
|           key: ValueKey(model.id), | ||||
|           child: ListView( | ||||
|             padding: const EdgeInsets.all(16), | ||||
|             children: [ | ||||
|               TextFormField( | ||||
|                 initialValue: model.title ?? '', | ||||
|                 decoration: const InputDecoration( | ||||
|                   labelText: 'Title', | ||||
|                   border: OutlineInputBorder( | ||||
|                     borderRadius: BorderRadius.all(Radius.circular(16)), | ||||
|                   ), | ||||
|                 ), | ||||
|                 textInputAction: TextInputAction.next, | ||||
|                 maxLength: 256, | ||||
|                 onChanged: notifier.setTitle, | ||||
|                 onTapOutside: | ||||
|                     (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 validator: (v) { | ||||
|                   if (v == null || v.trim().isEmpty) { | ||||
|                     return 'Title is required'; | ||||
|                   } | ||||
|                   return null; | ||||
|                 }, | ||||
|               ), | ||||
|               const Gap(12), | ||||
|               TextFormField( | ||||
|                 initialValue: model.description ?? '', | ||||
|                 decoration: const InputDecoration( | ||||
|                   labelText: 'Description', | ||||
|                   alignLabelWithHint: true, | ||||
|                   border: OutlineInputBorder( | ||||
|                     borderRadius: BorderRadius.all(Radius.circular(16)), | ||||
|                   ), | ||||
|                 ), | ||||
|                 maxLines: 3, | ||||
|                 maxLength: 4096, | ||||
|                 onChanged: notifier.setDescription, | ||||
|                 onTapOutside: | ||||
|                     (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|               ), | ||||
|               const Gap(12), | ||||
|               _EndDatePicker( | ||||
|                 value: model.endedAt, | ||||
|                 onChanged: notifier.setEndedAt, | ||||
|               ), | ||||
|               const Gap(24), | ||||
|               Row( | ||||
|       body: Column( | ||||
|         children: [ | ||||
|           Expanded( | ||||
|             child: Form( | ||||
|               key: ValueKey(model.id), | ||||
|               child: ListView( | ||||
|                 padding: const EdgeInsets.all(16), | ||||
|                 children: [ | ||||
|                   Text( | ||||
|                     'Questions', | ||||
|                     style: Theme.of(context).textTheme.titleLarge, | ||||
|                   ), | ||||
|                   const Spacer(), | ||||
|                   MenuAnchor( | ||||
|                     builder: (context, controller, child) { | ||||
|                       return FilledButton.icon( | ||||
|                         onPressed: () { | ||||
|                           controller.isOpen | ||||
|                               ? controller.close() | ||||
|                               : controller.open(); | ||||
|                         }, | ||||
|                         icon: const Icon(Icons.add), | ||||
|                         label: const Text('Add question'), | ||||
|                       ); | ||||
|                   TextFormField( | ||||
|                     initialValue: model.title ?? '', | ||||
|                     decoration: InputDecoration( | ||||
|                       labelText: 'title'.tr(), | ||||
|                       border: OutlineInputBorder( | ||||
|                         borderRadius: BorderRadius.all(Radius.circular(16)), | ||||
|                       ), | ||||
|                     ), | ||||
|                     textInputAction: TextInputAction.next, | ||||
|                     maxLength: 256, | ||||
|                     onChanged: notifier.setTitle, | ||||
|                     onTapOutside: | ||||
|                         (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                     validator: (v) { | ||||
|                       if (v == null || v.trim().isEmpty) { | ||||
|                         return 'pollTitleRequired'.tr(); | ||||
|                       } | ||||
|                       return null; | ||||
|                     }, | ||||
|                     menuChildren: | ||||
|                         SnPollQuestionType.values | ||||
|                             .map( | ||||
|                               (t) => MenuItemButton( | ||||
|                                 leadingIcon: Icon(_iconForType(t)), | ||||
|                                 onPressed: () => notifier.addQuestion(t), | ||||
|                                 child: Text(_labelForType(t)), | ||||
|                               ), | ||||
|                             ) | ||||
|                             .toList(), | ||||
|                   ), | ||||
|                   const Gap(12), | ||||
|                   TextFormField( | ||||
|                     initialValue: model.description ?? '', | ||||
|                     decoration: InputDecoration( | ||||
|                       labelText: 'description'.tr(), | ||||
|                       alignLabelWithHint: true, | ||||
|                       border: OutlineInputBorder( | ||||
|                         borderRadius: BorderRadius.all(Radius.circular(16)), | ||||
|                       ), | ||||
|                     ), | ||||
|                     maxLines: 3, | ||||
|                     maxLength: 4096, | ||||
|                     onChanged: notifier.setDescription, | ||||
|                     onTapOutside: | ||||
|                         (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                   ), | ||||
|                   const Gap(12), | ||||
|                   _EndDatePicker( | ||||
|                     value: model.endedAt, | ||||
|                     onChanged: notifier.setEndedAt, | ||||
|                   ), | ||||
|                   const Gap(24), | ||||
|                   Row( | ||||
|                     children: [ | ||||
|                       Text( | ||||
|                         'questions'.tr(), | ||||
|                         style: Theme.of(context).textTheme.titleLarge, | ||||
|                       ), | ||||
|                       const Spacer(), | ||||
|                       MenuAnchor( | ||||
|                         builder: (context, controller, child) { | ||||
|                           return FilledButton.icon( | ||||
|                             onPressed: () { | ||||
|                               controller.isOpen | ||||
|                                   ? controller.close() | ||||
|                                   : controller.open(); | ||||
|                             }, | ||||
|                             icon: const Icon(Icons.add), | ||||
|                             label: Text('pollAddQuestion'.tr()), | ||||
|                           ); | ||||
|                         }, | ||||
|                         menuChildren: | ||||
|                             SnPollQuestionType.values | ||||
|                                 .map( | ||||
|                                   (t) => MenuItemButton( | ||||
|                                     leadingIcon: Icon(_iconForType(t)), | ||||
|                                     onPressed: () => notifier.addQuestion(t), | ||||
|                                     child: Text(_labelForType(t)), | ||||
|                                   ), | ||||
|                                 ) | ||||
|                                 .toList(), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                   const Gap(8), | ||||
|                   if (model.questions.isEmpty) | ||||
|                     _EmptyState( | ||||
|                       title: 'pollNoQuestionsYet'.tr(), | ||||
|                       subtitle: | ||||
|                           'pollNoQuestionsHint'.tr(), | ||||
|                     ) | ||||
|                   else | ||||
|                     ReorderableListView.builder( | ||||
|                       shrinkWrap: true, | ||||
|                       physics: const NeverScrollableScrollPhysics(), | ||||
|                       itemCount: model.questions.length, | ||||
|                       onReorder: (oldIndex, newIndex) { | ||||
|                         // Convert to stepwise moves using provided functions | ||||
|                         if (newIndex > oldIndex) newIndex -= 1; | ||||
|                         final steps = newIndex - oldIndex; | ||||
|                         if (steps == 0) return; | ||||
|                         if (steps > 0) { | ||||
|                           for (int i = 0; i < steps; i++) { | ||||
|                             notifier.moveQuestionDown(oldIndex + i); | ||||
|                           } | ||||
|                         } else { | ||||
|                           for (int i = 0; i > steps; i--) { | ||||
|                             notifier.moveQuestionUp(oldIndex + i); | ||||
|                           } | ||||
|                         } | ||||
|                       }, | ||||
|                       buildDefaultDragHandles: false, | ||||
|                       itemBuilder: (context, index) { | ||||
|                         final q = model.questions[index]; | ||||
|                         return Card( | ||||
|                           key: ValueKey('q_$index'), | ||||
|                           margin: const EdgeInsets.symmetric(vertical: 8), | ||||
|                           clipBehavior: Clip.antiAlias, | ||||
|                           child: Column( | ||||
|                             children: [ | ||||
|                               _QuestionHeader( | ||||
|                                 index: index, | ||||
|                                 question: q, | ||||
|                                 onMoveUp: | ||||
|                                     index > 0 | ||||
|                                         ? () => notifier.moveQuestionUp(index) | ||||
|                                         : null, | ||||
|                                 onMoveDown: | ||||
|                                     index < model.questions.length - 1 | ||||
|                                         ? () => notifier.moveQuestionDown(index) | ||||
|                                         : null, | ||||
|                                 onDelete: () => notifier.removeQuestion(index), | ||||
|                               ), | ||||
|                               const Divider(height: 1), | ||||
|                               Padding( | ||||
|                                 padding: const EdgeInsets.all(16), | ||||
|                                 child: _QuestionEditor( | ||||
|                                   index: index, | ||||
|                                   question: q, | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ], | ||||
|                           ), | ||||
|                         ); | ||||
|                       }, | ||||
|                     ), | ||||
|                   const Gap(96), | ||||
|                 ], | ||||
|               ), | ||||
|               const Gap(8), | ||||
|               if (model.questions.isEmpty) | ||||
|                 _EmptyState( | ||||
|                   title: 'No questions yet', | ||||
|                   subtitle: 'Use "Add question" to start building your poll.', | ||||
|                 ) | ||||
|               else | ||||
|                 ReorderableListView.builder( | ||||
|                   shrinkWrap: true, | ||||
|                   physics: const NeverScrollableScrollPhysics(), | ||||
|                   itemCount: model.questions.length, | ||||
|                   onReorder: (oldIndex, newIndex) { | ||||
|                     // Convert to stepwise moves using provided functions | ||||
|                     if (newIndex > oldIndex) newIndex -= 1; | ||||
|                     final steps = newIndex - oldIndex; | ||||
|                     if (steps == 0) return; | ||||
|                     if (steps > 0) { | ||||
|                       for (int i = 0; i < steps; i++) { | ||||
|                         notifier.moveQuestionDown(oldIndex + i); | ||||
|                       } | ||||
|                     } else { | ||||
|                       for (int i = 0; i > steps; i--) { | ||||
|                         notifier.moveQuestionUp(oldIndex + i); | ||||
|                       } | ||||
|                     } | ||||
|                   }, | ||||
|                   buildDefaultDragHandles: false, | ||||
|                   itemBuilder: (context, index) { | ||||
|                     final q = model.questions[index]; | ||||
|                     return Card( | ||||
|                       key: ValueKey('q_$index'), | ||||
|                       margin: const EdgeInsets.symmetric(vertical: 8), | ||||
|                       clipBehavior: Clip.antiAlias, | ||||
|                       child: Column( | ||||
|                         children: [ | ||||
|                           _QuestionHeader( | ||||
|                             index: index, | ||||
|                             question: q, | ||||
|                             onMoveUp: | ||||
|                                 index > 0 | ||||
|                                     ? () => notifier.moveQuestionUp(index) | ||||
|                                     : null, | ||||
|                             onMoveDown: | ||||
|                                 index < model.questions.length - 1 | ||||
|                                     ? () => notifier.moveQuestionDown(index) | ||||
|                                     : null, | ||||
|                             onDelete: () => notifier.removeQuestion(index), | ||||
|                           ), | ||||
|                           const Divider(height: 1), | ||||
|                           Padding( | ||||
|                             padding: const EdgeInsets.all(16), | ||||
|                             child: _QuestionEditor(index: index, question: q), | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ); | ||||
|                   }, | ||||
|                 ), | ||||
|               const Gap(96), | ||||
|             ), | ||||
|           ), | ||||
|           Row( | ||||
|             children: [ | ||||
|               OutlinedButton.icon( | ||||
|                 onPressed: () { | ||||
|                   Navigator.of(context).maybePop(); | ||||
|                 }, | ||||
|                 icon: const Icon(Icons.close), | ||||
|                 label: Text('cancel'.tr()), | ||||
|               ), | ||||
|               const Spacer(), | ||||
|               FilledButton.icon( | ||||
|                 onPressed: () { | ||||
|                   _submitPoll(context, ref); | ||||
|                 }, | ||||
|                 icon: const Icon(Icons.cloud_upload_outlined), | ||||
|                 label: Text(model.id == null ? 'create'.tr() : 'update'.tr()), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|       bottomNavigationBar: Padding( | ||||
|         padding: EdgeInsets.fromLTRB( | ||||
|           16, | ||||
|           8, | ||||
|           16, | ||||
|           16 + MediaQuery.of(context).padding.bottom, | ||||
|         ), | ||||
|         child: Row( | ||||
|           children: [ | ||||
|             OutlinedButton.icon( | ||||
|               onPressed: () { | ||||
|                 Navigator.of(context).maybePop(); | ||||
|               }, | ||||
|               icon: const Icon(Icons.close), | ||||
|               label: const Text('Cancel'), | ||||
|             ), | ||||
|             const Spacer(), | ||||
|             FilledButton.icon( | ||||
|               onPressed: () { | ||||
|                 _submitPoll(context, ref); | ||||
|               }, | ||||
|               icon: const Icon(Icons.cloud_upload_outlined), | ||||
|               label: Text(model.id == null ? 'Create' : 'Update'), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| @@ -636,14 +638,14 @@ class PollEditorScreen extends ConsumerWidget { | ||||
|       context: context, | ||||
|       builder: | ||||
|           (_) => AlertDialog( | ||||
|             title: const Text('Debug Preview'), | ||||
|             title: Text('pollDebugPreview'.tr()), | ||||
|             content: SingleChildScrollView( | ||||
|               child: SelectableText(buf.toString()), | ||||
|             ), | ||||
|             actions: [ | ||||
|               TextButton( | ||||
|                 onPressed: () => Navigator.of(context).pop(), | ||||
|                 child: const Text('Close'), | ||||
|                 child: Text('close'.tr()), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
| @@ -672,15 +674,15 @@ IconData _iconForType(SnPollQuestionType t) { | ||||
| String _labelForType(SnPollQuestionType t) { | ||||
|   switch (t) { | ||||
|     case SnPollQuestionType.singleChoice: | ||||
|       return 'Single choice'; | ||||
|       return 'pollQuestionTypeSingleChoice'.tr(); | ||||
|     case SnPollQuestionType.multipleChoice: | ||||
|       return 'Multiple choice'; | ||||
|       return 'pollQuestionTypeMultipleChoice'.tr(); | ||||
|     case SnPollQuestionType.freeText: | ||||
|       return 'Free text'; | ||||
|       return 'pollQuestionTypeFreeText'.tr(); | ||||
|     case SnPollQuestionType.yesNo: | ||||
|       return 'Yes / No'; | ||||
|       return 'pollQuestionTypeYesNo'.tr(); | ||||
|     case SnPollQuestionType.rating: | ||||
|       return 'Rating'; | ||||
|       return 'pollQuestionTypeRating'.tr(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -697,8 +699,8 @@ class _EndDatePicker extends StatelessWidget { | ||||
|       children: [ | ||||
|         Expanded( | ||||
|           child: InputDecorator( | ||||
|             decoration: const InputDecoration( | ||||
|               labelText: 'End date & time (optional)', | ||||
|             decoration: InputDecoration( | ||||
|               labelText: 'pollEndDateOptional'.tr(), | ||||
|               border: OutlineInputBorder( | ||||
|                 borderRadius: BorderRadius.all(Radius.circular(16)), | ||||
|               ), | ||||
| @@ -710,7 +712,7 @@ class _EndDatePicker extends StatelessWidget { | ||||
|                 Icon(Icons.event, color: Theme.of(context).colorScheme.primary), | ||||
|                 Text( | ||||
|                   value == null | ||||
|                       ? 'Not set' | ||||
|                       ? 'notSet'.tr() | ||||
|                       : MaterialLocalizations.of( | ||||
|                         context, | ||||
|                       ).formatFullDate(value!), | ||||
| @@ -758,12 +760,12 @@ class _EndDatePicker extends StatelessWidget { | ||||
|                     ); | ||||
|                     onChanged(dt); | ||||
|                   }, | ||||
|                   child: const Text('Pick'), | ||||
|                   child: Text('pick'.tr()), | ||||
|                 ), | ||||
|                 if (value != null) | ||||
|                   TextButton( | ||||
|                     onPressed: () => onChanged(null), | ||||
|                     child: const Text('Clear'), | ||||
|                     child: Text('clear'.tr()), | ||||
|                   ), | ||||
|               ], | ||||
|             ), | ||||
| @@ -798,7 +800,7 @@ class _QuestionHeader extends StatelessWidget { | ||||
|         child: const Icon(Icons.drag_handle), | ||||
|       ), | ||||
|       title: Text( | ||||
|         question.title.isEmpty ? 'Untitled question' : question.title, | ||||
|         question.title.isEmpty ? 'pollUntitledQuestion'.tr() : question.title, | ||||
|         maxLines: 1, | ||||
|         overflow: TextOverflow.ellipsis, | ||||
|       ), | ||||
| @@ -807,17 +809,17 @@ class _QuestionHeader extends StatelessWidget { | ||||
|         spacing: 4, | ||||
|         children: [ | ||||
|           IconButton( | ||||
|             tooltip: 'Move up', | ||||
|             tooltip: 'moveUp'.tr(), | ||||
|             onPressed: onMoveUp, | ||||
|             icon: const Icon(Icons.arrow_upward), | ||||
|           ), | ||||
|           IconButton( | ||||
|             tooltip: 'Move down', | ||||
|             tooltip: 'moveDown'.tr(), | ||||
|             onPressed: onMoveDown, | ||||
|             icon: const Icon(Icons.arrow_downward), | ||||
|           ), | ||||
|           IconButton( | ||||
|             tooltip: 'Delete', | ||||
|             tooltip: 'delete'.tr(), | ||||
|             onPressed: onDelete, | ||||
|             icon: const Icon(Icons.delete_outline), | ||||
|             color: Theme.of(context).colorScheme.error, | ||||
| @@ -852,7 +854,7 @@ class _QuestionEditor extends ConsumerWidget { | ||||
|               onChanged: (t) => notifier.setQuestionType(index, t), | ||||
|             ), | ||||
|             FilterChip( | ||||
|               label: const Text('Required'), | ||||
|               label: Text('required'.tr()), | ||||
|               selected: question.isRequired, | ||||
|               onSelected: (v) => notifier.setQuestionRequired(index, v), | ||||
|               avatar: Icon( | ||||
| @@ -866,8 +868,8 @@ class _QuestionEditor extends ConsumerWidget { | ||||
|         const Gap(12), | ||||
|         TextFormField( | ||||
|           initialValue: question.title, | ||||
|           decoration: const InputDecoration( | ||||
|             labelText: 'Question title', | ||||
|           decoration: InputDecoration( | ||||
|             labelText: 'pollQuestionTitle'.tr(), | ||||
|             border: OutlineInputBorder( | ||||
|               borderRadius: BorderRadius.all(Radius.circular(16)), | ||||
|             ), | ||||
| @@ -878,7 +880,7 @@ class _QuestionEditor extends ConsumerWidget { | ||||
|           onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|           validator: (v) { | ||||
|             if (v == null || v.trim().isEmpty) { | ||||
|               return 'Question title is required'; | ||||
|               return 'pollQuestionTitleRequired'.tr(); | ||||
|             } | ||||
|             return null; | ||||
|           }, | ||||
| @@ -886,8 +888,8 @@ class _QuestionEditor extends ConsumerWidget { | ||||
|         const Gap(12), | ||||
|         TextFormField( | ||||
|           initialValue: question.description ?? '', | ||||
|           decoration: const InputDecoration( | ||||
|             labelText: 'Question description (optional)', | ||||
|           decoration: InputDecoration( | ||||
|             labelText: 'pollQuestionDescriptionOptional'.tr(), | ||||
|             border: OutlineInputBorder( | ||||
|               borderRadius: BorderRadius.all(Radius.circular(16)), | ||||
|             ), | ||||
| @@ -901,7 +903,7 @@ class _QuestionEditor extends ConsumerWidget { | ||||
|         ), | ||||
|         if (question.options != null) ...[ | ||||
|           const Gap(16), | ||||
|           Text('Options', style: Theme.of(context).textTheme.titleMedium), | ||||
|           Text('options'.tr(), style: Theme.of(context).textTheme.titleMedium), | ||||
|           const Gap(8), | ||||
|           _OptionsEditor(index: index, options: question.options!), | ||||
|           const Gap(4), | ||||
| @@ -910,7 +912,7 @@ class _QuestionEditor extends ConsumerWidget { | ||||
|             child: OutlinedButton.icon( | ||||
|               onPressed: () => notifier.addOption(index), | ||||
|               icon: const Icon(Icons.add), | ||||
|               label: const Text('Add option'), | ||||
|               label: Text('pollAddOption'.tr()), | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
| @@ -936,8 +938,8 @@ class _QuestionTypePicker extends StatelessWidget { | ||||
|   Widget build(BuildContext context) { | ||||
|     return DropdownButtonFormField<SnPollQuestionType>( | ||||
|       value: value, | ||||
|       decoration: const InputDecoration( | ||||
|         labelText: 'Type', | ||||
|       decoration: InputDecoration( | ||||
|         labelText: 'Type'.tr(), | ||||
|         border: OutlineInputBorder( | ||||
|           borderRadius: BorderRadius.all(Radius.circular(16)), | ||||
|         ), | ||||
| @@ -986,8 +988,8 @@ class _OptionsEditor extends ConsumerWidget { | ||||
|                   child: TextFormField( | ||||
|                     key: ValueKey(options[i].id), | ||||
|                     initialValue: options[i].label, | ||||
|                     decoration: const InputDecoration( | ||||
|                       labelText: 'Option label', | ||||
|                     decoration: InputDecoration( | ||||
|                       labelText: 'pollOptionLabel'.tr(), | ||||
|                       border: OutlineInputBorder( | ||||
|                         borderRadius: BorderRadius.all(Radius.circular(16)), | ||||
|                       ), | ||||
| @@ -1002,7 +1004,7 @@ class _OptionsEditor extends ConsumerWidget { | ||||
|                 SizedBox( | ||||
|                   width: 40, | ||||
|                   child: IconButton( | ||||
|                     tooltip: 'Move up', | ||||
|                     tooltip: 'moveUp'.tr(), | ||||
|                     onPressed: | ||||
|                         i > 0 ? () => notifier.moveOptionUp(index, i) : null, | ||||
|                     icon: const Icon(Icons.arrow_upward), | ||||
| @@ -1011,7 +1013,7 @@ class _OptionsEditor extends ConsumerWidget { | ||||
|                 SizedBox( | ||||
|                   width: 40, | ||||
|                   child: IconButton( | ||||
|                     tooltip: 'Move down', | ||||
|                     tooltip: 'moveDown'.tr(), | ||||
|                     onPressed: | ||||
|                         i < options.length - 1 | ||||
|                             ? () => notifier.moveOptionDown(index, i) | ||||
| @@ -1022,7 +1024,7 @@ class _OptionsEditor extends ConsumerWidget { | ||||
|                 SizedBox( | ||||
|                   width: 40, | ||||
|                   child: IconButton( | ||||
|                     tooltip: 'Delete', | ||||
|                     tooltip: 'delete'.tr(), | ||||
|                     onPressed: () => notifier.removeOption(index, i), | ||||
|                     icon: const Icon(Icons.close), | ||||
|                   ), | ||||
| @@ -1047,7 +1049,7 @@ class _TextAnswerPreview extends StatelessWidget { | ||||
|       maxLines: long ? 4 : 1, | ||||
|       decoration: InputDecoration( | ||||
|         labelText: | ||||
|             long ? 'Long text answer (preview)' : 'Short text answer (preview)', | ||||
|             long ? 'pollLongTextAnswerPreview'.tr() : 'pollShortTextAnswerPreview'.tr(), | ||||
|         border: const OutlineInputBorder( | ||||
|           borderRadius: BorderRadius.all(Radius.circular(16)), | ||||
|         ), | ||||
| @@ -1081,9 +1083,9 @@ class _EmptyState extends StatelessWidget { | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Text(title, style: Theme.of(context).textTheme.titleMedium), | ||||
|                 Text('pollNoQuestionsYet'.tr(), style: Theme.of(context).textTheme.titleMedium), | ||||
|                 const Gap(4), | ||||
|                 Text(subtitle, style: Theme.of(context).textTheme.bodyMedium), | ||||
|                 Text('pollNoQuestionsHint'.tr(), style: Theme.of(context).textTheme.bodyMedium), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|   | ||||
| @@ -92,6 +92,7 @@ class PostDetailScreen extends HookConsumerWidget { | ||||
|                   right: 0, | ||||
|                   child: Material( | ||||
|                     elevation: 2, | ||||
|                     color: Theme.of(context).colorScheme.surfaceContainer, | ||||
|                     child: postState | ||||
|                         .when( | ||||
|                           data: | ||||
| @@ -107,8 +108,8 @@ class PostDetailScreen extends HookConsumerWidget { | ||||
|                           error: (_, _) => const SizedBox.shrink(), | ||||
|                         ) | ||||
|                         .padding( | ||||
|                           bottom: MediaQuery.of(context).padding.bottom + 16, | ||||
|                           top: 16, | ||||
|                           bottom: MediaQuery.of(context).padding.bottom + 8, | ||||
|                           top: 8, | ||||
|                           horizontal: 16, | ||||
|                         ), | ||||
|                   ), | ||||
|   | ||||
| @@ -51,12 +51,12 @@ class PostSearchNotifier | ||||
|       final offset = cursor == null ? 0 : int.parse(cursor); | ||||
|  | ||||
|       final response = await client.get( | ||||
|         '/sphere/posts/search', | ||||
|         '/sphere/posts', | ||||
|         queryParameters: { | ||||
|           'query': _currentQuery, | ||||
|           'offset': offset, | ||||
|           'take': _pageSize, | ||||
|           'useVector': false, | ||||
|           'vector': false, | ||||
|         }, | ||||
|       ); | ||||
|  | ||||
|   | ||||
| @@ -7,7 +7,7 @@ import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/models/publisher.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/services/color.dart'; | ||||
|   | ||||
| @@ -488,6 +488,7 @@ class _RealmMemberListSheet extends HookConsumerWidget { | ||||
|     Future<void> invitePerson() async { | ||||
|       final result = await showModalBottomSheet( | ||||
|         isScrollControlled: true, | ||||
|         useRootNavigator: true, | ||||
|         context: context, | ||||
|         builder: (context) => const AccountPickerSheet(), | ||||
|       ); | ||||
|   | ||||
| @@ -10,7 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:island/main.dart'; | ||||
| import 'package:island/route.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/widgets/app_notification.dart'; | ||||
| import 'package:top_snackbar_flutter/top_snack_bar.dart'; | ||||
| @@ -26,7 +26,12 @@ StreamSubscription<WebSocketPacket> setupNotificationListener( | ||||
|       final notification = SnNotification.fromJson(pkt.data!); | ||||
|       showTopSnackBar( | ||||
|         globalOverlay.currentState!, | ||||
|         NotificationCard(notification: notification), | ||||
|         Center( | ||||
|           child: ConstrainedBox( | ||||
|             constraints: const BoxConstraints(maxWidth: 480), | ||||
|             child: NotificationCard(notification: notification), | ||||
|           ), | ||||
|         ), | ||||
|         onTap: () { | ||||
|           if (notification.meta['action_uri'] != null) { | ||||
|             var uri = notification.meta['action_uri'] as String; | ||||
| @@ -53,9 +58,9 @@ StreamSubscription<WebSocketPacket> setupNotificationListener( | ||||
|                       (Platform.isMacOS || | ||||
|                           Platform.isWindows || | ||||
|                           Platform.isLinux)) | ||||
|                   ? 24 | ||||
|                   ? 28 | ||||
|                   // ignore: use_build_context_synchronously | ||||
|                   : MediaQuery.of(context).padding.top + 8, | ||||
|                   : MediaQuery.of(context).padding.top + 16, | ||||
|           bottom: 16, | ||||
|         ), | ||||
|       ); | ||||
| @@ -67,6 +72,9 @@ Future<void> subscribePushNotification( | ||||
|   Dio apiClient, { | ||||
|   bool detailedErrors = false, | ||||
| }) async { | ||||
|   if (Platform.isLinux) { | ||||
|     return; | ||||
|   } | ||||
|   await FirebaseMessaging.instance.requestPermission( | ||||
|     alert: true, | ||||
|     badge: true, | ||||
|   | ||||
| @@ -1,19 +1,28 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:developer'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_app_update/azhon_app_update.dart'; | ||||
| import 'package:flutter_app_update/update_model.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:package_info_plus/package_info_plus.dart'; | ||||
| import 'package:collection/collection.dart'; // Added for firstWhereOrNull | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:url_launcher/url_launcher.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
|  | ||||
| /// Data model for a GitHub release we care about | ||||
| class GithubReleaseInfo { | ||||
|   final String tagName; // e.g. 3.1.0+118 | ||||
|   final String name; // release title | ||||
|   final String body; // changelog markdown | ||||
|   final String htmlUrl; // release page | ||||
|   final String tagName; | ||||
|   final String name; | ||||
|   final String body; | ||||
|   final String htmlUrl; | ||||
|   final DateTime createdAt; | ||||
|   final List<GithubReleaseAsset> assets; | ||||
|  | ||||
|   const GithubReleaseInfo({ | ||||
|     required this.tagName, | ||||
| @@ -21,9 +30,28 @@ class GithubReleaseInfo { | ||||
|     required this.body, | ||||
|     required this.htmlUrl, | ||||
|     required this.createdAt, | ||||
|     this.assets = const [], | ||||
|   }); | ||||
| } | ||||
|  | ||||
| /// Data model for a GitHub release asset | ||||
| class GithubReleaseAsset { | ||||
|   final String name; | ||||
|   final String browserDownloadUrl; | ||||
|  | ||||
|   const GithubReleaseAsset({ | ||||
|     required this.name, | ||||
|     required this.browserDownloadUrl, | ||||
|   }); | ||||
|  | ||||
|   factory GithubReleaseAsset.fromJson(Map<String, dynamic> json) { | ||||
|     return GithubReleaseAsset( | ||||
|       name: json['name'] as String, | ||||
|       browserDownloadUrl: json['browser_download_url'] as String, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// Parses version and build number from "x.y.z+build" | ||||
| class _ParsedVersion implements Comparable<_ParsedVersion> { | ||||
|   final int major; | ||||
| @@ -62,7 +90,7 @@ class _ParsedVersion implements Comparable<_ParsedVersion> { | ||||
| } | ||||
|  | ||||
| class UpdateService { | ||||
|   UpdateService({Dio? dio}) | ||||
|   UpdateService({Dio? dio, this.useProxy = false}) | ||||
|     : _dio = | ||||
|           dio ?? | ||||
|           Dio( | ||||
| @@ -78,6 +106,9 @@ class UpdateService { | ||||
|           ); | ||||
|  | ||||
|   final Dio _dio; | ||||
|   final bool useProxy; | ||||
|  | ||||
|   static const _proxyBaseUrl = 'https://ghfast.top/'; | ||||
|  | ||||
|   static const _releasesLatestApi = | ||||
|       'https://api.github.com/repos/solsynth/solian/releases/latest'; | ||||
| @@ -85,31 +116,52 @@ class UpdateService { | ||||
|   /// Checks GitHub for the latest release and compares against the current app version. | ||||
|   /// If update is available, shows a bottom sheet with changelog and an action to open release page. | ||||
|   Future<void> checkForUpdates(BuildContext context) async { | ||||
|     log('[Update] Checking for updates...'); | ||||
|     try { | ||||
|       final release = await fetchLatestRelease(); | ||||
|       if (release == null) return; | ||||
|       if (release == null) { | ||||
|         log('[Update] No latest release found or could not fetch.'); | ||||
|         return; | ||||
|       } | ||||
|       log('[Update] Fetched latest release: ${release.tagName}'); | ||||
|  | ||||
|       final info = await PackageInfo.fromPlatform(); | ||||
|       final localVersionStr = '${info.version}+${info.buildNumber}'; | ||||
|       log('[Update] Local app version: $localVersionStr'); | ||||
|  | ||||
|       final latest = _ParsedVersion.tryParse(release.tagName); | ||||
|       final local = _ParsedVersion.tryParse(localVersionStr); | ||||
|  | ||||
|       if (latest == null || local == null) { | ||||
|         log( | ||||
|           '[Update] Failed to parse versions. Latest: ${release.tagName}, Local: $localVersionStr', | ||||
|         ); | ||||
|         // If parsing fails, do nothing silently | ||||
|         return; | ||||
|       } | ||||
|       log('[Update] Parsed versions. Latest: $latest, Local: $local'); | ||||
|  | ||||
|       final needsUpdate = latest.compareTo(local) > 0; | ||||
|       if (!needsUpdate) return; | ||||
|       if (!needsUpdate) { | ||||
|         log('[Update] App is up to date. No update needed.'); | ||||
|         return; | ||||
|       } | ||||
|       log('[Update] Update available! Latest: $latest, Local: $local'); | ||||
|  | ||||
|       if (!context.mounted) return; | ||||
|       if (!context.mounted) { | ||||
|         log('[Update] Context not mounted, cannot show update sheet.'); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Delay to ensure UI is ready (if called at startup) | ||||
|       await Future.delayed(const Duration(milliseconds: 100)); | ||||
|  | ||||
|       await showUpdateSheet(context, release); | ||||
|     } catch (_) { | ||||
|       if (context.mounted) { | ||||
|         await showUpdateSheet(context, release); | ||||
|         log('[Update] Update sheet shown.'); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       log('[Update] Error checking for updates: $e'); | ||||
|       // Ignore errors (network, api, etc.) | ||||
|       return; | ||||
|     } | ||||
| @@ -126,25 +178,68 @@ class UpdateService { | ||||
|       context: context, | ||||
|       isScrollControlled: true, | ||||
|       useRootNavigator: true, | ||||
|       builder: | ||||
|           (ctx) => _UpdateSheet( | ||||
|             release: release, | ||||
|             onOpen: () async { | ||||
|               final uri = Uri.parse(release.htmlUrl); | ||||
|               if (await canLaunchUrl(uri)) { | ||||
|                 await launchUrl(uri, mode: LaunchMode.externalApplication); | ||||
|               } | ||||
|             }, | ||||
|           ), | ||||
|       builder: (ctx) { | ||||
|         String? androidUpdateUrl; | ||||
|         if (Platform.isAndroid) { | ||||
|           androidUpdateUrl = _getAndroidUpdateUrl(release.assets); | ||||
|         } | ||||
|         return _UpdateSheet( | ||||
|           release: release, | ||||
|           onOpen: () async { | ||||
|             final uri = Uri.parse(release.htmlUrl); | ||||
|             if (await canLaunchUrl(uri)) { | ||||
|               await launchUrl(uri, mode: LaunchMode.externalApplication); | ||||
|             } | ||||
|           }, | ||||
|           androidUpdateUrl: androidUpdateUrl, | ||||
|           useProxy: useProxy, // Pass the useProxy flag | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   String? _getAndroidUpdateUrl(List<GithubReleaseAsset> assets) { | ||||
|     final arm64 = assets.firstWhereOrNull( | ||||
|       (asset) => asset.name == 'app-arm64-v8a-release.apk', | ||||
|     ); | ||||
|     final armeabi = assets.firstWhereOrNull( | ||||
|       (asset) => asset.name == 'app-armeabi-v7a-release.apk', | ||||
|     ); | ||||
|     final x86_64 = assets.firstWhereOrNull( | ||||
|       (asset) => asset.name == 'app-x86_64-release.apk', | ||||
|     ); | ||||
|  | ||||
|     // Prioritize arm64, then armeabi, then x86_64 | ||||
|     if (arm64 != null) { | ||||
|       return arm64.browserDownloadUrl; | ||||
|     } else if (armeabi != null) { | ||||
|       return armeabi.browserDownloadUrl; | ||||
|     } else if (x86_64 != null) { | ||||
|       return x86_64.browserDownloadUrl; | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   /// Fetch the latest release info from GitHub. | ||||
|   /// Public so other screens (e.g., About) can manually trigger update checks. | ||||
|   Future<GithubReleaseInfo?> fetchLatestRelease() async { | ||||
|     final resp = await _dio.get(_releasesLatestApi); | ||||
|     if (resp.statusCode != 200) return null; | ||||
|     final apiEndpoint = | ||||
|         useProxy | ||||
|             ? '$_proxyBaseUrl${Uri.encodeComponent(_releasesLatestApi)}' | ||||
|             : _releasesLatestApi; | ||||
|  | ||||
|     log( | ||||
|       '[Update] Fetching latest release from GitHub API: $apiEndpoint (Proxy: $useProxy)', | ||||
|     ); | ||||
|     final resp = await _dio.get(apiEndpoint); | ||||
|     if (resp.statusCode != 200) { | ||||
|       log( | ||||
|         '[Update] Failed to fetch latest release. Status code: ${resp.statusCode}', | ||||
|       ); | ||||
|       return null; | ||||
|     } | ||||
|     final data = resp.data as Map<String, dynamic>; | ||||
|     log('[Update] Successfully fetched release data.'); | ||||
|  | ||||
|     final tagName = (data['tag_name'] ?? '').toString(); | ||||
|     final name = (data['name'] ?? tagName).toString(); | ||||
| @@ -152,25 +247,70 @@ class UpdateService { | ||||
|     final htmlUrl = (data['html_url'] ?? '').toString(); | ||||
|     final createdAtStr = (data['created_at'] ?? '').toString(); | ||||
|     final createdAt = DateTime.tryParse(createdAtStr) ?? DateTime.now(); | ||||
|     final assetsData = | ||||
|         (data['assets'] as List<dynamic>?) | ||||
|             ?.map((e) => GithubReleaseAsset.fromJson(e as Map<String, dynamic>)) | ||||
|             .toList() ?? | ||||
|         []; | ||||
|  | ||||
|     if (tagName.isEmpty || htmlUrl.isEmpty) return null; | ||||
|     if (tagName.isEmpty || htmlUrl.isEmpty) { | ||||
|       log( | ||||
|         '[Update] Missing tag_name or html_url in release data. TagName: "$tagName", HtmlUrl: "$htmlUrl"', | ||||
|       ); | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     log('[Update] Returning GithubReleaseInfo for tag: $tagName'); | ||||
|     return GithubReleaseInfo( | ||||
|       tagName: tagName, | ||||
|       name: name, | ||||
|       body: body, | ||||
|       htmlUrl: htmlUrl, | ||||
|       createdAt: createdAt, | ||||
|       assets: assetsData, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _UpdateSheet extends StatelessWidget { | ||||
|   const _UpdateSheet({required this.release, required this.onOpen}); | ||||
| class _UpdateSheet extends StatefulWidget { | ||||
|   const _UpdateSheet({ | ||||
|     required this.release, | ||||
|     required this.onOpen, | ||||
|     this.androidUpdateUrl, | ||||
|     this.useProxy = false, | ||||
|   }); | ||||
|  | ||||
|   final String? androidUpdateUrl; | ||||
|   final bool useProxy; | ||||
|   final GithubReleaseInfo release; | ||||
|   final VoidCallback onOpen; | ||||
|  | ||||
|   @override | ||||
|   State<_UpdateSheet> createState() => _UpdateSheetState(); | ||||
| } | ||||
|  | ||||
| class _UpdateSheetState extends State<_UpdateSheet> { | ||||
|   late bool _useProxy; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _useProxy = widget.useProxy; | ||||
|   } | ||||
|  | ||||
|   Future<void> _installUpdate(String url) async { | ||||
|     final downloadUrl = | ||||
|         _useProxy ? 'https://ghfast.top/${Uri.encodeComponent(url)}' : url; | ||||
|  | ||||
|     UpdateModel model = UpdateModel( | ||||
|       downloadUrl, | ||||
|       "solian-update-${widget.release.tagName}.apk", | ||||
|       "launcher_icon", | ||||
|       'https://apps.apple.com/us/app/solian/id6499032345', | ||||
|     ); | ||||
|     AzhonAppUpdate.update(model); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final theme = Theme.of(context); | ||||
| @@ -186,8 +326,11 @@ class _UpdateSheet extends StatelessWidget { | ||||
|             Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Text(release.name, style: theme.textTheme.titleMedium).bold(), | ||||
|                 Text(release.tagName).fontSize(12), | ||||
|                 Text( | ||||
|                   widget.release.name, | ||||
|                   style: theme.textTheme.titleMedium, | ||||
|                 ).bold(), | ||||
|                 Text(widget.release.tagName).fontSize(12), | ||||
|               ], | ||||
|             ).padding(vertical: 16, horizontal: 16), | ||||
|             const Divider(height: 1), | ||||
| @@ -197,21 +340,45 @@ class _UpdateSheet extends StatelessWidget { | ||||
|                   horizontal: 16, | ||||
|                   vertical: 16, | ||||
|                 ), | ||||
|                 child: SelectableText( | ||||
|                   release.body.isEmpty | ||||
|                       ? 'No changelog provided.' | ||||
|                       : release.body, | ||||
|                   style: theme.textTheme.bodyMedium, | ||||
|                 child: MarkdownTextContent( | ||||
|                   content: | ||||
|                       widget.release.body.isEmpty | ||||
|                           ? 'No changelog provided.' | ||||
|                           : widget.release.body, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             if (!kIsWeb && Platform.isAndroid) | ||||
|               SwitchListTile( | ||||
|                 title: const Text('Use GitHub Proxy for Download'), | ||||
|                 value: _useProxy, | ||||
|                 onChanged: (value) { | ||||
|                   setState(() { | ||||
|                     _useProxy = value; | ||||
|                   }); | ||||
|                 }, | ||||
|               ).padding(horizontal: 8), | ||||
|             Column( | ||||
|               children: [ | ||||
|                 Row( | ||||
|                   spacing: 8, | ||||
|                   children: [ | ||||
|                     if (!kIsWeb && | ||||
|                         Platform.isAndroid && | ||||
|                         widget.androidUpdateUrl != null) | ||||
|                       Expanded( | ||||
|                         child: FilledButton.icon( | ||||
|                           onPressed: () { | ||||
|                             log(widget.androidUpdateUrl!); | ||||
|                             _installUpdate(widget.androidUpdateUrl!); | ||||
|                           }, | ||||
|                           icon: const Icon(Symbols.update), | ||||
|                           label: const Text('Install update'), | ||||
|                         ), | ||||
|                       ), | ||||
|                     Expanded( | ||||
|                       child: FilledButton.icon( | ||||
|                         onPressed: onOpen, | ||||
|                         onPressed: widget.onOpen, | ||||
|                         icon: const Icon(Icons.open_in_new), | ||||
|                         label: const Text('Open release page'), | ||||
|                       ), | ||||
|   | ||||
| @@ -3,33 +3,34 @@ import 'dart:convert'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/auth.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/services/udid.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:island/widgets/response.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| 
 | ||||
| part 'account_session_sheet.g.dart'; | ||||
| part 'account_devices.g.dart'; | ||||
| 
 | ||||
| @riverpod | ||||
| Future<List<SnAuthDevice>> authDevices(Ref ref) async { | ||||
| Future<List<SnAuthDeviceWithChallenge>> authDevices(Ref ref) async { | ||||
|   final resp = await ref | ||||
|       .watch(apiClientProvider) | ||||
|       .get('/id/accounts/me/devices'); | ||||
|   final sessionId = resp.headers.value('x-auth-session'); | ||||
|   final currentId = await getUdid(); | ||||
|   final data = | ||||
|       resp.data.map<SnAuthDevice>((e) { | ||||
|         final ele = SnAuthDevice.fromJson(e); | ||||
|         return ele.copyWith(isCurrent: ele.sessions.first.id == sessionId); | ||||
|       resp.data.map<SnAuthDeviceWithChallenge>((e) { | ||||
|         final ele = SnAuthDeviceWithChallenge.fromJson(e); | ||||
|         return ele.copyWith(isCurrent: ele.deviceId == currentId); | ||||
|       }).toList(); | ||||
|   return data; | ||||
| } | ||||
| 
 | ||||
| class _DeviceListTile extends StatelessWidget { | ||||
|   final SnAuthDevice device; | ||||
|   final SnAuthDeviceWithChallenge device; | ||||
|   final Function(String) updateDeviceLabel; | ||||
|   final Function(String) logoutDevice; | ||||
| 
 | ||||
| @@ -57,17 +58,16 @@ class _DeviceListTile extends StatelessWidget { | ||||
|       subtitle: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           Text('authSessionsCount'.plural(device.sessions.length)), | ||||
|           Text( | ||||
|             'lastActiveAt'.tr( | ||||
|               args: [ | ||||
|                 DateFormat().format( | ||||
|                   device.sessions.first.lastGrantedAt.toLocal(), | ||||
|                   device.challenges.first.createdAt.toLocal(), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|           Text(device.sessions.first.challenge.ipAddress), | ||||
|           Text(device.challenges.first.ipAddress), | ||||
|           if (device.isCurrent) | ||||
|             Row( | ||||
|               children: [ | ||||
| @@ -84,7 +84,7 @@ class _DeviceListTile extends StatelessWidget { | ||||
|             ).padding(top: 4), | ||||
|         ], | ||||
|       ), | ||||
|       title: Text(device.label ?? device.sessions.first.challenge.userAgent), | ||||
|       title: Text(device.deviceLabel ?? device.deviceName), | ||||
|       trailing: | ||||
|           isWideScreen(context) | ||||
|               ? Row( | ||||
| @@ -93,14 +93,13 @@ class _DeviceListTile extends StatelessWidget { | ||||
|                   IconButton( | ||||
|                     icon: Icon(Icons.edit), | ||||
|                     tooltip: 'authDeviceEditLabel'.tr(), | ||||
|                     onPressed: | ||||
|                         () => updateDeviceLabel(device.sessions.first.id), | ||||
|                     onPressed: () => updateDeviceLabel(device.deviceId), | ||||
|                   ), | ||||
|                   if (!device.isCurrent) | ||||
|                     IconButton( | ||||
|                       icon: Icon(Icons.logout), | ||||
|                       tooltip: 'authDeviceLogout'.tr(), | ||||
|                       onPressed: () => logoutDevice(device.sessions.first.id), | ||||
|                       onPressed: () => logoutDevice(device.deviceId), | ||||
|                     ), | ||||
|                 ], | ||||
|               ) | ||||
| @@ -124,7 +123,7 @@ class AccountSessionSheet extends HookConsumerWidget { | ||||
|       if (!confirm || !context.mounted) return; | ||||
|       try { | ||||
|         final apiClient = ref.watch(apiClientProvider); | ||||
|         await apiClient.delete('/id/accounts/me/sessions/$sessionId'); | ||||
|         await apiClient.delete('/id/accounts/me/devices/$sessionId'); | ||||
|         ref.invalidate(authDevicesProvider); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
| @@ -163,7 +162,7 @@ class AccountSessionSheet extends HookConsumerWidget { | ||||
|       try { | ||||
|         final apiClient = ref.watch(apiClientProvider); | ||||
|         await apiClient.patch( | ||||
|           '/accounts/me/sessions/$sessionId/label', | ||||
|           '/id/accounts/me/devices/$sessionId/label', | ||||
|           data: jsonEncode(label), | ||||
|         ); | ||||
|         ref.invalidate(authDevicesProvider); | ||||
| @@ -194,7 +193,7 @@ class AccountSessionSheet extends HookConsumerWidget { | ||||
|                     ); | ||||
|                   } else { | ||||
|                     return Dismissible( | ||||
|                       key: Key('device-${device.sessions.first.id}'), | ||||
|                       key: Key('device-${device.id}'), | ||||
|                       direction: | ||||
|                           device.isCurrent | ||||
|                               ? DismissDirection.startToEnd | ||||
| @@ -213,7 +212,7 @@ class AccountSessionSheet extends HookConsumerWidget { | ||||
|                       ), | ||||
|                       confirmDismiss: (direction) async { | ||||
|                         if (direction == DismissDirection.startToEnd) { | ||||
|                           updateDeviceLabel(device.sessions.first.id); | ||||
|                           updateDeviceLabel(device.deviceId); | ||||
|                           return false; | ||||
|                         } else { | ||||
|                           final confirm = await showConfirmAlert( | ||||
| @@ -221,7 +220,7 @@ class AccountSessionSheet extends HookConsumerWidget { | ||||
|                             'authDeviceLogout'.tr(), | ||||
|                           ); | ||||
|                           if (confirm && context.mounted) { | ||||
|                             logoutDevice(device.sessions.first.id); | ||||
|                             logoutDevice(device.deviceId); | ||||
|                           } | ||||
|                           return false; // Don't dismiss | ||||
|                         } | ||||
| @@ -1,17 +1,17 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| 
 | ||||
| part of 'account_session_sheet.dart'; | ||||
| part of 'account_devices.dart'; | ||||
| 
 | ||||
| // ************************************************************************** | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
| 
 | ||||
| String _$authDevicesHash() => r'8bc41a1ffc37df8e757c977b4ddae11db8faaeb5'; | ||||
| String _$authDevicesHash() => r'feb19238f759921e51c888f8b443a3d7761e68da'; | ||||
| 
 | ||||
| /// See also [authDevices]. | ||||
| @ProviderFor(authDevices) | ||||
| final authDevicesProvider = | ||||
|     AutoDisposeFutureProvider<List<SnAuthDevice>>.internal( | ||||
|     AutoDisposeFutureProvider<List<SnAuthDeviceWithChallenge>>.internal( | ||||
|       authDevices, | ||||
|       name: r'authDevicesProvider', | ||||
|       debugGetCreateSourceHash: | ||||
| @@ -24,6 +24,7 @@ final authDevicesProvider = | ||||
| 
 | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| typedef AuthDevicesRef = AutoDisposeFutureProviderRef<List<SnAuthDevice>>; | ||||
| typedef AuthDevicesRef = | ||||
|     AutoDisposeFutureProviderRef<List<SnAuthDeviceWithChallenge>>; | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package | ||||
| @@ -1,7 +1,7 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/models/wallet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| @@ -44,9 +45,8 @@ class AccountPickerSheet extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     return Container( | ||||
|       constraints: BoxConstraints( | ||||
|         maxHeight: MediaQuery.of(context).size.height * 0.4, | ||||
|       ), | ||||
|       padding: MediaQuery.of(context).viewInsets, | ||||
|       height: MediaQuery.of(context).size.height * 0.6, | ||||
|       child: Column( | ||||
|         children: [ | ||||
|           Padding( | ||||
| @@ -54,8 +54,8 @@ class AccountPickerSheet extends HookConsumerWidget { | ||||
|             child: TextField( | ||||
|               controller: searchController, | ||||
|               onChanged: onSearchChanged, | ||||
|               decoration: const InputDecoration( | ||||
|                 hintText: 'Search accounts...', | ||||
|               decoration: InputDecoration( | ||||
|                 hintText: 'searchAccounts'.tr(), | ||||
|                 contentPadding: EdgeInsets.symmetric( | ||||
|                   horizontal: 18, | ||||
|                   vertical: 16, | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/models/badge.dart'; | ||||
|  | ||||
| class BadgeList extends StatelessWidget { | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/screens/account/profile.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/widgets/account/status.dart'; | ||||
| @@ -55,7 +55,7 @@ class AccountStatusCreationSheet extends HookConsumerWidget { | ||||
|             'attitude': attitude.value, | ||||
|             'is_invisible': isInvisible.value, | ||||
|             'is_not_disturb': isNotDisturb.value, | ||||
|             'cleared_at': clearedAt.value?.toIso8601String(), | ||||
|             'cleared_at': clearedAt.value?.toUtc().toIso8601String(), | ||||
|             if (labelController.text.isNotEmpty) 'label': labelController.text, | ||||
|           }, | ||||
|           options: Options(method: initialStatus == null ? 'POST' : 'PATCH'), | ||||
|   | ||||
| @@ -11,7 +11,12 @@ export 'content/alert.native.dart' | ||||
| void showSnackBar(String message, {SnackBarAction? action}) { | ||||
|   showTopSnackBar( | ||||
|     globalOverlay.currentState!, | ||||
|     Card(child: Text(message).padding(horizontal: 20, vertical: 16)), | ||||
|     ConstrainedBox( | ||||
|       constraints: const BoxConstraints(maxWidth: 480), | ||||
|       child: Center( | ||||
|         child: Card(child: Text(message).padding(horizontal: 20, vertical: 16)), | ||||
|       ), | ||||
|     ), | ||||
|     snackBarPosition: SnackBarPosition.bottom, | ||||
|   ); | ||||
| } | ||||
| @@ -69,7 +74,7 @@ void showLoadingModal(BuildContext context) { | ||||
|                 child: Column( | ||||
|                   mainAxisSize: MainAxisSize.min, | ||||
|                   children: [ | ||||
|                     CircularProgressIndicator(year2023: true), | ||||
|                     CircularProgressIndicator(year2023: false), | ||||
|                     const Gap(24), | ||||
|                     Text('loading'.tr()), | ||||
|                   ], | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|   | ||||
| @@ -331,7 +331,7 @@ class _WebSocketIndicator extends HookConsumerWidget { | ||||
|     final user = ref.watch(userInfoProvider); | ||||
|     final websocketState = ref.watch(websocketStateProvider); | ||||
|     final indicatorHeight = | ||||
|         MediaQuery.of(context).padding.top + (isDesktop ? 27.5 : 20); | ||||
|         MediaQuery.of(context).padding.top + (isDesktop ? 27.5 : 25); | ||||
|  | ||||
|     Color indicatorColor; | ||||
|     String indicatorText; | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/services/notify.dart'; | ||||
| import 'package:island/services/sharing_intent.dart'; | ||||
| import 'package:island/services/update_service.dart'; | ||||
| import 'package:island/widgets/content/network_status_sheet.dart'; | ||||
| import 'package:island/widgets/tour/tour.dart'; | ||||
|  | ||||
| @@ -21,6 +22,7 @@ class AppWrapper extends HookConsumerWidget { | ||||
|       }); | ||||
|       final sharingService = SharingIntentService(); | ||||
|       sharingService.initialize(context); | ||||
|       UpdateService().checkForUpdates(context); | ||||
|       return () { | ||||
|         sharingService.dispose(); | ||||
|         ntySubs?.cancel(); | ||||
|   | ||||
| @@ -31,6 +31,7 @@ class CloudFileList extends HookConsumerWidget { | ||||
|   final bool disableZoomIn; | ||||
|   final bool disableConstraint; | ||||
|   final EdgeInsets? padding; | ||||
|   final bool isColumn; | ||||
|   const CloudFileList({ | ||||
|     super.key, | ||||
|     required this.files, | ||||
| @@ -40,6 +41,7 @@ class CloudFileList extends HookConsumerWidget { | ||||
|     this.disableZoomIn = false, | ||||
|     this.disableConstraint = false, | ||||
|     this.padding, | ||||
|     this.isColumn = false, | ||||
|   }); | ||||
|  | ||||
|   double calculateAspectRatio() { | ||||
| @@ -63,6 +65,74 @@ class CloudFileList extends HookConsumerWidget { | ||||
|     ); | ||||
|  | ||||
|     if (files.isEmpty) return const SizedBox.shrink(); | ||||
|  | ||||
|     if (isColumn) { | ||||
|       final children = <Widget>[]; | ||||
|       const maxFiles = 2; | ||||
|       final filesToShow = files.take(maxFiles).toList(); | ||||
|  | ||||
|       for (var i = 0; i < filesToShow.length; i++) { | ||||
|         final file = filesToShow[i]; | ||||
|         final isImage = file.mimeType?.startsWith('image') ?? false; | ||||
|         final isAudio = file.mimeType?.startsWith('audio') ?? false; | ||||
|         final widgetItem = ClipRRect( | ||||
|           borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|           child: _CloudFileListEntry( | ||||
|             file: file, | ||||
|             heroTag: heroTags[i], | ||||
|             isImage: isImage, | ||||
|             disableZoomIn: disableZoomIn, | ||||
|             onTap: () { | ||||
|               if (!isImage) { | ||||
|                 return; | ||||
|               } | ||||
|               if (!disableZoomIn) { | ||||
|                 context.pushTransparentRoute( | ||||
|                   CloudFileZoomIn(item: file, heroTag: heroTags[i]), | ||||
|                   rootNavigator: true, | ||||
|                 ); | ||||
|               } | ||||
|             }, | ||||
|           ), | ||||
|         ); | ||||
|  | ||||
|         Widget item; | ||||
|         if (isAudio) { | ||||
|           item = SizedBox(height: 120, child: widgetItem); | ||||
|         } else { | ||||
|           item = AspectRatio( | ||||
|             aspectRatio: file.fileMeta?['ratio'] as double? ?? 1.0, | ||||
|             child: widgetItem, | ||||
|           ); | ||||
|         } | ||||
|         children.add(item); | ||||
|         if (i < filesToShow.length - 1) { | ||||
|           children.add(const Gap(8)); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       if (files.length > maxFiles) { | ||||
|         children.add(const Gap(8)); | ||||
|         children.add( | ||||
|           Text( | ||||
|             'filesListAdditional'.plural(files.length - filesToShow.length), | ||||
|             textAlign: TextAlign.center, | ||||
|             style: Theme.of(context).textTheme.bodyMedium?.copyWith( | ||||
|               color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       return Padding( | ||||
|         padding: padding ?? EdgeInsets.zero, | ||||
|         child: Column( | ||||
|           mainAxisSize: MainAxisSize.min, | ||||
|           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|           children: children, | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|     if (files.length == 1) { | ||||
|       final isImage = files.first.mimeType?.startsWith('image') ?? false; | ||||
|       final isAudio = files.first.mimeType?.startsWith('audio') ?? false; | ||||
|   | ||||
| @@ -142,7 +142,7 @@ class CloudVideoWidget extends HookConsumerWidget { | ||||
|               mainAxisAlignment: MainAxisAlignment.end, | ||||
|               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|               children: [ | ||||
|                 Row( | ||||
|                 Wrap( | ||||
|                   spacing: 8, | ||||
|                   children: [ | ||||
|                     if (item.fileMeta?['duration'] != null) | ||||
| @@ -199,8 +199,8 @@ class CloudVideoWidget extends HookConsumerWidget { | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ).padding(horizontal: 16, bottom: 12), | ||||
|             ).padding(horizontal: 16, bottom: 12), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|       onTap: () { | ||||
|   | ||||
| @@ -57,11 +57,11 @@ class EmbedLinkWidget extends StatelessWidget { | ||||
|                     Row( | ||||
|                       children: [ | ||||
|                         // Favicon | ||||
|                         if (link.faviconUrl.isNotEmpty) ...[ | ||||
|                         if (link.faviconUrl?.isNotEmpty ?? false) ...[ | ||||
|                           ClipRRect( | ||||
|                             borderRadius: BorderRadius.circular(4), | ||||
|                             child: UniversalImage( | ||||
|                               uri: link.faviconUrl, | ||||
|                               uri: link.faviconUrl!, | ||||
|                               width: 16, | ||||
|                               height: 16, | ||||
|                               fit: BoxFit.cover, | ||||
| @@ -80,8 +80,8 @@ class EmbedLinkWidget extends StatelessWidget { | ||||
|                         // Site name | ||||
|                         Expanded( | ||||
|                           child: Text( | ||||
|                             link.siteName.isNotEmpty | ||||
|                                 ? link.siteName | ||||
|                             (link.siteName?.isNotEmpty ?? false) | ||||
|                                 ? link.siteName! | ||||
|                                 : Uri.parse(link.url).host, | ||||
|                             style: theme.textTheme.bodySmall?.copyWith( | ||||
|                               color: colorScheme.onSurfaceVariant, | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_highlight/themes/a11y-dark.dart'; | ||||
| import 'package:flutter_highlight/themes/a11y-light.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:google_fonts/google_fonts.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| @@ -71,7 +72,22 @@ class MarkdownTextContent extends HookConsumerWidget { | ||||
|             textStyle: textStyle ?? Theme.of(context).textTheme.bodyMedium!, | ||||
|           ), | ||||
|           HrConfig(height: 1, color: Theme.of(context).dividerColor), | ||||
|           PreConfig(theme: isDark ? a11yDarkTheme : a11yLightTheme), | ||||
|           PreConfig( | ||||
|             theme: isDark ? a11yDarkTheme : a11yLightTheme, | ||||
|             textStyle: GoogleFonts.robotoMono(fontSize: 14), | ||||
|             styleNotMatched: GoogleFonts.robotoMono(fontSize: 14), | ||||
|             decoration: BoxDecoration( | ||||
|               color: Theme.of(context).colorScheme.surfaceContainerHighest, | ||||
|               borderRadius: BorderRadius.all(Radius.circular(8.0)), | ||||
|             ), | ||||
|           ), | ||||
|           TableConfig( | ||||
|             wrapper: | ||||
|                 (child) => SingleChildScrollView( | ||||
|                   scrollDirection: Axis.horizontal, | ||||
|                   child: child, | ||||
|                 ), | ||||
|           ), | ||||
|           LinkConfig( | ||||
|             style: | ||||
|                 linkStyle ?? | ||||
| @@ -160,16 +176,22 @@ class MarkdownTextContent extends HookConsumerWidget { | ||||
|                           uri: stickerUri, | ||||
|                           width: size, | ||||
|                           height: size, | ||||
|                           fit: BoxFit.cover, | ||||
|                           fit: BoxFit.contain, | ||||
|                           noCacheOptimization: true, | ||||
|                         ), | ||||
|                       ), | ||||
|                     ); | ||||
|                 } | ||||
|               } | ||||
|               final content = ConstrainedBox( | ||||
|                 constraints: BoxConstraints(maxHeight: 360), | ||||
|                 child: UniversalImage(uri: uri.toString(), fit: BoxFit.contain), | ||||
|               final content = ClipRRect( | ||||
|                 borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                 child: ConstrainedBox( | ||||
|                   constraints: BoxConstraints(maxHeight: 360), | ||||
|                   child: UniversalImage( | ||||
|                     uri: uri.toString(), | ||||
|                     fit: BoxFit.contain, | ||||
|                   ), | ||||
|                 ), | ||||
|               ); | ||||
|               return content; | ||||
|             }, | ||||
|   | ||||
| @@ -248,7 +248,7 @@ class _PaymentContentState extends ConsumerState<_PaymentContent> { | ||||
|     try { | ||||
|       final client = ref.read(apiClientProvider); | ||||
|       final response = await client.post( | ||||
|         '/orders/${widget.order.id}/pay', | ||||
|         '/id/orders/${widget.order.id}/pay', | ||||
|         data: {'pin_code': pin}, | ||||
|       ); | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,14 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/poll.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/screens/creators/poll/poll_list.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:island/widgets/poll/poll_stats_widget.dart'; | ||||
| import 'package:island/widgets/response.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| @@ -52,59 +57,60 @@ class PollFeedbackNotifier extends _$PollFeedbackNotifier | ||||
| class PollFeedbackSheet extends HookConsumerWidget { | ||||
|   final String pollId; | ||||
|   final String? title; | ||||
|   final SnPoll poll; | ||||
|   final Map<String, dynamic>? stats; // stats object similar to PollSubmit | ||||
|   const PollFeedbackSheet({ | ||||
|     super.key, | ||||
|     required this.pollId, | ||||
|     required this.poll, | ||||
|     this.title, | ||||
|     this.stats, | ||||
|   }); | ||||
|   const PollFeedbackSheet({super.key, required this.pollId, this.title}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final poll = ref.watch(pollWithStatsProvider(pollId)); | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: title ?? 'Poll feedback', | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           _PollHeader(poll: poll, stats: stats), | ||||
|           const Divider(height: 1), | ||||
|           Expanded( | ||||
|             child: PagingHelperView( | ||||
|               provider: pollFeedbackNotifierProvider(pollId), | ||||
|               futureRefreshable: pollFeedbackNotifierProvider(pollId).future, | ||||
|               notifierRefreshable: | ||||
|                   pollFeedbackNotifierProvider(pollId).notifier, | ||||
|               contentBuilder: | ||||
|                   (data, widgetCount, endItemView) => ListView.separated( | ||||
|                     padding: const EdgeInsets.symmetric(vertical: 4), | ||||
|                     itemCount: widgetCount, | ||||
|                     itemBuilder: (context, index) { | ||||
|                       if (index == widgetCount - 1) { | ||||
|                         // Provided by PagingHelperView to indicate end/loading | ||||
|                         return endItemView; | ||||
|                       } | ||||
|                       final answer = data.items[index]; | ||||
|                       return _PollAnswerTile(answer: answer, poll: poll); | ||||
|                     }, | ||||
|                     separatorBuilder: | ||||
|                         (context, index) => | ||||
|                             const Divider(height: 1).padding(vertical: 4), | ||||
|                   ), | ||||
|       child: poll.when( | ||||
|         data: | ||||
|             (data) => CustomScrollView( | ||||
|               slivers: [ | ||||
|                 SliverToBoxAdapter(child: _PollHeader(poll: data)), | ||||
|                 SliverToBoxAdapter(child: const Divider(height: 1)), | ||||
|                 SliverGap(4), | ||||
|                 PagingHelperSliverView( | ||||
|                   provider: pollFeedbackNotifierProvider(pollId), | ||||
|                   futureRefreshable: | ||||
|                       pollFeedbackNotifierProvider(pollId).future, | ||||
|                   notifierRefreshable: | ||||
|                       pollFeedbackNotifierProvider(pollId).notifier, | ||||
|                   contentBuilder: | ||||
|                       (val, widgetCount, endItemView) => SliverList.separated( | ||||
|                         itemCount: widgetCount, | ||||
|                         itemBuilder: (context, index) { | ||||
|                           if (index == widgetCount - 1) { | ||||
|                             // Provided by PagingHelperView to indicate end/loading | ||||
|                             return endItemView; | ||||
|                           } | ||||
|                           final answer = val.items[index]; | ||||
|                           return _PollAnswerTile(answer: answer, poll: data); | ||||
|                         }, | ||||
|                         separatorBuilder: | ||||
|                             (context, index) => | ||||
|                                 const Divider(height: 1).padding(vertical: 4), | ||||
|                       ), | ||||
|                 ), | ||||
|                 SliverGap(4 + MediaQuery.of(context).padding.bottom), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|         error: | ||||
|             (err, _) => ResponseErrorWidget( | ||||
|               error: err, | ||||
|               onRetry: () => ref.invalidate(pollWithStatsProvider(pollId)), | ||||
|             ), | ||||
|         loading: () => ResponseLoadingWidget(), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _PollHeader extends StatelessWidget { | ||||
|   const _PollHeader({required this.poll, this.stats}); | ||||
|   final SnPoll poll; | ||||
|   final Map<String, dynamic>? stats; | ||||
|   const _PollHeader({required this.poll}); | ||||
|   final SnPollWithStats poll; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
| @@ -112,18 +118,32 @@ class _PollHeader extends StatelessWidget { | ||||
|  | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       spacing: 12, | ||||
|       children: [ | ||||
|         if (poll.title != null) | ||||
|           Text(poll.title!, style: theme.textTheme.titleLarge), | ||||
|         if (poll.description != null) | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.only(top: 2), | ||||
|             child: Text( | ||||
|               poll.description!, | ||||
|               style: theme.textTheme.bodyMedium?.copyWith( | ||||
|                 color: theme.textTheme.bodyMedium?.color?.withOpacity(0.7), | ||||
|               ), | ||||
|             ), | ||||
|         if (poll.title != null || (poll.description?.isNotEmpty ?? false)) | ||||
|           Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               if (poll.title != null) | ||||
|                 Text(poll.title!, style: theme.textTheme.titleLarge), | ||||
|               if (poll.description?.isNotEmpty ?? false) | ||||
|                 Text( | ||||
|                   poll.description!, | ||||
|                   style: theme.textTheme.bodyMedium?.copyWith( | ||||
|                     color: theme.textTheme.bodyMedium?.color?.withOpacity(0.7), | ||||
|                   ), | ||||
|                 ), | ||||
|             ], | ||||
|           ), | ||||
|         Text('pollQuestions').tr().fontSize(17).bold(), | ||||
|         for (final q in poll.questions) | ||||
|           Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               if (q.title.isNotEmpty) Text(q.title).bold(), | ||||
|               if (q.description?.isNotEmpty ?? false) Text(q.description!), | ||||
|               PollStatsWidget(question: q, stats: poll.stats), | ||||
|             ], | ||||
|           ), | ||||
|       ], | ||||
|     ).padding(horizontal: 20, vertical: 16); | ||||
| @@ -132,7 +152,7 @@ class _PollHeader extends StatelessWidget { | ||||
|  | ||||
| class _PollAnswerTile extends StatelessWidget { | ||||
|   final SnPollAnswer answer; | ||||
|   final SnPoll poll; | ||||
|   final SnPollWithStats poll; | ||||
|   const _PollAnswerTile({required this.answer, required this.poll}); | ||||
|  | ||||
|   String _formatPerQuestionAnswer( | ||||
|   | ||||
							
								
								
									
										233
									
								
								lib/widgets/poll/poll_stats_widget.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										233
									
								
								lib/widgets/poll/poll_stats_widget.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,233 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:island/models/poll.dart'; | ||||
|  | ||||
| class PollStatsWidget extends StatelessWidget { | ||||
|   const PollStatsWidget({ | ||||
|     super.key, | ||||
|     required this.question, | ||||
|     required this.stats, | ||||
|   }); | ||||
|  | ||||
|   final SnPollQuestion question; | ||||
|   final Map<String, dynamic>? stats; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (stats == null) return const SizedBox.shrink(); | ||||
|     final raw = stats![question.id]; | ||||
|     if (raw == null) return const SizedBox.shrink(); | ||||
|  | ||||
|     Widget? body; | ||||
|  | ||||
|     switch (question.type) { | ||||
|       case SnPollQuestionType.rating: | ||||
|         // rating: avg score (double or int) | ||||
|         final avg = (raw['rating'] as num?)?.toDouble(); | ||||
|         if (avg == null) break; | ||||
|         final theme = Theme.of(context); | ||||
|         body = Row( | ||||
|           mainAxisAlignment: MainAxisAlignment.start, | ||||
|           children: [ | ||||
|             Icon(Icons.star, color: Colors.amber.shade600, size: 18), | ||||
|             const SizedBox(width: 6), | ||||
|             Text( | ||||
|               avg.toStringAsFixed(1), | ||||
|               style: theme.textTheme.labelMedium?.copyWith( | ||||
|                 color: theme.colorScheme.onSurfaceVariant, | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ); | ||||
|         break; | ||||
|  | ||||
|       case SnPollQuestionType.yesNo: | ||||
|         // yes/no: map {true: count, false: count} | ||||
|         if (raw is Map) { | ||||
|           final int yes = | ||||
|               (raw[true] is int) | ||||
|                   ? raw[true] as int | ||||
|                   : int.tryParse('${raw[true]}') ?? 0; | ||||
|           final int no = | ||||
|               (raw[false] is int) | ||||
|                   ? raw[false] as int | ||||
|                   : int.tryParse('${raw[false]}') ?? 0; | ||||
|           final total = (yes + no).clamp(0, 1 << 31); | ||||
|           final yesPct = total == 0 ? 0.0 : yes / total; | ||||
|           final noPct = total == 0 ? 0.0 : no / total; | ||||
|           final theme = Theme.of(context); | ||||
|           body = Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               _BarStatRow( | ||||
|                 label: 'Yes', | ||||
|                 count: yes, | ||||
|                 fraction: yesPct, | ||||
|                 color: Colors.green.shade600, | ||||
|               ), | ||||
|               const SizedBox(height: 6), | ||||
|               _BarStatRow( | ||||
|                 label: 'No', | ||||
|                 count: no, | ||||
|                 fraction: noPct, | ||||
|                 color: Colors.red.shade600, | ||||
|               ), | ||||
|               const SizedBox(height: 4), | ||||
|               Text( | ||||
|                 'Total: $total', | ||||
|                 style: theme.textTheme.labelSmall?.copyWith( | ||||
|                   color: theme.colorScheme.onSurfaceVariant, | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ); | ||||
|         } | ||||
|         break; | ||||
|  | ||||
|       case SnPollQuestionType.singleChoice: | ||||
|       case SnPollQuestionType.multipleChoice: | ||||
|         // map optionId -> count | ||||
|         if (raw is Map) { | ||||
|           final options = [...?question.options] | ||||
|             ..sort((a, b) => a.order.compareTo(b.order)); | ||||
|           final List<_OptionCount> items = []; | ||||
|           int total = 0; | ||||
|           for (final opt in options) { | ||||
|             final dynamic v = raw[opt.id]; | ||||
|             final int count = v is int ? v : int.tryParse('$v') ?? 0; | ||||
|             total += count; | ||||
|             items.add(_OptionCount(id: opt.id, label: opt.label, count: count)); | ||||
|           } | ||||
|           if (items.isNotEmpty) { | ||||
|             items.sort( | ||||
|               (a, b) => b.count.compareTo(a.count), | ||||
|             ); // show highest first | ||||
|           } | ||||
|           body = Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               for (final it in items) | ||||
|                 Padding( | ||||
|                   padding: const EdgeInsets.only(bottom: 6), | ||||
|                   child: _BarStatRow( | ||||
|                     label: it.label, | ||||
|                     count: it.count, | ||||
|                     fraction: total == 0 ? 0 : it.count / total, | ||||
|                   ), | ||||
|                 ), | ||||
|               if (items.isNotEmpty) | ||||
|                 Text( | ||||
|                   'Total: $total', | ||||
|                   style: Theme.of(context).textTheme.labelSmall?.copyWith( | ||||
|                     color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                   ), | ||||
|                 ), | ||||
|             ], | ||||
|           ); | ||||
|         } | ||||
|         break; | ||||
|  | ||||
|       case SnPollQuestionType.freeText: | ||||
|         // No stats | ||||
|         break; | ||||
|     } | ||||
|  | ||||
|     if (body == null) return Text('No stats available'); | ||||
|  | ||||
|     return Padding( | ||||
|       padding: const EdgeInsets.only(top: 8), | ||||
|       child: DecoratedBox( | ||||
|         decoration: BoxDecoration( | ||||
|           color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.35), | ||||
|           borderRadius: BorderRadius.circular(8), | ||||
|         ), | ||||
|         child: Padding( | ||||
|           padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               Text( | ||||
|                 'Stats', | ||||
|                 style: Theme.of(context).textTheme.labelLarge?.copyWith( | ||||
|                   color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                 ), | ||||
|               ), | ||||
|               const SizedBox(height: 8), | ||||
|               body, | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _OptionCount { | ||||
|   final String id; | ||||
|   final String label; | ||||
|   final int count; | ||||
|   const _OptionCount({ | ||||
|     required this.id, | ||||
|     required this.label, | ||||
|     required this.count, | ||||
|   }); | ||||
| } | ||||
|  | ||||
| class _BarStatRow extends StatelessWidget { | ||||
|   const _BarStatRow({ | ||||
|     required this.label, | ||||
|     required this.count, | ||||
|     required this.fraction, | ||||
|     this.color, | ||||
|   }); | ||||
|  | ||||
|   final String label; | ||||
|   final int count; | ||||
|   final double fraction; | ||||
|   final Color? color; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final barColor = color ?? Theme.of(context).colorScheme.primary; | ||||
|     final bgColor = Theme.of( | ||||
|       context, | ||||
|     ).colorScheme.surfaceVariant.withOpacity(0.6); | ||||
|     final fg = | ||||
|         (fraction.isNaN || fraction.isInfinite) | ||||
|             ? 0.0 | ||||
|             : fraction.clamp(0.0, 1.0); | ||||
|  | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         Text('$label · $count', style: Theme.of(context).textTheme.labelMedium), | ||||
|         const SizedBox(height: 4), | ||||
|         LayoutBuilder( | ||||
|           builder: (context, constraints) { | ||||
|             final width = constraints.maxWidth; | ||||
|             final filled = width * fg; | ||||
|             return Stack( | ||||
|               children: [ | ||||
|                 Container( | ||||
|                   height: 8, | ||||
|                   width: width, | ||||
|                   decoration: BoxDecoration( | ||||
|                     color: bgColor, | ||||
|                     borderRadius: BorderRadius.circular(999), | ||||
|                   ), | ||||
|                 ), | ||||
|                 Container( | ||||
|                   height: 8, | ||||
|                   width: filled, | ||||
|                   decoration: BoxDecoration( | ||||
|                     color: barColor, | ||||
|                     borderRadius: BorderRadius.circular(999), | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ); | ||||
|           }, | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,9 +1,11 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:island/models/poll.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/poll/poll_stats_widget.dart'; | ||||
|  | ||||
| class PollSubmit extends ConsumerStatefulWidget { | ||||
|   const PollSubmit({ | ||||
| @@ -14,6 +16,7 @@ class PollSubmit extends ConsumerStatefulWidget { | ||||
|     this.initialAnswers, | ||||
|     this.onCancel, | ||||
|     this.showProgress = true, | ||||
|     this.isReadonly = false, | ||||
|   }); | ||||
|  | ||||
|   final SnPollWithStats poll; | ||||
| @@ -31,6 +34,8 @@ class PollSubmit extends ConsumerStatefulWidget { | ||||
|   /// Whether to show a progress indicator (e.g., "2 / N"). | ||||
|   final bool showProgress; | ||||
|  | ||||
|   final bool isReadonly; | ||||
|  | ||||
|   @override | ||||
|   ConsumerState<PollSubmit> createState() => _PollSubmitState(); | ||||
| } | ||||
| @@ -39,6 +44,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> { | ||||
|   late final List<SnPollQuestion> _questions; | ||||
|   int _index = 0; | ||||
|   bool _submitting = false; | ||||
|   bool _isModifying = false; // New state to track if user is modifying answers | ||||
|  | ||||
|   /// Collected answers, keyed by questionId | ||||
|   late Map<String, dynamic> _answers; | ||||
| @@ -59,7 +65,14 @@ class _PollSubmitState extends ConsumerState<PollSubmit> { | ||||
|     _questions = [...widget.poll.questions] | ||||
|       ..sort((a, b) => a.order.compareTo(b.order)); | ||||
|     _answers = Map<String, dynamic>.from(widget.initialAnswers ?? {}); | ||||
|     _loadCurrentIntoLocalState(); | ||||
|     if (!widget.isReadonly) { | ||||
|       _loadCurrentIntoLocalState(); | ||||
|       // If initial answers are provided, set _isModifying to false initially | ||||
|       // so the "Modify" button is shown. | ||||
|       if (widget.initialAnswers != null && widget.initialAnswers!.isNotEmpty) { | ||||
|         _isModifying = false; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -74,7 +87,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> { | ||||
|           [...widget.poll.questions] | ||||
|             ..sort((a, b) => a.order.compareTo(b.order)), | ||||
|         ); | ||||
|       _loadCurrentIntoLocalState(); | ||||
|       if (!widget.isReadonly) { | ||||
|         _loadCurrentIntoLocalState(); | ||||
|         // If poll ID changes, reset modification state | ||||
|         _isModifying = false; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -196,7 +213,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> { | ||||
|       // Only call onSubmit after server accepts | ||||
|       widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers)); | ||||
|  | ||||
|       showSnackBar('Poll answer has been submitted.'); | ||||
|       showSnackBar('pollAnswerSubmitted'.tr()); | ||||
|       HapticFeedback.heavyImpact(); | ||||
|     } catch (e) { | ||||
|       showErrorAlert(e); | ||||
| @@ -268,7 +285,8 @@ class _PollSubmitState extends ConsumerState<PollSubmit> { | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         if (widget.showProgress) | ||||
|         if (widget.showProgress && | ||||
|             _isModifying) // Only show progress when modifying | ||||
|           Text( | ||||
|             '${_index + 1} / ${_questions.length}', | ||||
|             style: Theme.of(context).textTheme.labelMedium, | ||||
| @@ -310,154 +328,13 @@ class _PollSubmitState extends ConsumerState<PollSubmit> { | ||||
|   } | ||||
|  | ||||
|   Widget _buildStats(BuildContext context, SnPollQuestion q) { | ||||
|     if (widget.stats == null) return const SizedBox.shrink(); | ||||
|     final raw = widget.stats![q.id]; | ||||
|     if (raw == null) return const SizedBox.shrink(); | ||||
|  | ||||
|     Widget? body; | ||||
|  | ||||
|     switch (q.type) { | ||||
|       case SnPollQuestionType.rating: | ||||
|         // rating: avg score (double or int) | ||||
|         final avg = (raw['rating'] as num?)?.toDouble(); | ||||
|         if (avg == null) break; | ||||
|         final theme = Theme.of(context); | ||||
|         body = Row( | ||||
|           mainAxisAlignment: MainAxisAlignment.start, | ||||
|           children: [ | ||||
|             Icon(Icons.star, color: Colors.amber.shade600, size: 18), | ||||
|             const SizedBox(width: 6), | ||||
|             Text( | ||||
|               avg.toStringAsFixed(1), | ||||
|               style: theme.textTheme.labelMedium?.copyWith( | ||||
|                 color: theme.colorScheme.onSurfaceVariant, | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ); | ||||
|         break; | ||||
|  | ||||
|       case SnPollQuestionType.yesNo: | ||||
|         // yes/no: map {true: count, false: count} | ||||
|         if (raw is Map) { | ||||
|           final int yes = | ||||
|               (raw[true] is int) | ||||
|                   ? raw[true] as int | ||||
|                   : int.tryParse('${raw[true]}') ?? 0; | ||||
|           final int no = | ||||
|               (raw[false] is int) | ||||
|                   ? raw[false] as int | ||||
|                   : int.tryParse('${raw[false]}') ?? 0; | ||||
|           final total = (yes + no).clamp(0, 1 << 31); | ||||
|           final yesPct = total == 0 ? 0.0 : yes / total; | ||||
|           final noPct = total == 0 ? 0.0 : no / total; | ||||
|           final theme = Theme.of(context); | ||||
|           body = Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               _BarStatRow( | ||||
|                 label: 'Yes', | ||||
|                 count: yes, | ||||
|                 fraction: yesPct, | ||||
|                 color: Colors.green.shade600, | ||||
|               ), | ||||
|               const SizedBox(height: 6), | ||||
|               _BarStatRow( | ||||
|                 label: 'No', | ||||
|                 count: no, | ||||
|                 fraction: noPct, | ||||
|                 color: Colors.red.shade600, | ||||
|               ), | ||||
|               const SizedBox(height: 4), | ||||
|               Text( | ||||
|                 'Total: $total', | ||||
|                 style: theme.textTheme.labelSmall?.copyWith( | ||||
|                   color: theme.colorScheme.onSurfaceVariant, | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ); | ||||
|         } | ||||
|         break; | ||||
|  | ||||
|       case SnPollQuestionType.singleChoice: | ||||
|       case SnPollQuestionType.multipleChoice: | ||||
|         // map optionId -> count | ||||
|         if (raw is Map) { | ||||
|           final options = [...?q.options] | ||||
|             ..sort((a, b) => a.order.compareTo(b.order)); | ||||
|           final List<_OptionCount> items = []; | ||||
|           int total = 0; | ||||
|           for (final opt in options) { | ||||
|             final dynamic v = raw[opt.id]; | ||||
|             final int count = v is int ? v : int.tryParse('$v') ?? 0; | ||||
|             total += count; | ||||
|             items.add(_OptionCount(id: opt.id, label: opt.label, count: count)); | ||||
|           } | ||||
|           if (items.isNotEmpty) { | ||||
|             items.sort( | ||||
|               (a, b) => b.count.compareTo(a.count), | ||||
|             ); // show highest first | ||||
|           } | ||||
|           body = Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               for (final it in items) | ||||
|                 Padding( | ||||
|                   padding: const EdgeInsets.only(bottom: 6), | ||||
|                   child: _BarStatRow( | ||||
|                     label: it.label, | ||||
|                     count: it.count, | ||||
|                     fraction: total == 0 ? 0 : it.count / total, | ||||
|                   ), | ||||
|                 ), | ||||
|               if (items.isNotEmpty) | ||||
|                 Text( | ||||
|                   'Total: $total', | ||||
|                   style: Theme.of(context).textTheme.labelSmall?.copyWith( | ||||
|                     color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                   ), | ||||
|                 ), | ||||
|             ], | ||||
|           ); | ||||
|         } | ||||
|         break; | ||||
|  | ||||
|       case SnPollQuestionType.freeText: | ||||
|         // No stats | ||||
|         break; | ||||
|     } | ||||
|  | ||||
|     if (body == null) return const SizedBox.shrink(); | ||||
|  | ||||
|     return Padding( | ||||
|       padding: const EdgeInsets.only(top: 8), | ||||
|       child: DecoratedBox( | ||||
|         decoration: BoxDecoration( | ||||
|           color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.35), | ||||
|           borderRadius: BorderRadius.circular(8), | ||||
|         ), | ||||
|         child: Padding( | ||||
|           padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               Text( | ||||
|                 'Stats', | ||||
|                 style: Theme.of(context).textTheme.labelLarge?.copyWith( | ||||
|                   color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                 ), | ||||
|               ), | ||||
|               const SizedBox(height: 8), | ||||
|               body, | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|     return PollStatsWidget(question: q, stats: widget.stats); | ||||
|   } | ||||
|  | ||||
|   Widget _buildBody(BuildContext context) { | ||||
|     if (widget.initialAnswers != null && !widget.isReadonly && !_isModifying) { | ||||
|       return const SizedBox.shrink(); // Collapse input fields if already submitted and not modifying | ||||
|     } | ||||
|     final q = _current; | ||||
|     switch (q.type) { | ||||
|       case SnPollQuestionType.singleChoice: | ||||
| @@ -517,9 +394,9 @@ class _PollSubmitState extends ConsumerState<PollSubmit> { | ||||
|       children: [ | ||||
|         Expanded( | ||||
|           child: SegmentedButton<bool>( | ||||
|             segments: const [ | ||||
|               ButtonSegment(value: true, label: Text('Yes')), | ||||
|               ButtonSegment(value: false, label: Text('No')), | ||||
|             segments: [ | ||||
|               ButtonSegment(value: true, label: Text('yes'.tr())), | ||||
|               ButtonSegment(value: false, label: Text('no'.tr())), | ||||
|             ], | ||||
|             selected: _yesNoSelected == null ? {} : {_yesNoSelected!}, | ||||
|             onSelectionChanged: (sel) { | ||||
| @@ -568,12 +445,39 @@ class _PollSubmitState extends ConsumerState<PollSubmit> { | ||||
|     final isLast = _index == _questions.length - 1; | ||||
|     final canProceed = _isCurrentAnswered() && !_submitting; | ||||
|  | ||||
|     if (widget.initialAnswers != null && !_isModifying && !widget.isReadonly) { | ||||
|       // If poll is submitted and not in modification mode, show "Modify" button | ||||
|       return FilledButton.icon( | ||||
|         icon: const Icon(Icons.edit), | ||||
|         label: Text('modifyAnswers'.tr()), | ||||
|         onPressed: () { | ||||
|           setState(() { | ||||
|             _isModifying = true; | ||||
|             _index = 0; // Reset to first question for modification | ||||
|             _loadCurrentIntoLocalState(); | ||||
|           }); | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Row( | ||||
|       children: [ | ||||
|         OutlinedButton.icon( | ||||
|           icon: const Icon(Icons.arrow_back), | ||||
|           label: Text(_index == 0 ? 'Cancel' : 'Back'), | ||||
|           onPressed: _submitting ? null : _back, | ||||
|           label: Text(_index == 0 ? 'cancel'.tr() : 'back'.tr()), | ||||
|           onPressed: | ||||
|               _submitting | ||||
|                   ? null | ||||
|                   : () { | ||||
|                     if (_index == 0 && _isModifying) { | ||||
|                       // If at first question and in modification mode, go back to submitted view | ||||
|                       setState(() { | ||||
|                         _isModifying = false; | ||||
|                       }); | ||||
|                     } else { | ||||
|                       _back(); | ||||
|                     } | ||||
|                   }, | ||||
|         ), | ||||
|         const Spacer(), | ||||
|         FilledButton.icon( | ||||
| @@ -585,19 +489,188 @@ class _PollSubmitState extends ConsumerState<PollSubmit> { | ||||
|                     child: CircularProgressIndicator(strokeWidth: 2), | ||||
|                   ) | ||||
|                   : Icon(isLast ? Icons.check : Icons.arrow_forward), | ||||
|           label: Text(isLast ? 'Submit' : 'Next'), | ||||
|           label: Text(isLast ? 'submit'.tr() : 'next'.tr()), | ||||
|           onPressed: canProceed ? _next : null, | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildSubmittedView(BuildContext context) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         if (widget.poll.title != null || widget.poll.description != null) | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.only(bottom: 12), | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 if (widget.poll.title?.isNotEmpty ?? false) | ||||
|                   Text( | ||||
|                     widget.poll.title!, | ||||
|                     style: Theme.of(context).textTheme.titleLarge, | ||||
|                   ), | ||||
|                 if (widget.poll.description?.isNotEmpty ?? false) | ||||
|                   Padding( | ||||
|                     padding: const EdgeInsets.only(top: 4), | ||||
|                     child: Text( | ||||
|                       widget.poll.description!, | ||||
|                       style: Theme.of(context).textTheme.bodyMedium?.copyWith( | ||||
|                         color: Theme.of( | ||||
|                           context, | ||||
|                         ).textTheme.bodyMedium?.color?.withOpacity(0.7), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         for (final q in _questions) | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.only(bottom: 16.0), | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Row( | ||||
|                   children: [ | ||||
|                     Expanded( | ||||
|                       child: Text( | ||||
|                         q.title, | ||||
|                         style: Theme.of(context).textTheme.titleMedium, | ||||
|                       ), | ||||
|                     ), | ||||
|                     if (q.isRequired) | ||||
|                       Padding( | ||||
|                         padding: const EdgeInsets.only(left: 8), | ||||
|                         child: Text( | ||||
|                           '*', | ||||
|                           style: Theme.of( | ||||
|                             context, | ||||
|                           ).textTheme.titleMedium?.copyWith( | ||||
|                             color: Theme.of(context).colorScheme.error, | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|                   ], | ||||
|                 ), | ||||
|                 if (q.description != null) | ||||
|                   Padding( | ||||
|                     padding: const EdgeInsets.only(top: 4), | ||||
|                     child: Text( | ||||
|                       q.description!, | ||||
|                       style: Theme.of(context).textTheme.bodySmall?.copyWith( | ||||
|                         color: Theme.of( | ||||
|                           context, | ||||
|                         ).textTheme.bodySmall?.color?.withOpacity(0.7), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 _buildStats(context, q), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildReadonlyView(BuildContext context) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         if (widget.poll.title != null || widget.poll.description != null) | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.only(bottom: 12), | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 if (widget.poll.title != null) | ||||
|                   Text( | ||||
|                     widget.poll.title!, | ||||
|                     style: Theme.of(context).textTheme.titleLarge, | ||||
|                   ), | ||||
|                 if (widget.poll.description != null) | ||||
|                   Padding( | ||||
|                     padding: const EdgeInsets.only(top: 4), | ||||
|                     child: Text( | ||||
|                       widget.poll.description!, | ||||
|                       style: Theme.of(context).textTheme.bodyMedium?.copyWith( | ||||
|                         color: Theme.of( | ||||
|                           context, | ||||
|                         ).textTheme.bodyMedium?.color?.withOpacity(0.7), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         for (final q in _questions) | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.only(bottom: 16.0), | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Row( | ||||
|                   children: [ | ||||
|                     Expanded( | ||||
|                       child: Text( | ||||
|                         q.title, | ||||
|                         style: Theme.of(context).textTheme.titleMedium, | ||||
|                       ), | ||||
|                     ), | ||||
|                     if (q.isRequired) | ||||
|                       Padding( | ||||
|                         padding: const EdgeInsets.only(left: 8), | ||||
|                         child: Text( | ||||
|                           '*', | ||||
|                           style: Theme.of( | ||||
|                             context, | ||||
|                           ).textTheme.titleMedium?.copyWith( | ||||
|                             color: Theme.of(context).colorScheme.error, | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|                   ], | ||||
|                 ), | ||||
|                 if (q.description != null) | ||||
|                   Padding( | ||||
|                     padding: const EdgeInsets.only(top: 4), | ||||
|                     child: Text( | ||||
|                       q.description!, | ||||
|                       style: Theme.of(context).textTheme.bodySmall?.copyWith( | ||||
|                         color: Theme.of( | ||||
|                           context, | ||||
|                         ).textTheme.bodySmall?.color?.withOpacity(0.7), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 _buildStats(context, q), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (_questions.isEmpty) { | ||||
|       return const SizedBox.shrink(); | ||||
|     } | ||||
|  | ||||
|     // If poll is already submitted and not in readonly mode, and not in modification mode, show submitted view | ||||
|     if (widget.initialAnswers != null && !widget.isReadonly && !_isModifying) { | ||||
|       return Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [_buildSubmittedView(context), _buildNavBar(context)], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // If poll is in readonly mode, show readonly view | ||||
|     if (widget.isReadonly) { | ||||
|       return _buildReadonlyView(context); | ||||
|     } | ||||
|  | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|       children: [ | ||||
| @@ -617,77 +690,6 @@ class _PollSubmitState extends ConsumerState<PollSubmit> { | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _OptionCount { | ||||
|   final String id; | ||||
|   final String label; | ||||
|   final int count; | ||||
|   const _OptionCount({ | ||||
|     required this.id, | ||||
|     required this.label, | ||||
|     required this.count, | ||||
|   }); | ||||
| } | ||||
|  | ||||
| class _BarStatRow extends StatelessWidget { | ||||
|   const _BarStatRow({ | ||||
|     required this.label, | ||||
|     required this.count, | ||||
|     required this.fraction, | ||||
|     this.color, | ||||
|   }); | ||||
|  | ||||
|   final String label; | ||||
|   final int count; | ||||
|   final double fraction; | ||||
|   final Color? color; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final barColor = color ?? Theme.of(context).colorScheme.primary; | ||||
|     final bgColor = Theme.of( | ||||
|       context, | ||||
|     ).colorScheme.surfaceVariant.withOpacity(0.6); | ||||
|     final fg = | ||||
|         (fraction.isNaN || fraction.isInfinite) | ||||
|             ? 0.0 | ||||
|             : fraction.clamp(0.0, 1.0); | ||||
|  | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         Text('$label · $count', style: Theme.of(context).textTheme.labelMedium), | ||||
|         const SizedBox(height: 4), | ||||
|         LayoutBuilder( | ||||
|           builder: (context, constraints) { | ||||
|             final width = constraints.maxWidth; | ||||
|             final filled = width * fg; | ||||
|             return Stack( | ||||
|               children: [ | ||||
|                 Container( | ||||
|                   height: 8, | ||||
|                   width: width, | ||||
|                   decoration: BoxDecoration( | ||||
|                     color: bgColor, | ||||
|                     borderRadius: BorderRadius.circular(999), | ||||
|                   ), | ||||
|                 ), | ||||
|                 Container( | ||||
|                   height: 8, | ||||
|                   width: filled, | ||||
|                   decoration: BoxDecoration( | ||||
|                     color: barColor, | ||||
|                     borderRadius: BorderRadius.circular(999), | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ); | ||||
|           }, | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// Simple fade/slide transition between questions. | ||||
| class _AnimatedStep extends StatelessWidget { | ||||
|   const _AnimatedStep({super.key, required this.child}); | ||||
|   | ||||
| @@ -186,10 +186,9 @@ class ComposePollSheet extends HookConsumerWidget { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget? _buildPollSubtitle(SnPoll poll) { | ||||
|   Widget? _buildPollSubtitle(SnPollWithStats poll) { | ||||
|     try { | ||||
|       final SnPoll dyn = poll; | ||||
|       final List<SnPollQuestion> options = dyn.questions; | ||||
|       final List<SnPollQuestion> options = poll.questions; | ||||
|       if (options.isEmpty) return null; | ||||
|       final preview = options.take(3).map((e) => e.title).join(' · '); | ||||
|       if (preview.trim().isEmpty) return null; | ||||
|   | ||||
| @@ -244,7 +244,6 @@ class ComposeSettingsSheet extends HookConsumerWidget { | ||||
|             ), | ||||
|  | ||||
|             // Categories field | ||||
|             // FIXME: Sometimes the entire dropdown crashes: 'package:flutter/src/rendering/stack.dart': Failed assertion: line 799 pos 12: 'firstChild == null || child != null': is not true. | ||||
|             DropdownButtonFormField2<SnPostCategory>( | ||||
|               isExpanded: true, | ||||
|               decoration: InputDecoration( | ||||
| @@ -306,7 +305,7 @@ class ComposeSettingsSheet extends HookConsumerWidget { | ||||
|               value: currentCategories.isEmpty ? null : currentCategories.last, | ||||
|               onChanged: (_) {}, | ||||
|               selectedItemBuilder: (context) { | ||||
|                 return currentCategories.map((item) { | ||||
|                 return (postCategories.value ?? []).map((item) { | ||||
|                   return SingleChildScrollView( | ||||
|                     scrollDirection: Axis.horizontal, | ||||
|                     child: Row( | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:island/widgets/post/post_item.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:island/pods/config.dart'; // Import config.dart for shared preferences keys and provider | ||||
|  | ||||
| part 'post_featured.g.dart'; | ||||
|  | ||||
| @@ -25,7 +26,13 @@ class PostFeaturedList extends HookConsumerWidget { | ||||
|     final featuredPostsAsync = ref.watch(featuredPostsProvider); | ||||
|  | ||||
|     final pageViewController = usePageController(); | ||||
|     final prefs = ref.watch(sharedPreferencesProvider); | ||||
|     final pageViewCurrent = useState(0); | ||||
|     final previousFirstPostId = useState<String?>(null); | ||||
|     final storedCollapsedId = useState<String?>( | ||||
|       prefs.getString(kFeaturedPostsCollapsedId), | ||||
|     ); | ||||
|     final isCollapsed = useState(false); | ||||
|  | ||||
|     useEffect(() { | ||||
|       pageViewController.addListener(() { | ||||
| @@ -34,6 +41,59 @@ class PostFeaturedList extends HookConsumerWidget { | ||||
|       return null; | ||||
|     }, [pageViewController]); | ||||
|  | ||||
|     // Log isCollapsed state changes | ||||
|     useEffect(() { | ||||
|       debugPrint( | ||||
|         'PostFeaturedList: isCollapsed changed to ${isCollapsed.value}', | ||||
|       ); | ||||
|       return null; | ||||
|     }, [isCollapsed.value]); | ||||
|  | ||||
|     useEffect(() { | ||||
|       if (featuredPostsAsync.hasValue && featuredPostsAsync.value!.isNotEmpty) { | ||||
|         final currentFirstPostId = featuredPostsAsync.value!.first.id; | ||||
|         debugPrint( | ||||
|           'PostFeaturedList: Current first post ID: $currentFirstPostId', | ||||
|         ); | ||||
|         debugPrint( | ||||
|           'PostFeaturedList: Previous first post ID: ${previousFirstPostId.value}', | ||||
|         ); | ||||
|         debugPrint( | ||||
|           'PostFeaturedList: Stored collapsed ID: ${storedCollapsedId.value}', | ||||
|         ); | ||||
|  | ||||
|         if (previousFirstPostId.value == null) { | ||||
|           // Initial load | ||||
|           previousFirstPostId.value = currentFirstPostId; | ||||
|           isCollapsed.value = (storedCollapsedId.value == currentFirstPostId); | ||||
|           debugPrint( | ||||
|             'PostFeaturedList: Initial load. isCollapsed set to ${isCollapsed.value}', | ||||
|           ); | ||||
|         } else if (previousFirstPostId.value != currentFirstPostId) { | ||||
|           // First post changed, expand by default | ||||
|           previousFirstPostId.value = currentFirstPostId; | ||||
|           isCollapsed.value = false; | ||||
|           prefs.remove( | ||||
|             kFeaturedPostsCollapsedId, | ||||
|           ); // Clear stored ID if post changes | ||||
|           debugPrint( | ||||
|             'PostFeaturedList: First post changed. isCollapsed set to false.', | ||||
|           ); | ||||
|         } else { | ||||
|           // Same first post, maintain current collapse state | ||||
|           // No change needed for isCollapsed.value unless manually toggled | ||||
|           debugPrint( | ||||
|             'PostFeaturedList: Same first post. Maintaining current collapse state.', | ||||
|           ); | ||||
|         } | ||||
|       } else { | ||||
|         debugPrint( | ||||
|           'PostFeaturedList: featuredPostsAsync has no value or is empty.', | ||||
|         ); | ||||
|       } | ||||
|       return null; | ||||
|     }, [featuredPostsAsync.value]); | ||||
|  | ||||
|     return ClipRRect( | ||||
|       borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|       child: Card( | ||||
| @@ -73,29 +133,69 @@ class PostFeaturedList extends HookConsumerWidget { | ||||
|                   }, | ||||
|                   icon: const Icon(Symbols.arrow_right), | ||||
|                 ), | ||||
|                 IconButton( | ||||
|                   padding: EdgeInsets.zero, | ||||
|                   visualDensity: VisualDensity.compact, | ||||
|                   constraints: const BoxConstraints(), | ||||
|                   onPressed: () { | ||||
|                     isCollapsed.value = !isCollapsed.value; | ||||
|                     debugPrint( | ||||
|                       'PostFeaturedList: Manual toggle. isCollapsed set to ${isCollapsed.value}', | ||||
|                     ); | ||||
|                     if (isCollapsed.value && | ||||
|                         featuredPostsAsync.hasValue && | ||||
|                         featuredPostsAsync.value!.isNotEmpty) { | ||||
|                       prefs.setString( | ||||
|                         kFeaturedPostsCollapsedId, | ||||
|                         featuredPostsAsync.value!.first.id, | ||||
|                       ); | ||||
|                       debugPrint( | ||||
|                         'PostFeaturedList: Stored collapsed ID: ${featuredPostsAsync.value!.first.id}', | ||||
|                       ); | ||||
|                     } else { | ||||
|                       prefs.remove(kFeaturedPostsCollapsedId); | ||||
|                       debugPrint( | ||||
|                         'PostFeaturedList: Removed stored collapsed ID.', | ||||
|                       ); | ||||
|                     } | ||||
|                   }, | ||||
|                   icon: Icon( | ||||
|                     isCollapsed.value | ||||
|                         ? Symbols.expand_more | ||||
|                         : Symbols.expand_less, | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ).padding(horizontal: 16, vertical: 8), | ||||
|             featuredPostsAsync.when( | ||||
|               loading: () => const Center(child: CircularProgressIndicator()), | ||||
|               error: (error, stack) => Center(child: Text('Error: $error')), | ||||
|               data: (posts) { | ||||
|                 return SizedBox( | ||||
|                   height: 320, | ||||
|                   child: PageView.builder( | ||||
|                     controller: pageViewController, | ||||
|                     scrollDirection: Axis.horizontal, | ||||
|                     itemCount: posts.length, | ||||
|                     itemBuilder: (context, index) { | ||||
|                       return SingleChildScrollView( | ||||
|                         child: PostActionableItem( | ||||
|                           item: posts[index], | ||||
|                           borderRadius: 8, | ||||
|                         ), | ||||
|                       ); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ); | ||||
|               }, | ||||
|             AnimatedSize( | ||||
|               duration: const Duration(milliseconds: 300), | ||||
|               curve: Curves.easeInOut, | ||||
|               child: Visibility( | ||||
|                 visible: !isCollapsed.value, | ||||
|                 child: featuredPostsAsync.when( | ||||
|                   loading: | ||||
|                       () => const Center(child: CircularProgressIndicator()), | ||||
|                   error: (error, stack) => Center(child: Text('Error: $error')), | ||||
|                   data: (posts) { | ||||
|                     return SizedBox( | ||||
|                       height: 320, | ||||
|                       child: PageView.builder( | ||||
|                         controller: pageViewController, | ||||
|                         scrollDirection: Axis.horizontal, | ||||
|                         itemCount: posts.length, | ||||
|                         itemBuilder: (context, index) { | ||||
|                           return SingleChildScrollView( | ||||
|                             child: PostActionableItem( | ||||
|                               item: posts[index], | ||||
|                               borderRadius: 8, | ||||
|                             ), | ||||
|                           ); | ||||
|                         }, | ||||
|                       ), | ||||
|                     ); | ||||
|                   }, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -7,11 +7,9 @@ import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/cloud_file_collection.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:island/widgets/post/post_item.dart'; | ||||
| import 'package:island/widgets/post/post_shared.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:super_context_menu/super_context_menu.dart'; | ||||
|  | ||||
| class PostItemCreator extends HookConsumerWidget { | ||||
| @@ -81,7 +79,6 @@ class PostItemCreator extends HookConsumerWidget { | ||||
|               title: 'copyLink'.tr(), | ||||
|               image: MenuImage.icon(Symbols.link), | ||||
|               callback: () { | ||||
|                 // Copy post link to clipboard | ||||
|                 context.pushNamed( | ||||
|                   'postDetail', | ||||
|                   pathParameters: {'id': item.id}, | ||||
| @@ -105,8 +102,9 @@ class PostItemCreator extends HookConsumerWidget { | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 _buildPostHeader(context), | ||||
|                 _buildPostContent(context), | ||||
|                 PostHeader(item: item), | ||||
|                 PostBody(item: item), | ||||
|                 ReferencedPostWidget(item: item), | ||||
|                 const Gap(16), | ||||
|                 _buildAnalyticsSection(context), | ||||
|               ], | ||||
| @@ -117,128 +115,12 @@ class PostItemCreator extends HookConsumerWidget { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildPostHeader(BuildContext context) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         // Post ID and timestamp row | ||||
|         Row( | ||||
|           children: [ | ||||
|             Container( | ||||
|               padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), | ||||
|               decoration: BoxDecoration( | ||||
|                 color: Theme.of(context).colorScheme.primaryContainer, | ||||
|                 borderRadius: BorderRadius.circular(4), | ||||
|               ), | ||||
|               child: Text( | ||||
|                 'ID: ${item.id.substring(0, 6)}', | ||||
|                 style: TextStyle( | ||||
|                   fontSize: 12, | ||||
|                   fontWeight: FontWeight.bold, | ||||
|                   color: Theme.of(context).colorScheme.onPrimaryContainer, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             const Spacer(), | ||||
|             Icon( | ||||
|               _getVisibilityIcon(item.visibility), | ||||
|               size: 16, | ||||
|               color: Theme.of(context).colorScheme.secondary, | ||||
|             ), | ||||
|             const SizedBox(width: 4), | ||||
|             Text( | ||||
|               _getVisibilityText(item.visibility).tr(), | ||||
|               style: TextStyle( | ||||
|                 fontSize: 12, | ||||
|                 color: Theme.of(context).colorScheme.secondary, | ||||
|               ), | ||||
|             ), | ||||
|             const Gap(8), | ||||
|             Text( | ||||
|               item.publishedAt?.formatSystem() ?? '', | ||||
|               style: TextStyle( | ||||
|                 fontSize: 12, | ||||
|                 color: Theme.of(context).colorScheme.secondary, | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|         const Gap(8), | ||||
|  | ||||
|         // Title and description | ||||
|         if (item.title?.isNotEmpty ?? false) | ||||
|           Text( | ||||
|             item.title!, | ||||
|             style: Theme.of( | ||||
|               context, | ||||
|             ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), | ||||
|           ), | ||||
|         if (item.description?.isNotEmpty ?? false) | ||||
|           Text( | ||||
|             item.description!, | ||||
|             style: Theme.of(context).textTheme.bodyMedium?.copyWith( | ||||
|               color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|             ), | ||||
|           ).padding(top: 4), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildPostContent(BuildContext context) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         // Content preview | ||||
|         if (item.content?.isNotEmpty ?? false) | ||||
|           Container( | ||||
|             margin: const EdgeInsets.only(top: 12), | ||||
|             child: MarkdownTextContent(content: item.content!), | ||||
|           ), | ||||
|  | ||||
|         // Attachments | ||||
|         if (item.attachments.isNotEmpty) | ||||
|           CloudFileList( | ||||
|             files: item.attachments, | ||||
|             maxWidth: MediaQuery.of(context).size.width * 0.85, | ||||
|             padding: EdgeInsets.only(top: 8), | ||||
|           ), | ||||
|  | ||||
|         // Reference post indicator | ||||
|         if (item.repliedPost != null || item.forwardedPost != null) | ||||
|           Container( | ||||
|             margin: const EdgeInsets.only(top: 8), | ||||
|             child: Row( | ||||
|               children: [ | ||||
|                 Icon( | ||||
|                   item.repliedPost != null ? Symbols.reply : Symbols.forward, | ||||
|                   size: 16, | ||||
|                   color: Theme.of(context).colorScheme.secondary, | ||||
|                 ), | ||||
|                 const Gap(4), | ||||
|                 Text( | ||||
|                   item.repliedPost != null | ||||
|                       ? 'repliedTo'.tr() | ||||
|                       : 'forwarded'.tr(), | ||||
|                   style: TextStyle( | ||||
|                     fontSize: 12, | ||||
|                     color: Theme.of(context).colorScheme.secondary, | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildAnalyticsSection(BuildContext context) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         Text('Analytics', style: Theme.of(context).textTheme.titleSmall), | ||||
|         const Gap(8), | ||||
|  | ||||
|         // Engagement metrics in a card | ||||
|         Card( | ||||
|           elevation: 1, | ||||
|           margin: EdgeInsets.zero, | ||||
| @@ -279,15 +161,9 @@ class PostItemCreator extends HookConsumerWidget { | ||||
|           ), | ||||
|         ), | ||||
|         const Gap(16), | ||||
|  | ||||
|         // Reactions summary | ||||
|         if (item.reactionsCount.isNotEmpty) _buildReactionsSection(context), | ||||
|  | ||||
|         // Metadata section | ||||
|         if (item.meta != null && item.meta!.isNotEmpty) | ||||
|           _buildMetadataSection(context), | ||||
|  | ||||
|         // Creation and modification timestamps | ||||
|         const Gap(16), | ||||
|         Row( | ||||
|           mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
| @@ -425,31 +301,3 @@ class PostItemCreator extends HookConsumerWidget { | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Helper method to get the appropriate icon for each visibility status | ||||
| IconData _getVisibilityIcon(int visibility) { | ||||
|   switch (visibility) { | ||||
|     case 1: // Friends | ||||
|       return Symbols.group; | ||||
|     case 2: // Unlisted | ||||
|       return Symbols.link_off; | ||||
|     case 3: // Private | ||||
|       return Symbols.lock; | ||||
|     default: // Public (0) or unknown | ||||
|       return Symbols.public; | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Helper method to get the translation key for each visibility status | ||||
| String _getVisibilityText(int visibility) { | ||||
|   switch (visibility) { | ||||
|     case 1: // Friends | ||||
|       return 'postVisibilityFriends'; | ||||
|     case 2: // Unlisted | ||||
|       return 'postVisibilityUnlisted'; | ||||
|     case 3: // Private | ||||
|       return 'postVisibilityPrivate'; | ||||
|     default: // Public (0) or unknown | ||||
|       return 'postVisibilityPublic'; | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										135
									
								
								lib/widgets/post/post_item_screenshot.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								lib/widgets/post/post_item_screenshot.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,135 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/widgets/post/post_shared.dart'; | ||||
| import 'package:qr_flutter/qr_flutter.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class PostItemScreenshot extends ConsumerWidget { | ||||
|   final SnPost item; | ||||
|   final EdgeInsets? padding; | ||||
|   final bool isFullPost; | ||||
|   final bool isShowReference; | ||||
|   const PostItemScreenshot({ | ||||
|     super.key, | ||||
|     required this.item, | ||||
|     this.padding, | ||||
|     this.isFullPost = false, | ||||
|     this.isShowReference = true, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final renderingPadding = | ||||
|         padding ?? const EdgeInsets.symmetric(horizontal: 8, vertical: 8); | ||||
|  | ||||
|     final mostReaction = | ||||
|         item.reactionsCount.isEmpty | ||||
|             ? null | ||||
|             : item.reactionsCount.entries | ||||
|                 .sortedBy((e) => e.value) | ||||
|                 .map((e) => e.key) | ||||
|                 .last; | ||||
|  | ||||
|     final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark; | ||||
|  | ||||
|     return Material( | ||||
|       elevation: 0, | ||||
|       color: Theme.of(context).colorScheme.surface, | ||||
|       child: Column( | ||||
|         mainAxisSize: MainAxisSize.min, | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           Gap(renderingPadding.vertical), | ||||
|           PostHeader( | ||||
|             item: item, | ||||
|             isFullPost: isFullPost, | ||||
|             isInteractive: false, | ||||
|             renderingPadding: renderingPadding, | ||||
|             isRelativeTime: false, | ||||
|             trailing: | ||||
|                 mostReaction != null | ||||
|                     ? Row( | ||||
|                       children: [ | ||||
|                         Text( | ||||
|                           kReactionTemplates[mostReaction]?.icon ?? '', | ||||
|                           style: const TextStyle(fontSize: 20), | ||||
|                         ), | ||||
|                         const Gap(4), | ||||
|                         Text( | ||||
|                           'x${item.reactionsCount[mostReaction]}', | ||||
|                           style: const TextStyle(fontSize: 11), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ) | ||||
|                     : null, | ||||
|           ), | ||||
|           PostBody( | ||||
|             item: item, | ||||
|             renderingPadding: renderingPadding, | ||||
|             isFullPost: isFullPost, | ||||
|             isTextSelectable: false, | ||||
|             isInteractive: false, | ||||
|           ), | ||||
|           if (isShowReference) | ||||
|             ReferencedPostWidget( | ||||
|               item: item, | ||||
|               isInteractive: false, | ||||
|               renderingPadding: renderingPadding, | ||||
|             ), | ||||
|           Container( | ||||
|             color: Theme.of(context).colorScheme.surfaceContainerLow, | ||||
|             margin: const EdgeInsets.only(top: 8), | ||||
|             padding: EdgeInsets.symmetric( | ||||
|               horizontal: renderingPadding.horizontal, | ||||
|               vertical: 4, | ||||
|             ), | ||||
|             child: Row( | ||||
|               children: [ | ||||
|                 SizedBox( | ||||
|                   width: 44, | ||||
|                   height: 44, | ||||
|                   child: Image.asset( | ||||
|                     'assets/icons/icon${isDark ? '-dark' : ''}.png', | ||||
|                     width: 40, | ||||
|                     height: 40, | ||||
|                   ), | ||||
|                 ).padding(vertical: 8, right: 12), | ||||
|                 Expanded( | ||||
|                   child: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       const Text( | ||||
|                         'Solar Network', | ||||
|                         style: TextStyle( | ||||
|                           fontSize: 14, | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                         ), | ||||
|                       ), | ||||
|                       const Text( | ||||
|                         'sharePostSlogan', | ||||
|                         style: TextStyle(fontSize: 12), | ||||
|                       ).tr().opacity(0.9), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|                 QrImageView( | ||||
|                   data: 'https://solian.app/posts/${item.id}', | ||||
|                   version: QrVersions.auto, | ||||
|                   size: 60, | ||||
|                   errorCorrectionLevel: QrErrorCorrectLevel.M, | ||||
|                   backgroundColor: Colors.transparent, | ||||
|                   foregroundColor: Theme.of(context).colorScheme.onSurface, | ||||
|                   padding: const EdgeInsets.all(8), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,11 +1,13 @@ | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/models/publisher.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/screens/creators/publishers.dart'; | ||||
| import 'package:island/screens/posts/compose.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/post/publishers_modal.dart'; | ||||
| @@ -14,8 +16,14 @@ import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class PostQuickReply extends HookConsumerWidget { | ||||
|   final SnPost parent; | ||||
|   final Function? onPosted; | ||||
|   const PostQuickReply({super.key, required this.parent, this.onPosted}); | ||||
|   final VoidCallback? onPosted; | ||||
|   final VoidCallback? onLaunch; | ||||
|   const PostQuickReply({ | ||||
|     super.key, | ||||
|     required this.parent, | ||||
|     this.onPosted, | ||||
|     this.onLaunch, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -48,7 +56,7 @@ class PostQuickReply extends HookConsumerWidget { | ||||
|             'content': contentController.text, | ||||
|             'replied_post_id': parent.id, | ||||
|           }, | ||||
|           options: Options(headers: {'X-Pub': currentPublisher.value?.name}), | ||||
|           queryParameters: {'pub': currentPublisher.value?.name}, | ||||
|         ); | ||||
|         contentController.clear(); | ||||
|         onPosted?.call(); | ||||
| @@ -83,9 +91,10 @@ class PostQuickReply extends HookConsumerWidget { | ||||
|                 child: TextField( | ||||
|                   controller: contentController, | ||||
|                   decoration: InputDecoration( | ||||
|                     hintText: 'Post your reply', | ||||
|                     border: const OutlineInputBorder(), | ||||
|                     hintText: 'postReplyPlaceholder'.tr(), | ||||
|                     border: InputBorder.none, | ||||
|                     isDense: true, | ||||
|                     isCollapsed: true, | ||||
|                     contentPadding: EdgeInsets.symmetric( | ||||
|                       horizontal: 12, | ||||
|                       vertical: 8, | ||||
| @@ -97,6 +106,26 @@ class PostQuickReply extends HookConsumerWidget { | ||||
|                       (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ), | ||||
|               ), | ||||
|               IconButton( | ||||
|                 onPressed: () { | ||||
|                   onLaunch?.call(); | ||||
|                   GoRouter.of(context) | ||||
|                       .pushNamed( | ||||
|                         'postCompose', | ||||
|                         extra: PostComposeInitialState( | ||||
|                           content: contentController.text, | ||||
|                           replyingTo: parent, | ||||
|                         ), | ||||
|                       ) | ||||
|                       .then((value) { | ||||
|                         if (value != null) onPosted?.call(); | ||||
|                       }); | ||||
|                 }, | ||||
|                 icon: const Icon(Symbols.launch, size: 20), | ||||
|                 padding: EdgeInsets.zero, | ||||
|                 visualDensity: VisualDensity.compact, | ||||
|                 constraints: const BoxConstraints(), | ||||
|               ), | ||||
|               IconButton( | ||||
|                 padding: EdgeInsets.zero, | ||||
|                 visualDensity: VisualDensity.compact, | ||||
| @@ -110,6 +139,7 @@ class PostQuickReply extends HookConsumerWidget { | ||||
|                         : Icon(Symbols.send, size: 20), | ||||
|                 color: Theme.of(context).colorScheme.primary, | ||||
|                 onPressed: submitting.value ? null : performAction, | ||||
|                 constraints: const BoxConstraints(), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|   | ||||
| @@ -38,14 +38,18 @@ class PostRepliesSheet extends HookConsumerWidget { | ||||
|           if (user.value != null) | ||||
|             Material( | ||||
|               elevation: 2, | ||||
|               color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|               child: PostQuickReply( | ||||
|                 parent: post, | ||||
|                 onPosted: () { | ||||
|                   ref.invalidate(postRepliesNotifierProvider(post.id)); | ||||
|                 }, | ||||
|                 onLaunch: () { | ||||
|                   Navigator.of(context).pop(); | ||||
|                 }, | ||||
|               ).padding( | ||||
|                 bottom: MediaQuery.of(context).padding.bottom + 16, | ||||
|                 top: 16, | ||||
|                 bottom: MediaQuery.of(context).padding.bottom + 8, | ||||
|                 top: 8, | ||||
|                 horizontal: 16, | ||||
|               ), | ||||
|             ), | ||||
|   | ||||
							
								
								
									
										841
									
								
								lib/widgets/post/post_shared.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										841
									
								
								lib/widgets/post/post_shared.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,841 @@ | ||||
| import 'dart:math' as math; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/embed.dart'; | ||||
| import 'package:island/models/poll.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/utils/mapping.dart'; | ||||
| import 'package:island/widgets/account/account_name.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/cloud_file_collection.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/embed/link.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:island/widgets/poll/poll_submit.dart'; | ||||
| import 'package:island/widgets/post/post_replies_sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| part 'post_shared.g.dart'; | ||||
|  | ||||
| @riverpod | ||||
| Future<SnPost?> postFeaturedReply(Ref ref, String id) async { | ||||
|   final client = ref.watch(apiClientProvider); | ||||
|   try { | ||||
|     final resp = await client.get('/sphere/posts/$id/replies/featured'); | ||||
|     return SnPost.fromJson(resp.data); | ||||
|   } catch (_) { | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
|  | ||||
| class PostVisibilityHelpers { | ||||
|   static IconData getVisibilityIcon(int visibility) { | ||||
|     switch (visibility) { | ||||
|       case 1: | ||||
|         return Symbols.group; | ||||
|       case 2: | ||||
|         return Symbols.link_off; | ||||
|       case 3: | ||||
|         return Symbols.lock; | ||||
|       default: | ||||
|         return Symbols.public; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static String getVisibilityText(int visibility) { | ||||
|     switch (visibility) { | ||||
|       case 1: | ||||
|         return 'postVisibilityFriends'; | ||||
|       case 2: | ||||
|         return 'postVisibilityUnlisted'; | ||||
|       case 3: | ||||
|         return 'postVisibilityPrivate'; | ||||
|       default: | ||||
|         return 'postVisibilityPublic'; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| class PostReplyPreview extends HookConsumerWidget { | ||||
|   final SnPost parent; | ||||
|   final bool isOpenable; | ||||
|   final bool isCompact; | ||||
|   final bool isAutoload; | ||||
|   final VoidCallback? onOpen; | ||||
|   const PostReplyPreview({ | ||||
|     super.key, | ||||
|     required this.parent, | ||||
|     this.isOpenable = false, | ||||
|     this.isCompact = false, | ||||
|     this.isAutoload = true, | ||||
|     this.onOpen, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final posts = useState<List<SnPost>>([]); | ||||
|     final loading = useState(false); | ||||
|  | ||||
|     Future<void> fetchMoreReplies({int pageSize = 3}) async { | ||||
|       final client = ref.read(apiClientProvider); | ||||
|       loading.value = true; | ||||
|  | ||||
|       try { | ||||
|         final response = await client.get( | ||||
|           '/sphere/posts/${parent.id}/replies', | ||||
|           queryParameters: {'offset': posts.value.length, 'take': pageSize}, | ||||
|         ); | ||||
|         try { | ||||
|           posts.value = [ | ||||
|             ...posts.value, | ||||
|             ...response.data.map((e) => SnPost.fromJson(e)), | ||||
|           ]; | ||||
|         } catch (_) { | ||||
|           // ignore disposed | ||||
|         } | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         try { | ||||
|           loading.value = false; | ||||
|         } catch (_) { | ||||
|           // ignore disposed | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       if (isAutoload) fetchMoreReplies(); | ||||
|       return null; | ||||
|     }, [parent]); | ||||
|  | ||||
|     final featuredReply = | ||||
|         isOpenable ? null : ref.watch(PostFeaturedReplyProvider(parent.id)); | ||||
|  | ||||
|     final itemWidget = | ||||
|         isOpenable | ||||
|             ? Column( | ||||
|               children: [ | ||||
|                 for (final post in posts.value) | ||||
|                   Column( | ||||
|                     children: [ | ||||
|                       InkWell( | ||||
|                         child: Row( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                           spacing: 8, | ||||
|                           children: [ | ||||
|                             ProfilePictureWidget( | ||||
|                               file: post.publisher.picture, | ||||
|                               radius: 12, | ||||
|                             ).padding(top: 4), | ||||
|                             if (post.content?.isNotEmpty ?? false) | ||||
|                               Expanded( | ||||
|                                 child: MarkdownTextContent( | ||||
|                                   content: post.content!, | ||||
|                                 ).padding(top: 2), | ||||
|                               ) | ||||
|                             else | ||||
|                               Expanded( | ||||
|                                 child: Text( | ||||
|                                   'postHasAttachments', | ||||
|                                 ).plural(post.attachments.length), | ||||
|                               ), | ||||
|                           ], | ||||
|                         ), | ||||
|                         onTap: () { | ||||
|                           onOpen?.call(); | ||||
|                           context.pushNamed( | ||||
|                             'postDetail', | ||||
|                             pathParameters: {'id': post.id}, | ||||
|                           ); | ||||
|                         }, | ||||
|                       ), | ||||
|                       if (post.repliesCount > 0) | ||||
|                         PostReplyPreview( | ||||
|                           parent: post, | ||||
|                           isOpenable: true, | ||||
|                           isCompact: true, | ||||
|                           isAutoload: false, | ||||
|                           onOpen: onOpen, | ||||
|                         ).padding(left: 24), | ||||
|                     ], | ||||
|                   ), | ||||
|                 if (loading.value) | ||||
|                   Row( | ||||
|                     spacing: 8, | ||||
|                     children: [ | ||||
|                       SizedBox( | ||||
|                         width: 16, | ||||
|                         height: 16, | ||||
|                         child: CircularProgressIndicator(), | ||||
|                       ), | ||||
|                       Text('loading').tr(), | ||||
|                     ], | ||||
|                   ) | ||||
|                 else if (posts.value.length < parent.repliesCount) | ||||
|                   InkWell( | ||||
|                     child: Row( | ||||
|                       spacing: 8, | ||||
|                       children: [ | ||||
|                         const Icon(Symbols.keyboard_arrow_down, size: 20), | ||||
|                         Text('repliesLoadMore').tr(), | ||||
|                       ], | ||||
|                     ), | ||||
|                     onTap: () { | ||||
|                       fetchMoreReplies(); | ||||
|                     }, | ||||
|                   ), | ||||
|               ], | ||||
|             ) | ||||
|             : (featuredReply!).map( | ||||
|               data: | ||||
|                   (data) => Row( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                     spacing: 8, | ||||
|                     children: [ | ||||
|                       ProfilePictureWidget( | ||||
|                         file: data.value?.publisher.picture, | ||||
|                         radius: 12, | ||||
|                       ).padding(top: 4), | ||||
|                       if (data.value?.content?.isNotEmpty ?? false) | ||||
|                         Expanded( | ||||
|                           child: MarkdownTextContent( | ||||
|                             content: data.value!.content!, | ||||
|                           ), | ||||
|                         ) | ||||
|                       else | ||||
|                         Expanded( | ||||
|                           child: Text( | ||||
|                             'postHasAttachments', | ||||
|                           ).plural(data.value?.attachments.length ?? 0), | ||||
|                         ), | ||||
|                     ], | ||||
|                   ), | ||||
|               error: | ||||
|                   (e) => Row( | ||||
|                     spacing: 8, | ||||
|                     children: [ | ||||
|                       const Icon(Symbols.close, size: 18), | ||||
|                       Text(e.error.toString()), | ||||
|                     ], | ||||
|                   ), | ||||
|               loading: | ||||
|                   (_) => Row( | ||||
|                     spacing: 8, | ||||
|                     children: [ | ||||
|                       SizedBox( | ||||
|                         width: 16, | ||||
|                         height: 16, | ||||
|                         child: CircularProgressIndicator(), | ||||
|                       ), | ||||
|                       Text('loading').tr(), | ||||
|                     ], | ||||
|                   ), | ||||
|             ); | ||||
|  | ||||
|     final contentWidget = | ||||
|         isCompact | ||||
|             ? itemWidget | ||||
|             : Container( | ||||
|               padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), | ||||
|               decoration: BoxDecoration( | ||||
|                 color: Theme.of(context).colorScheme.surfaceContainerLow, | ||||
|                 border: Border.all( | ||||
|                   color: Theme.of(context).dividerColor.withOpacity(0.5), | ||||
|                 ), | ||||
|                 borderRadius: BorderRadius.all(Radius.circular(8)), | ||||
|               ), | ||||
|               child: Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                 spacing: 4, | ||||
|                 children: [ | ||||
|                   Text('repliesCount') | ||||
|                       .plural(parent.repliesCount) | ||||
|                       .fontSize(15) | ||||
|                       .bold() | ||||
|                       .padding(horizontal: 5), | ||||
|                   itemWidget, | ||||
|                 ], | ||||
|               ), | ||||
|             ); | ||||
|  | ||||
|     return InkWell( | ||||
|       borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|       onTap: () { | ||||
|         showModalBottomSheet( | ||||
|           context: context, | ||||
|           isScrollControlled: true, | ||||
|           useRootNavigator: true, | ||||
|           builder: (context) => PostRepliesSheet(post: parent), | ||||
|         ); | ||||
|       }, | ||||
|       child: contentWidget, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class PostTruncateHint extends StatelessWidget { | ||||
|   final bool isCompact; | ||||
|   final EdgeInsets? margin; | ||||
|   final bool withArrow; | ||||
|  | ||||
|   const PostTruncateHint({ | ||||
|     super.key, | ||||
|     this.isCompact = false, | ||||
|     this.margin, | ||||
|     this.withArrow = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Container( | ||||
|       margin: margin ?? EdgeInsets.only(top: isCompact ? 4 : 8), | ||||
|       padding: EdgeInsets.symmetric( | ||||
|         horizontal: isCompact ? 8 : 12, | ||||
|         vertical: isCompact ? 4 : 8, | ||||
|       ), | ||||
|       decoration: BoxDecoration( | ||||
|         color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), | ||||
|         borderRadius: BorderRadius.circular(8), | ||||
|         border: Border.all( | ||||
|           color: Theme.of(context).colorScheme.outline.withOpacity(0.2), | ||||
|         ), | ||||
|       ), | ||||
|       child: Row( | ||||
|         mainAxisSize: MainAxisSize.min, | ||||
|         children: [ | ||||
|           Icon( | ||||
|             Symbols.more_horiz, | ||||
|             size: isCompact ? 14 : 16, | ||||
|             color: Theme.of(context).colorScheme.secondary, | ||||
|           ), | ||||
|           SizedBox(width: isCompact ? 4 : 6), | ||||
|           Flexible( | ||||
|             child: Text( | ||||
|               'postTruncated'.tr(), | ||||
|               style: TextStyle( | ||||
|                 fontSize: isCompact ? 10 : 12, | ||||
|                 color: Theme.of(context).colorScheme.secondary, | ||||
|                 fontStyle: FontStyle.italic, | ||||
|               ), | ||||
|               maxLines: 1, | ||||
|               overflow: TextOverflow.ellipsis, | ||||
|             ), | ||||
|           ), | ||||
|           if (withArrow) ...[ | ||||
|             SizedBox(width: isCompact ? 3 : 4), | ||||
|             Icon( | ||||
|               Symbols.arrow_forward, | ||||
|               size: isCompact ? 12 : 14, | ||||
|               color: Theme.of(context).colorScheme.secondary, | ||||
|             ), | ||||
|           ], | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class ReferencedPostWidget extends StatelessWidget { | ||||
|   final SnPost item; | ||||
|   final bool isInteractive; | ||||
|   final EdgeInsets renderingPadding; | ||||
|  | ||||
|   const ReferencedPostWidget({ | ||||
|     super.key, | ||||
|     required this.item, | ||||
|     this.isInteractive = true, | ||||
|     this.renderingPadding = EdgeInsets.zero, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final referencePost = item.repliedPost ?? item.forwardedPost; | ||||
|     if (referencePost == null) return const SizedBox.shrink(); | ||||
|  | ||||
|     final isReply = item.repliedPost != null; | ||||
|  | ||||
|     final content = Container( | ||||
|       padding: EdgeInsets.symmetric( | ||||
|         horizontal: renderingPadding.horizontal, | ||||
|         vertical: 8, | ||||
|       ), | ||||
|       margin: EdgeInsets.only( | ||||
|         top: 8, | ||||
|         left: renderingPadding.vertical, | ||||
|         right: renderingPadding.vertical, | ||||
|       ), | ||||
|       decoration: BoxDecoration( | ||||
|         color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5), | ||||
|         borderRadius: BorderRadius.circular(12), | ||||
|         border: Border.all( | ||||
|           color: Theme.of(context).dividerColor.withOpacity(0.5), | ||||
|         ), | ||||
|       ), | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           Row( | ||||
|             children: [ | ||||
|               Icon( | ||||
|                 isReply ? Symbols.reply : Symbols.forward, | ||||
|                 size: 16, | ||||
|                 color: Theme.of(context).colorScheme.secondary, | ||||
|               ), | ||||
|               const SizedBox(width: 6), | ||||
|               Text( | ||||
|                 isReply ? 'repliedTo'.tr() : 'forwarded'.tr(), | ||||
|                 style: TextStyle( | ||||
|                   color: Theme.of(context).colorScheme.secondary, | ||||
|                   fontWeight: FontWeight.w500, | ||||
|                   fontSize: 12, | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|           const SizedBox(height: 8), | ||||
|           Row( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               ProfilePictureWidget( | ||||
|                 fileId: referencePost.publisher.picture?.id, | ||||
|                 radius: 16, | ||||
|               ), | ||||
|               const SizedBox(width: 8), | ||||
|               Expanded( | ||||
|                 child: Column( | ||||
|                   crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                   children: [ | ||||
|                     Text( | ||||
|                       referencePost.publisher.nick, | ||||
|                       style: const TextStyle( | ||||
|                         fontWeight: FontWeight.bold, | ||||
|                         fontSize: 14, | ||||
|                       ), | ||||
|                     ), | ||||
|                     if (referencePost.visibility != 0) | ||||
|                       Row( | ||||
|                         mainAxisSize: MainAxisSize.min, | ||||
|                         children: [ | ||||
|                           Icon( | ||||
|                             PostVisibilityHelpers.getVisibilityIcon( | ||||
|                               referencePost.visibility, | ||||
|                             ), | ||||
|                             size: 12, | ||||
|                             color: Theme.of(context).colorScheme.secondary, | ||||
|                           ), | ||||
|                           const SizedBox(width: 4), | ||||
|                           Text( | ||||
|                             PostVisibilityHelpers.getVisibilityText( | ||||
|                               referencePost.visibility, | ||||
|                             ).tr(), | ||||
|                             style: TextStyle( | ||||
|                               fontSize: 10, | ||||
|                               color: Theme.of(context).colorScheme.secondary, | ||||
|                             ), | ||||
|                           ), | ||||
|                         ], | ||||
|                       ).padding(top: 2, bottom: 2), | ||||
|                     if (referencePost.title?.isNotEmpty ?? false) | ||||
|                       Text( | ||||
|                         referencePost.title!, | ||||
|                         style: TextStyle( | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                           fontSize: 13, | ||||
|                           color: Theme.of(context).colorScheme.onSurface, | ||||
|                         ), | ||||
|                       ).padding(top: 2, bottom: 2), | ||||
|                     if (referencePost.description?.isNotEmpty ?? false) | ||||
|                       Text( | ||||
|                         referencePost.description!, | ||||
|                         style: TextStyle( | ||||
|                           fontSize: 12, | ||||
|                           color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                         ), | ||||
|                         maxLines: 2, | ||||
|                         overflow: TextOverflow.ellipsis, | ||||
|                       ).padding(bottom: 2), | ||||
|                     if (referencePost.content?.isNotEmpty ?? false) | ||||
|                       MarkdownTextContent( | ||||
|                         content: referencePost.content!, | ||||
|                         textStyle: const TextStyle(fontSize: 14), | ||||
|                         isSelectable: false, | ||||
|                         linesMargin: | ||||
|                             referencePost.type == 0 | ||||
|                                 ? const EdgeInsets.only(bottom: 4) | ||||
|                                 : null, | ||||
|                         attachments: item.attachments, | ||||
|                       ).padding(bottom: 4), | ||||
|                     if (referencePost.isTruncated) | ||||
|                       const PostTruncateHint( | ||||
|                         isCompact: true, | ||||
|                         margin: EdgeInsets.only(top: 4, bottom: 8), | ||||
|                       ), | ||||
|                     if (referencePost.attachments.isNotEmpty && | ||||
|                         referencePost.type != 1) | ||||
|                       Row( | ||||
|                         mainAxisSize: MainAxisSize.min, | ||||
|                         children: [ | ||||
|                           Icon( | ||||
|                             Symbols.attach_file, | ||||
|                             size: 12, | ||||
|                             color: Theme.of(context).colorScheme.secondary, | ||||
|                           ), | ||||
|                           const SizedBox(width: 4), | ||||
|                           Text( | ||||
|                             'postHasAttachments'.plural( | ||||
|                               referencePost.attachments.length, | ||||
|                             ), | ||||
|                             style: TextStyle( | ||||
|                               color: Theme.of(context).colorScheme.secondary, | ||||
|                               fontSize: 12, | ||||
|                             ), | ||||
|                           ), | ||||
|                         ], | ||||
|                       ).padding(vertical: 2), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     if (!isInteractive) { | ||||
|       return content; | ||||
|     } | ||||
|  | ||||
|     return content.gestures( | ||||
|       onTap: | ||||
|           () => context.pushNamed( | ||||
|             'postDetail', | ||||
|             pathParameters: {'id': referencePost.id}, | ||||
|           ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class PostHeader extends StatelessWidget { | ||||
|   final SnPost item; | ||||
|   final bool isFullPost; | ||||
|   final Widget? trailing; | ||||
|   final bool isInteractive; | ||||
|   final EdgeInsets renderingPadding; | ||||
|   final bool isRelativeTime; | ||||
|  | ||||
|   const PostHeader({ | ||||
|     super.key, | ||||
|     required this.item, | ||||
|     this.isFullPost = false, | ||||
|     this.trailing, | ||||
|     this.isInteractive = true, | ||||
|     this.renderingPadding = EdgeInsets.zero, | ||||
|     this.isRelativeTime = true, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Row( | ||||
|       crossAxisAlignment: CrossAxisAlignment.center, | ||||
|       spacing: 12, | ||||
|       children: [ | ||||
|         GestureDetector( | ||||
|           onTap: | ||||
|               isInteractive | ||||
|                   ? () { | ||||
|                     context.pushNamed( | ||||
|                       'publisherProfile', | ||||
|                       pathParameters: {'name': item.publisher.name}, | ||||
|                     ); | ||||
|                   } | ||||
|                   : null, | ||||
|           child: ProfilePictureWidget(file: item.publisher.picture, radius: 16), | ||||
|         ), | ||||
|         Expanded( | ||||
|           child: Column( | ||||
|             mainAxisSize: MainAxisSize.min, | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               Row( | ||||
|                 spacing: 4, | ||||
|                 children: [ | ||||
|                   Text(item.publisher.nick).bold(), | ||||
|                   if (item.publisher.verification != null) | ||||
|                     VerificationMark(mark: item.publisher.verification!), | ||||
|                   Text('@${item.publisher.name}').fontSize(11), | ||||
|                 ], | ||||
|               ), | ||||
|               Row( | ||||
|                 spacing: 6, | ||||
|                 crossAxisAlignment: CrossAxisAlignment.end, | ||||
|                 children: [ | ||||
|                   Text( | ||||
|                     !isFullPost && isRelativeTime | ||||
|                         ? (item.publishedAt ?? item.createdAt)!.formatRelative( | ||||
|                           context, | ||||
|                         ) | ||||
|                         : (item.publishedAt ?? item.createdAt)!.formatSystem(), | ||||
|                   ).fontSize(10), | ||||
|                   if (item.editedAt != null) | ||||
|                     Text( | ||||
|                       'editedAt'.tr( | ||||
|                         args: [ | ||||
|                           !isFullPost && isRelativeTime | ||||
|                               ? item.editedAt!.formatRelative(context) | ||||
|                               : item.editedAt!.formatSystem(), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ).fontSize(10), | ||||
|                   if (item.visibility != 0) | ||||
|                     Text( | ||||
|                       PostVisibilityHelpers.getVisibilityText( | ||||
|                         item.visibility, | ||||
|                       ).tr(), | ||||
|                     ).fontSize(10), | ||||
|                 ], | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|         if (trailing != null) trailing!, | ||||
|       ], | ||||
|     ).padding(horizontal: renderingPadding.horizontal, bottom: 4); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class PostBody extends ConsumerWidget { | ||||
|   final SnPost item; | ||||
|   final bool isFullPost; | ||||
|   final bool isTextSelectable; | ||||
|   final Widget? translationSection; | ||||
|   final bool isInteractive; | ||||
|   final EdgeInsets renderingPadding; | ||||
|  | ||||
|   const PostBody({ | ||||
|     super.key, | ||||
|     required this.item, | ||||
|     this.isFullPost = false, | ||||
|     this.isTextSelectable = true, | ||||
|     this.translationSection, | ||||
|     this.isInteractive = true, | ||||
|     this.renderingPadding = EdgeInsets.zero, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         if (!isFullPost && item.type == 1) | ||||
|           Container( | ||||
|             decoration: BoxDecoration( | ||||
|               color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|               border: Border.all( | ||||
|                 color: Theme.of(context).dividerColor.withOpacity(0.5), | ||||
|               ), | ||||
|               borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|             ), | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), | ||||
|             margin: EdgeInsets.only( | ||||
|               top: 4, | ||||
|               left: renderingPadding.horizontal, | ||||
|               right: renderingPadding.vertical, | ||||
|             ), | ||||
|             child: Column( | ||||
|               mainAxisSize: MainAxisSize.min, | ||||
|               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|               children: [ | ||||
|                 Align( | ||||
|                   alignment: Alignment.centerLeft, | ||||
|                   child: Badge( | ||||
|                     label: const Text('postArticle').tr(), | ||||
|                     backgroundColor: Theme.of(context).colorScheme.primary, | ||||
|                     textColor: Theme.of(context).colorScheme.onPrimary, | ||||
|                   ), | ||||
|                 ), | ||||
|                 const Gap(4), | ||||
|                 if (item.title != null) | ||||
|                   Text( | ||||
|                     item.title!, | ||||
|                     style: Theme.of(context).textTheme.titleMedium!.copyWith( | ||||
|                       fontWeight: FontWeight.bold, | ||||
|                     ), | ||||
|                   ), | ||||
|                 if (item.description != null) | ||||
|                   Text( | ||||
|                     item.description!, | ||||
|                     style: Theme.of(context).textTheme.bodyMedium, | ||||
|                   ) | ||||
|                 else | ||||
|                   MarkdownTextContent(content: '${item.content!}...'), | ||||
|               ], | ||||
|             ), | ||||
|           ) | ||||
|         else if ((item.content?.isNotEmpty ?? false) || | ||||
|             (item.title?.isNotEmpty ?? false) || | ||||
|             (item.description?.isNotEmpty ?? false)) | ||||
|           Padding( | ||||
|             padding: EdgeInsets.only( | ||||
|               left: renderingPadding.horizontal, | ||||
|               right: renderingPadding.horizontal, | ||||
|             ), | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|               children: [ | ||||
|                 if ((item.title?.isNotEmpty ?? false) || | ||||
|                     (item.description?.isNotEmpty ?? false)) | ||||
|                   Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       if (item.title?.isNotEmpty ?? false) | ||||
|                         Text( | ||||
|                           item.title!, | ||||
|                           style: Theme.of(context).textTheme.titleMedium! | ||||
|                               .copyWith(fontWeight: FontWeight.bold), | ||||
|                         ), | ||||
|                       if (item.description?.isNotEmpty ?? false) | ||||
|                         Text( | ||||
|                           item.description!, | ||||
|                           style: Theme.of(context).textTheme.bodyMedium, | ||||
|                         ), | ||||
|                     ], | ||||
|                   ).padding(bottom: 4), | ||||
|                 MarkdownTextContent( | ||||
|                   content: | ||||
|                       item.isTruncated ? '${item.content!}...' : item.content!, | ||||
|                   isSelectable: isTextSelectable, | ||||
|                 ), | ||||
|                 if (translationSection != null) translationSection!, | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         if (item.isTruncated && item.type != 1) | ||||
|           PostTruncateHint( | ||||
|             isCompact: true, | ||||
|             withArrow: isInteractive, | ||||
|             margin: EdgeInsets.only( | ||||
|               top: 4, | ||||
|               bottom: 4, | ||||
|               left: renderingPadding.horizontal, | ||||
|               right: renderingPadding.horizontal, | ||||
|             ), | ||||
|           ), | ||||
|         if (item.attachments.isNotEmpty && item.type != 1) | ||||
|           CloudFileList( | ||||
|             files: item.attachments, | ||||
|             isColumn: !isInteractive, | ||||
|             padding: EdgeInsets.symmetric( | ||||
|               horizontal: renderingPadding.horizontal, | ||||
|               vertical: 4, | ||||
|             ), | ||||
|           ), | ||||
|         if (item.tags.isNotEmpty || item.categories.isNotEmpty) | ||||
|           Column( | ||||
|             mainAxisSize: MainAxisSize.min, | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             spacing: 2, | ||||
|             children: [ | ||||
|               if (item.tags.isNotEmpty) | ||||
|                 Wrap( | ||||
|                   runAlignment: WrapAlignment.center, | ||||
|                   spacing: 8, | ||||
|                   children: [ | ||||
|                     const Icon(Symbols.label, size: 16).padding(top: 2), | ||||
|                     for (final tag | ||||
|                         in isFullPost ? item.tags : item.tags.take(3)) | ||||
|                       InkWell( | ||||
|                         onTap: | ||||
|                             isInteractive | ||||
|                                 ? () { | ||||
|                                   GoRouter.of(context).pushNamed( | ||||
|                                     'postTagDetail', | ||||
|                                     pathParameters: {'slug': tag.slug}, | ||||
|                                   ); | ||||
|                                 } | ||||
|                                 : null, | ||||
|                         child: Text('#${tag.name ?? tag.slug}'), | ||||
|                       ), | ||||
|                     if (!isFullPost && item.tags.length > 3) | ||||
|                       Text('+${item.tags.length - 3}').opacity(0.6), | ||||
|                   ], | ||||
|                 ), | ||||
|               if (item.categories.isNotEmpty) | ||||
|                 Wrap( | ||||
|                   runAlignment: WrapAlignment.center, | ||||
|                   spacing: 8, | ||||
|                   children: [ | ||||
|                     const Icon(Symbols.category, size: 16).padding(top: 2), | ||||
|                     for (final category | ||||
|                         in isFullPost | ||||
|                             ? item.categories | ||||
|                             : item.categories.take(2)) | ||||
|                       InkWell( | ||||
|                         onTap: | ||||
|                             isInteractive | ||||
|                                 ? () { | ||||
|                                   GoRouter.of(context).pushNamed( | ||||
|                                     'postCategoryDetail', | ||||
|                                     pathParameters: {'slug': category.slug}, | ||||
|                                   ); | ||||
|                                 } | ||||
|                                 : null, | ||||
|                         child: Text(category.categoryDisplayTitle), | ||||
|                       ), | ||||
|                     if (!isFullPost && item.categories.length > 2) | ||||
|                       Text('+${item.categories.length - 2}').opacity(0.6), | ||||
|                   ], | ||||
|                 ), | ||||
|             ], | ||||
|           ).padding(horizontal: renderingPadding.horizontal + 4, top: 4), | ||||
|         if (item.meta?['embeds'] != null) | ||||
|           ...((item.meta!['embeds'] as List<dynamic>) | ||||
|               .map((embedData) => convertMapKeysToSnakeCase(embedData)) | ||||
|               .map( | ||||
|                 (embedData) => switch (embedData['type']) { | ||||
|                   'link' => EmbedLinkWidget( | ||||
|                     link: SnScrappedLink.fromJson(embedData), | ||||
|                     maxWidth: math.min( | ||||
|                       MediaQuery.of(context).size.width, | ||||
|                       kWideScreenWidth, | ||||
|                     ), | ||||
|                     margin: EdgeInsets.only( | ||||
|                       top: 4, | ||||
|                       bottom: 4, | ||||
|                       left: renderingPadding.horizontal, | ||||
|                       right: renderingPadding.horizontal, | ||||
|                     ), | ||||
|                   ), | ||||
|                   'poll' => Card( | ||||
|                     margin: EdgeInsets.symmetric( | ||||
|                       horizontal: renderingPadding.horizontal, | ||||
|                       vertical: 8, | ||||
|                     ), | ||||
|                     child: | ||||
|                         embedData['poll'] == null | ||||
|                             ? const Text('Poll was not loaded...') | ||||
|                             : PollSubmit( | ||||
|                               initialAnswers: | ||||
|                                   embedData['poll']?['user_answer']?['answer'], | ||||
|                               stats: embedData['poll']?['stats'], | ||||
|                               poll: SnPollWithStats.fromJson(embedData['poll']), | ||||
|                               onSubmit: (_) {}, | ||||
|                               isReadonly: !isInteractive, | ||||
|                             ).padding(horizontal: 16, vertical: 12), | ||||
|                   ), | ||||
|                   _ => Text('Unable show embed: ${embedData['type']}'), | ||||
|                 }, | ||||
|               )), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user