Compare commits
	
		
			31 Commits
		
	
	
		
			2.1.1+36
			...
			619c90cdd9
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 619c90cdd9 | |||
| 168d51c9fe | |||
| d4b831f98e | |||
| 4d96a15c31 | |||
| 06dd3e092a | |||
| 82fe9e287a | |||
| dc1c285de1 | |||
| 5a3313e94f | |||
| 61032c84f1 | |||
| 36a5b8fb39 | |||
| 3eda464e03 | |||
| 7a3ab6fd7d | |||
| 3d15c0b9f9 | |||
| 67a29b4305 | |||
| 594f57e0d3 | |||
| d1eb51c596 | |||
| 85d2eff7f8 | |||
| 2375c46852 | |||
| fd2eb5cda6 | |||
| 1256f440bd | |||
| 5b05ca67b6 | |||
| 95af7140cd | |||
| 77e9994204 | |||
| 3f6c186c13 | |||
| 9ac4a940dd | |||
| ec050ab712 | |||
| 77e3ce8bcc | |||
| f5dcf71e10 | |||
| 7fc18b40db | |||
| 8c8ab24c9e | |||
| a319bd7f8c | 
| @@ -10,8 +10,9 @@ plugins { | ||||
| } | ||||
|  | ||||
| dependencies { | ||||
|     implementation "androidx.glance:glance:1.1.1" | ||||
|     implementation "androidx.glance:glance-appwidget:1.1.1" | ||||
|     implementation 'com.google.android.material:material:1.12.0' | ||||
|     implementation 'androidx.glance:glance:1.1.1' | ||||
|     implementation 'androidx.glance:glance-appwidget:1.1.1' | ||||
|     implementation 'androidx.compose.foundation:foundation-layout-android:1.7.6' | ||||
|     implementation 'com.google.code.gson:gson:2.10.1' | ||||
|     implementation 'com.squareup.okhttp3:okhttp:4.12.0' | ||||
| @@ -19,6 +20,12 @@ dependencies { | ||||
|     implementation 'io.coil-kt.coil3:coil-network-okhttp:3.0.4' | ||||
| } | ||||
|  | ||||
| def keystoreProperties = new Properties() | ||||
| def keystorePropertiesFile = rootProject.file('key.properties') | ||||
| if (keystorePropertiesFile.exists()) { | ||||
|     keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) | ||||
| } | ||||
|  | ||||
| android { | ||||
|     buildFeatures { | ||||
|         compose true | ||||
| @@ -49,6 +56,15 @@ android { | ||||
|         versionName = flutter.versionName | ||||
|     } | ||||
|  | ||||
|     signingConfigs { | ||||
|         release { | ||||
|             keyAlias = keystoreProperties['keyAlias'] | ||||
|             keyPassword = keystoreProperties['keyPassword'] | ||||
|             storeFile = keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null | ||||
|             storePassword = keystoreProperties['storePassword'] | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     buildTypes { | ||||
|         debug { | ||||
|             debuggable true | ||||
| @@ -56,9 +72,7 @@ android { | ||||
|             proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' | ||||
|         } | ||||
|         release { | ||||
|             // TODO: Add your own signing config for the release build. | ||||
|             // Signing with the debug keys for now, so `flutter run --release` works. | ||||
|             signingConfig = signingConfigs.debug | ||||
|             signingConfig = signingConfigs.release | ||||
|  | ||||
|             proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' | ||||
|         } | ||||
|   | ||||
| Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 537 B | 
| Before Width: | Height: | Size: 717 B After Width: | Height: | Size: 372 B | 
| Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 736 B | 
| Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.1 KiB | 
| Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.5 KiB | 
| @@ -1,4 +1,5 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <resources> | ||||
|   <color name="ic_launcher_background">#FFFFFFFF</color> | ||||
|   <color name="ic_notification_background">#00000000</color> | ||||
| </resources> | ||||
| @@ -1,7 +1,7 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <resources> | ||||
|     <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off --> | ||||
|     <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar"> | ||||
|     <style name="LaunchTheme" parent="Theme.MaterialComponents.Light.NoActionBar"> | ||||
|         <!-- Show a splash screen on the activity. Automatically removed when | ||||
|              the Flutter engine draws its first frame --> | ||||
|         <item name="android:windowBackground">@drawable/launch_background</item> | ||||
| @@ -16,7 +16,7 @@ | ||||
|          running. | ||||
|  | ||||
|          This Theme is only used starting with V2 of Flutter's Android embedding. --> | ||||
|     <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> | ||||
|     <style name="NormalTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar"> | ||||
|         <item name="android:windowBackground">?android:colorBackground</item> | ||||
|     </style> | ||||
| </resources> | ||||
|   | ||||
							
								
								
									
										30
									
								
								api/Passport/Developer Notify All Users.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,30 @@ | ||||
| meta { | ||||
|   name: Developer Notify All Users | ||||
|   type: http | ||||
|   seq: 1 | ||||
| } | ||||
|  | ||||
| post { | ||||
|   url: {{endpoint}}/cgi/id/dev/notify/all | ||||
|   body: json | ||||
|   auth: bearer | ||||
| } | ||||
|  | ||||
| auth:bearer { | ||||
|   token: {{atk}} | ||||
| } | ||||
|  | ||||
| body:json { | ||||
|   { | ||||
|     "client_id": "{{third_client_id}}", | ||||
|     "client_secret":"{{third_client_tk}}", | ||||
|     "type": "general", | ||||
|     "subject": "Merry Christmas!", | ||||
|     "subtitle": "一条来自 Solar Network 团队的信息", | ||||
|     "content": "今天是 12 月 25 日 (UTC+8),小羊祝您圣诞快乐 🎄", | ||||
|     "metadata": { | ||||
|       "image": "6EqsYQwmFRCkbmhR" | ||||
|     }, | ||||
|     "priority": 10 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										9
									
								
								api/bruno.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
| { | ||||
|   "version": "1", | ||||
|   "name": "Solar Network", | ||||
|   "type": "collection", | ||||
|   "ignore": [ | ||||
|     "node_modules", | ||||
|     ".git" | ||||
|   ] | ||||
| } | ||||
							
								
								
									
										8
									
								
								api/environments/Prod.bru
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,8 @@ | ||||
| vars { | ||||
|   endpoint: https://api.sn.solsynth.dev | ||||
|   third_client_id: alphabot | ||||
| } | ||||
| vars:secret [ | ||||
|   atk, | ||||
|   third_client_tk | ||||
| ] | ||||
| @@ -57,7 +57,7 @@ | ||||
|   "reply": "Reply", | ||||
|   "unset": "Unset", | ||||
|   "untitled": "Untitled", | ||||
|   "postDetail": "Post detail", | ||||
|   "postDetail": "Post Detail", | ||||
|   "postNoun": "Post", | ||||
|   "postReadMore": "Read more", | ||||
|   "postReadEstimate": "Est read time {}", | ||||
| @@ -139,6 +139,9 @@ | ||||
|   "fieldPostTitle": "Title", | ||||
|   "fieldPostDescription": "Description", | ||||
|   "fieldPostTags": "Tags", | ||||
|   "fieldPostCategories": "Categories", | ||||
|   "fieldPostAlias": "Alias", | ||||
|   "fieldPostAliasHint": "Optional, used to represent the post in URL, should follow URL-Safe.", | ||||
|   "postPublish": "Publish", | ||||
|   "postPosted": "Post has been posted.", | ||||
|   "postPublishedAt": "Published At", | ||||
| @@ -176,12 +179,18 @@ | ||||
|     "other": "{} comments" | ||||
|   }, | ||||
|   "settingsAppearance": "Appearance", | ||||
|   "settingsAppBarTransparent": "Transparent App Bar", | ||||
|   "settingsAppBarTransparentDescription": "Enable transparent effect for the app bar.", | ||||
|   "settingsBackgroundImage": "Background Image", | ||||
|   "settingsBackgroundImageDescription": "Set the background image that will be applied globally.", | ||||
|   "settingsBackgroundImageClear": "Clear Existing Background Image", | ||||
|   "settingsBackgroundImageClearDescription": "Reset the background image to blank.", | ||||
|   "settingsThemeMaterial3": "Use Material You Design", | ||||
|   "settingsThemeMaterial3Description": "Set the application theme to Material 3 Design.", | ||||
|   "settingsColorScheme": "Color Scheme", | ||||
|   "settingsColorSchemeDescription": "Set the application primary color.", | ||||
|   "settingsColorSeed": "Color Seed", | ||||
|   "settingsColorSeedDescription": "Select one of the present color schemes.", | ||||
|   "settingsNetwork": "Network", | ||||
|   "settingsNetworkServer": "HyperNet Server", | ||||
|   "settingsNetworkServerDescription": "Set the HyperNet server address, choose ours or build your own.", | ||||
| @@ -272,16 +281,22 @@ | ||||
|     "one": "{} attachment", | ||||
|     "other": "{} attachments" | ||||
|   }, | ||||
|   "fieldAttachmentRandomId": "Random ID", | ||||
|   "addAttachmentFromAlbum": "Add from album", | ||||
|   "addAttachmentFromClipboard": "Paste file", | ||||
|   "addAttachmentFromCameraPhoto": "Take photo", | ||||
|   "addAttachmentFromCameraVideo": "Take video", | ||||
|   "addAttachmentFromRandomId": "Link via RID", | ||||
|   "attachmentPastedImage": "Pasted Image", | ||||
|   "attachmentInsertLink": "Insert Link", | ||||
|   "attachmentSetAsPostThumbnail": "Set as post thumbnail", | ||||
|   "attachmentUnsetAsPostThumbnail": "Unset as post thumbnail", | ||||
|   "attachmentSetThumbnail": "Set thumbnail", | ||||
|   "attachmentCopyRandomId": "Copy RID", | ||||
|   "attachmentUpload": "Upload", | ||||
|   "attachmentInputDialog": "Upload attachments", | ||||
|   "attachmentInputUseRandomId": "Use Random ID", | ||||
|   "attachmentInputNew": "New Upload", | ||||
|   "notification": "Notification", | ||||
|   "notificationUnreadCount": { | ||||
|     "zero": "All notifications read", | ||||
| @@ -369,9 +384,26 @@ | ||||
|   "dailyCheckNegativeHint5Description": "Lost connection at a crucial moment", | ||||
|   "dailyCheckNegativeHint6": "Going out", | ||||
|   "dailyCheckNegativeHint6Description": "Forgot your umbrella and got caught in the rain", | ||||
|   "happyBirthday": "Happy birthday, {}!", | ||||
|   "celebrateBirthday": "Happy birthday, {}!", | ||||
|   "celebrateMerryXmas": "Merry christmas, {}!", | ||||
|   "celebrateNewYear": "Happy new year, {}!", | ||||
|   "celebrateValentineDay": "Today is valentine's day, {}!", | ||||
|   "celebrateLaborDay": "Today is labor day, {}.", | ||||
|   "celebrateMotherDay": "Today is mother's day, {}.", | ||||
|   "celebrateChildrenDay": "Today is children's day, {}!", | ||||
|   "celebrateFatherDay": "Today is father's day, {}.", | ||||
|   "celebrateHalloween": "Happy halloween, {}!", | ||||
|   "celebrateThanksgiving": "Today is thanksgiving day, {}!", | ||||
|   "pendingBirthday": "Birthday in {}", | ||||
|   "pendingMerryXmas": "Christmas in {}", | ||||
|   "pendingNewYear": "New year in {}", | ||||
|   "pendingValentineDay": "Valentine's day in {}", | ||||
|   "pendingLaborDay": "Labor day in {}", | ||||
|   "pendingMotherDay": "Mother's day in {}", | ||||
|   "pendingChildrenDay": "Children's day in {}", | ||||
|   "pendingFatherDay": "Father's day in {}", | ||||
|   "pendingHalloween": "Halloween in {}", | ||||
|   "pendingThanksgiving": "Thanksgiving day in {}", | ||||
|   "friendNew": "Add Friend", | ||||
|   "friendRequests": "Friend Requests", | ||||
|   "friendRequestsDescription": { | ||||
| @@ -448,7 +480,7 @@ | ||||
|   "publisherBlockHintDescription": "You are going to block this publisher's maintainer, this will also block publishers that run by the same user.", | ||||
|   "userUnblocked": "{} has been unblocked.", | ||||
|   "userBlocked": "{} has been blocked.", | ||||
|   "postSharingViaPicture": "Capturing post as picture, please stand by...", | ||||
|   "postSharingViaPicture": "Capturing post as picture, please wait...", | ||||
|   "postImageShareReadMore": "Scan the QR code to read full post", | ||||
|   "postImageShareAds": "Explore posts on the Solar Network", | ||||
|   "postShare": "Share", | ||||
| @@ -459,5 +491,27 @@ | ||||
|   "shareIntentDescription":  "What do you want to do with the content you are sharing?", | ||||
|   "shareIntentPostStory": "Post a Story", | ||||
|   "updateAvailable": "Update Available", | ||||
|   "updateOngoing": "正在更新,请稍后..." | ||||
|   "updateOngoing": "Updating, please wait...", | ||||
|   "custom": "Custom", | ||||
|   "colorSchemeIndigo": "Indigo", | ||||
|   "colorSchemeBlue": "Blue", | ||||
|   "colorSchemeGreen": "Green", | ||||
|   "colorSchemeYellow": "Yellow", | ||||
|   "colorSchemeOrange": "Orange", | ||||
|   "colorSchemeRed": "Red", | ||||
|   "colorSchemeWhite": "White", | ||||
|   "colorSchemeBlack": "Black", | ||||
|   "colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.", | ||||
|   "postCategoryTechnology": "Technology", | ||||
|   "postCategoryGaming": "Gaming", | ||||
|   "postCategoryLife": "Life", | ||||
|   "postCategoryArts": "Arts", | ||||
|   "postCategorySports": "Sports", | ||||
|   "postCategoryMusic": "Music", | ||||
|   "postCategoryNews": "News", | ||||
|   "postCategoryKnowledge": "Knowledge", | ||||
|   "postCategoryLiterature": "Literature", | ||||
|   "postCategoryFunny": "Funny", | ||||
|   "postCategoryUncategorized": "Uncategorized", | ||||
|   "waitingForUpload": "Waiting for upload" | ||||
| } | ||||
|   | ||||
| @@ -123,6 +123,9 @@ | ||||
|   "fieldPostTitle": "标题", | ||||
|   "fieldPostDescription": "描述", | ||||
|   "fieldPostTags": "标签", | ||||
|   "fieldPostCategories": "分类", | ||||
|   "fieldPostAlias": "别名", | ||||
|   "fieldPostAliasHint": "可选项,用于在 URL 中表示该帖子,应遵循 URL-Safe 的原则。", | ||||
|   "postPublish": "发布", | ||||
|   "postPublishedAt": "发布于", | ||||
|   "postPublishedUntil": "取消发布于", | ||||
| @@ -180,6 +183,12 @@ | ||||
|   "settingsBackgroundImageClearDescription": "将应用背景图重置为空白。", | ||||
|   "settingsThemeMaterial3": "使用 Material You 设计范式", | ||||
|   "settingsThemeMaterial3Description": "将应用主题设置为 Material 3 设计范式的主题。", | ||||
|   "settingsAppBarTransparent": "透明顶栏", | ||||
|   "settingsAppBarTransparentDescription": "为顶栏启用透明效果。", | ||||
|   "settingsColorScheme": "主题色", | ||||
|   "settingsColorSchemeDescription": "设置应用主题色。", | ||||
|   "settingsColorSeed": "预设色彩主题", | ||||
|   "settingsColorSeedDescription": "选择一个预设色彩主题。", | ||||
|   "settingsNetwork": "网络", | ||||
|   "settingsNetworkServer": "HyperNet 服务器", | ||||
|   "settingsNetworkServerDescription": "设置 HyperNet 服务器地址,选择我们提供的,或者自己搭建。", | ||||
| @@ -270,16 +279,22 @@ | ||||
|     "one": "{} 个附件", | ||||
|     "other": "{} 个附件" | ||||
|   }, | ||||
|   "fieldAttachmentRandomId": "访问 ID", | ||||
|   "addAttachmentFromAlbum": "从相册中添加附件", | ||||
|   "addAttachmentFromClipboard": "粘贴附件", | ||||
|   "addAttachmentFromCameraPhoto": "拍摄照片", | ||||
|   "addAttachmentFromCameraVideo": "拍摄视频", | ||||
|   "addAttachmentFromRandomId": "通过访问 ID 链接", | ||||
|   "attachmentPastedImage": "粘贴的图片", | ||||
|   "attachmentInsertLink": "插入连接", | ||||
|   "attachmentSetAsPostThumbnail": "设置为帖子缩略图", | ||||
|   "attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图", | ||||
|   "attachmentSetThumbnail": "设置缩略图", | ||||
|   "attachmentCopyRandomId": "复制访问 ID", | ||||
|   "attachmentUpload": "上传", | ||||
|   "attachmentInputDialog": "上传附件", | ||||
|   "attachmentInputUseRandomId": "使用访问 ID", | ||||
|   "attachmentInputNew": "新上传附件", | ||||
|   "notification": "通知", | ||||
|   "notificationUnreadCount": { | ||||
|     "zero": "无未读通知", | ||||
| @@ -367,9 +382,26 @@ | ||||
|   "dailyCheckNegativeHint5Description": "关键时刻断网", | ||||
|   "dailyCheckNegativeHint6": "出门", | ||||
|   "dailyCheckNegativeHint6Description": "忘带伞遇上大雨", | ||||
|   "happyBirthday": "生日快乐,{}!", | ||||
|   "celebrateBirthday": "生日快乐,{}!", | ||||
|   "celebrateMerryXmas": "圣诞快乐,{}!", | ||||
|   "celebrateNewYear": "新年快乐,{}!", | ||||
|   "celebrateValentineDay": "今天是情人节,{}!", | ||||
|   "celebrateLaborDay": "今天是劳动节,{}。", | ||||
|   "celebrateMotherDay": "今天是母亲节,{}。", | ||||
|   "celebrateChildrenDay": "今天是儿童节,{}!", | ||||
|   "celebrateFatherDay": "今天是父亲节,{}。", | ||||
|   "celebrateHalloween": "快乐在圣诞节,{}!", | ||||
|   "celebrateThanksgiving": "今天是感恩节,{}!", | ||||
|   "pendingBirthday": "{} 过生日", | ||||
|   "pendingMerryXmas": "{} 过圣诞节", | ||||
|   "pendingNewYear": "{} 跨年", | ||||
|   "pendingValentineDay": "{} 过情人节", | ||||
|   "pendingLaborDay": "{} 过劳动节", | ||||
|   "pendingMotherDay": "{} 过母亲节", | ||||
|   "pendingChildrenDay": "{} 过儿童节", | ||||
|   "pendingFatherDay": "{} 过父亲节", | ||||
|   "pendingHalloween": "{} 过圣诞节", | ||||
|   "pendingThanksgiving": "{} 过感恩节", | ||||
|   "friendNew": "添加好友", | ||||
|   "friendRequests": "好友请求", | ||||
|   "friendRequestsDescription": { | ||||
| @@ -408,7 +440,7 @@ | ||||
|   "accountStatus": "状态", | ||||
|   "accountStatusOnline": "在线", | ||||
|   "accountStatusOffline": "离线", | ||||
|   "accountStatusLastSeen": "最后一次在 {} 上线", | ||||
|   "accountStatusLastSeen": "最后一次上线于 {}", | ||||
|   "postArticle": "Solar Network 上的文章", | ||||
|   "postStory": "Solar Network 上的故事", | ||||
|   "articleWrittenAt": "发表于 {}", | ||||
| @@ -457,5 +489,27 @@ | ||||
|   "shareIntentDescription": "您想对您分享的内容做些什么?", | ||||
|   "shareIntentPostStory": "发布动态", | ||||
|   "updateAvailable": "检测到更新可用", | ||||
|   "updateOngoing": "正在更新,请稍后……" | ||||
|   "updateOngoing": "正在更新,请稍后……", | ||||
|   "custom": "自定义", | ||||
|   "colorSchemeIndigo": "靛蓝", | ||||
|   "colorSchemeBlue": "蓝色", | ||||
|   "colorSchemeGreen": "绿色", | ||||
|   "colorSchemeYellow": "黄色", | ||||
|   "colorSchemeOrange": "橙色", | ||||
|   "colorSchemeRed": "红色", | ||||
|   "colorSchemeWhite": "白色", | ||||
|   "colorSchemeBlack": "黑色", | ||||
|   "colorSchemeApplied": "主题色已应用,可能需要重启来生效。", | ||||
|   "postCategoryTechnology": "技术", | ||||
|   "postCategoryGaming": "游戏", | ||||
|   "postCategoryLife": "生活", | ||||
|   "postCategoryArts": "艺术", | ||||
|   "postCategorySports": "体育", | ||||
|   "postCategoryMusic": "音乐", | ||||
|   "postCategoryNews": "新闻", | ||||
|   "postCategoryKnowledge": "知识", | ||||
|   "postCategoryLiterature": "文学", | ||||
|   "postCategoryFunny": "搞笑", | ||||
|   "postCategoryUncategorized": "未分类", | ||||
|   "waitingForUpload": "等待上传" | ||||
| } | ||||
|   | ||||
| @@ -123,6 +123,9 @@ | ||||
|   "fieldPostTitle": "標題", | ||||
|   "fieldPostDescription": "描述", | ||||
|   "fieldPostTags": "標籤", | ||||
|   "fieldPostCategories": "分類", | ||||
|   "fieldPostAlias": "別名", | ||||
|   "fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。", | ||||
|   "postPublish": "發佈", | ||||
|   "postPublishedAt": "發佈於", | ||||
|   "postPublishedUntil": "取消發佈於", | ||||
| @@ -180,6 +183,12 @@ | ||||
|   "settingsBackgroundImageClearDescription": "將應用背景圖重置為空白。", | ||||
|   "settingsThemeMaterial3": "使用 Material You 設計範式", | ||||
|   "settingsThemeMaterial3Description": "將應用主題設置為 Material 3 設計範式的主題。", | ||||
|   "settingsAppBarTransparent": "透明頂欄", | ||||
|   "settingsAppBarTransparentDescription": "為頂欄啓用透明效果。", | ||||
|   "settingsColorScheme": "主題色", | ||||
|   "settingsColorSchemeDescription": "設置應用主題色。", | ||||
|   "settingsColorSeed": "預設色彩主題", | ||||
|   "settingsColorSeedDescription": "選擇一個預設色彩主題。", | ||||
|   "settingsNetwork": "網絡", | ||||
|   "settingsNetworkServer": "HyperNet 服務器", | ||||
|   "settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。", | ||||
| @@ -367,7 +376,26 @@ | ||||
|   "dailyCheckNegativeHint5Description": "關鍵時刻斷網", | ||||
|   "dailyCheckNegativeHint6": "出門", | ||||
|   "dailyCheckNegativeHint6Description": "忘帶傘遇上大雨", | ||||
|   "happyBirthday": "生日快樂,{}!", | ||||
|   "celebrateBirthday": "生日快樂,{}!", | ||||
|   "celebrateMerryXmas": "聖誕快樂,{}!", | ||||
|   "celebrateNewYear": "新年快樂,{}!", | ||||
|   "celebrateValentineDay": "今天是情人節,{}!", | ||||
|   "celebrateLaborDay": "今天是勞動節,{}。", | ||||
|   "celebrateMotherDay": "今天是母親節,{}。", | ||||
|   "celebrateChildrenDay": "今天是兒童節,{}!", | ||||
|   "celebrateFatherDay": "今天是父親節,{}。", | ||||
|   "celebrateHalloween": "快樂在聖誕節,{}!", | ||||
|   "celebrateThanksgiving": "今天是感恩節,{}!", | ||||
|   "pendingBirthday": "{} 過生日", | ||||
|   "pendingMerryXmas": "{} 過聖誕節", | ||||
|   "pendingNewYear": "{} 跨年", | ||||
|   "pendingValentineDay": "{} 過情人節", | ||||
|   "pendingLaborDay": "{} 過勞動節", | ||||
|   "pendingMotherDay": "{} 過母親節", | ||||
|   "pendingChildrenDay": "{} 過兒童節", | ||||
|   "pendingFatherDay": "{} 過父親節", | ||||
|   "pendingHalloween": "{} 過聖誕節", | ||||
|   "pendingThanksgiving": "{} 過感恩節", | ||||
|   "friendNew": "添加好友", | ||||
|   "friendRequests": "好友請求", | ||||
|   "friendRequestsDescription": { | ||||
| @@ -406,7 +434,7 @@ | ||||
|   "accountStatus": "狀態", | ||||
|   "accountStatusOnline": "在線", | ||||
|   "accountStatusOffline": "離線", | ||||
|   "accountStatusLastSeen": "最後一次在 {} 上線", | ||||
|   "accountStatusLastSeen": "最後一次上線於 {}", | ||||
|   "postArticle": "Solar Network 上的文章", | ||||
|   "postStory": "Solar Network 上的故事", | ||||
|   "articleWrittenAt": "發表於 {}", | ||||
| @@ -453,5 +481,28 @@ | ||||
|   "poweredBy": "由 {} 提供支持", | ||||
|   "shareIntent": "分享", | ||||
|   "shareIntentDescription": "您想對您分享的內容做些什麼?", | ||||
|   "shareIntentPostStory": "發佈動態" | ||||
|   "shareIntentPostStory": "發佈動態", | ||||
|   "updateAvailable": "檢測到更新可用", | ||||
|   "updateOngoing": "正在更新,請稍後……", | ||||
|   "custom": "自定義", | ||||
|   "colorSchemeIndigo": "靛藍", | ||||
|   "colorSchemeBlue": "藍色", | ||||
|   "colorSchemeGreen": "綠色", | ||||
|   "colorSchemeYellow": "黃色", | ||||
|   "colorSchemeOrange": "橙色", | ||||
|   "colorSchemeRed": "紅色", | ||||
|   "colorSchemeWhite": "白色", | ||||
|   "colorSchemeBlack": "黑色", | ||||
|   "colorSchemeApplied": "主題色已應用,可能需要重啓來生效。", | ||||
|   "postCategoryTechnology": "技術", | ||||
|   "postCategoryGaming": "遊戲", | ||||
|   "postCategoryLife": "生活", | ||||
|   "postCategoryArts": "藝術", | ||||
|   "postCategorySports": "體育", | ||||
|   "postCategoryMusic": "音樂", | ||||
|   "postCategoryNews": "新聞", | ||||
|   "postCategoryKnowledge": "知識", | ||||
|   "postCategoryLiterature": "文學", | ||||
|   "postCategoryFunny": "搞笑", | ||||
|   "postCategoryUncategorized": "未分類" | ||||
| } | ||||
|   | ||||
| @@ -123,6 +123,9 @@ | ||||
|   "fieldPostTitle": "標題", | ||||
|   "fieldPostDescription": "描述", | ||||
|   "fieldPostTags": "標籤", | ||||
|   "fieldPostCategories": "分類", | ||||
|   "fieldPostAlias": "別名", | ||||
|   "fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。", | ||||
|   "postPublish": "釋出", | ||||
|   "postPublishedAt": "釋出於", | ||||
|   "postPublishedUntil": "取消釋出於", | ||||
| @@ -180,6 +183,12 @@ | ||||
|   "settingsBackgroundImageClearDescription": "將應用背景圖重置為空白。", | ||||
|   "settingsThemeMaterial3": "使用 Material You 設計正規化", | ||||
|   "settingsThemeMaterial3Description": "將應用主題設定為 Material 3 設計正規化的主題。", | ||||
|   "settingsAppBarTransparent": "透明頂欄", | ||||
|   "settingsAppBarTransparentDescription": "為頂欄啟用透明效果。", | ||||
|   "settingsColorScheme": "主題色", | ||||
|   "settingsColorSchemeDescription": "設定應用主題色。", | ||||
|   "settingsColorSeed": "預設色彩主題", | ||||
|   "settingsColorSeedDescription": "選擇一個預設色彩主題。", | ||||
|   "settingsNetwork": "網路", | ||||
|   "settingsNetworkServer": "HyperNet 伺服器", | ||||
|   "settingsNetworkServerDescription": "設定 HyperNet 伺服器地址,選擇我們提供的,或者自己搭建。", | ||||
| @@ -367,7 +376,26 @@ | ||||
|   "dailyCheckNegativeHint5Description": "關鍵時刻斷網", | ||||
|   "dailyCheckNegativeHint6": "出門", | ||||
|   "dailyCheckNegativeHint6Description": "忘帶傘遇上大雨", | ||||
|   "happyBirthday": "生日快樂,{}!", | ||||
|   "celebrateBirthday": "生日快樂,{}!", | ||||
|   "celebrateMerryXmas": "聖誕快樂,{}!", | ||||
|   "celebrateNewYear": "新年快樂,{}!", | ||||
|   "celebrateValentineDay": "今天是情人節,{}!", | ||||
|   "celebrateLaborDay": "今天是勞動節,{}。", | ||||
|   "celebrateMotherDay": "今天是母親節,{}。", | ||||
|   "celebrateChildrenDay": "今天是兒童節,{}!", | ||||
|   "celebrateFatherDay": "今天是父親節,{}。", | ||||
|   "celebrateHalloween": "快樂在聖誕節,{}!", | ||||
|   "celebrateThanksgiving": "今天是感恩節,{}!", | ||||
|   "pendingBirthday": "{} 過生日", | ||||
|   "pendingMerryXmas": "{} 過聖誕節", | ||||
|   "pendingNewYear": "{} 跨年", | ||||
|   "pendingValentineDay": "{} 過情人節", | ||||
|   "pendingLaborDay": "{} 過勞動節", | ||||
|   "pendingMotherDay": "{} 過母親節", | ||||
|   "pendingChildrenDay": "{} 過兒童節", | ||||
|   "pendingFatherDay": "{} 過父親節", | ||||
|   "pendingHalloween": "{} 過聖誕節", | ||||
|   "pendingThanksgiving": "{} 過感恩節", | ||||
|   "friendNew": "新增好友", | ||||
|   "friendRequests": "好友請求", | ||||
|   "friendRequestsDescription": { | ||||
| @@ -406,7 +434,7 @@ | ||||
|   "accountStatus": "狀態", | ||||
|   "accountStatusOnline": "線上", | ||||
|   "accountStatusOffline": "離線", | ||||
|   "accountStatusLastSeen": "最後一次在 {} 上線", | ||||
|   "accountStatusLastSeen": "最後一次上線於 {}", | ||||
|   "postArticle": "Solar Network 上的文章", | ||||
|   "postStory": "Solar Network 上的故事", | ||||
|   "articleWrittenAt": "發表於 {}", | ||||
| @@ -453,5 +481,28 @@ | ||||
|   "poweredBy": "由 {} 提供支援", | ||||
|   "shareIntent": "分享", | ||||
|   "shareIntentDescription": "您想對您分享的內容做些什麼?", | ||||
|   "shareIntentPostStory": "釋出動態" | ||||
|   "shareIntentPostStory": "釋出動態", | ||||
|   "updateAvailable": "檢測到更新可用", | ||||
|   "updateOngoing": "正在更新,請稍後……", | ||||
|   "custom": "自定義", | ||||
|   "colorSchemeIndigo": "靛藍", | ||||
|   "colorSchemeBlue": "藍色", | ||||
|   "colorSchemeGreen": "綠色", | ||||
|   "colorSchemeYellow": "黃色", | ||||
|   "colorSchemeOrange": "橙色", | ||||
|   "colorSchemeRed": "紅色", | ||||
|   "colorSchemeWhite": "白色", | ||||
|   "colorSchemeBlack": "黑色", | ||||
|   "colorSchemeApplied": "主題色已應用,可能需要重啟來生效。", | ||||
|   "postCategoryTechnology": "技術", | ||||
|   "postCategoryGaming": "遊戲", | ||||
|   "postCategoryLife": "生活", | ||||
|   "postCategoryArts": "藝術", | ||||
|   "postCategorySports": "體育", | ||||
|   "postCategoryMusic": "音樂", | ||||
|   "postCategoryNews": "新聞", | ||||
|   "postCategoryKnowledge": "知識", | ||||
|   "postCategoryLiterature": "文學", | ||||
|   "postCategoryFunny": "搞笑", | ||||
|   "postCategoryUncategorized": "未分類" | ||||
| } | ||||
|   | ||||
| @@ -173,7 +173,7 @@ PODS: | ||||
|   - in_app_review (2.0.0): | ||||
|     - Flutter | ||||
|   - Kingfisher (8.1.3) | ||||
|   - livekit_client (2.3.2): | ||||
|   - livekit_client (2.3.3): | ||||
|     - Flutter | ||||
|     - flutter_webrtc | ||||
|     - WebRTC-SDK (= 125.6422.06) | ||||
| @@ -386,7 +386,7 @@ SPEC CHECKSUMS: | ||||
|   image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 | ||||
|   in_app_review: a31b5257259646ea78e0e35fc914979b0031d011 | ||||
|   Kingfisher: f2af9028b16baf9dc6c07c570072bc41cbf009ef | ||||
|   livekit_client: 6108dad8b77db3142bafd4c630f471d0a54335cd | ||||
|   livekit_client: 02cf2cc4357a655af12ccee70ff5596ae4e6feef | ||||
|   media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 | ||||
|   media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a | ||||
|   media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e | ||||
|   | ||||
| @@ -80,7 +80,11 @@ class NotificationService: UNNotificationServiceExtension { | ||||
|          | ||||
|         let metadataCopy = metadata as? [String: String] ?? [:] | ||||
|         let avatarUrl = getAttachmentUrl(for: avatarIdentifier) | ||||
|         KingfisherManager.shared.retrieveImage(with: URL(string: avatarUrl)!, completionHandler: { result in | ||||
|          | ||||
|         let targetSize = 640 | ||||
|         let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit) | ||||
|          | ||||
|         KingfisherManager.shared.retrieveImage(with: URL(string: avatarUrl)!, options: [.processor(scaleProcessor)], completionHandler: { result in | ||||
|             var image: Data? | ||||
|             switch result { | ||||
|             case .success(let value): | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import 'dart:io'; | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| @@ -152,6 +153,7 @@ class PostWriteController extends ChangeNotifier { | ||||
|   final TextEditingController contentController = TextEditingController(); | ||||
|   final TextEditingController titleController = TextEditingController(); | ||||
|   final TextEditingController descriptionController = TextEditingController(); | ||||
|   final TextEditingController aliasController = TextEditingController(); | ||||
|  | ||||
|   PostWriteController() { | ||||
|     titleController.addListener(() => notifyListeners()); | ||||
| @@ -176,6 +178,7 @@ class PostWriteController extends ChangeNotifier { | ||||
|   List<int> visibleUsers = List.empty(); | ||||
|   List<int> invisibleUsers = List.empty(); | ||||
|   List<String> tags = List.empty(); | ||||
|   List<String> categories = List.empty(); | ||||
|   PostWriteMedia? thumbnail; | ||||
|   List<PostWriteMedia> attachments = List.empty(growable: true); | ||||
|   DateTime? publishedAt, publishedUntil; | ||||
| @@ -198,12 +201,14 @@ class PostWriteController extends ChangeNotifier { | ||||
|         titleController.text = post.body['title'] ?? ''; | ||||
|         descriptionController.text = post.body['description'] ?? ''; | ||||
|         contentController.text = post.body['content'] ?? ''; | ||||
|         aliasController.text = post.alias ?? ''; | ||||
|         publishedAt = post.publishedAt; | ||||
|         publishedUntil = post.publishedUntil; | ||||
|         visibleUsers = List.from(post.visibleUsersList ?? []); | ||||
|         invisibleUsers = List.from(post.invisibleUsersList ?? []); | ||||
|         visibility = post.visibility; | ||||
|         tags = List.from(post.tags.map((ele) => ele.alias)); | ||||
|         categories = List.from(post.categories.map((ele) => ele.alias)); | ||||
|         attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); | ||||
|  | ||||
|         if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { | ||||
| @@ -269,7 +274,7 @@ class PostWriteController extends ChangeNotifier { | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   Future<void> post(BuildContext context) async { | ||||
|   Future<void> sendPost(BuildContext context) async { | ||||
|     if (isBusy || publisher == null) return; | ||||
|  | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
| @@ -305,12 +310,14 @@ class PostWriteController extends ChangeNotifier { | ||||
|           place.$2, | ||||
|           onProgress: (progress) { | ||||
|             // Calculate overall progress for attachments | ||||
|             progress = ((i + progress) / attachments.length) * kAttachmentProgressWeight; | ||||
|             progress = math.max(((i + progress) / attachments.length) * kAttachmentProgressWeight, progress); | ||||
|             notifyListeners(); | ||||
|           }, | ||||
|         ); | ||||
|  | ||||
|         progress = (i + 1) / attachments.length * kAttachmentProgressWeight; | ||||
|         attachments[i] = PostWriteMedia(item); | ||||
|         notifyListeners(); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       isBusy = false; | ||||
| @@ -334,11 +341,13 @@ class PostWriteController extends ChangeNotifier { | ||||
|         data: { | ||||
|           'publisher': publisher!.id, | ||||
|           'content': contentController.text, | ||||
|           if (aliasController.text.isNotEmpty) 'alias': aliasController.text, | ||||
|           if (titleController.text.isNotEmpty) 'title': titleController.text, | ||||
|           if (descriptionController.text.isNotEmpty) 'description': descriptionController.text, | ||||
|           if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid, | ||||
|           'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(), | ||||
|           'tags': tags.map((ele) => {'alias': ele}).toList(), | ||||
|           'categories': categories.map((ele) => {'alias': ele}).toList(), | ||||
|           'visibility': visibility, | ||||
|           'visible_users_list': visibleUsers, | ||||
|           'invisible_users_list': invisibleUsers, | ||||
| @@ -425,6 +434,11 @@ class PostWriteController extends ChangeNotifier { | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void setCategories(List<String> value) { | ||||
|     categories = value; | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void setVisibility(int value) { | ||||
|     visibility = value; | ||||
|     notifyListeners(); | ||||
| @@ -461,6 +475,9 @@ class PostWriteController extends ChangeNotifier { | ||||
|     titleController.clear(); | ||||
|     descriptionController.clear(); | ||||
|     contentController.clear(); | ||||
|     aliasController.clear(); | ||||
|     tags.clear(); | ||||
|     categories.clear(); | ||||
|     attachments.clear(); | ||||
|     editingPost = null; | ||||
|     replyingPost = null; | ||||
| @@ -474,6 +491,7 @@ class PostWriteController extends ChangeNotifier { | ||||
|     contentController.dispose(); | ||||
|     titleController.dispose(); | ||||
|     descriptionController.dispose(); | ||||
|     aliasController.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -30,6 +30,7 @@ import 'package:surface/providers/post.dart'; | ||||
| import 'package:surface/providers/relationship.dart'; | ||||
| import 'package:surface/providers/sn_attachment.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/special_day.dart'; | ||||
| import 'package:surface/providers/theme.dart'; | ||||
| import 'package:surface/providers/user_directory.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
| @@ -148,6 +149,9 @@ class SolianApp extends StatelessWidget { | ||||
|             ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)), | ||||
|             ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)), | ||||
|             ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)), | ||||
|  | ||||
|             // Additional helper layer | ||||
|             Provider(create: (ctx) => SpecialDayProvider(ctx)), | ||||
|           ], | ||||
|           child: _AppDelegate(), | ||||
|         ), | ||||
| @@ -265,6 +269,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> { | ||||
|       // The Network initialization will also save initialize the Config, so it not need to be initialized again | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       await sn.initializeUserAgent(); | ||||
|       await sn.setConfigWithNative(); | ||||
|       if (!mounted) return; | ||||
|       final ua = context.read<UserProvider>(); | ||||
|       await ua.initialize(); | ||||
|   | ||||
| @@ -9,6 +9,10 @@ const kRtkStoreKey = 'nex_user_rtk'; | ||||
| const kNetworkServerDefault = 'https://api.sn.solsynth.dev'; | ||||
| const kNetworkServerStoreKey = 'app_server_url'; | ||||
|  | ||||
| const kAppbarTransparentStoreKey = 'app_bar_transparent'; | ||||
| const kAppBackgroundStoreKey = 'app_has_background'; | ||||
| const kAppColorSchemeStoreKey = 'app_color_scheme'; | ||||
|  | ||||
| const Map<String, FilterQuality> kImageQualityLevel = { | ||||
|   'settingsImageQualityLowest': FilterQuality.none, | ||||
|   'settingsImageQualityLow': FilterQuality.low, | ||||
|   | ||||
							
								
								
									
										41
									
								
								lib/providers/experience.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,41 @@ | ||||
| import 'package:intl/intl.dart'; | ||||
|  | ||||
| const List<int> kExperienceToLevelRequirements = [ | ||||
|   0, // Level 0 | ||||
|   1000, // Level 1 | ||||
|   4000, // Level 2 | ||||
|   9000, // Level 3 | ||||
|   16000, // Level 4 | ||||
|   25000, // Level 5 | ||||
|   36000, // Level 6 | ||||
|   49000, // Level 7 | ||||
|   64000, // Level 8 | ||||
|   81000, // Level 9 | ||||
|   100000, // Level 10 | ||||
|   121000, // Level 11 | ||||
|   144000, // Level 12 | ||||
|   368000 // Level 13 | ||||
| ]; | ||||
|  | ||||
| int getLevelFromExp(int experience) { | ||||
|   final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience); | ||||
|   final idx = kExperienceToLevelRequirements.indexOf(exp); | ||||
|   return idx; | ||||
| } | ||||
|  | ||||
| double calcLevelUpProgress(int experience) { | ||||
|   final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience); | ||||
|   final idx = kExperienceToLevelRequirements.indexOf(exp); | ||||
|   if (idx + 1 >= kExperienceToLevelRequirements.length) return 1; | ||||
|   final nextExp = kExperienceToLevelRequirements[idx + 1]; | ||||
|   return (experience - exp).abs() / (exp - nextExp).abs(); | ||||
| } | ||||
|  | ||||
| String calcLevelUpProgressLevel(int experience) { | ||||
|   final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience); | ||||
|   final idx = kExperienceToLevelRequirements.indexOf(exp); | ||||
|   if (idx + 1 >= kExperienceToLevelRequirements.length) return 'Infinity'; | ||||
|   final nextExp = exp - kExperienceToLevelRequirements[idx + 1]; | ||||
|   final formatter = NumberFormat.compactCurrency(symbol: '', decimalDigits: 1); | ||||
|   return '${formatter.format((exp - experience).abs())}/${formatter.format(nextExp.abs())}'; | ||||
| } | ||||
| @@ -83,12 +83,16 @@ class SnPostContentProvider { | ||||
|     int offset = 0, | ||||
|     String? type, | ||||
|     String? author, | ||||
|     Iterable<String>? categories, | ||||
|     Iterable<String>? tags, | ||||
|   }) async { | ||||
|     final resp = await _sn.client.get('/cgi/co/posts', queryParameters: { | ||||
|       'take': take, | ||||
|       'offset': offset, | ||||
|       if (type != null) 'type': type, | ||||
|       if (author != null) 'author': author, | ||||
|       if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), | ||||
|       if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','), | ||||
|     }); | ||||
|     final List<SnPost> out = await _preloadRelatedDataInBatch( | ||||
|       List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), | ||||
| @@ -118,12 +122,14 @@ class SnPostContentProvider { | ||||
|     int take = 10, | ||||
|     int offset = 0, | ||||
|     Iterable<String>? tags, | ||||
|     Iterable<String>? categories, | ||||
|   }) async { | ||||
|     final resp = await _sn.client.get('/cgi/co/posts/search', queryParameters: { | ||||
|       'take': take, | ||||
|       'offset': offset, | ||||
|       'probe': searchTerm, | ||||
|       if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), | ||||
|       if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','), | ||||
|     }); | ||||
|     final List<SnPost> out = await _preloadRelatedDataInBatch( | ||||
|       List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), | ||||
|   | ||||
| @@ -41,8 +41,7 @@ class SnAttachmentProvider { | ||||
|     return out; | ||||
|   } | ||||
|  | ||||
|   Future<List<SnAttachment?>> getMultiple(List<String> rids, | ||||
|       {noCache = false}) async { | ||||
|   Future<List<SnAttachment?>> getMultiple(List<String> rids, {noCache = false}) async { | ||||
|     final result = List<SnAttachment?>.filled(rids.length, null); | ||||
|     final Map<String, int> randomMapping = {}; | ||||
|     for (int i = 0; i < rids.length; i++) { | ||||
| @@ -63,9 +62,7 @@ class SnAttachmentProvider { | ||||
|           'id': pendingFetch.join(','), | ||||
|         }, | ||||
|       ); | ||||
|       final out = resp.data['data'] | ||||
|           .map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)) | ||||
|           .toList(); | ||||
|       final out = resp.data['data'].map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)).toList(); | ||||
|  | ||||
|       for (final item in out) { | ||||
|         if (item == null) continue; | ||||
| @@ -79,10 +76,7 @@ class SnAttachmentProvider { | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   static Map<String, String> mimetypeOverrides = { | ||||
|     'mov': 'video/quicktime', | ||||
|     'mp4': 'video/mp4' | ||||
|   }; | ||||
|   static Map<String, String> mimetypeOverrides = {'mov': 'video/quicktime', 'mp4': 'video/mp4'}; | ||||
|  | ||||
|   Future<SnAttachment> directUploadOne( | ||||
|     Uint8List data, | ||||
| @@ -93,11 +87,8 @@ class SnAttachmentProvider { | ||||
|     Function(double progress)? onProgress, | ||||
|   }) async { | ||||
|     final filePayload = MultipartFile.fromBytes(data, filename: filename); | ||||
|     final fileAlt = filename.contains('.') | ||||
|         ? filename.substring(0, filename.lastIndexOf('.')) | ||||
|         : filename; | ||||
|     final fileExt = | ||||
|         filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); | ||||
|     final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; | ||||
|     final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); | ||||
|  | ||||
|     String? mimetypeOverride; | ||||
|     if (mimetype != null) { | ||||
| @@ -133,11 +124,8 @@ class SnAttachmentProvider { | ||||
|     Map<String, dynamic>? metadata, { | ||||
|     String? mimetype, | ||||
|   }) async { | ||||
|     final fileAlt = filename.contains('.') | ||||
|         ? filename.substring(0, filename.lastIndexOf('.')) | ||||
|         : filename; | ||||
|     final fileExt = | ||||
|         filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); | ||||
|     final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; | ||||
|     final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); | ||||
|  | ||||
|     String? mimetypeOverride; | ||||
|     if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) { | ||||
| @@ -155,10 +143,7 @@ class SnAttachmentProvider { | ||||
|       if (mimetypeOverride != null) 'mimetype': mimetypeOverride, | ||||
|     }); | ||||
|  | ||||
|     return ( | ||||
|       SnAttachment.fromJson(resp.data['meta']), | ||||
|       resp.data['chunk_size'] as int | ||||
|     ); | ||||
|     return (SnAttachment.fromJson(resp.data['meta']), resp.data['chunk_size'] as int); | ||||
|   } | ||||
|  | ||||
|   Future<SnAttachment> _chunkedUploadOnePart( | ||||
| @@ -200,24 +185,17 @@ class SnAttachmentProvider { | ||||
|           (entry.value + 1) * chunkSize, | ||||
|           await file.length(), | ||||
|         ); | ||||
|         final data = Uint8List.fromList(await file | ||||
|             .openRead(beginCursor, endCursor) | ||||
|             .expand((chunk) => chunk) | ||||
|             .toList()); | ||||
|         final data = Uint8List.fromList(await file.openRead(beginCursor, endCursor).expand((chunk) => chunk).toList()); | ||||
|  | ||||
|         place = await _chunkedUploadOnePart( | ||||
|           data, | ||||
|           place.rid, | ||||
|           entry.key, | ||||
|           onProgress: (chunkProgress) { | ||||
|             final overallProgress = | ||||
|                 (currentTask + chunkProgress) / chunks.length; | ||||
|             if (onProgress != null) { | ||||
|               onProgress(overallProgress); | ||||
|             } | ||||
|           }, | ||||
|         ); | ||||
|  | ||||
|         final overallProgress = currentTask / chunks.length; | ||||
|         onProgress?.call(overallProgress); | ||||
|  | ||||
|         currentTask++; | ||||
|       }()); | ||||
|     } | ||||
| @@ -237,4 +215,18 @@ class SnAttachmentProvider { | ||||
|  | ||||
|     return place; | ||||
|   } | ||||
|  | ||||
|   Future<SnAttachment> updateOne( | ||||
|     int id, | ||||
|     String alt, { | ||||
|     required Map<String, dynamic> metadata, | ||||
|     bool isMature = false, | ||||
|   }) async { | ||||
|     final resp = await _sn.client.put('/cgi/uc/attachments/$id', data: { | ||||
|       'alt': alt, | ||||
|       'metadata': metadata, | ||||
|       'is_mature': isMature, | ||||
|     }); | ||||
|     return SnAttachment.fromJson(resp.data); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -68,9 +68,8 @@ class SnNetworkProvider { | ||||
|     _config.initialize().then((_) { | ||||
|       _prefs = _config.prefs; | ||||
|       client.options.baseUrl = _config.serverUrl; | ||||
|       if (!context.mounted) return; | ||||
|       _home.saveWidgetData("nex_server_url", client.options.baseUrl); | ||||
|     }); | ||||
|  | ||||
|   } | ||||
|  | ||||
|   static Future<Dio> createOffContextClient() async { | ||||
| @@ -109,6 +108,10 @@ class SnNetworkProvider { | ||||
|     return client; | ||||
|   } | ||||
|  | ||||
|   Future<void> setConfigWithNative() async { | ||||
|     _home.saveWidgetData("nex_server_url", client.options.baseUrl); | ||||
|   } | ||||
|  | ||||
|   static Future<String> _getUserAgent() async { | ||||
|     final String platformInfo; | ||||
|     if (kIsWeb) { | ||||
|   | ||||
							
								
								
									
										136
									
								
								lib/providers/special_day.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,136 @@ | ||||
| import 'package:flutter/widgets.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
|  | ||||
| // Stored as key: month, day | ||||
| const Map<String, (int, int)> kSpecialDays = { | ||||
|   // Birthday is dynamically generated according to the user's profile | ||||
|   'NewYear': (1, 1), | ||||
|   'ValentineDay': (2, 14), | ||||
|   'LaborDay': (5, 1), | ||||
|   'MotherDay': (5, 11), | ||||
|   'ChildrenDay': (6, 1), | ||||
|   'FatherDay': (8, 8), | ||||
|   'Halloween': (10, 31), | ||||
|   'Thanksgiving': (11, 28), | ||||
|   'MerryXmas': (12, 25), | ||||
| }; | ||||
|  | ||||
| const Map<String, String> kSpecialDaysSymbol = { | ||||
|   'Birthday': '🎂', | ||||
|   'NewYear': '🎉', | ||||
|   'MerryXmas': '🎄', | ||||
|   'ValentineDay': '💑', | ||||
|   'LaborDay': '🏋️', | ||||
|   'MotherDay': '👩', | ||||
|   'ChildrenDay': '👶', | ||||
|   'FatherDay': '👨', | ||||
|   'Halloween': '🎃', | ||||
|   'Thanksgiving': '🎅', | ||||
| }; | ||||
|  | ||||
| class SpecialDayProvider { | ||||
|   late final UserProvider _user; | ||||
|  | ||||
|   SpecialDayProvider(BuildContext context) { | ||||
|     _user = context.read<UserProvider>(); | ||||
|   } | ||||
|  | ||||
|   List<String> getSpecialDays() { | ||||
|     final now = DateTime.now().toLocal(); | ||||
|     final birthday = _user.user?.profile?.birthday?.toLocal(); | ||||
|     final isBirthday = birthday != null && birthday.day == now.day && birthday.month == now.month; | ||||
|  | ||||
|     return [ | ||||
|       if (isBirthday) 'Birthday', | ||||
|       ...kSpecialDays.keys.where( | ||||
|         (key) => kSpecialDays[key]!.$1 == now.month && kSpecialDays[key]!.$2 == now.day, | ||||
|       ), | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   (String, DateTime)? getLastSpecialDay() { | ||||
|     final now = DateTime.now().toLocal(); | ||||
|     final birthday = _user.user?.profile?.birthday?.toLocal(); | ||||
|  | ||||
|     final Map<String, (int, int)> specialDays = { | ||||
|       if (birthday != null) 'Birthday': (birthday.month, birthday.day), | ||||
|       ...kSpecialDays, | ||||
|     }; | ||||
|  | ||||
|     DateTime? lastDate; | ||||
|     String? lastEvent; | ||||
|  | ||||
|     for (final entry in specialDays.entries) { | ||||
|       final eventName = entry.key; | ||||
|       final (month, day) = entry.value; | ||||
|  | ||||
|       var specialDayThisYear = DateTime(now.year, month, day); | ||||
|       var specialDayLastYear = DateTime(now.year - 1, month, day); | ||||
|  | ||||
|       if (specialDayThisYear.isBefore(now)) { | ||||
|         if (lastDate == null || specialDayThisYear.isAfter(lastDate)) { | ||||
|           lastDate = specialDayThisYear; | ||||
|           lastEvent = eventName; | ||||
|         } | ||||
|       } else if (specialDayLastYear.isBefore(now)) { | ||||
|         if (lastDate == null || specialDayLastYear.isAfter(lastDate)) { | ||||
|           lastDate = specialDayLastYear; | ||||
|           lastEvent = eventName; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (lastEvent != null && lastDate != null) { | ||||
|       return (lastEvent, lastDate); | ||||
|     } | ||||
|  | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   (String, DateTime)? getNextSpecialDay() { | ||||
|     final now = DateTime.now().toLocal(); | ||||
|     final birthday = _user.user?.profile?.birthday?.toLocal(); | ||||
|  | ||||
|     // Stored as key: month, day | ||||
|     final Map<String, (int, int)> specialDays = { | ||||
|       if (birthday != null) 'Birthday': (birthday.month, birthday.day), | ||||
|       ...kSpecialDays, | ||||
|     }; | ||||
|  | ||||
|     DateTime? closestDate; | ||||
|     String? closestEvent; | ||||
|  | ||||
|     for (final entry in specialDays.entries) { | ||||
|       final eventName = entry.key; | ||||
|       final (month, day) = entry.value; | ||||
|  | ||||
|       // Calculate the special day's DateTime in the current year | ||||
|       var specialDay = DateTime(now.year, month, day); | ||||
|  | ||||
|       // If the special day has already passed this year, consider it for the next year | ||||
|       if (specialDay.isBefore(now)) { | ||||
|         specialDay = DateTime(now.year + 1, month, day); | ||||
|       } | ||||
|  | ||||
|       // Check if this special day is closer than the previously found one | ||||
|       if (closestDate == null || specialDay.isBefore(closestDate)) { | ||||
|         closestDate = specialDay; | ||||
|         closestEvent = eventName; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (closestEvent != null && closestDate != null) { | ||||
|       return (closestEvent, closestDate); | ||||
|     } | ||||
|  | ||||
|     // No special day found | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   double getSpecialDayProgress(DateTime last, DateTime next) { | ||||
|     final totalDuration = next.difference(last).inSeconds.toDouble(); | ||||
|     final elapsedDuration = DateTime.now().difference(last).inSeconds.toDouble(); | ||||
|     return (elapsedDuration / totalDuration).clamp(0.0, 1.0); | ||||
|   } | ||||
| } | ||||
| @@ -1,3 +1,5 @@ | ||||
| import 'dart:ui'; | ||||
|  | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:surface/theme.dart'; | ||||
|  | ||||
| @@ -11,8 +13,8 @@ class ThemeProvider extends ChangeNotifier { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   void reloadTheme({bool? useMaterial3}) { | ||||
|     createAppThemeSet().then((value) { | ||||
|   void reloadTheme({Color? seedColorOverride, bool? useMaterial3}) { | ||||
|     createAppThemeSet(seedColorOverride: seedColorOverride, useMaterial3: useMaterial3).then((value) { | ||||
|       theme = value; | ||||
|       notifyListeners(); | ||||
|     }); | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import 'dart:developer'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:path/path.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:shared_preferences/shared_preferences.dart'; | ||||
| import 'package:surface/providers/config.dart'; | ||||
| @@ -32,10 +31,10 @@ class UserProvider extends ChangeNotifier { | ||||
|     final value = _config.prefs.getString(kAtkStoreKey); | ||||
|     isAuthorized = value != null; | ||||
|     notifyListeners(); | ||||
|     refreshUser().then((value) { | ||||
|     refreshUser().then((value) async { | ||||
|       if (value != null) { | ||||
|         log('Logged in as @${value.name}'); | ||||
|         _home.saveWidgetData('user', value.toJson()); | ||||
|         log('Atk: ${await atk}'); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|   | ||||
| @@ -13,7 +13,7 @@ class HomeWidgetProvider { | ||||
|  | ||||
|   Future<void> initialize() async { | ||||
|     if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) return; | ||||
|     if (!kIsWeb && Platform.isIOS) { | ||||
|     if (Platform.isIOS) { | ||||
|       await HomeWidget.setAppGroupId("group.solsynth.solian"); | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -77,8 +77,11 @@ final _appRoutes = [ | ||||
|           GoRoute( | ||||
|             path: '/search', | ||||
|             name: 'postSearch', | ||||
|             builder: (context, state) => const AppBackground( | ||||
|               child: PostSearchScreen(), | ||||
|             builder: (context, state) => AppBackground( | ||||
|               child: PostSearchScreen( | ||||
|                 initialTags: state.uri.queryParameters['tags']?.split(','), | ||||
|                 initialCategories: state.uri.queryParameters['categories']?.split(','), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|           GoRoute( | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import 'dart:ui'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:fl_chart/fl_chart.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| @@ -9,10 +10,12 @@ import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:relative_time/relative_time.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/experience.dart'; | ||||
| import 'package:surface/providers/relationship.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/screens/abuse_report.dart'; | ||||
| import 'package:surface/types/account.dart'; | ||||
| import 'package:surface/types/check_in.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| @@ -61,6 +64,19 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<List<SnCheckInRecord>> _getCheckInRecords() async { | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14'); | ||||
|       return List.from( | ||||
|         resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [], | ||||
|       ); | ||||
|     } catch (err) { | ||||
|       if (mounted) context.showErrorDialog(err); | ||||
|       rethrow; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   SnAccountStatusInfo? _status; | ||||
|  | ||||
|   Future<void> _fetchStatus() async { | ||||
| @@ -228,65 +244,72 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | ||||
|       body: CustomScrollView( | ||||
|         controller: _scrollController, | ||||
|         slivers: [ | ||||
|           SliverAppBar( | ||||
|             expandedHeight: _appBarHeight, | ||||
|             title: _account == null | ||||
|                 ? Text('loading').tr() | ||||
|                 : RichText( | ||||
|                     textAlign: TextAlign.center, | ||||
|                     text: TextSpan(children: [ | ||||
|                       TextSpan( | ||||
|                         text: _account!.nick, | ||||
|                         style: Theme.of(context).textTheme.titleLarge!.copyWith( | ||||
|                               color: Theme.of(context).appBarTheme.foregroundColor!, | ||||
|                               shadows: labelShadows, | ||||
|                             ), | ||||
|                       ), | ||||
|                       const TextSpan(text: '\n'), | ||||
|                       TextSpan( | ||||
|                         text: '@${_account!.name}', | ||||
|                         style: Theme.of(context).textTheme.bodySmall!.copyWith( | ||||
|                               color: Theme.of(context).appBarTheme.foregroundColor!, | ||||
|                               shadows: labelShadows, | ||||
|                             ), | ||||
|                       ), | ||||
|                     ]), | ||||
|           Theme( | ||||
|             data: Theme.of(context).copyWith( | ||||
|               appBarTheme: Theme.of(context).appBarTheme.copyWith( | ||||
|                     foregroundColor: Colors.white, | ||||
|                   ), | ||||
|             pinned: true, | ||||
|             flexibleSpace: _account != null | ||||
|                 ? Stack( | ||||
|                     fit: StackFit.expand, | ||||
|                     children: [ | ||||
|                       UniversalImage( | ||||
|                         sn.getAttachmentUrl(_account!.banner), | ||||
|                         fit: BoxFit.cover, | ||||
|                         height: imageHeight, | ||||
|                         width: _appBarWidth, | ||||
|                         cacheHeight: imageHeight, | ||||
|                         cacheWidth: _appBarWidth, | ||||
|                       ), | ||||
|                       Positioned( | ||||
|                         top: 0, | ||||
|                         left: 0, | ||||
|                         right: 0, | ||||
|                         height: 56 + MediaQuery.of(context).padding.top, | ||||
|                         child: ClipRect( | ||||
|                           child: BackdropFilter( | ||||
|                             filter: ImageFilter.blur( | ||||
|                               sigmaX: _appBarBlur, | ||||
|                               sigmaY: _appBarBlur, | ||||
|                             ), | ||||
|                             child: Container( | ||||
|                               color: Colors.black.withOpacity( | ||||
|                                 clampDouble(_appBarBlur * 0.1, 0, 0.5), | ||||
|             ), | ||||
|             child: SliverAppBar( | ||||
|               expandedHeight: _appBarHeight, | ||||
|               title: _account == null | ||||
|                   ? Text('loading').tr() | ||||
|                   : RichText( | ||||
|                       textAlign: TextAlign.center, | ||||
|                       text: TextSpan(children: [ | ||||
|                         TextSpan( | ||||
|                           text: _account!.nick, | ||||
|                           style: Theme.of(context).textTheme.titleLarge!.copyWith( | ||||
|                                 color: Colors.white, | ||||
|                                 shadows: labelShadows, | ||||
|                               ), | ||||
|                         ), | ||||
|                         const TextSpan(text: '\n'), | ||||
|                         TextSpan( | ||||
|                           text: '@${_account!.name}', | ||||
|                           style: Theme.of(context).textTheme.bodySmall!.copyWith( | ||||
|                                 color: Colors.white, | ||||
|                                 shadows: labelShadows, | ||||
|                               ), | ||||
|                         ), | ||||
|                       ]), | ||||
|                     ), | ||||
|               pinned: true, | ||||
|               flexibleSpace: _account != null | ||||
|                   ? Stack( | ||||
|                       fit: StackFit.expand, | ||||
|                       children: [ | ||||
|                         UniversalImage( | ||||
|                           sn.getAttachmentUrl(_account!.banner), | ||||
|                           fit: BoxFit.cover, | ||||
|                           height: imageHeight, | ||||
|                           width: _appBarWidth, | ||||
|                           cacheHeight: imageHeight, | ||||
|                           cacheWidth: _appBarWidth, | ||||
|                         ), | ||||
|                         Positioned( | ||||
|                           top: 0, | ||||
|                           left: 0, | ||||
|                           right: 0, | ||||
|                           height: 56 + MediaQuery.of(context).padding.top, | ||||
|                           child: ClipRect( | ||||
|                             child: BackdropFilter( | ||||
|                               filter: ImageFilter.blur( | ||||
|                                 sigmaX: _appBarBlur, | ||||
|                                 sigmaY: _appBarBlur, | ||||
|                               ), | ||||
|                               child: Container( | ||||
|                                 color: Colors.black.withOpacity( | ||||
|                                   clampDouble(_appBarBlur * 0.1, 0, 0.5), | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ), | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ) | ||||
|                 : null, | ||||
|                       ], | ||||
|                     ) | ||||
|                   : null, | ||||
|             ), | ||||
|           ), | ||||
|           if (_account != null) | ||||
|             SliverToBoxAdapter( | ||||
| @@ -430,6 +453,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | ||||
|                   Column( | ||||
|                     children: [ | ||||
|                       Row( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                         children: [ | ||||
|                           const Icon(Symbols.calendar_add_on), | ||||
|                           const Gap(8), | ||||
| @@ -437,6 +461,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | ||||
|                         ], | ||||
|                       ), | ||||
|                       Row( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                         children: [ | ||||
|                           const Icon(Symbols.cake), | ||||
|                           const Gap(8), | ||||
| @@ -450,6 +475,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | ||||
|                         ], | ||||
|                       ), | ||||
|                       Row( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                         children: [ | ||||
|                           const Icon(Symbols.identity_platform), | ||||
|                           const Gap(8), | ||||
| @@ -459,6 +485,26 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | ||||
|                           ).opacity(0.8), | ||||
|                         ], | ||||
|                       ), | ||||
|                       Row( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                         children: [ | ||||
|                           const Icon(Symbols.star), | ||||
|                           const Gap(8), | ||||
|                           Text('Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'), | ||||
|                           const Gap(8), | ||||
|                           Text(calcLevelUpProgressLevel(_account?.profile?.experience ?? 0)).fontSize(11).opacity(0.5), | ||||
|                           const Gap(8), | ||||
|                           Container( | ||||
|                             width: double.infinity, | ||||
|                             constraints: const BoxConstraints(maxWidth: 160), | ||||
|                             child: LinearProgressIndicator( | ||||
|                               value: calcLevelUpProgress(_account?.profile?.experience ?? 0), | ||||
|                               borderRadius: BorderRadius.circular(8), | ||||
|                               backgroundColor: Theme.of(context).colorScheme.surfaceContainer, | ||||
|                             ).alignment(Alignment.centerLeft), | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ], | ||||
|                   ).padding(horizontal: 8), | ||||
|                 ], | ||||
| @@ -466,6 +512,27 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | ||||
|             ), | ||||
|           SliverToBoxAdapter(child: const Divider()), | ||||
|           const SliverGap(12), | ||||
|           SliverToBoxAdapter( | ||||
|             child: FutureBuilder<List<SnCheckInRecord>>( | ||||
|               future: _getCheckInRecords(), | ||||
|               builder: (context, snapshot) { | ||||
|                 if (!snapshot.hasData) return const SizedBox.shrink(); | ||||
|                 final records = snapshot.data!; | ||||
|                 return SizedBox( | ||||
|                   width: double.infinity, | ||||
|                   height: 240, | ||||
|                   child: CheckInRecordChart(records: records), | ||||
|                 ).padding( | ||||
|                   right: 24, | ||||
|                   left: 16, | ||||
|                   top: 12, | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|           const SliverGap(12), | ||||
|           SliverToBoxAdapter(child: const Divider()), | ||||
|           const SliverGap(12), | ||||
|           SliverToBoxAdapter( | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
| @@ -534,3 +601,105 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class CheckInRecordChart extends StatelessWidget { | ||||
|   const CheckInRecordChart({ | ||||
|     super.key, | ||||
|     required this.records, | ||||
|   }); | ||||
|  | ||||
|   final List<SnCheckInRecord> records; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return LineChart( | ||||
|       LineChartData( | ||||
|         lineBarsData: [ | ||||
|           LineChartBarData( | ||||
|             color: Theme.of(context).colorScheme.primary, | ||||
|             belowBarData: BarAreaData( | ||||
|               show: true, | ||||
|               gradient: LinearGradient( | ||||
|                 colors: List.filled( | ||||
|                   records.length, | ||||
|                   Theme.of(context).colorScheme.primary.withOpacity(0.3), | ||||
|                 ).toList(), | ||||
|               ), | ||||
|             ), | ||||
|             spots: records | ||||
|                 .map( | ||||
|                   (x) => FlSpot( | ||||
|                     x.createdAt | ||||
|                         .copyWith( | ||||
|                           hour: 0, | ||||
|                           minute: 0, | ||||
|                           second: 0, | ||||
|                           millisecond: 0, | ||||
|                           microsecond: 0, | ||||
|                         ) | ||||
|                         .millisecondsSinceEpoch | ||||
|                         .toDouble(), | ||||
|                     x.resultTier.toDouble(), | ||||
|                   ), | ||||
|                 ) | ||||
|                 .toList(), | ||||
|           ) | ||||
|         ], | ||||
|         lineTouchData: LineTouchData( | ||||
|           touchTooltipData: LineTouchTooltipData( | ||||
|             getTooltipItems: (spots) => spots | ||||
|                 .map( | ||||
|                   (spot) => LineTooltipItem( | ||||
|                     '${kCheckInResultTierSymbols[spot.y.toInt()]}\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}', | ||||
|                     TextStyle( | ||||
|                       color: Theme.of(context).colorScheme.onSurface, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ) | ||||
|                 .toList(), | ||||
|             getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|           ), | ||||
|         ), | ||||
|         titlesData: FlTitlesData( | ||||
|           topTitles: const AxisTitles( | ||||
|             sideTitles: SideTitles(showTitles: false), | ||||
|           ), | ||||
|           rightTitles: const AxisTitles( | ||||
|             sideTitles: SideTitles(showTitles: false), | ||||
|           ), | ||||
|           leftTitles: AxisTitles( | ||||
|             sideTitles: SideTitles( | ||||
|               showTitles: true, | ||||
|               reservedSize: 40, | ||||
|               interval: 1, | ||||
|               getTitlesWidget: (value, _) => Align( | ||||
|                 alignment: Alignment.centerRight, | ||||
|                 child: Text( | ||||
|                   kCheckInResultTierSymbols[value.toInt()], | ||||
|                   textAlign: TextAlign.right, | ||||
|                 ).padding(right: 8), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|           bottomTitles: AxisTitles( | ||||
|             sideTitles: SideTitles( | ||||
|               showTitles: true, | ||||
|               reservedSize: 28, | ||||
|               interval: 86400000, | ||||
|               getTitlesWidget: (value, _) => Text( | ||||
|                 DateFormat('dd').format( | ||||
|                   DateTime.fromMillisecondsSinceEpoch( | ||||
|                     value.toInt(), | ||||
|                   ), | ||||
|                 ), | ||||
|                 textAlign: TextAlign.center, | ||||
|               ).padding(top: 8), | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|         gridData: const FlGridData(show: false), | ||||
|         borderData: FlBorderData(show: false), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -5,12 +5,28 @@ import 'package:gap/gap.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/post.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
| import 'package:surface/widgets/app_bar_leading.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/post/post_item.dart'; | ||||
| import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | ||||
|  | ||||
| const Map<String, IconData> kCategoryIcons = { | ||||
|   'technology': Symbols.tools_wrench, | ||||
|   'gaming': Symbols.gamepad, | ||||
|   'life': Symbols.nightlife, | ||||
|   'arts': Symbols.format_paint, | ||||
|   'sports': Symbols.sports_soccer, | ||||
|   'music': Symbols.music_note, | ||||
|   'news': Symbols.newspaper, | ||||
|   'knowledge': Symbols.library_books, | ||||
|   'literature': Symbols.book, | ||||
|   'funny': Symbols.attractions, | ||||
| }; | ||||
|  | ||||
| class ExploreScreen extends StatefulWidget { | ||||
|   const ExploreScreen({super.key}); | ||||
|  | ||||
| @@ -24,15 +40,34 @@ class _ExploreScreenState extends State<ExploreScreen> { | ||||
|   bool _isBusy = true; | ||||
|  | ||||
|   final List<SnPost> _posts = List.empty(growable: true); | ||||
|   final List<SnPostCategory> _categories = List.empty(growable: true); | ||||
|   int? _postCount; | ||||
|  | ||||
|   String? _selectedCategory; | ||||
|  | ||||
|   Future<void> _fetchCategories() async { | ||||
|     _categories.clear(); | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = await sn.client.get('/cgi/co/categories?take=100'); | ||||
|       _categories.addAll(resp.data.map((e) => SnPostCategory.fromJson(e)).cast<SnPostCategory>() ?? []); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _fetchPosts() async { | ||||
|     if (_postCount != null && _posts.length >= _postCount!) return; | ||||
|  | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     final pt = context.read<SnPostContentProvider>(); | ||||
|     final result = await pt.listPosts(take: 10, offset: _posts.length); | ||||
|     final result = await pt.listPosts( | ||||
|       take: 10, | ||||
|       offset: _posts.length, | ||||
|       categories: _selectedCategory != null ? [_selectedCategory!] : null, | ||||
|     ); | ||||
|     final out = result.$1; | ||||
|  | ||||
|     if (!mounted) return; | ||||
| @@ -43,10 +78,17 @@ class _ExploreScreenState extends State<ExploreScreen> { | ||||
|     if (mounted) setState(() => _isBusy = false); | ||||
|   } | ||||
|  | ||||
|   Future<void> _refreshPosts() { | ||||
|     _postCount = null; | ||||
|     _posts.clear(); | ||||
|     return _fetchPosts(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _fetchPosts(); | ||||
|     _fetchCategories(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -59,27 +101,20 @@ class _ExploreScreenState extends State<ExploreScreen> { | ||||
|         type: ExpandableFabType.up, | ||||
|         childrenAnimation: ExpandableFabAnimation.none, | ||||
|         overlayStyle: ExpandableFabOverlayStyle( | ||||
|           color: Theme.of(context) | ||||
|               .colorScheme | ||||
|               .surface | ||||
|               .withAlpha((255 * 0.5).round()), | ||||
|           color: Theme.of(context).colorScheme.surface.withAlpha((255 * 0.5).round()), | ||||
|         ), | ||||
|         openButtonBuilder: RotateFloatingActionButtonBuilder( | ||||
|           child: const Icon(Symbols.add, size: 28), | ||||
|           fabSize: ExpandableFabSize.regular, | ||||
|           foregroundColor: | ||||
|               Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||
|           backgroundColor: | ||||
|               Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||
|           foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||
|           backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||
|           shape: const CircleBorder(), | ||||
|         ), | ||||
|         closeButtonBuilder: DefaultFloatingActionButtonBuilder( | ||||
|           child: const Icon(Symbols.close, size: 28), | ||||
|           fabSize: ExpandableFabSize.regular, | ||||
|           foregroundColor: | ||||
|               Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||
|           backgroundColor: | ||||
|               Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||
|           foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor, | ||||
|           backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor, | ||||
|           shape: const CircleBorder(), | ||||
|         ), | ||||
|         children: [ | ||||
| @@ -95,8 +130,7 @@ class _ExploreScreenState extends State<ExploreScreen> { | ||||
|                     'mode': 'stories', | ||||
|                   }).then((value) { | ||||
|                     if (value == true) { | ||||
|                       _posts.clear(); | ||||
|                       _fetchPosts(); | ||||
|                       _refreshPosts(); | ||||
|                     } | ||||
|                   }); | ||||
|                   _fabKey.currentState!.toggle(); | ||||
| @@ -117,8 +151,7 @@ class _ExploreScreenState extends State<ExploreScreen> { | ||||
|                     'mode': 'articles', | ||||
|                   }).then((value) { | ||||
|                     if (value == true) { | ||||
|                       _posts.clear(); | ||||
|                       _fetchPosts(); | ||||
|                       _refreshPosts(); | ||||
|                     } | ||||
|                   }); | ||||
|                   _fabKey.currentState!.toggle(); | ||||
| @@ -131,10 +164,7 @@ class _ExploreScreenState extends State<ExploreScreen> { | ||||
|       ), | ||||
|       body: RefreshIndicator( | ||||
|         displacement: 40 + MediaQuery.of(context).padding.top, | ||||
|         onRefresh: () { | ||||
|           _posts.clear(); | ||||
|           return _fetchPosts(); | ||||
|         }, | ||||
|         onRefresh: () => _refreshPosts(), | ||||
|         child: CustomScrollView( | ||||
|           slivers: [ | ||||
|             SliverAppBar( | ||||
| @@ -151,6 +181,34 @@ class _ExploreScreenState extends State<ExploreScreen> { | ||||
|                 ), | ||||
|                 const Gap(8), | ||||
|               ], | ||||
|               bottom: PreferredSize( | ||||
|                 preferredSize: const Size.fromHeight(50), | ||||
|                 child: SizedBox( | ||||
|                   height: 50, | ||||
|                   child: SingleChildScrollView( | ||||
|                     scrollDirection: Axis.horizontal, | ||||
|                     padding: const EdgeInsets.only(left: 8, right: 8, bottom: 12), | ||||
|                     child: Row( | ||||
|                       mainAxisAlignment: MainAxisAlignment.center, | ||||
|                       children: _categories.map((ele) { | ||||
|                         return StyledWidget(ChoiceChip( | ||||
|                           avatar: Icon(kCategoryIcons[ele.alias] ?? Symbols.question_mark), | ||||
|                           label: Text( | ||||
|                             'postCategory${ele.alias.capitalize()}'.trExists() | ||||
|                                 ? 'postCategory${ele.alias.capitalize()}'.tr() | ||||
|                                 : ele.name, | ||||
|                           ), | ||||
|                           selected: _selectedCategory == ele.alias, | ||||
|                           onSelected: (value) { | ||||
|                             _selectedCategory = value ? ele.alias : null; | ||||
|                             _refreshPosts(); | ||||
|                           }, | ||||
|                         )).padding(horizontal: 4); | ||||
|                       }).toList(), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             SliverInfiniteList( | ||||
|               itemCount: _posts.length, | ||||
| @@ -167,8 +225,7 @@ class _ExploreScreenState extends State<ExploreScreen> { | ||||
|                       setState(() => _posts[idx] = data); | ||||
|                     }, | ||||
|                     onDeleted: () { | ||||
|                       _posts.clear(); | ||||
|                       _fetchPosts(); | ||||
|                       _refreshPosts(); | ||||
|                     }, | ||||
|                   ), | ||||
|                   onTap: () { | ||||
|   | ||||
| @@ -11,20 +11,21 @@ import 'package:go_router/go_router.dart'; | ||||
| import 'package:google_fonts/google_fonts.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:relative_time/relative_time.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:surface/providers/config.dart'; | ||||
| import 'package:surface/providers/post.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/special_day.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
| import 'package:surface/providers/widget.dart'; | ||||
| import 'package:surface/types/check_in.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
| import 'package:surface/widgets/app_bar_leading.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/post/post_item.dart'; | ||||
|  | ||||
| import '../providers/widget.dart'; | ||||
|  | ||||
| class HomeScreenDashEntry { | ||||
|   final String name; | ||||
|   final Widget child; | ||||
| @@ -80,8 +81,8 @@ class _HomeScreenState extends State<HomeScreen> { | ||||
|                 child: Column( | ||||
|                   mainAxisAlignment: constraints.maxWidth > 640 ? MainAxisAlignment.center : MainAxisAlignment.start, | ||||
|                   children: [ | ||||
|                     _HomeDashSpecialDayWidget().padding(bottom: 8, horizontal: 8), | ||||
|                     _HomeDashUpdateWidget(padding: const EdgeInsets.only(bottom: 8, left: 8, right: 8)), | ||||
|                     _HomeDashSpecialDayWidget().padding(horizontal: 8), | ||||
|                     StaggeredGrid.extent( | ||||
|                       maxCrossAxisExtent: 280, | ||||
|                       mainAxisSpacing: 8, | ||||
| @@ -133,7 +134,7 @@ class _HomeDashUpdateWidget extends StatelessWidget { | ||||
|                           final model = UpdateModel( | ||||
|                             'https://files.solsynth.dev/d/production01/solian/app-arm64-v8a-release.apk', | ||||
|                             'solian-app-release-${config.updatableVersion!}.apk', | ||||
|                             'ic_notification', | ||||
|                             'ic_launcher', | ||||
|                             'https://apps.apple.com/us/app/solian/id6499032345', | ||||
|                           ); | ||||
|                           AzhonAppUpdate.update(model); | ||||
| @@ -157,36 +158,59 @@ class _HomeDashSpecialDayWidget extends StatelessWidget { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final ua = context.watch<UserProvider>(); | ||||
|     final today = DateTime.now(); | ||||
|     final birthday = ua.user?.profile?.birthday?.toLocal(); | ||||
|     final isBirthday = birthday != null && birthday.day == today.day && birthday.month == today.month; | ||||
|     final dayz = context.watch<SpecialDayProvider>(); | ||||
|  | ||||
|     return Column( | ||||
|       spacing: 8, | ||||
|       children: [ | ||||
|         if (isBirthday) | ||||
|           Card( | ||||
|             child: ListTile( | ||||
|               leading: Text('🎂').fontSize(24), | ||||
|               title: Text('happyBirthday').tr(args: [ua.user?.nick ?? 'user']), | ||||
|             ), | ||||
|           ).padding(bottom: 8), | ||||
|         if (today.month == 12 && today.day == 25) | ||||
|           Card( | ||||
|             child: ListTile( | ||||
|               leading: Text('🎄').fontSize(24), | ||||
|               title: Text('celebrateMerryXmas').tr(args: [ua.user?.nick ?? 'user']), | ||||
|             ), | ||||
|     final days = dayz.getSpecialDays(); | ||||
|  | ||||
|     if (days.isNotEmpty) { | ||||
|       return Column( | ||||
|           spacing: 8, | ||||
|           children: days.map((ele) { | ||||
|             return Card( | ||||
|               child: ListTile( | ||||
|                 leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24), | ||||
|                 title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']), | ||||
|                 subtitle: Text( | ||||
|                   DateFormat('y/M/d').format(DateTime.now().copyWith( | ||||
|                     month: kSpecialDays[ele]!.$1, | ||||
|                     day: kSpecialDays[ele]!.$2, | ||||
|                   )), | ||||
|                 ), | ||||
|               ), | ||||
|             ).padding(bottom: 8); | ||||
|           }).toList()); | ||||
|     } | ||||
|  | ||||
|     final nextOne = dayz.getNextSpecialDay(); | ||||
|     final lastOne = dayz.getLastSpecialDay(); | ||||
|  | ||||
|     if (nextOne != null && lastOne != null) { | ||||
|       var (name, date) = nextOne; | ||||
|       date = date.add(Duration(days: 1)); | ||||
|       final progress = dayz.getSpecialDayProgress(lastOne.$2, date); | ||||
|       final diff = nextOne.$2.add(-const Duration(days: 1)).difference(lastOne.$2); | ||||
|       return Card( | ||||
|         child: ListTile( | ||||
|           leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24), | ||||
|           title: Text('pending$name').tr(args: [RelativeTime(context).format(date)]), | ||||
|           subtitle: Row( | ||||
|             crossAxisAlignment: CrossAxisAlignment.center, | ||||
|             children: [ | ||||
|               Text('${diff.inDays}d · ${(progress * 100).toStringAsFixed(2)}%'), | ||||
|               const Gap(8), | ||||
|               Expanded( | ||||
|                 child: LinearProgressIndicator( | ||||
|                   value: progress, | ||||
|                   borderRadius: BorderRadius.circular(8), | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         if (today.month == 1 && today.day == 1) | ||||
|           Card( | ||||
|             child: ListTile( | ||||
|               leading: Text('🎉').fontSize(24), | ||||
|               title: Text('celebrateNewYear').tr(args: [ua.user?.nick ?? 'user']), | ||||
|             ), | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|         ), | ||||
|       ).padding(bottom: 8); | ||||
|     } | ||||
|  | ||||
|     return const SizedBox.shrink(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -212,7 +236,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> { | ||||
|       final home = context.read<HomeWidgetProvider>(); | ||||
|       final resp = await sn.client.get('/cgi/id/check-in/today'); | ||||
|       _todayRecord = SnCheckInRecord.fromJson(resp.data); | ||||
|       home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson()); | ||||
|       await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson()); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
| @@ -225,7 +249,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> { | ||||
|       final home = context.read<HomeWidgetProvider>(); | ||||
|       final resp = await sn.client.post('/cgi/id/check-in'); | ||||
|       _todayRecord = SnCheckInRecord.fromJson(resp.data); | ||||
|       home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson()); | ||||
|       await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson()); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
| @@ -494,9 +518,7 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati | ||||
|     setState(() => _isBusy = true); | ||||
|     try { | ||||
|       final pt = context.read<SnPostContentProvider>(); | ||||
|       final home = context.read<HomeWidgetProvider>(); | ||||
|       _posts = await pt.listRecommendations(); | ||||
|       home.saveWidgetData('post_featured', _posts!.first.toJson()); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|   | ||||
| @@ -13,6 +13,7 @@ import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:pasteboard/pasteboard.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/controllers/post_write_controller.dart'; | ||||
| import 'package:surface/providers/config.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| @@ -71,11 +72,14 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | ||||
|  | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final config = context.read<ConfigProvider>(); | ||||
|       final resp = await sn.client.get('/cgi/co/publishers/me'); | ||||
|       _publishers = List<SnPublisher>.from( | ||||
|         resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], | ||||
|       ); | ||||
|       _writeController.setPublisher(_publishers?.firstOrNull); | ||||
|       final beforeId = config.prefs.getInt('int_last_publisher_id'); | ||||
|       _writeController | ||||
|           .setPublisher(_publishers?.where((ele) => ele.id == beforeId).firstOrNull ?? _publishers?.firstOrNull); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
| @@ -92,38 +96,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   final _imagePicker = ImagePicker(); | ||||
|  | ||||
|   void _takeMedia(bool isVideo) async { | ||||
|     final result = isVideo | ||||
|         ? await _imagePicker.pickVideo(source: ImageSource.camera) | ||||
|         : await _imagePicker.pickImage(source: ImageSource.camera); | ||||
|     if (result == null) return; | ||||
|     _writeController.addAttachments([ | ||||
|       PostWriteMedia.fromFile(result), | ||||
|     ]); | ||||
|   } | ||||
|  | ||||
|   void _selectMedia() async { | ||||
|     final result = await _imagePicker.pickMultipleMedia(); | ||||
|     if (result.isEmpty) return; | ||||
|     _writeController.addAttachments( | ||||
|       result.map((e) => PostWriteMedia.fromFile(e)), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void _pasteMedia() async { | ||||
|     final imageBytes = await Pasteboard.image; | ||||
|     if (imageBytes == null) return; | ||||
|     _writeController.addAttachments([ | ||||
|       PostWriteMedia.fromBytes( | ||||
|         imageBytes, | ||||
|         'attachmentPastedImage'.tr(), | ||||
|         PostWriteMediaType.image, | ||||
|       ), | ||||
|     ]); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _writeController.dispose(); | ||||
| @@ -265,6 +237,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | ||||
|                       }); | ||||
|                     } else { | ||||
|                       _writeController.setPublisher(value); | ||||
|                       final config = context.read<ConfigProvider>(); | ||||
|                       config.prefs.setInt('int_last_publisher_id', value.id); | ||||
|                     } | ||||
|                   }, | ||||
|                   buttonStyleData: const ButtonStyleData( | ||||
| @@ -429,63 +403,12 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | ||||
|                               scrollDirection: Axis.vertical, | ||||
|                               child: Row( | ||||
|                                 children: [ | ||||
|                                   PopupMenuButton( | ||||
|                                     icon: Icon( | ||||
|                                       Symbols.add_photo_alternate, | ||||
|                                       color: Theme.of(context).colorScheme.primary, | ||||
|                                     ), | ||||
|                                     itemBuilder: (context) => [ | ||||
|                                       if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows) | ||||
|                                         PopupMenuItem( | ||||
|                                           child: Row( | ||||
|                                             children: [ | ||||
|                                               const Icon(Symbols.photo_camera), | ||||
|                                               const Gap(16), | ||||
|                                               Text('addAttachmentFromCameraPhoto').tr(), | ||||
|                                             ], | ||||
|                                           ), | ||||
|                                           onTap: () { | ||||
|                                             _takeMedia(false); | ||||
|                                           }, | ||||
|                                         ), | ||||
|                                       if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows) | ||||
|                                         PopupMenuItem( | ||||
|                                           child: Row( | ||||
|                                             children: [ | ||||
|                                               const Icon(Symbols.videocam), | ||||
|                                               const Gap(16), | ||||
|                                               Text('addAttachmentFromCameraVideo').tr(), | ||||
|                                             ], | ||||
|                                           ), | ||||
|                                           onTap: () { | ||||
|                                             _takeMedia(true); | ||||
|                                           }, | ||||
|                                         ), | ||||
|                                       PopupMenuItem( | ||||
|                                         child: Row( | ||||
|                                           children: [ | ||||
|                                             const Icon(Symbols.photo_library), | ||||
|                                             const Gap(16), | ||||
|                                             Text('addAttachmentFromAlbum').tr(), | ||||
|                                           ], | ||||
|                                         ), | ||||
|                                         onTap: () { | ||||
|                                           _selectMedia(); | ||||
|                                         }, | ||||
|                                       ), | ||||
|                                       PopupMenuItem( | ||||
|                                         child: Row( | ||||
|                                           children: [ | ||||
|                                             const Icon(Symbols.content_paste), | ||||
|                                             const Gap(16), | ||||
|                                             Text('addAttachmentFromClipboard').tr(), | ||||
|                                           ], | ||||
|                                         ), | ||||
|                                         onTap: () { | ||||
|                                           _pasteMedia(); | ||||
|                                         }, | ||||
|                                       ), | ||||
|                                     ], | ||||
|                                   AddPostMediaButton( | ||||
|                                     onAdd: (items) { | ||||
|                                       setState(() { | ||||
|                                         _writeController.addAttachments(items); | ||||
|                                       }); | ||||
|                                     }, | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ), | ||||
| @@ -496,7 +419,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | ||||
|                           onPressed: (_writeController.isBusy || _writeController.publisher == null) | ||||
|                               ? null | ||||
|                               : () { | ||||
|                                   _writeController.post(context).then((_) { | ||||
|                                   _writeController.sendPost(context).then((_) { | ||||
|                                     if (!context.mounted) return; | ||||
|                                     Navigator.pop(context, true); | ||||
|                                   }); | ||||
|   | ||||
| @@ -13,7 +13,10 @@ import 'package:surface/widgets/post/post_tags_field.dart'; | ||||
| import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | ||||
|  | ||||
| class PostSearchScreen extends StatefulWidget { | ||||
|   const PostSearchScreen({super.key}); | ||||
|   final Iterable<String>? initialTags; | ||||
|   final Iterable<String>? initialCategories; | ||||
|  | ||||
|   const PostSearchScreen({super.key, this.initialTags, this.initialCategories}); | ||||
|  | ||||
|   @override | ||||
|   State<PostSearchScreen> createState() => _PostSearchScreenState(); | ||||
| @@ -23,6 +26,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | ||||
|   bool _isBusy = false; | ||||
|  | ||||
|   List<String> _searchTags = List.empty(growable: true); | ||||
|   List<String> _searchCategories = List.empty(growable: true); | ||||
|  | ||||
|   final List<SnPost> _posts = List.empty(growable: true); | ||||
|   int? _postCount; | ||||
| @@ -30,8 +34,18 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | ||||
|   String _searchTerm = ''; | ||||
|   Duration? _lastTook; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _searchTags.addAll(widget.initialTags ?? []); | ||||
|     _searchCategories.addAll(widget.initialCategories ?? []); | ||||
|     if (_searchTags.isNotEmpty || _searchCategories.isNotEmpty) { | ||||
|       _fetchPosts(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _fetchPosts() async { | ||||
|     if (_searchTerm.isEmpty && _searchTags.isEmpty) return; | ||||
|     if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty) return; | ||||
|     if (_postCount != null && _posts.length >= _postCount!) return; | ||||
|  | ||||
|     setState(() => _isBusy = true); | ||||
| @@ -45,6 +59,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | ||||
|         take: 10, | ||||
|         offset: _posts.length, | ||||
|         tags: _searchTags, | ||||
|         categories: _searchCategories, | ||||
|       ); | ||||
|       final List<SnPost> out = result.$1; | ||||
|       _postCount = result.$2; | ||||
| @@ -73,9 +88,25 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | ||||
|               setState(() => _searchTags = value); | ||||
|             }, | ||||
|           ), | ||||
|           const Gap(4), | ||||
|           PostCategoriesField( | ||||
|             labelText: 'fieldPostCategories'.tr(), | ||||
|             initialCategories: _searchCategories, | ||||
|             onUpdate: (value) { | ||||
|               setState(() => _searchCategories = value); | ||||
|             }, | ||||
|           ), | ||||
|         ], | ||||
|       ).padding(horizontal: 24, vertical: 16), | ||||
|     ); | ||||
|     ).then((_) { | ||||
|       _refreshPosts(); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   Future<void> _refreshPosts() { | ||||
|     _postCount = null; | ||||
|     _posts.clear(); | ||||
|     return _fetchPosts(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -118,8 +149,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | ||||
|                     setState(() => _posts[idx] = data); | ||||
|                   }, | ||||
|                   onDeleted: () { | ||||
|                     _posts.clear(); | ||||
|                     _fetchPosts(); | ||||
|                     _refreshPosts(); | ||||
|                   }, | ||||
|                 ), | ||||
|                 onTap: () { | ||||
| @@ -150,10 +180,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | ||||
|                     _searchTerm = value; | ||||
|                   }, | ||||
|                   onSubmitted: (value) { | ||||
|                     setState(() => _posts.clear()); | ||||
|  | ||||
|                     _searchTerm = value; | ||||
|                     _fetchPosts(); | ||||
|                     _refreshPosts(); | ||||
|                   }, | ||||
|                 ), | ||||
|                 if (_lastTook != null) | ||||
|   | ||||
| @@ -45,17 +45,9 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | ||||
|   Future<void> _fetchPublisher() async { | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final ud = context.read<UserDirectoryProvider>(); | ||||
|       final rel = context.read<SnRelationshipProvider>(); | ||||
|       final resp = await sn.client.get('/cgi/co/publishers/${widget.name}'); | ||||
|       if (!mounted) return; | ||||
|       _publisher = SnPublisher.fromJson(resp.data); | ||||
|       _account = await ud.getAccount(_publisher?.accountId); | ||||
|       _accountRelationship = await rel.getRelationship(_account!.id); | ||||
|       if (_publisher?.realmId != null && _publisher!.realmId != 0) { | ||||
|         final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}'); | ||||
|         _realm = SnRealm.fromJson(resp.data); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err).then((_) { | ||||
| @@ -65,6 +57,20 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | ||||
|     } finally { | ||||
|       setState(() {}); | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final ud = context.read<UserDirectoryProvider>(); | ||||
|       final rel = context.read<SnRelationshipProvider>(); | ||||
|       _account = await ud.getAccount(_publisher?.accountId); | ||||
|       _accountRelationship = await rel.getRelationship(_account!.id); | ||||
|       if (_publisher?.realmId != null && _publisher!.realmId != 0) { | ||||
|         final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}'); | ||||
|         _realm = SnRealm.fromJson(resp.data); | ||||
|       } | ||||
|     } catch (_) { | ||||
|       // ignore | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   bool _isSubscribing = false; | ||||
| @@ -277,70 +283,77 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | ||||
|               handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), | ||||
|               sliver: MultiSliver( | ||||
|                 children: [ | ||||
|                   SliverAppBar( | ||||
|                     expandedHeight: _appBarHeight, | ||||
|                     title: _publisher == null | ||||
|                         ? Text('loading').tr() | ||||
|                         : RichText( | ||||
|                             textAlign: TextAlign.center, | ||||
|                             text: TextSpan(children: [ | ||||
|                               TextSpan( | ||||
|                                 text: _publisher!.nick, | ||||
|                                 style: Theme.of(context).textTheme.titleLarge!.copyWith( | ||||
|                                       color: Theme.of(context).appBarTheme.foregroundColor!, | ||||
|                                       shadows: labelShadows, | ||||
|                                     ), | ||||
|                               ), | ||||
|                               const TextSpan(text: '\n'), | ||||
|                               TextSpan( | ||||
|                                 text: '@${_publisher!.name}', | ||||
|                                 style: Theme.of(context).textTheme.bodySmall!.copyWith( | ||||
|                                       color: Colors.white, | ||||
|                                       shadows: labelShadows, | ||||
|                                     ), | ||||
|                               ), | ||||
|                             ]), | ||||
|                           ), | ||||
|                     pinned: true, | ||||
|                     flexibleSpace: _publisher != null | ||||
|                         ? Stack( | ||||
|                             fit: StackFit.expand, | ||||
|                             children: [ | ||||
|                               if (_publisher!.banner.isNotEmpty) | ||||
|                                 UniversalImage( | ||||
|                                   sn.getAttachmentUrl(_publisher!.banner), | ||||
|                                   fit: BoxFit.cover, | ||||
|                                   height: imageHeight, | ||||
|                                   width: _appBarWidth, | ||||
|                                   cacheHeight: imageHeight, | ||||
|                                   cacheWidth: _appBarWidth, | ||||
|                                 ) | ||||
|                               else | ||||
|                                 Container( | ||||
|                                   color: Theme.of(context).colorScheme.surfaceContainer, | ||||
|                   Theme( | ||||
|                     data: Theme.of(context).copyWith( | ||||
|                       appBarTheme: Theme.of(context).appBarTheme.copyWith( | ||||
|                         foregroundColor: Colors.white, | ||||
|                       ), | ||||
|                     ), | ||||
|                     child: SliverAppBar( | ||||
|                       expandedHeight: _appBarHeight, | ||||
|                       title: _publisher == null | ||||
|                           ? Text('loading').tr() | ||||
|                           : RichText( | ||||
|                               textAlign: TextAlign.center, | ||||
|                               text: TextSpan(children: [ | ||||
|                                 TextSpan( | ||||
|                                   text: _publisher!.nick, | ||||
|                                   style: Theme.of(context).textTheme.titleLarge!.copyWith( | ||||
|                                         color: Colors.white, | ||||
|                                         shadows: labelShadows, | ||||
|                                       ), | ||||
|                                 ), | ||||
|                               Positioned( | ||||
|                                 top: 0, | ||||
|                                 left: 0, | ||||
|                                 right: 0, | ||||
|                                 height: 56 + MediaQuery.of(context).padding.top, | ||||
|                                 child: ClipRect( | ||||
|                                   child: BackdropFilter( | ||||
|                                     filter: ImageFilter.blur( | ||||
|                                       sigmaX: _appBarBlur, | ||||
|                                       sigmaY: _appBarBlur, | ||||
|                                     ), | ||||
|                                     child: Container( | ||||
|                                       color: Colors.black.withOpacity( | ||||
|                                         clampDouble(_appBarBlur * 0.1, 0, 0.5), | ||||
|                                 const TextSpan(text: '\n'), | ||||
|                                 TextSpan( | ||||
|                                   text: '@${_publisher!.name}', | ||||
|                                   style: Theme.of(context).textTheme.bodySmall!.copyWith( | ||||
|                                         color: Colors.white, | ||||
|                                         shadows: labelShadows, | ||||
|                                       ), | ||||
|                                 ), | ||||
|                               ]), | ||||
|                             ), | ||||
|                       pinned: true, | ||||
|                       flexibleSpace: _publisher != null | ||||
|                           ? Stack( | ||||
|                               fit: StackFit.expand, | ||||
|                               children: [ | ||||
|                                 if (_publisher!.banner.isNotEmpty) | ||||
|                                   UniversalImage( | ||||
|                                     sn.getAttachmentUrl(_publisher!.banner), | ||||
|                                     fit: BoxFit.cover, | ||||
|                                     height: imageHeight, | ||||
|                                     width: _appBarWidth, | ||||
|                                     cacheHeight: imageHeight, | ||||
|                                     cacheWidth: _appBarWidth, | ||||
|                                   ) | ||||
|                                 else | ||||
|                                   Container( | ||||
|                                     color: Theme.of(context).colorScheme.surfaceContainer, | ||||
|                                   ), | ||||
|                                 Positioned( | ||||
|                                   top: 0, | ||||
|                                   left: 0, | ||||
|                                   right: 0, | ||||
|                                   height: 56 + MediaQuery.of(context).padding.top, | ||||
|                                   child: ClipRect( | ||||
|                                     child: BackdropFilter( | ||||
|                                       filter: ImageFilter.blur( | ||||
|                                         sigmaX: _appBarBlur, | ||||
|                                         sigmaY: _appBarBlur, | ||||
|                                       ), | ||||
|                                       child: Container( | ||||
|                                         color: Colors.black.withOpacity( | ||||
|                                           clampDouble(_appBarBlur * 0.1, 0, 0.5), | ||||
|                                         ), | ||||
|                                       ), | ||||
|                                     ), | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ], | ||||
|                           ) | ||||
|                         : null, | ||||
|                               ], | ||||
|                             ) | ||||
|                           : null, | ||||
|                     ), | ||||
|                   ), | ||||
|                   if (_publisher != null) | ||||
|                     SliverToBoxAdapter( | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import 'package:dropdown_button2/dropdown_button2.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_colorpicker/flutter_colorpicker.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:image_picker/image_picker.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| @@ -18,6 +19,17 @@ import 'package:surface/providers/theme.dart'; | ||||
| import 'package:surface/theme.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
|  | ||||
| const Map<String, Color> kColorSchemes = { | ||||
|   'colorSchemeIndigo': Colors.indigo, | ||||
|   'colorSchemeBlue': Colors.blue, | ||||
|   'colorSchemeGreen': Colors.green, | ||||
|   'colorSchemeYellow': Colors.yellow, | ||||
|   'colorSchemeOrange': Colors.orange, | ||||
|   'colorSchemeRed': Colors.red, | ||||
|   'colorSchemeWhite': Colors.white, | ||||
|   'colorSchemeBlack': Colors.black, | ||||
| }; | ||||
|  | ||||
| class SettingsScreen extends StatefulWidget { | ||||
|   const SettingsScreen({super.key}); | ||||
|  | ||||
| @@ -77,7 +89,7 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|                       if (image == null) return; | ||||
|  | ||||
|                       await File(image.path).copy('$_docBasepath/app_background_image'); | ||||
|                       _prefs.setBool('has_background_image', true); | ||||
|                       _prefs.setBool(kAppBackgroundStoreKey, true); | ||||
|  | ||||
|                       setState(() {}); | ||||
|                     }, | ||||
| @@ -98,7 +110,7 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|                           trailing: const Icon(Symbols.chevron_right), | ||||
|                           onTap: () { | ||||
|                             File('$_docBasepath/app_background_image').deleteSync(); | ||||
|                             _prefs.remove('has_background_image'); | ||||
|                             _prefs.remove(kAppBackgroundStoreKey); | ||||
|                             setState(() {}); | ||||
|                           }, | ||||
|                         ); | ||||
| @@ -116,10 +128,118 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|                         value ?? false, | ||||
|                       ); | ||||
|                     }); | ||||
|                     final th = context.watch<ThemeProvider>(); | ||||
|                     final th = context.read<ThemeProvider>(); | ||||
|                     th.reloadTheme(useMaterial3: value ?? false); | ||||
|                   }, | ||||
|                 ), | ||||
|                 ListTile( | ||||
|                   leading: const Icon(Symbols.format_paint), | ||||
|                   title: Text('settingsColorScheme').tr(), | ||||
|                   subtitle: Text('settingsColorSchemeDescription').tr(), | ||||
|                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                   trailing: const Icon(Symbols.chevron_right), | ||||
|                   onTap: () async { | ||||
|                     Color pickerColor = Color(_prefs.getInt(kAppColorSchemeStoreKey) ?? Colors.indigo.value); | ||||
|                     final color = await showDialog<Color?>( | ||||
|                       context: context, | ||||
|                       builder: (context) => AlertDialog( | ||||
|                         content: SingleChildScrollView( | ||||
|                           child: ColorPicker( | ||||
|                             pickerColor: pickerColor, | ||||
|                             onColorChanged: (color) => pickerColor = color, | ||||
|                             enableAlpha: false, | ||||
|                             hexInputBar: true, | ||||
|                           ), | ||||
|                         ), | ||||
|                         actions: <Widget>[ | ||||
|                           TextButton( | ||||
|                             child: const Text('dialogDismiss').tr(), | ||||
|                             onPressed: () { | ||||
|                               Navigator.of(context).pop(); | ||||
|                             }, | ||||
|                           ), | ||||
|                           TextButton( | ||||
|                             child: const Text('dialogConfirm').tr(), | ||||
|                             onPressed: () { | ||||
|                               Navigator.of(context).pop(pickerColor); | ||||
|                             }, | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ); | ||||
|  | ||||
|                     if (color == null || !context.mounted) return; | ||||
|  | ||||
|                     _prefs.setInt(kAppColorSchemeStoreKey, color.value); | ||||
|                     final th = context.read<ThemeProvider>(); | ||||
|                     th.reloadTheme(seedColorOverride: color); | ||||
|                     setState(() {}); | ||||
|  | ||||
|                     context.showSnackbar('colorSchemeApplied'.tr()); | ||||
|                   }, | ||||
|                 ), | ||||
|                 ListTile( | ||||
|                   leading: const Icon(Symbols.palette), | ||||
|                   title: Text('settingsColorSeed').tr(), | ||||
|                   subtitle: Text('settingsColorSeedDescription').tr(), | ||||
|                   contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||
|                   trailing: DropdownButtonHideUnderline( | ||||
|                     child: DropdownButton2<int?>( | ||||
|                       isExpanded: true, | ||||
|                       items: [ | ||||
|                         ...kColorSchemes.entries.mapIndexed((idx, ele) { | ||||
|                           return DropdownMenuItem<int>( | ||||
|                             value: idx, | ||||
|                             child: Text(ele.key).tr(), | ||||
|                           ); | ||||
|                         }), | ||||
|                         DropdownMenuItem<int>( | ||||
|                           value: -1, | ||||
|                           child: Text('custom').tr(), | ||||
|                         ), | ||||
|                       ], | ||||
|                       value: _prefs.getInt(kAppColorSchemeStoreKey) == null | ||||
|                           ? 1 | ||||
|                           : kColorSchemes.values | ||||
|                               .toList() | ||||
|                               .indexWhere((ele) => ele.value == _prefs.getInt(kAppColorSchemeStoreKey)), | ||||
|                       onChanged: (int? value) { | ||||
|                         if (value != null && value != -1) { | ||||
|                           _prefs.setInt(kAppColorSchemeStoreKey, kColorSchemes.values.elementAt(value).value); | ||||
|                           final th = context.read<ThemeProvider>(); | ||||
|                           th.reloadTheme(seedColorOverride: kColorSchemes.values.elementAt(value)); | ||||
|                           setState(() {}); | ||||
|  | ||||
|                           context.showSnackbar('colorSchemeApplied'.tr()); | ||||
|                         } | ||||
|                       }, | ||||
|                       buttonStyleData: const ButtonStyleData( | ||||
|                         padding: EdgeInsets.symmetric( | ||||
|                           horizontal: 16, | ||||
|                           vertical: 5, | ||||
|                         ), | ||||
|                         height: 40, | ||||
|                         width: 160, | ||||
|                       ), | ||||
|                       menuItemStyleData: const MenuItemStyleData( | ||||
|                         height: 40, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|                 CheckboxListTile( | ||||
|                   secondary: const Icon(Symbols.blur_on), | ||||
|                   title: Text('settingsAppBarTransparent').tr(), | ||||
|                   subtitle: Text('settingsAppBarTransparentDescription').tr(), | ||||
|                   contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||
|                   value: _prefs.getBool(kAppbarTransparentStoreKey) ?? false, | ||||
|                   onChanged: (value) { | ||||
|                     _prefs.setBool(kAppbarTransparentStoreKey, value ?? false); | ||||
|                     final th = context.read<ThemeProvider>(); | ||||
|                     th.reloadTheme(); | ||||
|                     setState(() {}); | ||||
|                   }, | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|             Column( | ||||
| @@ -189,7 +309,7 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|                           horizontal: 16, | ||||
|                           vertical: 5, | ||||
|                         ), | ||||
|                         height: 40, | ||||
|                         height: 56, | ||||
|                         width: 160, | ||||
|                       ), | ||||
|                       menuItemStyleData: const MenuItemStyleData( | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:shared_preferences/shared_preferences.dart'; | ||||
| import 'package:surface/providers/config.dart'; | ||||
|  | ||||
| const kMaterialYouToggleStoreKey = 'app_theme_material_you'; | ||||
|  | ||||
| @@ -10,7 +11,7 @@ class ThemeSet { | ||||
|   ThemeSet({required this.light, required this.dark}); | ||||
| } | ||||
|  | ||||
| Future<ThemeSet> createAppThemeSet({bool? useMaterial3}) async { | ||||
| Future<ThemeSet> createAppThemeSet({Color? seedColorOverride, bool? useMaterial3}) async { | ||||
|   return ThemeSet( | ||||
|     light: await createAppTheme(Brightness.light, useMaterial3: useMaterial3), | ||||
|     dark: await createAppTheme(Brightness.dark, useMaterial3: useMaterial3), | ||||
| @@ -19,16 +20,21 @@ Future<ThemeSet> createAppThemeSet({bool? useMaterial3}) async { | ||||
|  | ||||
| Future<ThemeData> createAppTheme( | ||||
|   Brightness brightness, { | ||||
|     Color? seedColorOverride, | ||||
|   bool? useMaterial3, | ||||
| }) async { | ||||
|   final prefs = await SharedPreferences.getInstance(); | ||||
|  | ||||
|   final seedColorString = prefs.getInt(kAppColorSchemeStoreKey); | ||||
|   final seedColor = seedColorString != null ? Color(seedColorString) : Colors.indigo; | ||||
|  | ||||
|   final colorScheme = ColorScheme.fromSeed( | ||||
|     seedColor: Colors.indigo, | ||||
|     seedColor: seedColorOverride ?? seedColor, | ||||
|     brightness: brightness, | ||||
|   ); | ||||
|  | ||||
|   final hasBackground = prefs.getBool('has_background_image') ?? false; | ||||
|   final hasBackground = prefs.getBool(kAppBackgroundStoreKey) ?? false; | ||||
|   final hasAppBarBlurry = prefs.getBool(kAppbarTransparentStoreKey) ?? false; | ||||
|  | ||||
|   return ThemeData( | ||||
|     useMaterial3: useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? false), | ||||
| @@ -42,8 +48,9 @@ Future<ThemeData> createAppTheme( | ||||
|     ), | ||||
|     appBarTheme: AppBarTheme( | ||||
|       centerTitle: true, | ||||
|       backgroundColor: hasBackground ? colorScheme.primary.withOpacity(0.75) : colorScheme.primary, | ||||
|       foregroundColor: colorScheme.onPrimary, | ||||
|       elevation: hasAppBarBlurry ? 0 : null, | ||||
|       backgroundColor: hasAppBarBlurry ? colorScheme.surfaceContainer.withAlpha(200) : colorScheme.primary, | ||||
|       foregroundColor: hasAppBarBlurry ? colorScheme.onSurface : colorScheme.onPrimary, | ||||
|     ), | ||||
|     scaffoldBackgroundColor: Colors.transparent, | ||||
|   ); | ||||
|   | ||||
| @@ -3,6 +3,8 @@ import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| part 'check_in.freezed.dart'; | ||||
| part 'check_in.g.dart'; | ||||
|  | ||||
| const List<String> kCheckInResultTierSymbols = ['大凶', '凶', '中平', '吉', '大吉']; | ||||
|  | ||||
| @freezed | ||||
| class SnCheckInRecord with _$SnCheckInRecord { | ||||
|   const SnCheckInRecord._(); | ||||
| @@ -21,11 +23,5 @@ class SnCheckInRecord with _$SnCheckInRecord { | ||||
|   factory SnCheckInRecord.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnCheckInRecordFromJson(json); | ||||
|  | ||||
|   String get symbol => switch (resultTier) { | ||||
|         0 => '大凶', | ||||
|         1 => '凶', | ||||
|         2 => '中平', | ||||
|         3 => '吉', | ||||
|         _ => '大吉', | ||||
|       }; | ||||
|   String get symbol => kCheckInResultTierSymbols[resultTier]; | ||||
| } | ||||
|   | ||||
| @@ -19,7 +19,7 @@ class SnPost with _$SnPost { | ||||
|     required String? alias, | ||||
|     required String? aliasPrefix, | ||||
|     @Default([]) List<SnPostTag> tags, | ||||
|     @Default([]) List<dynamic> categories, | ||||
|     @Default([]) List<SnPostCategory> categories, | ||||
|     required List<SnPost>? replies, | ||||
|     required int? replyId, | ||||
|     required int? repostId, | ||||
| @@ -67,6 +67,23 @@ class SnPostTag with _$SnPostTag { | ||||
|       _$SnPostTagFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| class SnPostCategory with _$SnPostCategory { | ||||
|   const factory SnPostCategory({ | ||||
|     required int id, | ||||
|     required DateTime createdAt, | ||||
|     required DateTime updatedAt, | ||||
|     required dynamic deletedAt, | ||||
|     required String alias, | ||||
|     required String name, | ||||
|     required String description, | ||||
|     required dynamic posts, | ||||
|   }) = _SnPostCategory; | ||||
|  | ||||
|   factory SnPostCategory.fromJson(Map<String, Object?> json) => | ||||
|       _$SnPostCategoryFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| class SnPostPreload with _$SnPostPreload { | ||||
|   const factory SnPostPreload({ | ||||
|   | ||||
| @@ -30,7 +30,7 @@ mixin _$SnPost { | ||||
|   String? get alias => throw _privateConstructorUsedError; | ||||
|   String? get aliasPrefix => throw _privateConstructorUsedError; | ||||
|   List<SnPostTag> get tags => throw _privateConstructorUsedError; | ||||
|   List<dynamic> get categories => throw _privateConstructorUsedError; | ||||
|   List<SnPostCategory> get categories => throw _privateConstructorUsedError; | ||||
|   List<SnPost>? get replies => throw _privateConstructorUsedError; | ||||
|   int? get replyId => throw _privateConstructorUsedError; | ||||
|   int? get repostId => throw _privateConstructorUsedError; | ||||
| @@ -77,7 +77,7 @@ abstract class $SnPostCopyWith<$Res> { | ||||
|       String? alias, | ||||
|       String? aliasPrefix, | ||||
|       List<SnPostTag> tags, | ||||
|       List<dynamic> categories, | ||||
|       List<SnPostCategory> categories, | ||||
|       List<SnPost>? replies, | ||||
|       int? replyId, | ||||
|       int? repostId, | ||||
| @@ -197,7 +197,7 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost> | ||||
|       categories: null == categories | ||||
|           ? _value.categories | ||||
|           : categories // ignore: cast_nullable_to_non_nullable | ||||
|               as List<dynamic>, | ||||
|               as List<SnPostCategory>, | ||||
|       replies: freezed == replies | ||||
|           ? _value.replies | ||||
|           : replies // ignore: cast_nullable_to_non_nullable | ||||
| @@ -362,7 +362,7 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> { | ||||
|       String? alias, | ||||
|       String? aliasPrefix, | ||||
|       List<SnPostTag> tags, | ||||
|       List<dynamic> categories, | ||||
|       List<SnPostCategory> categories, | ||||
|       List<SnPost>? replies, | ||||
|       int? replyId, | ||||
|       int? repostId, | ||||
| @@ -485,7 +485,7 @@ class __$$SnPostImplCopyWithImpl<$Res> | ||||
|       categories: null == categories | ||||
|           ? _value._categories | ||||
|           : categories // ignore: cast_nullable_to_non_nullable | ||||
|               as List<dynamic>, | ||||
|               as List<SnPostCategory>, | ||||
|       replies: freezed == replies | ||||
|           ? _value._replies | ||||
|           : replies // ignore: cast_nullable_to_non_nullable | ||||
| @@ -584,7 +584,7 @@ class _$SnPostImpl extends _SnPost { | ||||
|       required this.alias, | ||||
|       required this.aliasPrefix, | ||||
|       final List<SnPostTag> tags = const [], | ||||
|       final List<dynamic> categories = const [], | ||||
|       final List<SnPostCategory> categories = const [], | ||||
|       required final List<SnPost>? replies, | ||||
|       required this.replyId, | ||||
|       required this.repostId, | ||||
| @@ -649,10 +649,10 @@ class _$SnPostImpl extends _SnPost { | ||||
|     return EqualUnmodifiableListView(_tags); | ||||
|   } | ||||
|  | ||||
|   final List<dynamic> _categories; | ||||
|   final List<SnPostCategory> _categories; | ||||
|   @override | ||||
|   @JsonKey() | ||||
|   List<dynamic> get categories { | ||||
|   List<SnPostCategory> get categories { | ||||
|     if (_categories is EqualUnmodifiableListView) return _categories; | ||||
|     // ignore: implicit_dynamic_type | ||||
|     return EqualUnmodifiableListView(_categories); | ||||
| @@ -853,7 +853,7 @@ abstract class _SnPost extends SnPost { | ||||
|       required final String? alias, | ||||
|       required final String? aliasPrefix, | ||||
|       final List<SnPostTag> tags, | ||||
|       final List<dynamic> categories, | ||||
|       final List<SnPostCategory> categories, | ||||
|       required final List<SnPost>? replies, | ||||
|       required final int? replyId, | ||||
|       required final int? repostId, | ||||
| @@ -899,7 +899,7 @@ abstract class _SnPost extends SnPost { | ||||
|   @override | ||||
|   List<SnPostTag> get tags; | ||||
|   @override | ||||
|   List<dynamic> get categories; | ||||
|   List<SnPostCategory> get categories; | ||||
|   @override | ||||
|   List<SnPost>? get replies; | ||||
|   @override | ||||
| @@ -1253,6 +1253,312 @@ abstract class _SnPostTag implements SnPostTag { | ||||
|       throw _privateConstructorUsedError; | ||||
| } | ||||
|  | ||||
| SnPostCategory _$SnPostCategoryFromJson(Map<String, dynamic> json) { | ||||
|   return _SnPostCategory.fromJson(json); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SnPostCategory { | ||||
|   int get id => throw _privateConstructorUsedError; | ||||
|   DateTime get createdAt => throw _privateConstructorUsedError; | ||||
|   DateTime get updatedAt => throw _privateConstructorUsedError; | ||||
|   dynamic get deletedAt => throw _privateConstructorUsedError; | ||||
|   String get alias => throw _privateConstructorUsedError; | ||||
|   String get name => throw _privateConstructorUsedError; | ||||
|   String get description => throw _privateConstructorUsedError; | ||||
|   dynamic get posts => throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Serializes this SnPostCategory to a JSON map. | ||||
|   Map<String, dynamic> toJson() => throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Create a copy of SnPostCategory | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   $SnPostCategoryCopyWith<SnPostCategory> get copyWith => | ||||
|       throw _privateConstructorUsedError; | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract class $SnPostCategoryCopyWith<$Res> { | ||||
|   factory $SnPostCategoryCopyWith( | ||||
|           SnPostCategory value, $Res Function(SnPostCategory) then) = | ||||
|       _$SnPostCategoryCopyWithImpl<$Res, SnPostCategory>; | ||||
|   @useResult | ||||
|   $Res call( | ||||
|       {int id, | ||||
|       DateTime createdAt, | ||||
|       DateTime updatedAt, | ||||
|       dynamic deletedAt, | ||||
|       String alias, | ||||
|       String name, | ||||
|       String description, | ||||
|       dynamic posts}); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| class _$SnPostCategoryCopyWithImpl<$Res, $Val extends SnPostCategory> | ||||
|     implements $SnPostCategoryCopyWith<$Res> { | ||||
|   _$SnPostCategoryCopyWithImpl(this._value, this._then); | ||||
|  | ||||
|   // ignore: unused_field | ||||
|   final $Val _value; | ||||
|   // ignore: unused_field | ||||
|   final $Res Function($Val) _then; | ||||
|  | ||||
|   /// Create a copy of SnPostCategory | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @pragma('vm:prefer-inline') | ||||
|   @override | ||||
|   $Res call({ | ||||
|     Object? id = null, | ||||
|     Object? createdAt = null, | ||||
|     Object? updatedAt = null, | ||||
|     Object? deletedAt = freezed, | ||||
|     Object? alias = null, | ||||
|     Object? name = null, | ||||
|     Object? description = null, | ||||
|     Object? posts = freezed, | ||||
|   }) { | ||||
|     return _then(_value.copyWith( | ||||
|       id: null == id | ||||
|           ? _value.id | ||||
|           : id // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       createdAt: null == createdAt | ||||
|           ? _value.createdAt | ||||
|           : createdAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime, | ||||
|       updatedAt: null == updatedAt | ||||
|           ? _value.updatedAt | ||||
|           : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime, | ||||
|       deletedAt: freezed == deletedAt | ||||
|           ? _value.deletedAt | ||||
|           : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as dynamic, | ||||
|       alias: null == alias | ||||
|           ? _value.alias | ||||
|           : alias // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       name: null == name | ||||
|           ? _value.name | ||||
|           : name // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       description: null == description | ||||
|           ? _value.description | ||||
|           : description // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       posts: freezed == posts | ||||
|           ? _value.posts | ||||
|           : posts // ignore: cast_nullable_to_non_nullable | ||||
|               as dynamic, | ||||
|     ) as $Val); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract class _$$SnPostCategoryImplCopyWith<$Res> | ||||
|     implements $SnPostCategoryCopyWith<$Res> { | ||||
|   factory _$$SnPostCategoryImplCopyWith(_$SnPostCategoryImpl value, | ||||
|           $Res Function(_$SnPostCategoryImpl) then) = | ||||
|       __$$SnPostCategoryImplCopyWithImpl<$Res>; | ||||
|   @override | ||||
|   @useResult | ||||
|   $Res call( | ||||
|       {int id, | ||||
|       DateTime createdAt, | ||||
|       DateTime updatedAt, | ||||
|       dynamic deletedAt, | ||||
|       String alias, | ||||
|       String name, | ||||
|       String description, | ||||
|       dynamic posts}); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| class __$$SnPostCategoryImplCopyWithImpl<$Res> | ||||
|     extends _$SnPostCategoryCopyWithImpl<$Res, _$SnPostCategoryImpl> | ||||
|     implements _$$SnPostCategoryImplCopyWith<$Res> { | ||||
|   __$$SnPostCategoryImplCopyWithImpl( | ||||
|       _$SnPostCategoryImpl _value, $Res Function(_$SnPostCategoryImpl) _then) | ||||
|       : super(_value, _then); | ||||
|  | ||||
|   /// Create a copy of SnPostCategory | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @pragma('vm:prefer-inline') | ||||
|   @override | ||||
|   $Res call({ | ||||
|     Object? id = null, | ||||
|     Object? createdAt = null, | ||||
|     Object? updatedAt = null, | ||||
|     Object? deletedAt = freezed, | ||||
|     Object? alias = null, | ||||
|     Object? name = null, | ||||
|     Object? description = null, | ||||
|     Object? posts = freezed, | ||||
|   }) { | ||||
|     return _then(_$SnPostCategoryImpl( | ||||
|       id: null == id | ||||
|           ? _value.id | ||||
|           : id // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       createdAt: null == createdAt | ||||
|           ? _value.createdAt | ||||
|           : createdAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime, | ||||
|       updatedAt: null == updatedAt | ||||
|           ? _value.updatedAt | ||||
|           : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime, | ||||
|       deletedAt: freezed == deletedAt | ||||
|           ? _value.deletedAt | ||||
|           : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as dynamic, | ||||
|       alias: null == alias | ||||
|           ? _value.alias | ||||
|           : alias // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       name: null == name | ||||
|           ? _value.name | ||||
|           : name // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       description: null == description | ||||
|           ? _value.description | ||||
|           : description // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       posts: freezed == posts | ||||
|           ? _value.posts | ||||
|           : posts // ignore: cast_nullable_to_non_nullable | ||||
|               as dynamic, | ||||
|     )); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
| class _$SnPostCategoryImpl implements _SnPostCategory { | ||||
|   const _$SnPostCategoryImpl( | ||||
|       {required this.id, | ||||
|       required this.createdAt, | ||||
|       required this.updatedAt, | ||||
|       required this.deletedAt, | ||||
|       required this.alias, | ||||
|       required this.name, | ||||
|       required this.description, | ||||
|       required this.posts}); | ||||
|  | ||||
|   factory _$SnPostCategoryImpl.fromJson(Map<String, dynamic> json) => | ||||
|       _$$SnPostCategoryImplFromJson(json); | ||||
|  | ||||
|   @override | ||||
|   final int id; | ||||
|   @override | ||||
|   final DateTime createdAt; | ||||
|   @override | ||||
|   final DateTime updatedAt; | ||||
|   @override | ||||
|   final dynamic deletedAt; | ||||
|   @override | ||||
|   final String alias; | ||||
|   @override | ||||
|   final String name; | ||||
|   @override | ||||
|   final String description; | ||||
|   @override | ||||
|   final dynamic posts; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'SnPostCategory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, alias: $alias, name: $name, description: $description, posts: $posts)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return identical(this, other) || | ||||
|         (other.runtimeType == runtimeType && | ||||
|             other is _$SnPostCategoryImpl && | ||||
|             (identical(other.id, id) || other.id == id) && | ||||
|             (identical(other.createdAt, createdAt) || | ||||
|                 other.createdAt == createdAt) && | ||||
|             (identical(other.updatedAt, updatedAt) || | ||||
|                 other.updatedAt == updatedAt) && | ||||
|             const DeepCollectionEquality().equals(other.deletedAt, deletedAt) && | ||||
|             (identical(other.alias, alias) || other.alias == alias) && | ||||
|             (identical(other.name, name) || other.name == name) && | ||||
|             (identical(other.description, description) || | ||||
|                 other.description == description) && | ||||
|             const DeepCollectionEquality().equals(other.posts, posts)); | ||||
|   } | ||||
|  | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   int get hashCode => Object.hash( | ||||
|       runtimeType, | ||||
|       id, | ||||
|       createdAt, | ||||
|       updatedAt, | ||||
|       const DeepCollectionEquality().hash(deletedAt), | ||||
|       alias, | ||||
|       name, | ||||
|       description, | ||||
|       const DeepCollectionEquality().hash(posts)); | ||||
|  | ||||
|   /// Create a copy of SnPostCategory | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   @pragma('vm:prefer-inline') | ||||
|   _$$SnPostCategoryImplCopyWith<_$SnPostCategoryImpl> get copyWith => | ||||
|       __$$SnPostCategoryImplCopyWithImpl<_$SnPostCategoryImpl>( | ||||
|           this, _$identity); | ||||
|  | ||||
|   @override | ||||
|   Map<String, dynamic> toJson() { | ||||
|     return _$$SnPostCategoryImplToJson( | ||||
|       this, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| abstract class _SnPostCategory implements SnPostCategory { | ||||
|   const factory _SnPostCategory( | ||||
|       {required final int id, | ||||
|       required final DateTime createdAt, | ||||
|       required final DateTime updatedAt, | ||||
|       required final dynamic deletedAt, | ||||
|       required final String alias, | ||||
|       required final String name, | ||||
|       required final String description, | ||||
|       required final dynamic posts}) = _$SnPostCategoryImpl; | ||||
|  | ||||
|   factory _SnPostCategory.fromJson(Map<String, dynamic> json) = | ||||
|       _$SnPostCategoryImpl.fromJson; | ||||
|  | ||||
|   @override | ||||
|   int get id; | ||||
|   @override | ||||
|   DateTime get createdAt; | ||||
|   @override | ||||
|   DateTime get updatedAt; | ||||
|   @override | ||||
|   dynamic get deletedAt; | ||||
|   @override | ||||
|   String get alias; | ||||
|   @override | ||||
|   String get name; | ||||
|   @override | ||||
|   String get description; | ||||
|   @override | ||||
|   dynamic get posts; | ||||
|  | ||||
|   /// Create a copy of SnPostCategory | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @override | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   _$$SnPostCategoryImplCopyWith<_$SnPostCategoryImpl> get copyWith => | ||||
|       throw _privateConstructorUsedError; | ||||
| } | ||||
|  | ||||
| SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) { | ||||
|   return _SnPostPreload.fromJson(json); | ||||
| } | ||||
|   | ||||
| @@ -22,7 +22,10 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl( | ||||
|               ?.map((e) => SnPostTag.fromJson(e as Map<String, dynamic>)) | ||||
|               .toList() ?? | ||||
|           const [], | ||||
|       categories: json['categories'] as List<dynamic>? ?? const [], | ||||
|       categories: (json['categories'] as List<dynamic>?) | ||||
|               ?.map((e) => SnPostCategory.fromJson(e as Map<String, dynamic>)) | ||||
|               .toList() ?? | ||||
|           const [], | ||||
|       replies: (json['replies'] as List<dynamic>?) | ||||
|           ?.map((e) => SnPost.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList(), | ||||
| @@ -80,7 +83,7 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) => | ||||
|       'alias': instance.alias, | ||||
|       'alias_prefix': instance.aliasPrefix, | ||||
|       'tags': instance.tags.map((e) => e.toJson()).toList(), | ||||
|       'categories': instance.categories, | ||||
|       'categories': instance.categories.map((e) => e.toJson()).toList(), | ||||
|       'replies': instance.replies?.map((e) => e.toJson()).toList(), | ||||
|       'reply_id': instance.replyId, | ||||
|       'repost_id': instance.repostId, | ||||
| @@ -127,6 +130,31 @@ Map<String, dynamic> _$$SnPostTagImplToJson(_$SnPostTagImpl instance) => | ||||
|       'posts': instance.posts, | ||||
|     }; | ||||
|  | ||||
| _$SnPostCategoryImpl _$$SnPostCategoryImplFromJson(Map<String, dynamic> json) => | ||||
|     _$SnPostCategoryImpl( | ||||
|       id: (json['id'] as num).toInt(), | ||||
|       createdAt: DateTime.parse(json['created_at'] as String), | ||||
|       updatedAt: DateTime.parse(json['updated_at'] as String), | ||||
|       deletedAt: json['deleted_at'], | ||||
|       alias: json['alias'] as String, | ||||
|       name: json['name'] as String, | ||||
|       description: json['description'] as String, | ||||
|       posts: json['posts'], | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$$SnPostCategoryImplToJson( | ||||
|         _$SnPostCategoryImpl instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt, | ||||
|       'alias': instance.alias, | ||||
|       'name': instance.name, | ||||
|       'description': instance.description, | ||||
|       'posts': instance.posts, | ||||
|     }; | ||||
|  | ||||
| _$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) => | ||||
|     _$SnPostPreloadImpl( | ||||
|       thumbnail: json['thumbnail'] == null | ||||
|   | ||||
							
								
								
									
										114
									
								
								lib/widgets/attachment/attachment_input.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,114 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:image_picker/image_picker.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/sn_attachment.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
|  | ||||
| class AttachmentInputDialog extends StatefulWidget { | ||||
|   final String? title; | ||||
|  | ||||
|   const AttachmentInputDialog({super.key, required this.title}); | ||||
|  | ||||
|   @override | ||||
|   State<AttachmentInputDialog> createState() => _AttachmentInputDialogState(); | ||||
| } | ||||
|  | ||||
| class _AttachmentInputDialogState extends State<AttachmentInputDialog> { | ||||
|   final _randomIdController = TextEditingController(); | ||||
|  | ||||
|   XFile? _thumbnailFile; | ||||
|  | ||||
|   void _pickImage() async { | ||||
|     final picker = ImagePicker(); | ||||
|     final result = await picker.pickImage(source: ImageSource.gallery); | ||||
|     if (result == null) return; | ||||
|     setState(() => _thumbnailFile = result); | ||||
|   } | ||||
|  | ||||
|   bool _isBusy = false; | ||||
|  | ||||
|   void _finishUp() async { | ||||
|     if (_isBusy) return; | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     final attach = context.read<SnAttachmentProvider>(); | ||||
|  | ||||
|     if (_randomIdController.text.isNotEmpty) { | ||||
|       try { | ||||
|         final attachment = await attach.getOne(_randomIdController.text); | ||||
|         if (!mounted) return; | ||||
|         Navigator.pop(context, attachment); | ||||
|       } catch (err) { | ||||
|         if (!mounted) return; | ||||
|         context.showErrorDialog(err); | ||||
|       } | ||||
|     } else if (_thumbnailFile != null) { | ||||
|       try { | ||||
|         final attachment = await attach.directUploadOne( | ||||
|           (await _thumbnailFile!.readAsBytes()).buffer.asUint8List(), | ||||
|           _thumbnailFile!.path, | ||||
|           'interactive', | ||||
|           null, | ||||
|         ); | ||||
|         if (!mounted) return; | ||||
|         Navigator.pop(context, attachment); | ||||
|       } catch (err) { | ||||
|         if (!mounted) return; | ||||
|         context.showErrorDialog(err); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AlertDialog( | ||||
|       title: Text(widget.title ?? 'attachmentInputDialog').tr(), | ||||
|       content: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         mainAxisSize: MainAxisSize.min, | ||||
|         children: [ | ||||
|           Text('attachmentInputUseRandomId').tr().fontSize(14), | ||||
|           const Gap(8), | ||||
|           TextField( | ||||
|             controller: _randomIdController, | ||||
|             decoration: InputDecoration( | ||||
|               labelText: 'fieldAttachmentRandomId'.tr(), | ||||
|               border: const OutlineInputBorder(), | ||||
|             ), | ||||
|             onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|           ), | ||||
|           const Gap(24), | ||||
|           Text('attachmentInputNew').tr().fontSize(14), | ||||
|           Card( | ||||
|             child: ListTile( | ||||
|               contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||
|               leading: const Icon(Symbols.add_photo_alternate), | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
|               title: Text('addAttachmentFromAlbum').tr(), | ||||
|               subtitle: _thumbnailFile == null ? Text('unset').tr() : Text('waitingForUpload').tr(), | ||||
|               onTap: () { | ||||
|                 _pickImage(); | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|           child: Text('dialogDismiss').tr(), | ||||
|           onPressed: _isBusy ? null : () { | ||||
|             Navigator.pop(context); | ||||
|           }, | ||||
|         ), | ||||
|         TextButton( | ||||
|           onPressed: _isBusy ? null : () => _finishUp(), | ||||
|           child: Text('dialogConfirm').tr(), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -10,6 +10,7 @@ import 'package:surface/providers/userinfo.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| import 'package:surface/widgets/attachment/attachment_list.dart'; | ||||
| import 'package:surface/widgets/context_menu.dart'; | ||||
| import 'package:surface/widgets/link_preview.dart'; | ||||
| import 'package:surface/widgets/markdown_content.dart'; | ||||
| import 'package:swipe_to/swipe_to.dart'; | ||||
| @@ -53,7 +54,7 @@ class ChatMessage extends StatelessWidget { | ||||
|       swipeSensitivity: 20, | ||||
|       onLeftSwipe: onReply != null ? (_) => onReply!(data) : null, | ||||
|       onRightSwipe: onEdit != null ? (_) => onEdit!(data) : null, | ||||
|       child: ContextMenuRegion( | ||||
|       child: ContextMenuArea( | ||||
|         contextMenu: ContextMenu( | ||||
|           entries: [ | ||||
|             MenuHeader(text: "eventResourceTag".tr(args: ['#${data.id}'])), | ||||
| @@ -142,7 +143,7 @@ class ChatMessage extends StatelessWidget { | ||||
|                             onEdit: onEdit, | ||||
|                             onDelete: onDelete, | ||||
|                           ), | ||||
|                         )).padding(bottom: 4, top: isMerged ? 4 : 2), | ||||
|                         )).padding(bottom: 4, top: 4), | ||||
|                       switch (data.type) { | ||||
|                         'messages.new' => _ChatMessageText(data: data), | ||||
|                         _ => _ChatMessageSystemNotify(data: data), | ||||
|   | ||||
| @@ -123,40 +123,6 @@ class ChatMessageInputState extends State<ChatMessageInput> { | ||||
|   } | ||||
|  | ||||
|   final List<PostWriteMedia> _attachments = List.empty(growable: true); | ||||
|   final _imagePicker = ImagePicker(); | ||||
|  | ||||
|   void _takeMedia(bool isVideo) async { | ||||
|     final result = isVideo | ||||
|         ? await _imagePicker.pickVideo(source: ImageSource.camera) | ||||
|         : await _imagePicker.pickImage(source: ImageSource.camera); | ||||
|     if (result == null) return; | ||||
|     _attachments.add( | ||||
|       PostWriteMedia.fromFile(result), | ||||
|     ); | ||||
|     setState(() {}); | ||||
|   } | ||||
|  | ||||
|   void _selectMedia() async { | ||||
|     final result = await _imagePicker.pickMultipleMedia(); | ||||
|     if (result.isEmpty) return; | ||||
|     _attachments.addAll( | ||||
|       result.map((e) => PostWriteMedia.fromFile(e)), | ||||
|     ); | ||||
|     setState(() {}); | ||||
|   } | ||||
|  | ||||
|   void _pasteMedia() async { | ||||
|     final imageBytes = await Pasteboard.image; | ||||
|     if (imageBytes == null) return; | ||||
|     _attachments.add( | ||||
|       PostWriteMedia.fromBytes( | ||||
|         imageBytes, | ||||
|         'attachmentPastedImage'.tr(), | ||||
|         PostWriteMediaType.image, | ||||
|       ), | ||||
|     ); | ||||
|     setState(() {}); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
| @@ -294,63 +260,12 @@ class ChatMessageInputState extends State<ChatMessageInput> { | ||||
|                 ), | ||||
|               ), | ||||
|               const Gap(8), | ||||
|               PopupMenuButton( | ||||
|                 icon: Icon( | ||||
|                   Symbols.add_photo_alternate, | ||||
|                   color: Theme.of(context).colorScheme.primary, | ||||
|                 ), | ||||
|                 itemBuilder: (context) => [ | ||||
|                   if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows) | ||||
|                     PopupMenuItem( | ||||
|                       child: Row( | ||||
|                         children: [ | ||||
|                           const Icon(Symbols.photo_camera), | ||||
|                           const Gap(16), | ||||
|                           Text('addAttachmentFromCameraPhoto').tr(), | ||||
|                         ], | ||||
|                       ), | ||||
|                       onTap: () { | ||||
|                         _takeMedia(false); | ||||
|                       }, | ||||
|                     ), | ||||
|                   if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows) | ||||
|                     PopupMenuItem( | ||||
|                       child: Row( | ||||
|                         children: [ | ||||
|                           const Icon(Symbols.videocam), | ||||
|                           const Gap(16), | ||||
|                           Text('addAttachmentFromCameraVideo').tr(), | ||||
|                         ], | ||||
|                       ), | ||||
|                       onTap: () { | ||||
|                         _takeMedia(true); | ||||
|                       }, | ||||
|                     ), | ||||
|                   PopupMenuItem( | ||||
|                     child: Row( | ||||
|                       children: [ | ||||
|                         const Icon(Symbols.photo_library), | ||||
|                         const Gap(16), | ||||
|                         Text('addAttachmentFromAlbum').tr(), | ||||
|                       ], | ||||
|                     ), | ||||
|                     onTap: () { | ||||
|                       _selectMedia(); | ||||
|                     }, | ||||
|                   ), | ||||
|                   PopupMenuItem( | ||||
|                     child: Row( | ||||
|                       children: [ | ||||
|                         const Icon(Symbols.content_paste), | ||||
|                         const Gap(16), | ||||
|                         Text('addAttachmentFromClipboard').tr(), | ||||
|                       ], | ||||
|                     ), | ||||
|                     onTap: () { | ||||
|                       _pasteMedia(); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ], | ||||
|               AddPostMediaButton( | ||||
|                 onAdd: (items) { | ||||
|                   setState(() { | ||||
|                     _attachments.addAll(items); | ||||
|                   }); | ||||
|                 }, | ||||
|               ), | ||||
|               IconButton( | ||||
|                 onPressed: _isBusy ? null : _sendMessage, | ||||
|   | ||||
							
								
								
									
										47
									
								
								lib/widgets/context_menu.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,47 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_animate/flutter_animate.dart'; | ||||
| import 'package:flutter_context_menu/flutter_context_menu.dart'; | ||||
| import 'package:responsive_framework/responsive_framework.dart'; | ||||
|  | ||||
| class ContextMenuArea extends StatelessWidget { | ||||
|   final ContextMenu contextMenu; | ||||
|   final Widget child; | ||||
|   final ValueChanged<dynamic>? onItemSelected; | ||||
|  | ||||
|   const ContextMenuArea({ | ||||
|     super.key, | ||||
|     required this.contextMenu, | ||||
|     required this.child, | ||||
|     this.onItemSelected, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     Offset mousePosition = Offset.zero; | ||||
|  | ||||
|     return Listener( | ||||
|       onPointerDown: (event) { | ||||
|         mousePosition = event.position; | ||||
|         final isCollapseDrawer = ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE); | ||||
|         if (!isCollapseDrawer) { | ||||
|           final isExpandDrawer = ResponsiveBreakpoints.of(context).largerThan(TABLET); | ||||
|           // Leave padding for side navigation | ||||
|           mousePosition = isExpandDrawer | ||||
|               ? mousePosition.copyWith(dx: mousePosition.dx - 304 * 2) | ||||
|               : mousePosition.copyWith(dx: mousePosition.dx - 72 * 2); | ||||
|         } | ||||
|       }, | ||||
|       child: GestureDetector( | ||||
|         onLongPress: () => _showMenu(context, mousePosition), | ||||
|         onSecondaryTap: () => _showMenu(context, mousePosition), | ||||
|         child: child, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void _showMenu(BuildContext context, Offset mousePosition) async { | ||||
|     final menu = contextMenu.copyWith(position: contextMenu.position ?? mousePosition); | ||||
|     final value = await showContextMenu(context, contextMenu: menu); | ||||
|     onItemSelected?.call(value); | ||||
|   } | ||||
| } | ||||
| @@ -17,6 +17,8 @@ import 'package:responsive_framework/responsive_framework.dart'; | ||||
| import 'package:screenshot/screenshot.dart'; | ||||
| import 'package:share_plus/share_plus.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/config.dart'; | ||||
| import 'package:surface/providers/link_preview.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
| @@ -83,6 +85,8 @@ class PostItem extends StatelessWidget { | ||||
|             child: MultiProvider( | ||||
|               providers: [ | ||||
|                 Provider<SnNetworkProvider>(create: (_) => context.read()), | ||||
|                 Provider<SnLinkPreviewProvider>(create: (_) => context.read()), | ||||
|                 ChangeNotifierProvider<ConfigProvider>(create: (_) => context.read()), | ||||
|               ], | ||||
|               child: ResponsiveBreakpoints.builder( | ||||
|                 breakpoints: ResponsiveBreakpoints.of(context).breakpoints, | ||||
| @@ -175,6 +179,7 @@ class PostItem extends StatelessWidget { | ||||
|                     children: [ | ||||
|                       if (data.visibility > 0) _PostVisibilityHint(data: data), | ||||
|                       _PostTruncatedHint(data: data), | ||||
|                       if (data.tags.isNotEmpty) _PostTagsList(data: data), | ||||
|                     ], | ||||
|                   ).padding(horizontal: 12), | ||||
|                   const Gap(8), | ||||
| @@ -182,7 +187,6 @@ class PostItem extends StatelessWidget { | ||||
|               ), | ||||
|             ), | ||||
|             Text('postArticle').tr().fontSize(13).opacity(0.75).padding(horizontal: 24, bottom: 8), | ||||
|             if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6), | ||||
|             _PostBottomAction( | ||||
|               data: data, | ||||
|               showComments: showComments, | ||||
| @@ -241,7 +245,7 @@ class PostItem extends StatelessWidget { | ||||
|                   horizontal: 16, | ||||
|                   vertical: 4, | ||||
|                 ), | ||||
|               if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6), | ||||
|               if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, top: 4, bottom: 6), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
| @@ -410,7 +414,7 @@ class PostShareImageWidget extends StatelessWidget { | ||||
|                     size: Size(28, 28), | ||||
|                   ), | ||||
|                   eyeStyle: QrEyeStyle( | ||||
|                     eyeShape: QrEyeShape.square, | ||||
|                     eyeShape: QrEyeShape.circle, | ||||
|                     color: Theme.of(context).colorScheme.onSurface, | ||||
|                   ), | ||||
|                   dataModuleStyle: QrDataModuleStyle( | ||||
| @@ -962,23 +966,69 @@ class _PostTagsList extends StatelessWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Wrap( | ||||
|       spacing: 4, | ||||
|       runSpacing: 4, | ||||
|       children: data.tags | ||||
|           .map( | ||||
|             (ele) => InkWell( | ||||
|               child: Text( | ||||
|                 '#${ele.alias}', | ||||
|                 style: TextStyle( | ||||
|                   decoration: TextDecoration.underline, | ||||
|     return Column( | ||||
|       mainAxisSize: MainAxisSize.min, | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         Wrap( | ||||
|           spacing: 4, | ||||
|           runSpacing: 4, | ||||
|           children: data.categories | ||||
|               .map( | ||||
|                 (ele) => InkWell( | ||||
|                   child: Row( | ||||
|                     mainAxisSize: MainAxisSize.min, | ||||
|                     children: [ | ||||
|                       const Icon(Symbols.category, size: 20), | ||||
|                       const Gap(4), | ||||
|                       Text( | ||||
|                         'postCategory${ele.alias.capitalize()}'.trExists() | ||||
|                             ? 'postCategory${ele.alias.capitalize()}'.tr() | ||||
|                             : ele.alias, | ||||
|                         style: GoogleFonts.robotoMono(), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                   onTap: () { | ||||
|                     GoRouter.of(context).pushNamed( | ||||
|                       'postSearch', | ||||
|                       queryParameters: { | ||||
|                         'categories': ele.alias, | ||||
|                       }, | ||||
|                     ); | ||||
|                   }, | ||||
|                 ), | ||||
|               ).fontSize(13), | ||||
|               onTap: () {}, | ||||
|             ), | ||||
|           ) | ||||
|           .toList(), | ||||
|     ).opacity(0.8); | ||||
|               ) | ||||
|               .toList(), | ||||
|         ).opacity(0.8), | ||||
|         Wrap( | ||||
|           spacing: 4, | ||||
|           runSpacing: 4, | ||||
|           children: data.tags | ||||
|               .map( | ||||
|                 (ele) => InkWell( | ||||
|                   child: Row( | ||||
|                     mainAxisSize: MainAxisSize.min, | ||||
|                     children: [ | ||||
|                       const Icon(Symbols.label, size: 20), | ||||
|                       const Gap(4), | ||||
|                       Text(ele.alias, style: GoogleFonts.robotoMono()), | ||||
|                     ], | ||||
|                   ), | ||||
|                   onTap: () { | ||||
|                     GoRouter.of(context).pushNamed( | ||||
|                       'postSearch', | ||||
|                       queryParameters: { | ||||
|                         'tags': ele.alias, | ||||
|                       }, | ||||
|                     ); | ||||
|                   }, | ||||
|                 ), | ||||
|               ) | ||||
|               .toList(), | ||||
|         ).opacity(0.8), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -1019,6 +1069,7 @@ class _PostTruncatedHint extends StatelessWidget { | ||||
|     return SingleChildScrollView( | ||||
|       scrollDirection: Axis.horizontal, | ||||
|       child: Row( | ||||
|         spacing: 8, | ||||
|         children: [ | ||||
|           if (data.body['content_length'] != null) | ||||
|             Row( | ||||
| @@ -1031,7 +1082,7 @@ class _PostTruncatedHint extends StatelessWidget { | ||||
|                   ).inSeconds}s', | ||||
|                 ]), | ||||
|               ], | ||||
|             ).padding(right: 8), | ||||
|             ), | ||||
|           if (data.body['content_length'] != null) | ||||
|             Row( | ||||
|               children: [ | ||||
|   | ||||
| @@ -6,15 +6,23 @@ import 'package:dismissible_page/dismissible_page.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_context_menu/flutter_context_menu.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:image_picker/image_picker.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:pasteboard/pasteboard.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/controllers/post_write_controller.dart'; | ||||
| import 'package:surface/providers/sn_attachment.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/attachment.dart'; | ||||
| import 'package:surface/widgets/attachment/attachment_input.dart'; | ||||
| import 'package:surface/widgets/attachment/attachment_zoom.dart'; | ||||
| import 'package:surface/widgets/context_menu.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/universal_image.dart'; | ||||
|  | ||||
| class PostMediaPendingList extends StatelessWidget { | ||||
|   final PostWriteMedia? thumbnail; | ||||
| @@ -70,6 +78,32 @@ class PostMediaPendingList extends StatelessWidget { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _setThumbnail(BuildContext context, int idx) async { | ||||
|     if (idx == -1) { | ||||
|       // Thumbnail only can set on video or audio. And thumbnail of the post must be an image, so it's not possible to set thumbnail on the post thumbnail. | ||||
|       return; | ||||
|     } else if (attachments[idx].attachment == null) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     final thumbnail = await showDialog<SnAttachment?>( | ||||
|       context: context, | ||||
|       builder: (context) => AttachmentInputDialog( | ||||
|         title: 'attachmentSetThumbnail'.tr(), | ||||
|       ), | ||||
|     ); | ||||
|     if (thumbnail == null) return; | ||||
|     if (!context.mounted) return; | ||||
|  | ||||
|     final attach = context.read<SnAttachmentProvider>(); | ||||
|     final newAttach = await attach.updateOne(attachments[idx].attachment!.id, thumbnail.alt, metadata: { | ||||
|       ...attachments[idx].attachment!.metadata, | ||||
|       'thumbnail': thumbnail.rid, | ||||
|     }); | ||||
|  | ||||
|     onUpdate!(idx, PostWriteMedia(newAttach)); | ||||
|   } | ||||
|  | ||||
|   Future<void> _deleteAttachment(BuildContext context, int idx) async { | ||||
|     final media = idx == -1 ? thumbnail! : attachments[idx]; | ||||
|     if (media.attachment == null) return; | ||||
| @@ -87,9 +121,17 @@ class PostMediaPendingList extends StatelessWidget { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   ContextMenu _buildContextMenu(BuildContext context, int idx, PostWriteMedia media) { | ||||
|   ContextMenu _createContextMenu(BuildContext context, int idx, PostWriteMedia media) { | ||||
|     return ContextMenu( | ||||
|       entries: [ | ||||
|         if (media.attachment != null && media.type == PostWriteMediaType.video) | ||||
|           MenuItem( | ||||
|             label: 'attachmentSetThumbnail'.tr(), | ||||
|             icon: Symbols.image, | ||||
|             onSelected: () { | ||||
|               _setThumbnail(context, idx); | ||||
|             }, | ||||
|           ), | ||||
|         if (media.attachment == null && onUpload != null) | ||||
|           MenuItem( | ||||
|               label: 'attachmentUpload'.tr(), | ||||
| @@ -97,7 +139,10 @@ class PostMediaPendingList extends StatelessWidget { | ||||
|               onSelected: () { | ||||
|                 onUpload!(idx); | ||||
|               }), | ||||
|         if (media.attachment != null && onPostSetThumbnail != null && idx != -1) | ||||
|         if (media.attachment != null && | ||||
|             media.type == PostWriteMediaType.image && | ||||
|             onPostSetThumbnail != null && | ||||
|             idx != -1) | ||||
|           MenuItem( | ||||
|             label: 'attachmentSetAsPostThumbnail'.tr(), | ||||
|             icon: Symbols.gallery_thumbnail, | ||||
| @@ -105,7 +150,7 @@ class PostMediaPendingList extends StatelessWidget { | ||||
|               onPostSetThumbnail!(idx); | ||||
|             }, | ||||
|           ) | ||||
|         else if (media.attachment != null && onPostSetThumbnail != null) | ||||
|         else if (media.attachment != null && media.type == PostWriteMediaType.image && onPostSetThumbnail != null) | ||||
|           MenuItem( | ||||
|             label: 'attachmentUnsetAsPostThumbnail'.tr(), | ||||
|             icon: Symbols.cancel, | ||||
| @@ -138,6 +183,14 @@ class PostMediaPendingList extends StatelessWidget { | ||||
|             icon: Symbols.crop, | ||||
|             onSelected: () => _cropImage(context, idx), | ||||
|           ), | ||||
|         if (media.attachment != null) | ||||
|           MenuItem( | ||||
|             label: 'attachmentCopyRandomId'.tr(), | ||||
|             icon: Symbols.content_copy, | ||||
|             onSelected: () { | ||||
|               Clipboard.setData(ClipboardData(text: media.attachment!.rid)); | ||||
|             }, | ||||
|           ), | ||||
|         if (media.attachment != null && onRemove != null) | ||||
|           MenuItem( | ||||
|             label: 'delete'.tr(), | ||||
| @@ -168,45 +221,17 @@ class PostMediaPendingList extends StatelessWidget { | ||||
|   Widget build(BuildContext context) { | ||||
|     final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; | ||||
|  | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|  | ||||
|     return Container( | ||||
|       constraints: const BoxConstraints(maxHeight: 120), | ||||
|       child: Row( | ||||
|         children: [ | ||||
|           const Gap(8), | ||||
|           if (thumbnail != null) | ||||
|             ContextMenuRegion( | ||||
|               contextMenu: _buildContextMenu(context, -1, thumbnail!), | ||||
|               child: Container( | ||||
|                 decoration: BoxDecoration( | ||||
|                   border: Border.all( | ||||
|                     color: Theme.of(context).dividerColor, | ||||
|                     width: 1, | ||||
|                   ), | ||||
|                   borderRadius: BorderRadius.circular(8), | ||||
|                 ), | ||||
|                 child: ClipRRect( | ||||
|                   borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                   child: AspectRatio( | ||||
|                     aspectRatio: 1, | ||||
|                     child: switch (thumbnail!.type) { | ||||
|                       PostWriteMediaType.image => LayoutBuilder(builder: (context, constraints) { | ||||
|                           return Image( | ||||
|                             image: thumbnail!.getImageProvider( | ||||
|                               context, | ||||
|                               width: (constraints.maxWidth * devicePixelRatio).round(), | ||||
|                               height: (constraints.maxHeight * devicePixelRatio).round(), | ||||
|                             )!, | ||||
|                             fit: BoxFit.cover, | ||||
|                           ); | ||||
|                         }), | ||||
|                       _ => Container( | ||||
|                           color: Theme.of(context).colorScheme.surface, | ||||
|                           child: const Icon(Symbols.docs).center(), | ||||
|                         ), | ||||
|                     }, | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ContextMenuArea( | ||||
|               contextMenu: _createContextMenu(context, -1, thumbnail!), | ||||
|               child: _PostMediaPendingItem(media: thumbnail!), | ||||
|             ), | ||||
|           if (thumbnail != null) | ||||
|             const VerticalDivider(width: 1, thickness: 1).padding( | ||||
| @@ -221,39 +246,9 @@ class PostMediaPendingList extends StatelessWidget { | ||||
|               itemCount: attachments.length, | ||||
|               itemBuilder: (context, idx) { | ||||
|                 final media = attachments[idx]; | ||||
|                 return ContextMenuRegion( | ||||
|                   contextMenu: _buildContextMenu(context, idx, media), | ||||
|                   child: Container( | ||||
|                     decoration: BoxDecoration( | ||||
|                       border: Border.all( | ||||
|                         color: Theme.of(context).dividerColor, | ||||
|                         width: 1, | ||||
|                       ), | ||||
|                       borderRadius: BorderRadius.circular(8), | ||||
|                     ), | ||||
|                     child: ClipRRect( | ||||
|                       borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                       child: AspectRatio( | ||||
|                         aspectRatio: 1, | ||||
|                         child: switch (media.type) { | ||||
|                           PostWriteMediaType.image => LayoutBuilder(builder: (context, constraints) { | ||||
|                               return Image( | ||||
|                                 image: media.getImageProvider( | ||||
|                                   context, | ||||
|                                   width: (constraints.maxWidth * devicePixelRatio).round(), | ||||
|                                   height: (constraints.maxHeight * devicePixelRatio).round(), | ||||
|                                 )!, | ||||
|                                 fit: BoxFit.cover, | ||||
|                               ); | ||||
|                             }), | ||||
|                           _ => Container( | ||||
|                               color: Theme.of(context).colorScheme.surface, | ||||
|                               child: const Icon(Symbols.docs).center(), | ||||
|                             ), | ||||
|                         }, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 return ContextMenuArea( | ||||
|                   contextMenu: _createContextMenu(context, idx, media), | ||||
|                   child: _PostMediaPendingItem(media: media), | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
| @@ -263,3 +258,218 @@ class PostMediaPendingList extends StatelessWidget { | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _PostMediaPendingItem extends StatelessWidget { | ||||
|   final PostWriteMedia media; | ||||
|  | ||||
|   const _PostMediaPendingItem({ | ||||
|     required this.media, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; | ||||
|  | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|  | ||||
|     return Container( | ||||
|       decoration: BoxDecoration( | ||||
|         border: Border.all( | ||||
|           color: Theme.of(context).dividerColor, | ||||
|           width: 1, | ||||
|         ), | ||||
|         borderRadius: BorderRadius.circular(8), | ||||
|       ), | ||||
|       child: ClipRRect( | ||||
|         borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|         child: AspectRatio( | ||||
|           aspectRatio: 1, | ||||
|           child: switch (media.type) { | ||||
|             PostWriteMediaType.image => Container( | ||||
|                 color: Theme.of(context).colorScheme.surfaceContainer, | ||||
|                 child: LayoutBuilder(builder: (context, constraints) { | ||||
|                   return Image( | ||||
|                     image: media.getImageProvider( | ||||
|                       context, | ||||
|                       width: (constraints.maxWidth * devicePixelRatio).round(), | ||||
|                       height: (constraints.maxHeight * devicePixelRatio).round(), | ||||
|                     )!, | ||||
|                     fit: BoxFit.contain, | ||||
|                   ); | ||||
|                 }), | ||||
|               ), | ||||
|             PostWriteMediaType.video => Container( | ||||
|                 color: Theme.of(context).colorScheme.surfaceContainer, | ||||
|                 child: media.attachment?.metadata['thumbnail'] != null | ||||
|                     ? AutoResizeUniversalImage(sn.getAttachmentUrl(media.attachment?.metadata['thumbnail'])) | ||||
|                     : const Icon(Symbols.videocam).center(), | ||||
|               ), | ||||
|             _ => Container( | ||||
|                 color: Theme.of(context).colorScheme.surfaceContainer, | ||||
|                 child: const Icon(Symbols.docs).center(), | ||||
|               ), | ||||
|           }, | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class AddPostMediaButton extends StatelessWidget { | ||||
|   final Function(Iterable<PostWriteMedia>) onAdd; | ||||
|  | ||||
|   const AddPostMediaButton({super.key, required this.onAdd}); | ||||
|  | ||||
|   void _takeMedia(bool isVideo) async { | ||||
|     final picker = ImagePicker(); | ||||
|     final result = isVideo | ||||
|         ? await picker.pickVideo(source: ImageSource.camera) | ||||
|         : await picker.pickImage(source: ImageSource.camera); | ||||
|     if (result == null) return; | ||||
|     onAdd([PostWriteMedia.fromFile(result)]); | ||||
|   } | ||||
|  | ||||
|   void _selectMedia() async { | ||||
|     final picker = ImagePicker(); | ||||
|     final result = await picker.pickMultipleMedia(); | ||||
|     if (result.isEmpty) return; | ||||
|     onAdd( | ||||
|       result.map((e) => PostWriteMedia.fromFile(e)), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void _pasteMedia() async { | ||||
|     final imageBytes = await Pasteboard.image; | ||||
|     if (imageBytes == null) return; | ||||
|     onAdd([ | ||||
|       PostWriteMedia.fromBytes( | ||||
|         imageBytes, | ||||
|         'attachmentPastedImage'.tr(), | ||||
|         PostWriteMediaType.image, | ||||
|       ), | ||||
|     ]); | ||||
|   } | ||||
|  | ||||
|   void _linkRandomId(BuildContext context) async { | ||||
|     final randomIdController = TextEditingController(); | ||||
|     final randomId = await showDialog<String?>( | ||||
|       context: context, | ||||
|       builder: (context) => AlertDialog( | ||||
|         title: Text('addAttachmentFromRandomId').tr(), | ||||
|         content: Column( | ||||
|           mainAxisSize: MainAxisSize.min, | ||||
|           children: [ | ||||
|             TextField( | ||||
|               controller: randomIdController, | ||||
|               decoration: InputDecoration( | ||||
|                 labelText: 'fieldAttachmentRandomId'.tr(), | ||||
|                 border: const UnderlineInputBorder(), | ||||
|               ), | ||||
|             ), | ||||
|             const Gap(8), | ||||
|           ], | ||||
|         ), | ||||
|         actions: [ | ||||
|           TextButton( | ||||
|             child: Text('dialogDismiss').tr(), | ||||
|             onPressed: () { | ||||
|               Navigator.pop(context); | ||||
|             }, | ||||
|           ), | ||||
|           TextButton( | ||||
|             child: Text('dialogConfirm').tr(), | ||||
|             onPressed: () { | ||||
|               Navigator.pop(context, randomIdController.text); | ||||
|             }, | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|     WidgetsBinding.instance.addPostFrameCallback((_) { | ||||
|       randomIdController.dispose(); | ||||
|     }); | ||||
|     if (randomId == null || randomId.isEmpty) return; | ||||
|     if (!context.mounted) return; | ||||
|  | ||||
|     final attach = context.read<SnAttachmentProvider>(); | ||||
|     final attachment = await attach.getOne(randomId); | ||||
|  | ||||
|     onAdd([ | ||||
|       PostWriteMedia(attachment), | ||||
|     ]); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return PopupMenuButton( | ||||
|       icon: Icon( | ||||
|         Symbols.add_photo_alternate, | ||||
|         color: Theme.of(context).colorScheme.primary, | ||||
|       ), | ||||
|       itemBuilder: (context) => [ | ||||
|         if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows) | ||||
|           PopupMenuItem( | ||||
|             child: Row( | ||||
|               children: [ | ||||
|                 const Icon(Symbols.photo_camera), | ||||
|                 const Gap(16), | ||||
|                 Text('addAttachmentFromCameraPhoto').tr(), | ||||
|               ], | ||||
|             ), | ||||
|             onTap: () { | ||||
|               _takeMedia(false); | ||||
|             }, | ||||
|           ), | ||||
|         if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows) | ||||
|           PopupMenuItem( | ||||
|             child: Row( | ||||
|               children: [ | ||||
|                 const Icon(Symbols.videocam), | ||||
|                 const Gap(16), | ||||
|                 Text('addAttachmentFromCameraVideo').tr(), | ||||
|               ], | ||||
|             ), | ||||
|             onTap: () { | ||||
|               _takeMedia(true); | ||||
|             }, | ||||
|           ), | ||||
|         PopupMenuItem( | ||||
|           child: Row( | ||||
|             children: [ | ||||
|               const Icon(Symbols.photo_library), | ||||
|               const Gap(16), | ||||
|               Text('addAttachmentFromAlbum').tr(), | ||||
|             ], | ||||
|           ), | ||||
|           onTap: () { | ||||
|             _selectMedia(); | ||||
|           }, | ||||
|         ), | ||||
|         PopupMenuItem( | ||||
|           child: Row( | ||||
|             children: [ | ||||
|               const Icon(Symbols.link), | ||||
|               const Gap(16), | ||||
|               Text('addAttachmentFromRandomId').tr(), | ||||
|             ], | ||||
|           ), | ||||
|           onTap: () { | ||||
|             _linkRandomId(context); | ||||
|           }, | ||||
|         ), | ||||
|         PopupMenuItem( | ||||
|           child: Row( | ||||
|             children: [ | ||||
|               const Icon(Symbols.content_paste), | ||||
|               const Gap(16), | ||||
|               Text('addAttachmentFromClipboard').tr(), | ||||
|             ], | ||||
|           ), | ||||
|           onTap: () { | ||||
|             _pasteMedia(); | ||||
|           }, | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -83,155 +83,178 @@ class PostMetaEditor extends StatelessWidget { | ||||
|     return ListenableBuilder( | ||||
|       listenable: controller, | ||||
|       builder: (context, _) { | ||||
|         return Column( | ||||
|           children: [ | ||||
|             TextField( | ||||
|               controller: controller.titleController, | ||||
|               decoration: InputDecoration( | ||||
|                 labelText: 'fieldPostTitle'.tr(), | ||||
|                 border: UnderlineInputBorder(), | ||||
|               ), | ||||
|               onTapOutside: (_) => | ||||
|                   FocusManager.instance.primaryFocus?.unfocus(), | ||||
|             ).padding(horizontal: 24), | ||||
|             if (controller.mode == 'articles') const Gap(4), | ||||
|             if (controller.mode == 'articles') | ||||
|         return SingleChildScrollView( | ||||
|           padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8), | ||||
|           child: Column( | ||||
|             children: [ | ||||
|               TextField( | ||||
|                 controller: controller.descriptionController, | ||||
|                 maxLines: null, | ||||
|                 controller: controller.titleController, | ||||
|                 decoration: InputDecoration( | ||||
|                   labelText: 'fieldPostDescription'.tr(), | ||||
|                   labelText: 'fieldPostTitle'.tr(), | ||||
|                   border: UnderlineInputBorder(), | ||||
|                 ), | ||||
|                 onTapOutside: (_) => | ||||
|                     FocusManager.instance.primaryFocus?.unfocus(), | ||||
|               ).padding(horizontal: 24), | ||||
|             const Gap(4), | ||||
|             PostTagsField( | ||||
|               initialTags: controller.tags, | ||||
|               labelText: 'fieldPostTags'.tr(), | ||||
|               onUpdate: (value) { | ||||
|                 controller.setTags(value); | ||||
|               }, | ||||
|             ).padding(horizontal: 24), | ||||
|             const Gap(12), | ||||
|             ListTile( | ||||
|               contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|               leading: const Icon(Symbols.visibility), | ||||
|               title: Text('postVisibility').tr(), | ||||
|               subtitle: Text('postVisibilityDescription').tr(), | ||||
|               trailing: SizedBox( | ||||
|                 width: 180, | ||||
|                 child: DropdownButtonHideUnderline( | ||||
|                   child: DropdownButton2<int>( | ||||
|                     isExpanded: true, | ||||
|                     items: kPostVisibilityLevel.entries | ||||
|                         .map( | ||||
|                           (entry) => DropdownMenuItem<int>( | ||||
|                             value: entry.key, | ||||
|                             child: Text( | ||||
|                               entry.value, | ||||
|                               style: const TextStyle(fontSize: 14), | ||||
|                             ).tr(), | ||||
|                           ), | ||||
|                         ) | ||||
|                         .toList(), | ||||
|                     value: controller.visibility, | ||||
|                     onChanged: (int? value) { | ||||
|                       if (value != null) { | ||||
|                         controller.setVisibility(value); | ||||
|                       } | ||||
|                     }, | ||||
|                     buttonStyleData: const ButtonStyleData( | ||||
|                       height: 40, | ||||
|                       padding: EdgeInsets.symmetric( | ||||
|                         horizontal: 4, | ||||
|                         vertical: 8, | ||||
|               if (controller.mode == 'articles') const Gap(4), | ||||
|               if (controller.mode == 'articles') | ||||
|                 TextField( | ||||
|                   controller: controller.descriptionController, | ||||
|                   maxLines: null, | ||||
|                   decoration: InputDecoration( | ||||
|                     labelText: 'fieldPostDescription'.tr(), | ||||
|                     border: UnderlineInputBorder(), | ||||
|                   ), | ||||
|                   onTapOutside: (_) => | ||||
|                       FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ).padding(horizontal: 24), | ||||
|               const Gap(4), | ||||
|               PostTagsField( | ||||
|                 initialTags: controller.tags, | ||||
|                 labelText: 'fieldPostTags'.tr(), | ||||
|                 onUpdate: (value) { | ||||
|                   controller.setTags(value); | ||||
|                 }, | ||||
|               ).padding(horizontal: 24), | ||||
|               const Gap(4), | ||||
|               PostCategoriesField( | ||||
|                 initialCategories: controller.categories, | ||||
|                 labelText: 'fieldPostCategories'.tr(), | ||||
|                 onUpdate: (value) { | ||||
|                   controller.setCategories(value); | ||||
|                 }, | ||||
|               ).padding(horizontal: 24), | ||||
|               const Gap(4), | ||||
|               TextField( | ||||
|                 controller: controller.aliasController, | ||||
|                 decoration: InputDecoration( | ||||
|                   labelText: 'fieldPostAlias'.tr(), | ||||
|                   helperText: 'fieldPostAliasHint'.tr(), | ||||
|                   helperMaxLines: 2, | ||||
|                   border: UnderlineInputBorder(), | ||||
|                 ), | ||||
|                 onTapOutside: (_) => | ||||
|                     FocusManager.instance.primaryFocus?.unfocus(), | ||||
|               ).padding(horizontal: 24), | ||||
|               const Gap(12), | ||||
|               ListTile( | ||||
|                 contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                 leading: const Icon(Symbols.visibility), | ||||
|                 title: Text('postVisibility').tr(), | ||||
|                 subtitle: Text('postVisibilityDescription').tr(), | ||||
|                 trailing: SizedBox( | ||||
|                   width: 180, | ||||
|                   child: DropdownButtonHideUnderline( | ||||
|                     child: DropdownButton2<int>( | ||||
|                       isExpanded: true, | ||||
|                       items: kPostVisibilityLevel.entries | ||||
|                           .map( | ||||
|                             (entry) => DropdownMenuItem<int>( | ||||
|                               value: entry.key, | ||||
|                               child: Text( | ||||
|                                 entry.value, | ||||
|                                 style: const TextStyle(fontSize: 14), | ||||
|                               ).tr(), | ||||
|                             ), | ||||
|                           ) | ||||
|                           .toList(), | ||||
|                       value: controller.visibility, | ||||
|                       onChanged: (int? value) { | ||||
|                         if (value != null) { | ||||
|                           controller.setVisibility(value); | ||||
|                         } | ||||
|                       }, | ||||
|                       buttonStyleData: const ButtonStyleData( | ||||
|                         height: 40, | ||||
|                         padding: EdgeInsets.symmetric( | ||||
|                           horizontal: 4, | ||||
|                           vertical: 8, | ||||
|                         ), | ||||
|                       ), | ||||
|                       menuItemStyleData: const MenuItemStyleData(height: 40), | ||||
|                     ), | ||||
|                     menuItemStyleData: const MenuItemStyleData(height: 40), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             if (controller.visibility == 2) | ||||
|               if (controller.visibility == 2) | ||||
|                 ListTile( | ||||
|                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                   leading: Icon(Symbols.person), | ||||
|                   trailing: Icon(Symbols.chevron_right), | ||||
|                   title: Text('postVisibleUsers').tr(), | ||||
|                   subtitle: Text('postSelectedUsers') | ||||
|                       .plural(controller.visibleUsers.length), | ||||
|                   onTap: () { | ||||
|                     _selectVisibleUser(context); | ||||
|                   }, | ||||
|                 ), | ||||
|               if (controller.visibility == 3) | ||||
|                 ListTile( | ||||
|                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                   leading: Icon(Symbols.person), | ||||
|                   trailing: Icon(Symbols.chevron_right), | ||||
|                   title: Text('postInvisibleUsers').tr(), | ||||
|                   subtitle: Text('postSelectedUsers') | ||||
|                       .plural(controller.invisibleUsers.length), | ||||
|                   onTap: () { | ||||
|                     _selectInvisibleUser(context); | ||||
|                   }, | ||||
|                 ), | ||||
|               ListTile( | ||||
|                 contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                 leading: Icon(Symbols.person), | ||||
|                 trailing: Icon(Symbols.chevron_right), | ||||
|                 title: Text('postVisibleUsers').tr(), | ||||
|                 subtitle: Text('postSelectedUsers') | ||||
|                     .plural(controller.visibleUsers.length), | ||||
|                 leading: const Icon(Symbols.event_available), | ||||
|                 title: Text('postPublishedAt').tr(), | ||||
|                 subtitle: Text( | ||||
|                   controller.publishedAt != null | ||||
|                       ? dateFormatter.format(controller.publishedAt!) | ||||
|                       : 'unset'.tr(), | ||||
|                 ), | ||||
|                 trailing: controller.publishedAt != null | ||||
|                     ? IconButton( | ||||
|                         icon: const Icon(Symbols.cancel), | ||||
|                         onPressed: () { | ||||
|                           controller.setPublishedAt(null); | ||||
|                         }, | ||||
|                       ) | ||||
|                     : null, | ||||
|                 contentPadding: const EdgeInsets.only(left: 24, right: 18), | ||||
|                 onTap: () { | ||||
|                   _selectVisibleUser(context); | ||||
|                   _selectDate( | ||||
|                     context, | ||||
|                     initialDateTime: controller.publishedAt, | ||||
|                   ).then((value) { | ||||
|                     controller.setPublishedAt(value); | ||||
|                   }); | ||||
|                 }, | ||||
|               ), | ||||
|             if (controller.visibility == 3) | ||||
|               ListTile( | ||||
|                 contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                 leading: Icon(Symbols.person), | ||||
|                 trailing: Icon(Symbols.chevron_right), | ||||
|                 title: Text('postInvisibleUsers').tr(), | ||||
|                 subtitle: Text('postSelectedUsers') | ||||
|                     .plural(controller.invisibleUsers.length), | ||||
|                 leading: const Icon(Symbols.event_busy), | ||||
|                 title: Text('postPublishedUntil').tr(), | ||||
|                 subtitle: Text( | ||||
|                   controller.publishedUntil != null | ||||
|                       ? dateFormatter.format(controller.publishedUntil!) | ||||
|                       : 'unset'.tr(), | ||||
|                 ), | ||||
|                 trailing: controller.publishedUntil != null | ||||
|                     ? IconButton( | ||||
|                         icon: const Icon(Symbols.cancel), | ||||
|                         onPressed: () { | ||||
|                           controller.setPublishedUntil(null); | ||||
|                         }, | ||||
|                       ) | ||||
|                     : null, | ||||
|                 contentPadding: const EdgeInsets.only(left: 24, right: 18), | ||||
|                 onTap: () { | ||||
|                   _selectInvisibleUser(context); | ||||
|                   _selectDate( | ||||
|                     context, | ||||
|                     initialDateTime: controller.publishedUntil, | ||||
|                   ).then((value) { | ||||
|                     controller.setPublishedUntil(value); | ||||
|                   }); | ||||
|                 }, | ||||
|               ), | ||||
|             ListTile( | ||||
|               leading: const Icon(Symbols.event_available), | ||||
|               title: Text('postPublishedAt').tr(), | ||||
|               subtitle: Text( | ||||
|                 controller.publishedAt != null | ||||
|                     ? dateFormatter.format(controller.publishedAt!) | ||||
|                     : 'unset'.tr(), | ||||
|               ), | ||||
|               trailing: controller.publishedAt != null | ||||
|                   ? IconButton( | ||||
|                       icon: const Icon(Symbols.cancel), | ||||
|                       onPressed: () { | ||||
|                         controller.setPublishedAt(null); | ||||
|                       }, | ||||
|                     ) | ||||
|                   : null, | ||||
|               contentPadding: const EdgeInsets.only(left: 24, right: 18), | ||||
|               onTap: () { | ||||
|                 _selectDate( | ||||
|                   context, | ||||
|                   initialDateTime: controller.publishedAt, | ||||
|                 ).then((value) { | ||||
|                   controller.setPublishedAt(value); | ||||
|                 }); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
|               leading: const Icon(Symbols.event_busy), | ||||
|               title: Text('postPublishedUntil').tr(), | ||||
|               subtitle: Text( | ||||
|                 controller.publishedUntil != null | ||||
|                     ? dateFormatter.format(controller.publishedUntil!) | ||||
|                     : 'unset'.tr(), | ||||
|               ), | ||||
|               trailing: controller.publishedUntil != null | ||||
|                   ? IconButton( | ||||
|                       icon: const Icon(Symbols.cancel), | ||||
|                       onPressed: () { | ||||
|                         controller.setPublishedUntil(null); | ||||
|                       }, | ||||
|                     ) | ||||
|                   : null, | ||||
|               contentPadding: const EdgeInsets.only(left: 24, right: 18), | ||||
|               onTap: () { | ||||
|                 _selectDate( | ||||
|                   context, | ||||
|                   initialDateTime: controller.publishedUntil, | ||||
|                 ).then((value) { | ||||
|                   controller.setPublishedUntil(value); | ||||
|                 }); | ||||
|               }, | ||||
|             ), | ||||
|           ], | ||||
|         ).padding(vertical: 8); | ||||
|             ], | ||||
|           ).padding(vertical: 8), | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/controllers/post_write_controller.dart'; | ||||
| import 'package:surface/providers/config.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| @@ -16,6 +17,7 @@ import 'package:surface/widgets/loading_indicator.dart'; | ||||
| class PostMiniEditor extends StatefulWidget { | ||||
|   final int? postReplyId; | ||||
|   final Function? onPost; | ||||
|  | ||||
|   const PostMiniEditor({super.key, this.postReplyId, this.onPost}); | ||||
|  | ||||
|   @override | ||||
| @@ -26,6 +28,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> { | ||||
|   final PostWriteController _writeController = PostWriteController(); | ||||
|  | ||||
|   bool _isFetching = false; | ||||
|  | ||||
|   bool get _isLoading => _isFetching || _writeController.isLoading; | ||||
|  | ||||
|   List<SnPublisher>? _publishers; | ||||
| @@ -35,11 +38,14 @@ class _PostMiniEditorState extends State<PostMiniEditor> { | ||||
|  | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final config = context.read<ConfigProvider>(); | ||||
|       final resp = await sn.client.get('/cgi/co/publishers/me'); | ||||
|       _publishers = List<SnPublisher>.from( | ||||
|         resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], | ||||
|       ); | ||||
|       _writeController.setPublisher(_publishers?.firstOrNull); | ||||
|       final beforeId = config.prefs.getInt('int_last_publisher_id'); | ||||
|       _writeController | ||||
|           .setPublisher(_publishers?.where((ele) => ele.id == beforeId).firstOrNull ?? _publishers?.firstOrNull); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
| @@ -93,17 +99,11 @@ class _PostMiniEditorState extends State<PostMiniEditor> { | ||||
|                                 Expanded( | ||||
|                                   child: Column( | ||||
|                                     mainAxisSize: MainAxisSize.min, | ||||
|                                     crossAxisAlignment: | ||||
|                                         CrossAxisAlignment.start, | ||||
|                                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                                     children: [ | ||||
|                                       Text(item.nick).textStyle( | ||||
|                                           Theme.of(context) | ||||
|                                               .textTheme | ||||
|                                               .bodyMedium!), | ||||
|                                       Text(item.nick).textStyle(Theme.of(context).textTheme.bodyMedium!), | ||||
|                                       Text('@${item.name}') | ||||
|                                           .textStyle(Theme.of(context) | ||||
|                                               .textTheme | ||||
|                                               .bodySmall!) | ||||
|                                           .textStyle(Theme.of(context).textTheme.bodySmall!) | ||||
|                                           .fontSize(12), | ||||
|                                     ], | ||||
|                                   ), | ||||
| @@ -120,8 +120,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> { | ||||
|                           CircleAvatar( | ||||
|                             radius: 16, | ||||
|                             backgroundColor: Colors.transparent, | ||||
|                             foregroundColor: | ||||
|                                 Theme.of(context).colorScheme.onSurface, | ||||
|                             foregroundColor: Theme.of(context).colorScheme.onSurface, | ||||
|                             child: const Icon(Symbols.add), | ||||
|                           ), | ||||
|                           const Gap(8), | ||||
| @@ -130,8 +129,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> { | ||||
|                               mainAxisSize: MainAxisSize.min, | ||||
|                               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                               children: [ | ||||
|                                 Text('publishersNew').tr().textStyle( | ||||
|                                     Theme.of(context).textTheme.bodyMedium!), | ||||
|                                 Text('publishersNew').tr().textStyle(Theme.of(context).textTheme.bodyMedium!), | ||||
|                               ], | ||||
|                             ), | ||||
|                           ), | ||||
| @@ -142,9 +140,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> { | ||||
|                   value: _writeController.publisher, | ||||
|                   onChanged: (SnPublisher? value) { | ||||
|                     if (value == null) { | ||||
|                       GoRouter.of(context) | ||||
|                           .pushNamed('accountPublisherNew') | ||||
|                           .then((value) { | ||||
|                       GoRouter.of(context).pushNamed('accountPublisherNew').then((value) { | ||||
|                         if (value == true) { | ||||
|                           _publishers = null; | ||||
|                           _fetchPublishers(); | ||||
| @@ -152,6 +148,8 @@ class _PostMiniEditorState extends State<PostMiniEditor> { | ||||
|                       }); | ||||
|                     } else { | ||||
|                       _writeController.setPublisher(value); | ||||
|                       final config = context.read<ConfigProvider>(); | ||||
|                       config.prefs.setInt('int_last_publisher_id', value.id); | ||||
|                     } | ||||
|                   }, | ||||
|                   buttonStyleData: const ButtonStyleData( | ||||
| @@ -178,8 +176,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> { | ||||
|                     ), | ||||
|                     border: InputBorder.none, | ||||
|                   ), | ||||
|                   onTapOutside: (_) => | ||||
|                       FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                   onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ), | ||||
|               ), | ||||
|               const Gap(8), | ||||
| @@ -188,8 +185,7 @@ class _PostMiniEditorState extends State<PostMiniEditor> { | ||||
|                 TweenAnimationBuilder<double>( | ||||
|                   tween: Tween(begin: 0, end: _writeController.progress), | ||||
|                   duration: Duration(milliseconds: 300), | ||||
|                   builder: (context, value, _) => | ||||
|                       LinearProgressIndicator(value: value, minHeight: 2), | ||||
|                   builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2), | ||||
|                 ) | ||||
|               else if (_writeController.isBusy) | ||||
|                 const LinearProgressIndicator(value: null, minHeight: 2), | ||||
| @@ -206,18 +202,16 @@ class _PostMiniEditorState extends State<PostMiniEditor> { | ||||
|                         'postEditor', | ||||
|                         pathParameters: {'mode': 'stories'}, | ||||
|                         queryParameters: { | ||||
|                           if (widget.postReplyId != null) | ||||
|                             'replying': widget.postReplyId.toString(), | ||||
|                           if (widget.postReplyId != null) 'replying': widget.postReplyId.toString(), | ||||
|                         }, | ||||
|                       ); | ||||
|                     }, | ||||
|                   ), | ||||
|                   TextButton.icon( | ||||
|                     onPressed: (_writeController.isBusy || | ||||
|                             _writeController.publisher == null) | ||||
|                     onPressed: (_writeController.isBusy || _writeController.publisher == null) | ||||
|                         ? null | ||||
|                         : () { | ||||
|                             _writeController.post(context).then((_) { | ||||
|                             _writeController.sendPost(context).then((_) { | ||||
|                               if (!context.mounted) return; | ||||
|                               if (widget.onPost != null) widget.onPost!(); | ||||
|                               context.showSnackbar('postPosted'.tr()); | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
|  | ||||
| class PostTagsField extends StatefulWidget { | ||||
|   final List<String>? initialTags; | ||||
| @@ -21,9 +23,9 @@ class PostTagsField extends StatefulWidget { | ||||
|   State<PostTagsField> createState() => _PostTagsFieldState(); | ||||
| } | ||||
|  | ||||
| class _PostTagsFieldState extends State<PostTagsField> { | ||||
|   static const List<String> kTagsDividers = [' ', ',']; | ||||
| const List<String> kTagsDividers = [' ', ',']; | ||||
|  | ||||
| class _PostTagsFieldState extends State<PostTagsField> { | ||||
|   late final _Debounceable<List<String>?, String> _debouncedSearch; | ||||
|  | ||||
|   final List<String> _currentTags = List.empty(growable: true); | ||||
| @@ -100,8 +102,7 @@ class _PostTagsFieldState extends State<PostTagsField> { | ||||
|                             color: Theme.of(context).colorScheme.primary, | ||||
|                           ), | ||||
|                           margin: const EdgeInsets.only(right: 8), | ||||
|                           padding: const EdgeInsets.symmetric( | ||||
|                               horizontal: 10.0, vertical: 4.0), | ||||
|                           padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0), | ||||
|                           child: Row( | ||||
|                             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                             children: [ | ||||
| @@ -155,6 +156,141 @@ class _PostTagsFieldState extends State<PostTagsField> { | ||||
|   } | ||||
| } | ||||
|  | ||||
| class PostCategoriesField extends StatefulWidget { | ||||
|   final List<String>? initialCategories; | ||||
|   final String labelText; | ||||
|   final Function(List<String>) onUpdate; | ||||
|  | ||||
|   const PostCategoriesField({ | ||||
|     super.key, | ||||
|     this.initialCategories, | ||||
|     required this.labelText, | ||||
|     required this.onUpdate, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<PostCategoriesField> createState() => _PostCategoriesFieldState(); | ||||
| } | ||||
|  | ||||
| class _PostCategoriesFieldState extends State<PostCategoriesField> { | ||||
|   late final _Debounceable<List<String>?, String> _debouncedSearch; | ||||
|  | ||||
|   final List<String> _currentCategories = List.empty(growable: true); | ||||
|  | ||||
|   String? _currentSearchProbe; | ||||
|   List<String> _lastAutocompleteResult = List.empty(); | ||||
|   TextEditingController? _textEditingController; | ||||
|  | ||||
|   Future<List<String>?> _searchCategories(String probe) async { | ||||
|     _currentSearchProbe = probe; | ||||
|  | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|     final resp = await sn.client.get( | ||||
|       '/cgi/co/categories?take=10&probe=$_currentSearchProbe', | ||||
|     ); | ||||
|  | ||||
|     if (_currentSearchProbe != probe) { | ||||
|       return null; | ||||
|     } | ||||
|     _currentSearchProbe = null; | ||||
|  | ||||
|     return resp.data.map((x) => x['alias']).toList().cast<String>(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _debouncedSearch = _debounce<List<String>?, String>(_searchCategories); | ||||
|     if (widget.initialCategories != null) { | ||||
|       _currentCategories.addAll(widget.initialCategories!); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Autocomplete<String>( | ||||
|       optionsBuilder: (TextEditingValue textEditingValue) async { | ||||
|         final result = await _debouncedSearch(textEditingValue.text); | ||||
|         if (result == null) { | ||||
|           return _lastAutocompleteResult; | ||||
|         } | ||||
|         _lastAutocompleteResult = result; | ||||
|         return result; | ||||
|       }, | ||||
|       onSelected: (String value) { | ||||
|         if (value.isEmpty) return; | ||||
|         if (!_currentCategories.contains(value)) { | ||||
|           setState(() => _currentCategories.add(value)); | ||||
|         } | ||||
|         _textEditingController?.clear(); | ||||
|         widget.onUpdate(_currentCategories); | ||||
|       }, | ||||
|       fieldViewBuilder: (context, controller, focusNode, onSubmitted) { | ||||
|         _textEditingController = controller; | ||||
|         return TextField( | ||||
|           controller: controller, | ||||
|           focusNode: focusNode, | ||||
|           decoration: InputDecoration( | ||||
|             label: Text(widget.labelText), | ||||
|             border: const UnderlineInputBorder(), | ||||
|             prefixIconConstraints: BoxConstraints( | ||||
|               maxWidth: MediaQuery.of(context).size.width * 0.75, | ||||
|             ), | ||||
|             prefixIcon: _currentCategories.isNotEmpty | ||||
|                 ? SingleChildScrollView( | ||||
|                     scrollDirection: Axis.horizontal, | ||||
|                     child: Row( | ||||
|                       children: _currentCategories.map((String category) { | ||||
|                         return Container( | ||||
|                           decoration: BoxDecoration( | ||||
|                             borderRadius: const BorderRadius.all( | ||||
|                               Radius.circular(20.0), | ||||
|                             ), | ||||
|                             color: Theme.of(context).colorScheme.primary, | ||||
|                           ), | ||||
|                           margin: const EdgeInsets.only(right: 8), | ||||
|                           padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0), | ||||
|                           child: Row( | ||||
|                             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                             children: [ | ||||
|                               InkWell( | ||||
|                                 child: Text( | ||||
|                                   'postCategory${category.capitalize()}'.trExists() | ||||
|                                       ? 'postCategory${category.capitalize()}'.tr() | ||||
|                                       : '#$category', | ||||
|                                   style: const TextStyle(color: Colors.white), | ||||
|                                 ), | ||||
|                               ), | ||||
|                               const Gap(4), | ||||
|                               InkWell( | ||||
|                                 child: const Icon( | ||||
|                                   Icons.cancel, | ||||
|                                   size: 14.0, | ||||
|                                   color: Color.fromARGB(255, 233, 233, 233), | ||||
|                                 ), | ||||
|                                 onTap: () { | ||||
|                                   setState(() => _currentCategories.remove(category)); | ||||
|                                   widget.onUpdate(_currentCategories); | ||||
|                                 }, | ||||
|                               ) | ||||
|                             ], | ||||
|                           ), | ||||
|                         ); | ||||
|                       }).toList(), | ||||
|                     ), | ||||
|                   ) | ||||
|                 : null, | ||||
|           ), | ||||
|           onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|           onSubmitted: (_) { | ||||
|             onSubmitted(); | ||||
|           }, | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| typedef _Debounceable<S, T> = Future<S?> Function(T parameter); | ||||
|  | ||||
| _Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) { | ||||
|   | ||||
							
								
								
									
										34
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						| @@ -614,6 +614,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.1.1" | ||||
|   fl_chart: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: fl_chart | ||||
|       sha256: c724234b05e378383e958f3e82ca84a3e1e3c06a0898462044dd8a24b1ee9864 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.70.0" | ||||
|   flutter: | ||||
|     dependency: "direct main" | ||||
|     description: flutter | ||||
| @@ -643,6 +651,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.4.1" | ||||
|   flutter_colorpicker: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_colorpicker | ||||
|       sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.1.0" | ||||
|   flutter_context_menu: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -737,10 +753,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_udid | ||||
|       sha256: be464dc5b1fb7ee894f6a32d65c086ca5e177fdcf9375ac08d77495b98150f84 | ||||
|       sha256: "166bee5989a58c66b8b62000ea65edccc7c8167bbafdbb08022638db330dd030" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.0.1" | ||||
|     version: "4.0.0" | ||||
|   flutter_web_plugins: | ||||
|     dependency: "direct main" | ||||
|     description: flutter | ||||
| @@ -750,10 +766,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: flutter_webrtc | ||||
|       sha256: "430859fb5b763d7556d06ef287cfca582e17d9a2dc36da26017f25a5c0b2523e" | ||||
|       sha256: "0e138a0a3bf6830c29c8439b17be0e222d0de27fa72f24e6aee4d34de72f22ef" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.12.4" | ||||
|     version: "0.12.5" | ||||
|   freezed: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
| @@ -918,10 +934,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: image_picker_android | ||||
|       sha256: fa8141602fde3f7e2f81dbf043613eb44dfa325fa0bcf93c0f142c9f7a2c193e | ||||
|       sha256: aa6f1280b670861ac45220cc95adc59bb6ae130259d36f980ccb62220dc5e59f | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.8.12+18" | ||||
|     version: "0.8.12+19" | ||||
|   image_picker_for_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1070,10 +1086,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: livekit_client | ||||
|       sha256: "7802b5de1cae2ee3439db730d24d31c6dcbce173c5e6db2fc5774039a290bc2d" | ||||
|       sha256: a3ff529fe6745ee40cdedcd021d81c4a6ad946dd495e782596f2856eeeabc739 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.3.2" | ||||
|     version: "2.3.3" | ||||
|   logging: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2137,4 +2153,4 @@ packages: | ||||
|     version: "3.1.3" | ||||
| sdks: | ||||
|   dart: ">=3.6.0 <4.0.0" | ||||
|   flutter: ">=3.24.0" | ||||
|   flutter: ">=3.27.0" | ||||
|   | ||||
| @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev | ||||
| # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||
| # In Windows, build-name is used as the major, minor, and patch parts | ||||
| # of the product and file versions while build-number is used as the build suffix. | ||||
| version: 2.1.1+36 | ||||
| version: 2.1.1+39 | ||||
|  | ||||
| environment: | ||||
|   sdk: ^3.5.4 | ||||
| @@ -80,7 +80,7 @@ dependencies: | ||||
|   firebase_core: ^3.8.0 | ||||
|   firebase_messaging: ^15.1.5 | ||||
|   firebase_analytics: ^11.3.5 | ||||
|   flutter_udid: ^3.0.0 | ||||
|   flutter_udid: ^4.0.0 | ||||
|   media_kit: ^1.1.11 | ||||
|   media_kit_video: ^1.2.5 | ||||
|   media_kit_libs_video: ^1.0.5 | ||||
| @@ -111,6 +111,8 @@ dependencies: | ||||
|   flutter_app_update: ^3.2.2 | ||||
|   in_app_review: ^2.0.10 | ||||
|   version: ^3.0.2 | ||||
|   flutter_colorpicker: ^1.1.0 | ||||
|   fl_chart: ^0.70.0 | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|   | ||||
							
								
								
									
										221
									
								
								web/index.html
									
									
									
									
									
								
							
							
						
						| @@ -1,130 +1,133 @@ | ||||
| <!DOCTYPE html><html><head> | ||||
|   <!-- | ||||
|     If you are serving your web app in a path other than the root, change the | ||||
|     href value below to reflect the base path you are serving from. | ||||
| <!DOCTYPE html> | ||||
| <html lang="en" oncontextmenu="event.preventDefault();"> | ||||
| <head> | ||||
|     <!-- | ||||
|       If you are serving your web app in a path other than the root, change the | ||||
|       href value below to reflect the base path you are serving from. | ||||
|  | ||||
|     The path provided below has to start and end with a slash "/" in order for | ||||
|     it to work correctly. | ||||
|       The path provided below has to start and end with a slash "/" in order for | ||||
|       it to work correctly. | ||||
|  | ||||
|     For more details: | ||||
|     * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base | ||||
|       For more details: | ||||
|       * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base | ||||
|  | ||||
|     This is a placeholder for base href that will be replaced by the value of | ||||
|     the `--base-href` argument provided to `flutter build`. | ||||
|   --> | ||||
|   <base href="$FLUTTER_BASE_HREF"> | ||||
|       This is a placeholder for base href that will be replaced by the value of | ||||
|       the `--base-href` argument provided to `flutter build`. | ||||
|     --> | ||||
|     <base href="$FLUTTER_BASE_HREF"> | ||||
|  | ||||
|   <meta charset="UTF-8"> | ||||
|   <meta content="IE=Edge" http-equiv="X-UA-Compatible"> | ||||
|   <meta name="description" content="A new Flutter project."> | ||||
|     <meta charset="UTF-8"> | ||||
|     <meta content="IE=Edge" http-equiv="X-UA-Compatible"> | ||||
|     <meta name="description" content="A new Flutter project."> | ||||
|  | ||||
|   <!-- iOS meta tags & icons --> | ||||
|   <meta name="apple-mobile-web-app-capable" content="yes"> | ||||
|   <meta name="apple-mobile-web-app-status-bar-style" content="black"> | ||||
|   <meta name="apple-mobile-web-app-title" content="surface"> | ||||
|   <link rel="apple-touch-icon" href="icons/Icon-192.png"> | ||||
|     <!-- iOS meta tags & icons --> | ||||
|     <meta name="apple-mobile-web-app-capable" content="yes"> | ||||
|     <meta name="apple-mobile-web-app-status-bar-style" content="black"> | ||||
|     <meta name="apple-mobile-web-app-title" content="surface"> | ||||
|     <link rel="apple-touch-icon" href="icons/Icon-192.png"> | ||||
|  | ||||
|   <!-- Favicon --> | ||||
|   <link rel="icon" type="image/png" href="favicon.png"> | ||||
|     <!-- Favicon --> | ||||
|     <link rel="icon" type="image/png" href="favicon.png"> | ||||
|  | ||||
|   <title>Solian</title> | ||||
|   <link rel="manifest" href="manifest.json"> | ||||
|    | ||||
|    | ||||
|   <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport"> | ||||
|    | ||||
|    | ||||
|    | ||||
|    | ||||
|   <style id="splash-screen-style"> | ||||
|     html { | ||||
|       height: 100% | ||||
|     } | ||||
|     <title>Solian</title> | ||||
|     <link rel="manifest" href="manifest.json"> | ||||
|  | ||||
|     body { | ||||
|       margin: 0; | ||||
|       min-height: 100%; | ||||
|       background-color: #ffffff; | ||||
|           background-size: 100% 100%; | ||||
|     } | ||||
|  | ||||
|     .center { | ||||
|       margin: 0; | ||||
|       position: absolute; | ||||
|       top: 50%; | ||||
|       left: 50%; | ||||
|       -ms-transform: translate(-50%, -50%); | ||||
|       transform: translate(-50%, -50%); | ||||
|     } | ||||
|     <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" | ||||
|           name="viewport"> | ||||
|  | ||||
|     .contain { | ||||
|       display:block; | ||||
|       width:100%; height:100%; | ||||
|       object-fit: contain; | ||||
|     } | ||||
|  | ||||
|     .stretch { | ||||
|       display:block; | ||||
|       width:100%; height:100%; | ||||
|     } | ||||
|     <style id="splash-screen-style"> | ||||
|         html { | ||||
|           height: 100% | ||||
|         } | ||||
|  | ||||
|     .cover { | ||||
|       display:block; | ||||
|       width:100%; height:100%; | ||||
|       object-fit: cover; | ||||
|     } | ||||
|         body { | ||||
|           margin: 0; | ||||
|           min-height: 100%; | ||||
|           background-color: #ffffff; | ||||
|               background-size: 100% 100%; | ||||
|         } | ||||
|  | ||||
|     .bottom { | ||||
|       position: absolute; | ||||
|       bottom: 0; | ||||
|       left: 50%; | ||||
|       -ms-transform: translate(-50%, 0); | ||||
|       transform: translate(-50%, 0); | ||||
|     } | ||||
|         .center { | ||||
|           margin: 0; | ||||
|           position: absolute; | ||||
|           top: 50%; | ||||
|           left: 50%; | ||||
|           -ms-transform: translate(-50%, -50%); | ||||
|           transform: translate(-50%, -50%); | ||||
|         } | ||||
|  | ||||
|     .bottomLeft { | ||||
|       position: absolute; | ||||
|       bottom: 0; | ||||
|       left: 0; | ||||
|     } | ||||
|         .contain { | ||||
|           display:block; | ||||
|           width:100%; height:100%; | ||||
|           object-fit: contain; | ||||
|         } | ||||
|  | ||||
|     .bottomRight { | ||||
|       position: absolute; | ||||
|       bottom: 0; | ||||
|       right: 0; | ||||
|     } | ||||
|         .stretch { | ||||
|           display:block; | ||||
|           width:100%; height:100%; | ||||
|         } | ||||
|  | ||||
|     @media (prefers-color-scheme: dark) { | ||||
|       body { | ||||
|         background-color: #000000; | ||||
|           } | ||||
|     } | ||||
|   </style> | ||||
|   <script id="splash-screen-script"> | ||||
|     function removeSplashFromWeb() { | ||||
|       document.getElementById("splash")?.remove(); | ||||
|       document.getElementById("splash-branding")?.remove(); | ||||
|       document.body.style.background = "transparent"; | ||||
|     } | ||||
|   </script> | ||||
|         .cover { | ||||
|           display:block; | ||||
|           width:100%; height:100%; | ||||
|           object-fit: cover; | ||||
|         } | ||||
|  | ||||
|         .bottom { | ||||
|           position: absolute; | ||||
|           bottom: 0; | ||||
|           left: 50%; | ||||
|           -ms-transform: translate(-50%, 0); | ||||
|           transform: translate(-50%, 0); | ||||
|         } | ||||
|  | ||||
|         .bottomLeft { | ||||
|           position: absolute; | ||||
|           bottom: 0; | ||||
|           left: 0; | ||||
|         } | ||||
|  | ||||
|         .bottomRight { | ||||
|           position: absolute; | ||||
|           bottom: 0; | ||||
|           right: 0; | ||||
|         } | ||||
|  | ||||
|         @media (prefers-color-scheme: dark) { | ||||
|           body { | ||||
|             background-color: #000000; | ||||
|               } | ||||
|         } | ||||
|     </style> | ||||
|     <script id="splash-screen-script"> | ||||
|         function removeSplashFromWeb() { | ||||
|           document.getElementById("splash")?.remove(); | ||||
|           document.getElementById("splash-branding")?.remove(); | ||||
|           document.body.style.background = "transparent"; | ||||
|         } | ||||
|     </script> | ||||
| </head> | ||||
| <body> | ||||
|   <picture id="splash-branding"> | ||||
|     <source srcset="splash/img/branding-1x.png 1x, splash/img/branding-2x.png 2x, splash/img/branding-3x.png 3x, splash/img/branding-4x.png 4x" media="(prefers-color-scheme: light)"> | ||||
|     <source srcset="splash/img/branding-dark-1x.png 1x, splash/img/branding-dark-2x.png 2x, splash/img/branding-dark-3x.png 3x, splash/img/branding-dark-4x.png 4x" media="(prefers-color-scheme: dark)"> | ||||
| <picture id="splash-branding"> | ||||
|     <source srcset="splash/img/branding-1x.png 1x, splash/img/branding-2x.png 2x, splash/img/branding-3x.png 3x, splash/img/branding-4x.png 4x" | ||||
|             media="(prefers-color-scheme: light)"> | ||||
|     <source srcset="splash/img/branding-dark-1x.png 1x, splash/img/branding-dark-2x.png 2x, splash/img/branding-dark-3x.png 3x, splash/img/branding-dark-4x.png 4x" | ||||
|             media="(prefers-color-scheme: dark)"> | ||||
|     <img class="bottom" aria-hidden="true" src="splash/img/branding-1x.png" alt=""> | ||||
|   </picture> | ||||
|   <picture id="splash"> | ||||
|       <source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x" media="(prefers-color-scheme: light)"> | ||||
|       <source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)"> | ||||
|       <img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt=""> | ||||
|   </picture> | ||||
|    | ||||
|    | ||||
|    | ||||
|    | ||||
|    | ||||
|   <script src="flutter_bootstrap.js" async=""></script> | ||||
| </picture> | ||||
| <picture id="splash"> | ||||
|     <source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x" | ||||
|             media="(prefers-color-scheme: light)"> | ||||
|     <source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" | ||||
|             media="(prefers-color-scheme: dark)"> | ||||
|     <img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt=""> | ||||
| </picture> | ||||
|  | ||||
|  | ||||
| </body></html> | ||||
| <script src="flutter_bootstrap.js" async=""></script> | ||||
|  | ||||
|  | ||||
| </body> | ||||
| </html> | ||||