Compare commits
	
		
			52 Commits
		
	
	
		
			2.0.0+2
			...
			312d68286e
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 312d68286e | |||
| bedffbfad7 | |||
| 6a3cd0a60d | |||
| 356d3d4d3e | |||
| 41e2b08bcc | |||
| 731ab97209 | |||
| a59de65130 | |||
| 9b6544df46 | |||
| 7221af75eb | |||
| 66f41179ba | |||
| ed32a31819 | |||
| 33be7182d8 | |||
| 3cd08da3b6 | |||
| dfd80021b9 | |||
| d64a24454d | |||
| 0ed8c2373d | |||
| b8a1e5b5c0 | |||
| 5d6a52494e | |||
| 85a1dd3053 | |||
| 63499df99f | |||
| e70041fefa | |||
| 1af90cd9e7 | |||
| b52811d66e | |||
| 7e63611416 | |||
| d41e358c6a | |||
| 9fd30a1994 | |||
| 471d3deec5 | |||
| c7f059b6d7 | |||
| 6af695d74e | |||
| fd272ead37 | |||
| 6c5377d9fa | |||
| ce414d92a2 | |||
| 5032cccf38 | |||
| 9f7a3082cb | |||
| 359cd94532 | |||
| 432705c570 | |||
| 2065350698 | |||
| 285bb42b09 | |||
| e9fbd0c65f | |||
| 835203706d | |||
| 0e208cc320 | |||
| ee2cb0c989 | |||
| 37c61a0406 | |||
| fa73a28324 | |||
| d945b103ca | |||
| 8bc0da5188 | |||
| 2e68d227a0 | |||
| b8245b00b6 | |||
| 462e818078 | |||
| e4582b7d25 | |||
| 00eef6e45a | |||
| 9498d428cd | 
| @@ -9,6 +9,13 @@ | ||||
| # packages, and plugins designed to encourage good coding practices. | ||||
| include: package:flutter_lints/flutter.yaml | ||||
|  | ||||
| analyzer: | ||||
|   exclude: | ||||
|     - "**/*.g.dart" | ||||
|     - "**/*.freezed.dart" | ||||
|   errors: | ||||
|     invalid_annotation_target: ignore # Due to freezed + json_serializable issue, ref https://github.com/rrousselGit/freezed/issues/488#issuecomment-894358980 | ||||
|  | ||||
| linter: | ||||
|   # The lint rules applied to this project can be customized in the | ||||
|   # section below to disable rules from the `package:flutter_lints/flutter.yaml` | ||||
|   | ||||
| @@ -1,5 +1,9 @@ | ||||
| plugins { | ||||
|     id "com.android.application" | ||||
|     // START: FlutterFire Configuration | ||||
|     id 'com.google.gms.google-services' | ||||
|     id 'com.google.firebase.crashlytics' | ||||
|     // END: FlutterFire Configuration | ||||
|     id "kotlin-android" | ||||
|     // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. | ||||
|     id "dev.flutter.flutter-gradle-plugin" | ||||
| @@ -8,15 +12,15 @@ plugins { | ||||
| android { | ||||
|     namespace = "dev.solsynth.solian" | ||||
|     compileSdk = flutter.compileSdkVersion | ||||
|     ndkVersion = flutter.ndkVersion | ||||
|     ndkVersion = "27.0.12077973" | ||||
|  | ||||
|     compileOptions { | ||||
|         sourceCompatibility = JavaVersion.VERSION_1_8 | ||||
|         targetCompatibility = JavaVersion.VERSION_1_8 | ||||
|       sourceCompatibility JavaVersion.VERSION_17 | ||||
|       targetCompatibility JavaVersion.VERSION_17 | ||||
|     } | ||||
|  | ||||
|     kotlinOptions { | ||||
|         jvmTarget = JavaVersion.VERSION_1_8 | ||||
|         jvmTarget = JavaVersion.VERSION_17 | ||||
|     } | ||||
|  | ||||
|     defaultConfig { | ||||
|   | ||||
							
								
								
									
										29
									
								
								android/app/google-services.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								android/app/google-services.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| { | ||||
|   "project_info": { | ||||
|     "project_number": "961776991058", | ||||
|     "project_id": "solian-0x001", | ||||
|     "storage_bucket": "solian-0x001.firebasestorage.app" | ||||
|   }, | ||||
|   "client": [ | ||||
|     { | ||||
|       "client_info": { | ||||
|         "mobilesdk_app_id": "1:961776991058:android:a8d3f7995b0b8e86f4188b", | ||||
|         "android_client_info": { | ||||
|           "package_name": "dev.solsynth.solian" | ||||
|         } | ||||
|       }, | ||||
|       "oauth_client": [], | ||||
|       "api_key": [ | ||||
|         { | ||||
|           "current_key": "AIzaSyDvFNudXYs29uDtcCv6pFR8h5tXBs90FYk" | ||||
|         } | ||||
|       ], | ||||
|       "services": { | ||||
|         "appinvite_service": { | ||||
|           "other_platform_oauth_client": [] | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   ], | ||||
|   "configuration_version": "1" | ||||
| } | ||||
| @@ -1,6 +1,17 @@ | ||||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
|     <uses-feature android:name="android.hardware.camera" /> | ||||
|     <uses-feature android:name="android.hardware.camera.autofocus" /> | ||||
|     <uses-permission android:name="android.permission.INTERNET" /> | ||||
|     <uses-permission android:name="android.permission.CAMERA" /> | ||||
|     <uses-permission android:name="android.permission.RECORD_AUDIO" /> | ||||
|     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> | ||||
|     <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> | ||||
|     <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> | ||||
|     <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> | ||||
|     <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> | ||||
|  | ||||
|     <application | ||||
|         android:label="surface" | ||||
|         android:label="Solian" | ||||
|         android:name="${applicationName}" | ||||
|         android:icon="@mipmap/ic_launcher"> | ||||
|         <activity | ||||
| @@ -17,12 +28,12 @@ | ||||
|                  while the Flutter UI initializes. After that, this theme continues | ||||
|                  to determine the Window background behind the Flutter UI. --> | ||||
|             <meta-data | ||||
|               android:name="io.flutter.embedding.android.NormalTheme" | ||||
|               android:resource="@style/NormalTheme" | ||||
|               /> | ||||
|                 android:name="io.flutter.embedding.android.NormalTheme" | ||||
|                 android:resource="@style/NormalTheme" | ||||
|             /> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.MAIN"/> | ||||
|                 <category android:name="android.intent.category.LAUNCHER"/> | ||||
|                 <action android:name="android.intent.action.MAIN" /> | ||||
|                 <category android:name="android.intent.category.LAUNCHER" /> | ||||
|             </intent-filter> | ||||
|         </activity> | ||||
|         <!-- Don't delete the meta-data below. | ||||
| @@ -38,8 +49,8 @@ | ||||
|          In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> | ||||
|     <queries> | ||||
|         <intent> | ||||
|             <action android:name="android.intent.action.PROCESS_TEXT"/> | ||||
|             <data android:mimeType="text/plain"/> | ||||
|             <action android:name="android.intent.action.PROCESS_TEXT" /> | ||||
|             <data android:mimeType="text/plain" /> | ||||
|         </intent> | ||||
|     </queries> | ||||
| </manifest> | ||||
| </manifest> | ||||
| @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME | ||||
| distributionPath=wrapper/dists | ||||
| zipStoreBase=GRADLE_USER_HOME | ||||
| zipStorePath=wrapper/dists | ||||
| distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip | ||||
| distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip | ||||
|   | ||||
| @@ -18,7 +18,11 @@ pluginManagement { | ||||
|  | ||||
| plugins { | ||||
|     id "dev.flutter.flutter-plugin-loader" version "1.0.0" | ||||
|     id "com.android.application" version "8.1.0" apply false | ||||
|     id "com.android.application" version '8.7.2' apply false | ||||
|     // START: FlutterFire Configuration | ||||
|     id "com.google.gms.google-services" version "4.3.15" apply false | ||||
|     id "com.google.firebase.crashlytics" version "2.8.1" apply false | ||||
|     // END: FlutterFire Configuration | ||||
|     id "org.jetbrains.kotlin.android" version "1.8.22" apply false | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -17,6 +17,13 @@ | ||||
|   "screenSettings": "Settings", | ||||
|   "screenAlbum": "Album", | ||||
|   "screenChat": "Chat", | ||||
|   "screenChatManage": "Edit Channel", | ||||
|   "screenChatNew": "New Channel", | ||||
|   "screenRealm": "Realm", | ||||
|   "screenRealmManage": "Edit Realm", | ||||
|   "screenRealmNew": "New Realm", | ||||
|   "screenNotification": "Notification", | ||||
|   "screenPostSearch": "Search Posts", | ||||
|   "dialogOkay": "Okay", | ||||
|   "dialogCancel": "Cancel", | ||||
|   "dialogConfirm": "Confirm", | ||||
| @@ -32,6 +39,7 @@ | ||||
|   "next": "Next", | ||||
|   "edit": "Edit", | ||||
|   "apply": "Apply", | ||||
|   "cancel": "Cancel", | ||||
|   "create": "Create", | ||||
|   "preview": "Preview", | ||||
|   "loading": "Loading...", | ||||
| @@ -41,11 +49,19 @@ | ||||
|   "compress": "Compress", | ||||
|   "report": "Report", | ||||
|   "repost": "Repost", | ||||
|   "replyPost": "Reply", | ||||
|   "reply": "Reply", | ||||
|   "unset": "Unset", | ||||
|   "untitled": "Untitled", | ||||
|   "postDetail": "Post detail", | ||||
|   "postNoun": "Post", | ||||
|   "postReadMore": "Read more", | ||||
|   "postReadEstimate": "Est read time {}", | ||||
|   "postTotalLength": { | ||||
|     "zero": "No character", | ||||
|     "one": "{} character", | ||||
|     "other": "{} characters" | ||||
|   }, | ||||
|   "fieldUsername": "Username", | ||||
|   "fieldNickname": "Nickname", | ||||
|   "fieldEmail": "Email address", | ||||
| @@ -96,10 +112,20 @@ | ||||
|   "postRepostingNotice": "You're about to repost a post that posted {}.", | ||||
|   "postReact": "React", | ||||
|   "postReactions": "Reactions of Post", | ||||
|   "postReactionPoints": { | ||||
|     "zero": "{} pt", | ||||
|     "one": "{} pt", | ||||
|     "other": "{} pts" | ||||
|   "postReactionUpvote": { | ||||
|     "zero": "0 upvote", | ||||
|     "one": "{} upvote", | ||||
|     "other": "{} upvotes" | ||||
|   }, | ||||
|   "postReactionDownvote": { | ||||
|     "zero": "0 downvote", | ||||
|     "one": "{} downvote", | ||||
|     "other": "{} downvotes" | ||||
|   }, | ||||
|   "postReactionSocialPoint": { | ||||
|     "zero": "0 point", | ||||
|     "one": "{} point", | ||||
|     "other": "{} points" | ||||
|   }, | ||||
|   "postReactCompleted": "Reaction has been added.", | ||||
|   "postReactUncompleted": "Reaction has been removed.", | ||||
| @@ -131,5 +157,85 @@ | ||||
|   "sensitiveContent": "Sensitive Content", | ||||
|   "sensitiveContentCollapsed": "Sensitive content has been collapsed.", | ||||
|   "sensitiveContentDescription": "This content has been marked as sensitive, and may not be suitable for all viewers.", | ||||
|   "sensitiveContentReveal": "Reveal" | ||||
|   "sensitiveContentReveal": "Reveal", | ||||
|   "serverConnecting": "Connecting to server...", | ||||
|   "serverDisconnected": "Lost connection from server", | ||||
|   "fieldChatAlias": "Channel Alias", | ||||
|   "fieldChatAliasHint": "The unique channel alias within the site, used to represent the channel in URL, leave blank to auto generate. Should be URL-Safe.", | ||||
|   "fieldChatName": "Name", | ||||
|   "fieldChatDescription": "Description", | ||||
|   "fieldChatBelongToRealm": "Belongs to", | ||||
|   "fieldChatBelongToRealmUnset": "Unset Channel Belongs to Realm", | ||||
|   "channelEditingNotice": "You are editing channel {}", | ||||
|   "channelDeleted": "Chat channel {} has been deleted." , | ||||
|   "channelDelete": "Delete channel {}", | ||||
|   "channelDeleteDescription": "Are you sure you want to delete this channel? This operation is irreversible, all messages in this channel will be permanently deleted.", | ||||
|   "fieldRealmAlias": "Realm Alias", | ||||
|   "fieldRealmAliasHint": "The unique realm alias within the site, used to represent the realm in URL, leave blank to auto generate. Should be URL-Safe.", | ||||
|   "fieldRealmName": "Name", | ||||
|   "fieldRealmDescription": "Description", | ||||
|   "realmEditingNotice": "You are editing realm {}", | ||||
|   "realmDeleted": "Realm {} has been deleted.", | ||||
|   "realmDelete": "Delete realm {}", | ||||
|   "realmDeleteDescription": "Are you sure you want to delete this realm? This operation is irreversible, all resources (posts, chat channels, publishers, etc) belonging to this realm will be permanently deleted. Be careful and think twice!", | ||||
|   "fieldChatMessage": "Message in {}", | ||||
|   "eventResourceTag": "Event {}", | ||||
|   "messageDelete": "Delete message {}", | ||||
|   "messageDeleteDescription": "Are you sure you want to delete this message? This operation is irreversible. You will leave a record of the deleted message.", | ||||
|   "messageDeleted": "Message {} has been deleted", | ||||
|   "messageEdited": "Message {} has been edited", | ||||
|   "messageEditedHint": "Edited", | ||||
|   "messageUnsupported": "Unsupported message {}", | ||||
|   "messageFileHint": { | ||||
|     "zero": "No attachments", | ||||
|     "one": "{} attachment", | ||||
|     "other": "{} attachments" | ||||
|   }, | ||||
|   "addAttachmentFromAlbum": "Add from album", | ||||
|   "addAttachmentFromClipboard": "Paste file", | ||||
|   "attachmentPastedImage": "Pasted Image", | ||||
|   "notificationUnread": "未读", | ||||
|   "notificationRead": "已读", | ||||
|   "notificationMarkAllRead": "Mark all notifications as read", | ||||
|   "notificationMarkAllReadDescription": "Are you sure you want to mark all notifications as read? This operation is irreversible.", | ||||
|   "notificationMarkAllReadPrompt": { | ||||
|     "zero": "Marked 0 notification as read.", | ||||
|     "one": "Marked {} notification as read.", | ||||
|     "other": "Marked {} notifications as read." | ||||
|   }, | ||||
|   "notificationMarkOneReadPrompt": "Marked notification {} as read.", | ||||
|   "postSearchResult": { | ||||
|     "zero": "No results", | ||||
|     "one": "{} result", | ||||
|     "other": "{} results" | ||||
|   }, | ||||
|   "postSearchTook": "Took {}", | ||||
|   "call" : "Call", | ||||
|   "callOngoingNotice": "A call is ongoing", | ||||
|   "callJoin": "Join", | ||||
|   "callResume": "Resume", | ||||
|   "callMicrophone": "Microphone", | ||||
|   "callCamera": "Camera", | ||||
|   "callMicrophoneDisabled": "Microphone is disabled", | ||||
|   "callMicrophoneSelect": "Select a microphone", | ||||
|   "callCameraDisabled": "Camera is disabled", | ||||
|   "callCameraSelect": "Select a camera", | ||||
|   "callDisconnected": "Call has been disconnected", | ||||
|   "callEnded": "Call has been ended", | ||||
|   "callStatusConnected": "Connected", | ||||
|   "callStatusDisconnected": "Disconnected", | ||||
|   "callStatusConnecting": "Connecting", | ||||
|   "callStatusReconnecting": "Reconnecting", | ||||
|   "callDisconnect": "Disconnect", | ||||
|   "callDisconnectDescription": "Are you sure you want to disconnect from the call?", | ||||
|   "callMicrophoneOff": "Turn off microphone", | ||||
|   "callMicrophoneOn": "Turn on microphone", | ||||
|   "callCameraOff": "Turn off camera", | ||||
|   "callCameraOn": "Turn on camera", | ||||
|   "callVideoFlip": "Mirror video", | ||||
|   "callSpeakerphoneToggle": "Toggle speakerphone", | ||||
|   "callScreenOff": "Turn off screen share", | ||||
|   "callScreenOn": "Turn on screen share", | ||||
|   "callMessageEnded": "Call lasted {}", | ||||
|   "callMessageStarted": "Call started" | ||||
| } | ||||
| @@ -17,6 +17,13 @@ | ||||
|   "screenSettings": "设置", | ||||
|   "screenAlbum": "相册", | ||||
|   "screenChat": "聊天", | ||||
|   "screenChatManage": "编辑聊天频道", | ||||
|   "screenChatNew": "新建聊天频道", | ||||
|   "screenRealm": "领域", | ||||
|   "screenRealmManage": "编辑领域", | ||||
|   "screenRealmNew": "新建领域", | ||||
|   "screenNotification": "通知", | ||||
|   "screenPostSearch": "搜索帖子", | ||||
|   "dialogOkay": "好的", | ||||
|   "dialogCancel": "取消", | ||||
|   "dialogConfirm": "确认", | ||||
| @@ -33,6 +40,7 @@ | ||||
|   "next": "下一步", | ||||
|   "edit": "编辑", | ||||
|   "apply": "应用", | ||||
|   "cancel": "取消", | ||||
|   "create": "创建", | ||||
|   "preview": "预览", | ||||
|   "delete": "删除", | ||||
| @@ -41,11 +49,19 @@ | ||||
|   "compress": "压缩", | ||||
|   "report": "检举", | ||||
|   "repost": "转帖", | ||||
|   "reply": "回贴", | ||||
|   "replyPost": "回贴", | ||||
|   "reply": "回复", | ||||
|   "unset": "未设置", | ||||
|   "untitled": "无题", | ||||
|   "postDetail": "帖子详情", | ||||
|   "postNoun": "帖子", | ||||
|   "postReadMore": "阅读更多", | ||||
|   "postReadEstimate": "预计花费 {} 阅读", | ||||
|   "postTotalLength": { | ||||
|     "zero": "没有内容", | ||||
|     "one": "总计 {} 字", | ||||
|     "other": "总计 {} 字" | ||||
|   }, | ||||
|   "fieldUsername": "用户名", | ||||
|   "fieldNickname": "显示名", | ||||
|   "fieldEmail": "电子邮箱地址", | ||||
| @@ -96,10 +112,20 @@ | ||||
|   "postReact": "反应", | ||||
|   "postPosted": "帖子已经发表。", | ||||
|   "postReactions": "帖子的反应", | ||||
|   "postReactionPoints": { | ||||
|     "zero": "{} 点", | ||||
|     "one": "{} 点", | ||||
|     "other": "{} 点" | ||||
|   "postReactionUpvote": { | ||||
|     "zero": "0 个顶", | ||||
|     "one": "{} 个顶", | ||||
|     "other": "{} 个顶" | ||||
|   }, | ||||
|   "postReactionDownvote": { | ||||
|     "zero": "0 个踩", | ||||
|     "one": "{} 个踩", | ||||
|     "other": "{} 个踩" | ||||
|   }, | ||||
|   "postReactionSocialPoint": { | ||||
|     "zero": "无社会信用点变更", | ||||
|     "one": "{} 点社会信用点变更", | ||||
|     "other": "{} 点社会信用点变更" | ||||
|   }, | ||||
|   "postReactCompleted": "反应已被添加。", | ||||
|   "postReactUncompleted": "反应已被移除。", | ||||
| @@ -131,5 +157,85 @@ | ||||
|   "sensitiveContent": "敏感内容", | ||||
|   "sensitiveContentCollapsed": "敏感内容已折叠。", | ||||
|   "sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。", | ||||
|   "sensitiveContentReveal": "显示内容" | ||||
|   "sensitiveContentReveal": "显示内容", | ||||
|   "serverConnecting": "正在连接服务器…", | ||||
|   "serverDisconnected": "已与服务器断开连接", | ||||
|   "fieldChatAlias": "频道别名", | ||||
|   "fieldChatAliasHint": "全站范围内唯一的频道别名,用于在 URL 中表示该频道,留空则自动生成。应遵循 URL-Safe 的原则。", | ||||
|   "fieldChatName": "名称", | ||||
|   "fieldChatDescription": "描述", | ||||
|   "fieldChatBelongToRealm": "所属领域", | ||||
|   "fieldChatBelongToRealmUnset": "未设置频道所属领域", | ||||
|   "channelEditingNotice": "您正在编辑频道 {}", | ||||
|   "channelDeleted": "聊天频道 {} 已被删除" , | ||||
|   "channelDelete": "删除聊天频道 {}", | ||||
|   "channelDeleteDescription": "你确定要删除这个聊天频道吗?该操作不可撤销,其频道内的所有消息将被永久删除。", | ||||
|   "fieldRealmAlias": "领域别名", | ||||
|   "fieldRealmAliasHint": "全站范围内唯一的领域别名,用于在 URL 中表示该领域,留空则自动生成。应遵循 URL-Safe 的原则。", | ||||
|   "fieldRealmName": "名称", | ||||
|   "fieldRealmDescription": "描述", | ||||
|   "realmEditingNotice": "您正在编辑领域 {}", | ||||
|   "realmDeleted": "领域 {} 已被删除" , | ||||
|   "realmDelete": "删除领域 {}", | ||||
|   "realmDeleteDescription": "你确定要删除这个领域吗?该操作不可撤销,其隶属于该领域的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!", | ||||
|   "fieldChatMessage": "在 {} 中发消息", | ||||
|   "eventResourceTag": "消息 {}", | ||||
|   "messageDelete": "删除消息 {}", | ||||
|   "messageDeleteDescription": "你确定要删除这个消息吗?该操作不可撤销。同时您将留下一条删除消息的记录。", | ||||
|   "messageDeleted": "消息 {} 已被删除", | ||||
|   "messageEdited": "消息 {} 已被编辑", | ||||
|   "messageEditedHint": "已编辑", | ||||
|   "messageUnsupported": "不支持的消息 {}", | ||||
|   "messageFileHint": { | ||||
|     "zero": "没有附件", | ||||
|     "one": "{} 个附件", | ||||
|     "other": "{} 个附件" | ||||
|   }, | ||||
|   "addAttachmentFromAlbum": "从相册中添加附件", | ||||
|   "addAttachmentFromClipboard": "粘贴附件", | ||||
|   "attachmentPastedImage" : "粘贴的图片", | ||||
|   "notificationUnread": "未读", | ||||
|   "notificationRead": "已读", | ||||
|   "notificationMarkAllRead": "已读所有通知", | ||||
|   "notificationMarkAllReadDescription": "您确定要将所有通知设置为已读吗?该操作不可撤销。", | ||||
|   "notificationMarkAllReadPrompt": { | ||||
|     "zero": "已将 0 个通知标记为已读。", | ||||
|     "one": "已将 {} 个通知标记为已读。", | ||||
|     "other": "已将 {} 个通知标记为已读。" | ||||
|   }, | ||||
|   "notificationMarkOneReadPrompt": "已将通知 {} 标记为已读。", | ||||
|   "postSearchResult": { | ||||
|     "zero": "没有搜索到结果", | ||||
|     "one": "搜索到 {} 个结果", | ||||
|     "other": "搜索到 {} 个结果" | ||||
|   }, | ||||
|   "postSearchTook": "耗时 {}", | ||||
|   "call": "通话", | ||||
|   "callOngoingNotice": "一则通话进行中", | ||||
|   "callJoin": "加入", | ||||
|   "callResume": "恢复", | ||||
|   "callMicrophone": "麦克风", | ||||
|   "callCamera": "摄像头", | ||||
|   "callMicrophoneDisabled": "麦克风已禁用", | ||||
|   "callMicrophoneSelect": "选择麦克风", | ||||
|   "callCameraDisabled": "摄像头已禁用", | ||||
|   "callCameraSelect": "选择摄像头", | ||||
|   "callDisconnected": "通话已断开", | ||||
|   "callEnded": "通话已结束", | ||||
|   "callStatusConnected": "已连接", | ||||
|   "callStatusDisconnected": "未连接", | ||||
|   "callStatusConnecting": "正在连接", | ||||
|   "callStatusReconnecting": "正在重连", | ||||
|   "callDisconnect": "断开连接", | ||||
|   "callDisconnectDescription": "您确定要与通话断开连接吗?", | ||||
|   "callMicrophoneOff": "关闭麦克风", | ||||
|   "callMicrophoneOn": "打开麦克风", | ||||
|   "callCameraOff": "关闭摄像头", | ||||
|   "callCameraOn": "打开摄像头", | ||||
|   "callVideoFlip": "镜像画面", | ||||
|   "callSpeakerphoneToggle": "切换扬声器", | ||||
|   "callScreenOff": "关闭屏幕共享", | ||||
|   "callScreenOn": "开启屏幕共享", | ||||
|   "callMessageEnded": "通话持续了 {}", | ||||
|   "callMessageStarted": "通话开始了" | ||||
| } | ||||
							
								
								
									
										1
									
								
								firebase.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								firebase.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| {"flutter":{"platforms":{"android":{"default":{"projectId":"solian-0x001","appId":"1:961776991058:android:a8d3f7995b0b8e86f4188b","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"solian-0x001","appId":"1:961776991058:ios:727229d368cc47e1f4188b","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"macos":{"default":{"projectId":"solian-0x001","appId":"1:961776991058:ios:727229d368cc47e1f4188b","uploadDebugSymbols":false,"fileOutput":"macos/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"solian-0x001","configurations":{"android":"1:961776991058:android:a8d3f7995b0b8e86f4188b","ios":"1:961776991058:ios:727229d368cc47e1f4188b","macos":"1:961776991058:ios:727229d368cc47e1f4188b","web":"1:961776991058:web:b91d12f2892a5609f4188b","windows":"1:961776991058:web:f152fd119699e13ef4188b"}}}}}} | ||||
| @@ -1,5 +1,5 @@ | ||||
| # Uncomment this line to define a global platform for your project | ||||
| # platform :ios, '12.0' | ||||
| platform :ios, '13.0' | ||||
|  | ||||
| # CocoaPods analytics sends network stats synchronously affecting flutter build latency. | ||||
| ENV['COCOAPODS_DISABLE_STATS'] = 'true' | ||||
| @@ -40,5 +40,9 @@ end | ||||
| post_install do |installer| | ||||
|   installer.pods_project.targets.each do |target| | ||||
|     flutter_additional_ios_build_settings(target) | ||||
|     target.build_configurations.each do |config| | ||||
|       # Workaround for https://github.com/flutter/flutter/issues/64502 | ||||
|       config.build_settings['ONLY_ACTIVE_ARCH'] = 'YES' | ||||
|      end | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										257
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							
							
						
						
									
										257
									
								
								ios/Podfile.lock
									
									
									
									
									
								
							| @@ -6,6 +6,8 @@ PODS: | ||||
|     - Flutter | ||||
|   - cupertino_http (0.0.1): | ||||
|     - Flutter | ||||
|   - device_info_plus (0.0.1): | ||||
|     - Flutter | ||||
|   - DKImagePickerController/Core (4.3.9): | ||||
|     - DKImagePickerController/ImageDataManager | ||||
|     - DKImagePickerController/Resource | ||||
| @@ -40,19 +42,161 @@ PODS: | ||||
|   - file_picker (0.0.1): | ||||
|     - DKImagePickerController/PhotoGallery | ||||
|     - Flutter | ||||
|   - Firebase/Analytics (11.4.0): | ||||
|     - Firebase/Core | ||||
|   - Firebase/Core (11.4.0): | ||||
|     - Firebase/CoreOnly | ||||
|     - FirebaseAnalytics (~> 11.4.0) | ||||
|   - Firebase/CoreOnly (11.4.0): | ||||
|     - FirebaseCore (= 11.4.0) | ||||
|   - Firebase/Messaging (11.4.0): | ||||
|     - Firebase/CoreOnly | ||||
|     - FirebaseMessaging (~> 11.4.0) | ||||
|   - firebase_analytics (11.3.5): | ||||
|     - Firebase/Analytics (= 11.4.0) | ||||
|     - firebase_core | ||||
|     - Flutter | ||||
|   - firebase_core (3.8.0): | ||||
|     - Firebase/CoreOnly (= 11.4.0) | ||||
|     - Flutter | ||||
|   - firebase_messaging (15.1.5): | ||||
|     - Firebase/Messaging (= 11.4.0) | ||||
|     - firebase_core | ||||
|     - Flutter | ||||
|   - FirebaseAnalytics (11.4.0): | ||||
|     - FirebaseAnalytics/AdIdSupport (= 11.4.0) | ||||
|     - FirebaseCore (~> 11.0) | ||||
|     - FirebaseInstallations (~> 11.0) | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) | ||||
|     - GoogleUtilities/MethodSwizzler (~> 8.0) | ||||
|     - GoogleUtilities/Network (~> 8.0) | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.0)" | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - FirebaseAnalytics/AdIdSupport (11.4.0): | ||||
|     - FirebaseCore (~> 11.0) | ||||
|     - FirebaseInstallations (~> 11.0) | ||||
|     - GoogleAppMeasurement (= 11.4.0) | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) | ||||
|     - GoogleUtilities/MethodSwizzler (~> 8.0) | ||||
|     - GoogleUtilities/Network (~> 8.0) | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.0)" | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - FirebaseCore (11.4.0): | ||||
|     - FirebaseCoreInternal (~> 11.0) | ||||
|     - GoogleUtilities/Environment (~> 8.0) | ||||
|     - GoogleUtilities/Logger (~> 8.0) | ||||
|   - FirebaseCoreInternal (11.5.0): | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.0)" | ||||
|   - FirebaseInstallations (11.4.0): | ||||
|     - FirebaseCore (~> 11.0) | ||||
|     - GoogleUtilities/Environment (~> 8.0) | ||||
|     - GoogleUtilities/UserDefaults (~> 8.0) | ||||
|     - PromisesObjC (~> 2.4) | ||||
|   - FirebaseMessaging (11.4.0): | ||||
|     - FirebaseCore (~> 11.0) | ||||
|     - FirebaseInstallations (~> 11.0) | ||||
|     - GoogleDataTransport (~> 10.0) | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) | ||||
|     - GoogleUtilities/Environment (~> 8.0) | ||||
|     - GoogleUtilities/Reachability (~> 8.0) | ||||
|     - GoogleUtilities/UserDefaults (~> 8.0) | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - Flutter (1.0.0) | ||||
|   - flutter_native_splash (0.0.1): | ||||
|   - flutter_native_splash (2.4.3): | ||||
|     - Flutter | ||||
|   - flutter_secure_storage (3.3.1): | ||||
|   - flutter_udid (0.0.1): | ||||
|     - Flutter | ||||
|     - SAMKeychain | ||||
|   - flutter_webrtc (0.11.8): | ||||
|     - Flutter | ||||
|     - WebRTC-SDK (= 125.6422.05) | ||||
|   - GoogleAppMeasurement (11.4.0): | ||||
|     - GoogleAppMeasurement/AdIdSupport (= 11.4.0) | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) | ||||
|     - GoogleUtilities/MethodSwizzler (~> 8.0) | ||||
|     - GoogleUtilities/Network (~> 8.0) | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.0)" | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - GoogleAppMeasurement/AdIdSupport (11.4.0): | ||||
|     - GoogleAppMeasurement/WithoutAdIdSupport (= 11.4.0) | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) | ||||
|     - GoogleUtilities/MethodSwizzler (~> 8.0) | ||||
|     - GoogleUtilities/Network (~> 8.0) | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.0)" | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - GoogleAppMeasurement/WithoutAdIdSupport (11.4.0): | ||||
|     - GoogleUtilities/AppDelegateSwizzler (~> 8.0) | ||||
|     - GoogleUtilities/MethodSwizzler (~> 8.0) | ||||
|     - GoogleUtilities/Network (~> 8.0) | ||||
|     - "GoogleUtilities/NSData+zlib (~> 8.0)" | ||||
|     - nanopb (~> 3.30910.0) | ||||
|   - GoogleDataTransport (10.1.0): | ||||
|     - nanopb (~> 3.30910.0) | ||||
|     - PromisesObjC (~> 2.4) | ||||
|   - GoogleUtilities/AppDelegateSwizzler (8.0.2): | ||||
|     - GoogleUtilities/Environment | ||||
|     - GoogleUtilities/Logger | ||||
|     - GoogleUtilities/Network | ||||
|     - GoogleUtilities/Privacy | ||||
|   - GoogleUtilities/Environment (8.0.2): | ||||
|     - GoogleUtilities/Privacy | ||||
|   - GoogleUtilities/Logger (8.0.2): | ||||
|     - GoogleUtilities/Environment | ||||
|     - GoogleUtilities/Privacy | ||||
|   - GoogleUtilities/MethodSwizzler (8.0.2): | ||||
|     - GoogleUtilities/Logger | ||||
|     - GoogleUtilities/Privacy | ||||
|   - GoogleUtilities/Network (8.0.2): | ||||
|     - GoogleUtilities/Logger | ||||
|     - "GoogleUtilities/NSData+zlib" | ||||
|     - GoogleUtilities/Privacy | ||||
|     - GoogleUtilities/Reachability | ||||
|   - "GoogleUtilities/NSData+zlib (8.0.2)": | ||||
|     - GoogleUtilities/Privacy | ||||
|   - GoogleUtilities/Privacy (8.0.2) | ||||
|   - GoogleUtilities/Reachability (8.0.2): | ||||
|     - GoogleUtilities/Logger | ||||
|     - GoogleUtilities/Privacy | ||||
|   - GoogleUtilities/UserDefaults (8.0.2): | ||||
|     - GoogleUtilities/Logger | ||||
|     - GoogleUtilities/Privacy | ||||
|   - image_picker_ios (0.0.1): | ||||
|     - Flutter | ||||
|   - livekit_client (2.3.0): | ||||
|     - Flutter | ||||
|     - WebRTC-SDK (= 125.6422.05) | ||||
|   - media_kit_libs_ios_video (1.0.4): | ||||
|     - Flutter | ||||
|   - media_kit_native_event_loop (1.0.0): | ||||
|     - Flutter | ||||
|   - media_kit_video (0.0.1): | ||||
|     - Flutter | ||||
|   - nanopb (3.30910.0): | ||||
|     - nanopb/decode (= 3.30910.0) | ||||
|     - nanopb/encode (= 3.30910.0) | ||||
|   - nanopb/decode (3.30910.0) | ||||
|   - nanopb/encode (3.30910.0) | ||||
|   - package_info_plus (0.4.5): | ||||
|     - Flutter | ||||
|   - pasteboard (0.0.1): | ||||
|     - Flutter | ||||
|   - path_provider_foundation (0.0.1): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
|   - permission_handler_apple (9.3.0): | ||||
|     - Flutter | ||||
|   - PromisesObjC (2.4.0) | ||||
|   - SAMKeychain (1.5.3) | ||||
|   - screen_brightness_ios (0.1.0): | ||||
|     - Flutter | ||||
|   - SDWebImage (5.19.7): | ||||
|     - SDWebImage/Core (= 5.19.7) | ||||
|   - SDWebImage/Core (5.19.7) | ||||
|   - Sentry/HybridSDK (8.40.1) | ||||
|   - sentry_flutter (8.10.1): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
|     - Sentry/HybridSDK (= 8.40.1) | ||||
|   - shared_preferences_foundation (0.0.1): | ||||
|     - Flutter | ||||
|     - FlutterMacOS | ||||
| @@ -62,27 +206,62 @@ PODS: | ||||
|   - SwiftyGif (5.4.5) | ||||
|   - url_launcher_ios (0.0.1): | ||||
|     - Flutter | ||||
|   - volume_controller (0.0.1): | ||||
|     - Flutter | ||||
|   - wakelock_plus (0.0.1): | ||||
|     - Flutter | ||||
|   - WebRTC-SDK (125.6422.05) | ||||
|  | ||||
| DEPENDENCIES: | ||||
|   - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) | ||||
|   - croppy (from `.symlinks/plugins/croppy/ios`) | ||||
|   - cupertino_http (from `.symlinks/plugins/cupertino_http/ios`) | ||||
|   - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) | ||||
|   - file_picker (from `.symlinks/plugins/file_picker/ios`) | ||||
|   - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) | ||||
|   - firebase_core (from `.symlinks/plugins/firebase_core/ios`) | ||||
|   - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) | ||||
|   - Flutter (from `Flutter`) | ||||
|   - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) | ||||
|   - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) | ||||
|   - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) | ||||
|   - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) | ||||
|   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) | ||||
|   - livekit_client (from `.symlinks/plugins/livekit_client/ios`) | ||||
|   - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) | ||||
|   - media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`) | ||||
|   - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) | ||||
|   - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) | ||||
|   - pasteboard (from `.symlinks/plugins/pasteboard/ios`) | ||||
|   - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) | ||||
|   - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) | ||||
|   - screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`) | ||||
|   - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) | ||||
|   - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) | ||||
|   - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) | ||||
|   - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) | ||||
|   - volume_controller (from `.symlinks/plugins/volume_controller/ios`) | ||||
|   - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) | ||||
|  | ||||
| SPEC REPOS: | ||||
|   trunk: | ||||
|     - DKImagePickerController | ||||
|     - DKPhotoGallery | ||||
|     - Firebase | ||||
|     - FirebaseAnalytics | ||||
|     - FirebaseCore | ||||
|     - FirebaseCoreInternal | ||||
|     - FirebaseInstallations | ||||
|     - FirebaseMessaging | ||||
|     - GoogleAppMeasurement | ||||
|     - GoogleDataTransport | ||||
|     - GoogleUtilities | ||||
|     - nanopb | ||||
|     - PromisesObjC | ||||
|     - SAMKeychain | ||||
|     - SDWebImage | ||||
|     - Sentry | ||||
|     - SwiftyGif | ||||
|     - WebRTC-SDK | ||||
|  | ||||
| EXTERNAL SOURCES: | ||||
|   connectivity_plus: | ||||
| @@ -91,43 +270,105 @@ EXTERNAL SOURCES: | ||||
|     :path: ".symlinks/plugins/croppy/ios" | ||||
|   cupertino_http: | ||||
|     :path: ".symlinks/plugins/cupertino_http/ios" | ||||
|   device_info_plus: | ||||
|     :path: ".symlinks/plugins/device_info_plus/ios" | ||||
|   file_picker: | ||||
|     :path: ".symlinks/plugins/file_picker/ios" | ||||
|   firebase_analytics: | ||||
|     :path: ".symlinks/plugins/firebase_analytics/ios" | ||||
|   firebase_core: | ||||
|     :path: ".symlinks/plugins/firebase_core/ios" | ||||
|   firebase_messaging: | ||||
|     :path: ".symlinks/plugins/firebase_messaging/ios" | ||||
|   Flutter: | ||||
|     :path: Flutter | ||||
|   flutter_native_splash: | ||||
|     :path: ".symlinks/plugins/flutter_native_splash/ios" | ||||
|   flutter_secure_storage: | ||||
|     :path: ".symlinks/plugins/flutter_secure_storage/ios" | ||||
|   flutter_udid: | ||||
|     :path: ".symlinks/plugins/flutter_udid/ios" | ||||
|   flutter_webrtc: | ||||
|     :path: ".symlinks/plugins/flutter_webrtc/ios" | ||||
|   image_picker_ios: | ||||
|     :path: ".symlinks/plugins/image_picker_ios/ios" | ||||
|   livekit_client: | ||||
|     :path: ".symlinks/plugins/livekit_client/ios" | ||||
|   media_kit_libs_ios_video: | ||||
|     :path: ".symlinks/plugins/media_kit_libs_ios_video/ios" | ||||
|   media_kit_native_event_loop: | ||||
|     :path: ".symlinks/plugins/media_kit_native_event_loop/ios" | ||||
|   media_kit_video: | ||||
|     :path: ".symlinks/plugins/media_kit_video/ios" | ||||
|   package_info_plus: | ||||
|     :path: ".symlinks/plugins/package_info_plus/ios" | ||||
|   pasteboard: | ||||
|     :path: ".symlinks/plugins/pasteboard/ios" | ||||
|   path_provider_foundation: | ||||
|     :path: ".symlinks/plugins/path_provider_foundation/darwin" | ||||
|   permission_handler_apple: | ||||
|     :path: ".symlinks/plugins/permission_handler_apple/ios" | ||||
|   screen_brightness_ios: | ||||
|     :path: ".symlinks/plugins/screen_brightness_ios/ios" | ||||
|   sentry_flutter: | ||||
|     :path: ".symlinks/plugins/sentry_flutter/ios" | ||||
|   shared_preferences_foundation: | ||||
|     :path: ".symlinks/plugins/shared_preferences_foundation/darwin" | ||||
|   sqflite_darwin: | ||||
|     :path: ".symlinks/plugins/sqflite_darwin/darwin" | ||||
|   url_launcher_ios: | ||||
|     :path: ".symlinks/plugins/url_launcher_ios/ios" | ||||
|   volume_controller: | ||||
|     :path: ".symlinks/plugins/volume_controller/ios" | ||||
|   wakelock_plus: | ||||
|     :path: ".symlinks/plugins/wakelock_plus/ios" | ||||
|  | ||||
| SPEC CHECKSUMS: | ||||
|   connectivity_plus: 4c41c08fc6d7c91f63bc7aec70ffe3730b04f563 | ||||
|   croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321 | ||||
|   cupertino_http: 1a3a0f163c1b26e7f1a293b33d476e0fde7a64ec | ||||
|   device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 | ||||
|   DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c | ||||
|   DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 | ||||
|   file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 | ||||
|   Firebase: cf1b19f21410b029b6786a54e9764a0cacad3c99 | ||||
|   firebase_analytics: fa7e5b20c2b58042e3301f5112a473d365bd490c | ||||
|   firebase_core: 9efc3ecf689cdbc90f13f4dc58108c83ea46b266 | ||||
|   firebase_messaging: 6bf60adb4b33a848d135e16bc363fb4924f98fba | ||||
|   FirebaseAnalytics: 3feef9ae8733c567866342a1000691baaa7cad49 | ||||
|   FirebaseCore: e0510f1523bc0eb21653cac00792e1e2bd6f1771 | ||||
|   FirebaseCoreInternal: f47dd28ae7782e6a4738aad3106071a8fe0af604 | ||||
|   FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414 | ||||
|   FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2 | ||||
|   Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 | ||||
|   flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 | ||||
|   flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec | ||||
|   flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a | ||||
|   flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04 | ||||
|   flutter_webrtc: 4f730f3d58a28b0afdea039c8bf4a0f616a6b20c | ||||
|   GoogleAppMeasurement: 987769c4ca6b968f2479fbcc9fe3ce34af454b8e | ||||
|   GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 | ||||
|   GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d | ||||
|   image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 | ||||
|   livekit_client: 5c31e13cd17dd0d545a074290c937dbdff1d809d | ||||
|   media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 | ||||
|   media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a | ||||
|   media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e | ||||
|   nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 | ||||
|   package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 | ||||
|   pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0 | ||||
|   path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 | ||||
|   permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 | ||||
|   PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 | ||||
|   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c | ||||
|   screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 | ||||
|   SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3 | ||||
|   Sentry: e9215d7b17f7902692b4f8700e061e4f853e3521 | ||||
|   sentry_flutter: 927eed60d66951d1b0f1db37fe94ff5cb7c80231 | ||||
|   shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 | ||||
|   sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d | ||||
|   SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 | ||||
|   url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe | ||||
|   volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9 | ||||
|   wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1 | ||||
|   WebRTC-SDK: 1990a1a595bd0b59c17485ce13ff17f575732c12 | ||||
|  | ||||
| PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 | ||||
| PODFILE CHECKSUM: d2bdaa1cc7915e14cf47235c34a21fcb07b00390 | ||||
|  | ||||
| COCOAPODS: 1.15.2 | ||||
|   | ||||
| @@ -12,6 +12,7 @@ | ||||
| 		331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; | ||||
| 		3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; | ||||
| 		74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; | ||||
| 		8CD0929C27BC410DD5056EAB /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A2C24C5238FAC44EA2CCF738 /* GoogleService-Info.plist */; }; | ||||
| 		97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; | ||||
| 		97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; | ||||
| 		97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; | ||||
| @@ -53,6 +54,7 @@ | ||||
| 		4A2F84B6033057E3BD2C7CB8 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; | ||||
| 		64FBE78F9C282712818D6D95 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; }; | ||||
| 		72E9279EFA6DAC00BBAC493C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; | ||||
| 		73111C212CEE3D5E004CF4B3 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; }; | ||||
| 		74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; | ||||
| 		74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; | ||||
| 		7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; | ||||
| @@ -64,6 +66,7 @@ | ||||
| 		97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; | ||||
| 		97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; | ||||
| 		97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; | ||||
| 		A2C24C5238FAC44EA2CCF738 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; }; | ||||
| 		EDF483E994343CDFBF9BA347 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||
| /* End PBXFileReference section */ | ||||
|  | ||||
| @@ -124,6 +127,7 @@ | ||||
| 				331C8082294A63A400263BE5 /* RunnerTests */, | ||||
| 				F5165E3BD1F2519F85CD4BE2 /* Pods */, | ||||
| 				09229EB4EB35A0678AB9738D /* Frameworks */, | ||||
| 				A2C24C5238FAC44EA2CCF738 /* GoogleService-Info.plist */, | ||||
| 			); | ||||
| 			sourceTree = "<group>"; | ||||
| 		}; | ||||
| @@ -139,6 +143,7 @@ | ||||
| 		97C146F01CF9000F007C117D /* Runner */ = { | ||||
| 			isa = PBXGroup; | ||||
| 			children = ( | ||||
| 				73111C212CEE3D5E004CF4B3 /* Runner.entitlements */, | ||||
| 				97C146FA1CF9000F007C117D /* Main.storyboard */, | ||||
| 				97C146FD1CF9000F007C117D /* Assets.xcassets */, | ||||
| 				97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, | ||||
| @@ -198,6 +203,8 @@ | ||||
| 				9705A1C41CF9048500538489 /* Embed Frameworks */, | ||||
| 				3B06AD1E1E4923F5004D2608 /* Thin Binary */, | ||||
| 				FC4815D44D909666EB1FA614 /* [CP] Embed Pods Frameworks */, | ||||
| 				244E364B35B507EB14F7681C /* FlutterFire: "flutterfire upload-crashlytics-symbols" */, | ||||
| 				43B5CF57FD79BC21654EE037 /* [CP] Copy Pods Resources */, | ||||
| 			); | ||||
| 			buildRules = ( | ||||
| 			); | ||||
| @@ -263,12 +270,31 @@ | ||||
| 				3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, | ||||
| 				97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, | ||||
| 				97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, | ||||
| 				8CD0929C27BC410DD5056EAB /* GoogleService-Info.plist in Resources */, | ||||
| 			); | ||||
| 			runOnlyForDeploymentPostprocessing = 0; | ||||
| 		}; | ||||
| /* End PBXResourcesBuildPhase section */ | ||||
|  | ||||
| /* Begin PBXShellScriptBuildPhase section */ | ||||
| 		244E364B35B507EB14F7681C /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = { | ||||
| 			isa = PBXShellScriptBuildPhase; | ||||
| 			buildActionMask = 2147483647; | ||||
| 			files = ( | ||||
| 			); | ||||
| 			inputFileListPaths = ( | ||||
| 			); | ||||
| 			inputPaths = ( | ||||
| 			); | ||||
| 			name = "FlutterFire: \"flutterfire upload-crashlytics-symbols\""; | ||||
| 			outputFileListPaths = ( | ||||
| 			); | ||||
| 			outputPaths = ( | ||||
| 			); | ||||
| 			runOnlyForDeploymentPostprocessing = 0; | ||||
| 			shellPath = /bin/sh; | ||||
| 			shellScript = "\n#!/bin/bash\nPATH=${PATH}:$FLUTTER_ROOT/bin:$HOME/.pub-cache/bin\nflutterfire upload-crashlytics-symbols --upload-symbols-script-path=$PODS_ROOT/FirebaseCrashlytics/upload-symbols --platform=ios --apple-project-path=${SRCROOT} --env-platform-name=${PLATFORM_NAME} --env-configuration=${CONFIGURATION} --env-project-dir=${PROJECT_DIR} --env-built-products-dir=${BUILT_PRODUCTS_DIR} --env-dwarf-dsym-folder-path=${DWARF_DSYM_FOLDER_PATH} --env-dwarf-dsym-file-name=${DWARF_DSYM_FILE_NAME} --env-infoplist-path=${INFOPLIST_PATH} --default-config=default\n"; | ||||
| 		}; | ||||
| 		3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { | ||||
| 			isa = PBXShellScriptBuildPhase; | ||||
| 			alwaysOutOfDate = 1; | ||||
| @@ -285,6 +311,23 @@ | ||||
| 			shellPath = /bin/sh; | ||||
| 			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; | ||||
| 		}; | ||||
| 		43B5CF57FD79BC21654EE037 /* [CP] Copy Pods Resources */ = { | ||||
| 			isa = PBXShellScriptBuildPhase; | ||||
| 			buildActionMask = 2147483647; | ||||
| 			files = ( | ||||
| 			); | ||||
| 			inputFileListPaths = ( | ||||
| 				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", | ||||
| 			); | ||||
| 			name = "[CP] Copy Pods Resources"; | ||||
| 			outputFileListPaths = ( | ||||
| 				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", | ||||
| 			); | ||||
| 			runOnlyForDeploymentPostprocessing = 0; | ||||
| 			shellPath = /bin/sh; | ||||
| 			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; | ||||
| 			showEnvVarsInLog = 0; | ||||
| 		}; | ||||
| 		9740EEB61CF901F6004384FC /* Run Script */ = { | ||||
| 			isa = PBXShellScriptBuildPhase; | ||||
| 			alwaysOutOfDate = 1; | ||||
| @@ -469,11 +512,13 @@ | ||||
| 			buildSettings = { | ||||
| 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | ||||
| 				CLANG_ENABLE_MODULES = YES; | ||||
| 				CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; | ||||
| 				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; | ||||
| 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||
| 				ENABLE_BITCODE = NO; | ||||
| 				INFOPLIST_FILE = Runner/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
| @@ -653,11 +698,13 @@ | ||||
| 			buildSettings = { | ||||
| 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | ||||
| 				CLANG_ENABLE_MODULES = YES; | ||||
| 				CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; | ||||
| 				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; | ||||
| 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||
| 				ENABLE_BITCODE = NO; | ||||
| 				INFOPLIST_FILE = Runner/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
| @@ -677,11 +724,13 @@ | ||||
| 			buildSettings = { | ||||
| 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | ||||
| 				CLANG_ENABLE_MODULES = YES; | ||||
| 				CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; | ||||
| 				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; | ||||
| 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||
| 				ENABLE_BITCODE = NO; | ||||
| 				INFOPLIST_FILE = Runner/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
|   | ||||
							
								
								
									
										30
									
								
								ios/Runner/GoogleService-Info.plist
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								ios/Runner/GoogleService-Info.plist
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||
| <plist version="1.0"> | ||||
| <dict> | ||||
| 	<key>API_KEY</key> | ||||
| 	<string>AIzaSyCzQIyiYKoYHTpGXhN-IjgMML8z797WVD8</string> | ||||
| 	<key>GCM_SENDER_ID</key> | ||||
| 	<string>961776991058</string> | ||||
| 	<key>PLIST_VERSION</key> | ||||
| 	<string>1</string> | ||||
| 	<key>BUNDLE_ID</key> | ||||
| 	<string>dev.solsynth.solian</string> | ||||
| 	<key>PROJECT_ID</key> | ||||
| 	<string>solian-0x001</string> | ||||
| 	<key>STORAGE_BUCKET</key> | ||||
| 	<string>solian-0x001.firebasestorage.app</string> | ||||
| 	<key>IS_ADS_ENABLED</key> | ||||
| 	<false></false> | ||||
| 	<key>IS_ANALYTICS_ENABLED</key> | ||||
| 	<false></false> | ||||
| 	<key>IS_APPINVITE_ENABLED</key> | ||||
| 	<true></true> | ||||
| 	<key>IS_GCM_ENABLED</key> | ||||
| 	<true></true> | ||||
| 	<key>IS_SIGNIN_ENABLED</key> | ||||
| 	<true></true> | ||||
| 	<key>GOOGLE_APP_ID</key> | ||||
| 	<string>1:961776991058:ios:727229d368cc47e1f4188b</string> | ||||
| </dict> | ||||
| </plist> | ||||
| @@ -2,6 +2,8 @@ | ||||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||
| <plist version="1.0"> | ||||
| <dict> | ||||
| 	<key>CADisableMinimumFrameDurationOnPhone</key> | ||||
| 	<true/> | ||||
| 	<key>CFBundleDevelopmentRegion</key> | ||||
| 	<string>$(DEVELOPMENT_LANGUAGE)</string> | ||||
| 	<key>CFBundleDisplayName</key> | ||||
| @@ -12,6 +14,11 @@ | ||||
| 	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> | ||||
| 	<key>CFBundleInfoDictionaryVersion</key> | ||||
| 	<string>6.0</string> | ||||
| 	<key>CFBundleLocalizations</key> | ||||
| 	<array> | ||||
| 		<string>en</string> | ||||
| 		<string>zh_CN</string> | ||||
| 	</array> | ||||
| 	<key>CFBundleName</key> | ||||
| 	<string>Solian</string> | ||||
| 	<key>CFBundlePackageType</key> | ||||
| @@ -22,12 +29,31 @@ | ||||
| 	<string>????</string> | ||||
| 	<key>CFBundleVersion</key> | ||||
| 	<string>$(FLUTTER_BUILD_NUMBER)</string> | ||||
| 	<key>ITSAppUsesNonExemptEncryption</key> | ||||
| 	<false/> | ||||
| 	<key>LSRequiresIPhoneOS</key> | ||||
| 	<true/> | ||||
| 	<key>NSCameraUsageDescription</key> | ||||
| 	<string>Grant access to Photo Library will allow Solian take photo or video for your post.</string> | ||||
| 	<key>NSMicrophoneUsageDescription</key> | ||||
| 	<string>Grant access to Photo Library will allow Solian record audio for your post.</string> | ||||
| 	<key>NSPhotoLibraryUsageDescription</key> | ||||
| 	<string>Grant access to Photo Library will allow Solian upload photo or video for your post.</string> | ||||
| 	<key>UIApplicationSupportsIndirectInputEvents</key> | ||||
| 	<true/> | ||||
| 	<key>UIBackgroundModes</key> | ||||
| 	<array> | ||||
| 		<string>fetch</string> | ||||
| 		<string>remote-notification</string> | ||||
| 		<string>audio</string> | ||||
| 		<string>voip</string> | ||||
| 	</array> | ||||
| 	<key>UILaunchStoryboardName</key> | ||||
| 	<string>LaunchScreen</string> | ||||
| 	<key>UIMainStoryboardFile</key> | ||||
| 	<string>Main</string> | ||||
| 	<key>UIStatusBarHidden</key> | ||||
| 	<false/> | ||||
| 	<key>UISupportedInterfaceOrientations</key> | ||||
| 	<array> | ||||
| 		<string>UIInterfaceOrientationPortrait</string> | ||||
| @@ -41,24 +67,5 @@ | ||||
| 		<string>UIInterfaceOrientationLandscapeLeft</string> | ||||
| 		<string>UIInterfaceOrientationLandscapeRight</string> | ||||
| 	</array> | ||||
| 	<key>CADisableMinimumFrameDurationOnPhone</key> | ||||
| 	<true/> | ||||
| 	<key>UIApplicationSupportsIndirectInputEvents</key> | ||||
| 	<true/> | ||||
| 	<key>CFBundleLocalizations</key> | ||||
| 	<array> | ||||
| 		<string>en</string> | ||||
| 		<string>zh_CN</string> | ||||
| 	</array> | ||||
| 	<key>NSPhotoLibraryUsageDescription</key> | ||||
| 	<string>Grant access to Photo Library will allow Solian upload photo or video for your post.</string> | ||||
| 	<key>NSCameraUsageDescription</key> | ||||
| 	<string>Grant access to Photo Library will allow Solian take photo or video for your post.</string> | ||||
| 	<key>NSMicrophoneUsageDescription</key> | ||||
| 	<string>Grant access to Photo Library will allow Solian record audio for your post.</string> | ||||
| 	<key>ITSAppUsesNonExemptEncryption</key> | ||||
| 	<false/> | ||||
| 	<key>UIStatusBarHidden</key> | ||||
| 	<false/> | ||||
| </dict> | ||||
| </plist> | ||||
|   | ||||
							
								
								
									
										10
									
								
								ios/Runner/Runner.entitlements
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								ios/Runner/Runner.entitlements
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||
| <plist version="1.0"> | ||||
| <dict> | ||||
| 	<key>aps-environment</key> | ||||
| 	<string>development</string> | ||||
| 	<key>com.apple.developer.usernotifications.communication</key> | ||||
| 	<true/> | ||||
| </dict> | ||||
| </plist> | ||||
							
								
								
									
										432
									
								
								lib/controllers/chat_message_controller.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										432
									
								
								lib/controllers/chat_message_controller.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,432 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/sn_attachment.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/user_directory.dart'; | ||||
| import 'package:surface/providers/websocket.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| import 'package:uuid/uuid.dart'; | ||||
|  | ||||
| class ChatMessageController extends ChangeNotifier { | ||||
|   static const kChatMessageBoxPrefix = 'nex_chat_messages_'; | ||||
|   static const kSingleBatchLoadLimit = 100; | ||||
|  | ||||
|   late final SnNetworkProvider _sn; | ||||
|   late final UserDirectoryProvider _ud; | ||||
|   late final WebSocketProvider _ws; | ||||
|   late final SnAttachmentProvider _attach; | ||||
|  | ||||
|   StreamSubscription? _wsSubscription; | ||||
|  | ||||
|   ChatMessageController(BuildContext context) { | ||||
|     _sn = context.read<SnNetworkProvider>(); | ||||
|     _ud = context.read<UserDirectoryProvider>(); | ||||
|     _ws = context.read<WebSocketProvider>(); | ||||
|     _attach = context.read<SnAttachmentProvider>(); | ||||
|   } | ||||
|  | ||||
|   bool isPending = true; | ||||
|   bool isLoading = false; | ||||
|  | ||||
|   int? messageTotal; | ||||
|  | ||||
|   bool get isAllLoaded => | ||||
|       messageTotal != null && messages.length >= messageTotal!; | ||||
|  | ||||
|   String? _boxKey; | ||||
|   SnChannel? channel; | ||||
|   SnChannelMember? profile; | ||||
|  | ||||
|   /// Messages are the all the messages that in the channel | ||||
|   final List<SnChatMessage> messages = List.empty(growable: true); | ||||
|  | ||||
|   /// Unconfirmed messages are the messages that sent by client but did not receive the reply from websocket server. | ||||
|   /// Stored as a list of nonce to provide the loading state | ||||
|   final List<String> unconfirmedMessages = List.empty(growable: true); | ||||
|  | ||||
|   Box<SnChatMessage>? get _box => | ||||
|       (_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!); | ||||
|  | ||||
|   Future<void> initialize(SnChannel chan) async { | ||||
|     channel = chan; | ||||
|  | ||||
|     // Initialize local data | ||||
|     _boxKey = '$kChatMessageBoxPrefix${chan.id}'; | ||||
|     await Hive.openBox<SnChatMessage>(_boxKey!); | ||||
|  | ||||
|     // Fetch channel profile | ||||
|     final resp = await _sn.client.get( | ||||
|       '/cgi/im/channels/${chan.keyPath}/me', | ||||
|     ); | ||||
|     profile = SnChannelMember.fromJson( | ||||
|       resp.data as Map<String, dynamic>, | ||||
|     ); | ||||
|  | ||||
|     _wsSubscription = _ws.stream.stream.listen((event) { | ||||
|       switch (event.method) { | ||||
|         case 'events.new': | ||||
|           final payload = SnChatMessage.fromJson(event.payload!); | ||||
|           _addMessage(payload); | ||||
|           break; | ||||
|         case 'status.typing': | ||||
|           if (event.payload?['channel_id'] != channel?.id) break; | ||||
|           final member = SnChannelMember.fromJson(event.payload!['member']); | ||||
|           if (member.id == profile?.id) break; | ||||
|         // TODO impl typing users | ||||
|         // if (!_typingUsers.any((x) => x.id == member.id)) { | ||||
|         //   setState(() { | ||||
|         //     _typingUsers.add(member); | ||||
|         //   }); | ||||
|         // } | ||||
|         // _typingInactiveTimer[member.id]?.cancel(); | ||||
|         // _typingInactiveTimer[member.id] = Timer( | ||||
|         //   const Duration(seconds: 3), | ||||
|         //   () { | ||||
|         //     setState(() { | ||||
|         //       _typingUsers.removeWhere((x) => x.id == member.id); | ||||
|         //       _typingInactiveTimer.remove(member.id); | ||||
|         //     }); | ||||
|         //   }, | ||||
|         // ); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     isPending = false; | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async { | ||||
|     if (_box == null) return; | ||||
|     await _box!.putAll({ | ||||
|       for (final message in messages) message.id: message, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   Future<void> _addUnconfirmedMessage(SnChatMessage message) async { | ||||
|     SnChatMessage? quoteEvent; | ||||
|     if (message.quoteEventId != null) { | ||||
|       quoteEvent = await getMessage(message.quoteEventId as int); | ||||
|     } | ||||
|  | ||||
|     final attachmentRid = List<String>.from( | ||||
|       message.body['attachments']?.cast<String>() ?? [], | ||||
|     ); | ||||
|     final attachments = await _attach.getMultiple(attachmentRid); | ||||
|     message = message.copyWith( | ||||
|       preload: SnChatMessagePreload( | ||||
|         quoteEvent: quoteEvent, | ||||
|         attachments: attachments, | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     messages.insert(0, message); | ||||
|     unconfirmedMessages.add(message.uuid); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   Future<void> _addMessage(SnChatMessage message) async { | ||||
|     SnChatMessage? quoteEvent; | ||||
|     if (message.quoteEventId != null) { | ||||
|       quoteEvent = await getMessage(message.quoteEventId as int); | ||||
|     } | ||||
|  | ||||
|     final attachmentRid = List<String>.from( | ||||
|       message.body['attachments']?.cast<String>() ?? [], | ||||
|     ); | ||||
|     final attachments = await _attach.getMultiple(attachmentRid); | ||||
|     message = message.copyWith( | ||||
|       preload: SnChatMessagePreload( | ||||
|         quoteEvent: quoteEvent, | ||||
|         attachments: attachments, | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     final idx = messages.indexWhere((e) => e.uuid == message.uuid); | ||||
|     if (idx != -1) { | ||||
|       unconfirmedMessages.remove(message.uuid); | ||||
|       messages[idx] = message; | ||||
|     } else { | ||||
|       messages.insert(0, message); | ||||
|     } | ||||
|     await _applyMessage(message); | ||||
|     notifyListeners(); | ||||
|  | ||||
|     if (_box == null) return; | ||||
|     await _box!.put(message.id, message); | ||||
|   } | ||||
|  | ||||
|   Future<void> _applyMessage(SnChatMessage message) async { | ||||
|     if (message.channelId != channel?.id) return; | ||||
|  | ||||
|     switch (message.type) { | ||||
|       case 'messages.edit': | ||||
|         if (message.relatedEventId != null) { | ||||
|           final idx = | ||||
|               messages.indexWhere((x) => x.id == message.relatedEventId); | ||||
|           if (idx != -1) { | ||||
|             final newBody = message.body; | ||||
|             newBody.remove('related_event'); | ||||
|             messages[idx] = messages[idx].copyWith( | ||||
|               body: newBody, | ||||
|               updatedAt: message.updatedAt, | ||||
|             ); | ||||
|             if (_box!.containsKey(message.relatedEventId)) { | ||||
|               await _box!.put(message.relatedEventId, messages[idx]); | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       case 'messages.delete': | ||||
|         if (message.relatedEventId != null) { | ||||
|           messages.removeWhere((x) => x.id == message.relatedEventId); | ||||
|           if (_box!.containsKey(message.relatedEventId)) { | ||||
|             await _box!.delete(message.relatedEventId); | ||||
|           } | ||||
|         } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> sendMessage( | ||||
|     String type, | ||||
|     String content, { | ||||
|     int? quoteId, | ||||
|     int? relatedId, | ||||
|     List<String>? attachments, | ||||
|     SnChatMessage? editingMessage, | ||||
|   }) async { | ||||
|     if (channel == null) return; | ||||
|     const uuid = Uuid(); | ||||
|     final nonce = uuid.v4(); | ||||
|     final body = { | ||||
|       'text': content, | ||||
|       'algorithm': 'plain', | ||||
|       if (quoteId != null) 'quote_event': quoteId, | ||||
|       if (relatedId != null) 'related_event': relatedId, | ||||
|       if (attachments != null && attachments.isNotEmpty) | ||||
|         'attachments': attachments, | ||||
|     }; | ||||
|  | ||||
|     // Mock the message locally | ||||
|     final message = SnChatMessage( | ||||
|       id: 0, | ||||
|       createdAt: DateTime.now(), | ||||
|       updatedAt: DateTime.now(), | ||||
|       deletedAt: null, | ||||
|       uuid: nonce, | ||||
|       body: body, | ||||
|       type: type, | ||||
|       channel: channel!, | ||||
|       channelId: channel!.id, | ||||
|       sender: profile!, | ||||
|       senderId: profile!.id, | ||||
|       quoteEventId: quoteId, | ||||
|       relatedEventId: relatedId, | ||||
|     ); | ||||
|     _addUnconfirmedMessage(message); | ||||
|  | ||||
|     // Send to server | ||||
|     try { | ||||
|       await _sn.client.request( | ||||
|         editingMessage != null | ||||
|             ? '/cgi/im/channels/${channel!.keyPath}/messages/${editingMessage.id}' | ||||
|             : '/cgi/im/channels/${channel!.keyPath}/messages', | ||||
|         data: { | ||||
|           'type': type, | ||||
|           'uuid': nonce, | ||||
|           'body': body, | ||||
|         }, | ||||
|         options: Options( | ||||
|           method: editingMessage != null ? 'PUT' : 'POST', | ||||
|         ), | ||||
|       ); | ||||
|     } catch (err) { | ||||
|       // ignore | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> deleteMessage(SnChatMessage message) async { | ||||
|     if (message.channelId != channel?.id) return; | ||||
|  | ||||
|     try { | ||||
|       await _sn.client.delete( | ||||
|         '/cgi/im/channels/${channel!.keyPath}/messages/${message.id}', | ||||
|       ); | ||||
|       messages.removeWhere((x) => x.id == message.id); | ||||
|     } catch (err) { | ||||
|       // ignore | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /// Check the local storage is up to date with the server. | ||||
|   /// If the local storage is not up to date, it will be updated. | ||||
|   Future<void> checkUpdate() async { | ||||
|     if (_box == null) return; | ||||
|     if (_box!.isEmpty) return; | ||||
|  | ||||
|     isLoading = true; | ||||
|     notifyListeners(); | ||||
|  | ||||
|     try { | ||||
|       final resp = await _sn.client.get( | ||||
|         '/cgi/im/channels/${channel!.keyPath}/events/update', | ||||
|         queryParameters: { | ||||
|           'pivot': _box!.values.last.id, | ||||
|         }, | ||||
|       ); | ||||
|       if (resp.data['up_to_date'] == true) return; | ||||
|       // Only preload the first 100 messages to prevent first time check update cause load to server and waste local storage. | ||||
|       // FIXME If the local is missing more than 100 messages, it won't be fetched, this is a problem, we need to fix it. | ||||
|       final countToFetch = math.min(resp.data['count'] as int, 100); | ||||
|  | ||||
|       for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) { | ||||
|         await getMessages(kSingleBatchLoadLimit, idx, forceRemote: true); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       rethrow; | ||||
|     } finally { | ||||
|       await loadMessages(); | ||||
|       isLoading = false; | ||||
|       notifyListeners(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /// Get a single event from the current channel | ||||
|   /// If it was not found in local storage we will look it up in remote | ||||
|   Future<SnChatMessage?> getMessage(int id) async { | ||||
|     SnChatMessage? out; | ||||
|     if (_box != null && _box!.containsKey(id)) { | ||||
|       out = _box!.get(id); | ||||
|     } | ||||
|  | ||||
|     if (out == null) { | ||||
|       try { | ||||
|         final resp = await _sn.client | ||||
|             .get('/cgi/im/channels/${channel!.keyPath}/events/$id'); | ||||
|         out = SnChatMessage.fromJson(resp.data); | ||||
|         _saveMessageToLocal([out]); | ||||
|       } catch (_) { | ||||
|         // ignore, maybe not found | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Preload some related things if found | ||||
|     if (out != null) { | ||||
|       await _ud.listAccount([out.sender.accountId]); | ||||
|  | ||||
|       final attachments = await _attach.getMultiple( | ||||
|         out.body['attachments']?.cast<String>() ?? [], | ||||
|       ); | ||||
|       out = out.copyWith( | ||||
|         preload: SnChatMessagePreload( | ||||
|           attachments: attachments, | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return out; | ||||
|   } | ||||
|  | ||||
|   /// Get message from local storage first, then from the server. | ||||
|   /// Will not check local storage is up to date with the server. | ||||
|   /// If you need to do the sync, do the `checkUpdate` instead. | ||||
|   Future<List<SnChatMessage>> getMessages( | ||||
|     int take, | ||||
|     int offset, { | ||||
|     bool forceLocal = false, | ||||
|     bool forceRemote = false, | ||||
|   }) async { | ||||
|     late List<SnChatMessage> out; | ||||
|     if (_box != null && | ||||
|         (_box!.length >= take + offset || forceLocal) && | ||||
|         !forceRemote) { | ||||
|       out = _box!.keys | ||||
|           .toList() | ||||
|           .cast<int>() | ||||
|           .sorted((a, b) => b.compareTo(a)) | ||||
|           .skip(offset) | ||||
|           .take(take) | ||||
|           .map((key) => _box!.get(key)!) | ||||
|           .toList(); | ||||
|     } else { | ||||
|       final resp = await _sn.client.get( | ||||
|         '/cgi/im/channels/${channel!.keyPath}/events', | ||||
|         queryParameters: { | ||||
|           'take': take, | ||||
|           'offset': offset, | ||||
|         }, | ||||
|       ); | ||||
|       messageTotal = resp.data['count'] as int?; | ||||
|       out = List<SnChatMessage>.from( | ||||
|         resp.data['data']?.map((e) => SnChatMessage.fromJson(e)) ?? [], | ||||
|       ); | ||||
|       _saveMessageToLocal(out); | ||||
|     } | ||||
|  | ||||
|     // Preload attachments | ||||
|     final attachmentRid = List<String>.from( | ||||
|       out.expand((e) => (e.body['attachments'] as List<dynamic>?) ?? []), | ||||
|     ); | ||||
|     final attachments = await _attach.getMultiple(attachmentRid); | ||||
|  | ||||
|     // Putting preload back to data | ||||
|     for (var i = 0; i < out.length; i++) { | ||||
|       // Preload related events (quoted) | ||||
|       SnChatMessage? quoteEvent; | ||||
|       if (out[i].quoteEventId != null) { | ||||
|         quoteEvent = await getMessage(out[i].quoteEventId as int); | ||||
|       } | ||||
|  | ||||
|       out[i] = out[i].copyWith( | ||||
|         preload: SnChatMessagePreload( | ||||
|           quoteEvent: quoteEvent, | ||||
|           attachments: attachments | ||||
|               .where( | ||||
|                 (ele) => | ||||
|                     out[i].body['attachments']?.contains(ele?.rid) ?? false, | ||||
|               ) | ||||
|               .toList(), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // Preload sender accounts | ||||
|     final accountId = out | ||||
|         .where((ele) => ele.sender.accountId >= 0) | ||||
|         .map((ele) => ele.sender.accountId) | ||||
|         .toSet(); | ||||
|     await _ud.listAccount(accountId); | ||||
|  | ||||
|     return out; | ||||
|   } | ||||
|  | ||||
|   /// The load messages method work as same as the `getMessages` method. | ||||
|   /// But it won't return the messages instead append them to the value that controller has. | ||||
|   /// At the same time, this method provide the `isLoading` state. | ||||
|   /// The `skip` parameter is no longer required since it will skip the messages count that already loaded. | ||||
|   Future<void> loadMessages({int take = 20}) async { | ||||
|     isLoading = true; | ||||
|     notifyListeners(); | ||||
|  | ||||
|     try { | ||||
|       final out = await getMessages(take, messages.length); | ||||
|       messages.addAll(out); | ||||
|     } catch (err) { | ||||
|       rethrow; | ||||
|     } finally { | ||||
|       isLoading = false; | ||||
|       notifyListeners(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _box?.close(); | ||||
|     _wsSubscription?.cancel(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
| @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:image_picker/image_picker.dart'; | ||||
| import 'package:mime/mime.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/post.dart'; | ||||
| import 'package:surface/providers/sn_attachment.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/attachment.dart'; | ||||
| @@ -86,7 +87,10 @@ class PostWriteMedia { | ||||
|     if (file != null) { | ||||
|       return file!; | ||||
|     } else if (raw != null) { | ||||
|       return XFile.fromData(raw!, name: name); | ||||
|       return XFile.fromData( | ||||
|         raw!, | ||||
|         name: name, | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| @@ -177,53 +181,35 @@ class PostWriteController extends ChangeNotifier { | ||||
|     int? reposting, | ||||
|     int? replying, | ||||
|   }) async { | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|     final attach = context.read<SnAttachmentProvider>(); | ||||
|     final pt = context.read<SnPostContentProvider>(); | ||||
|  | ||||
|     isLoading = true; | ||||
|     notifyListeners(); | ||||
|  | ||||
|     try { | ||||
|       if (editing != null) { | ||||
|         final resp = await sn.client.get('/cgi/co/posts/$editing'); | ||||
|         final post = SnPost.fromJson(resp.data); | ||||
|         final alts = await attach | ||||
|             .getMultiple(post.body['attachments']?.cast<String>() ?? []); | ||||
|         final post = await pt.getPost(editing); | ||||
|         publisher = post.publisher; | ||||
|         titleController.text = post.body['title'] ?? ''; | ||||
|         descriptionController.text = post.body['description'] ?? ''; | ||||
|         contentController.text = post.body['content'] ?? ''; | ||||
|         publishedAt = post.publishedAt; | ||||
|         publishedUntil = post.publishedUntil; | ||||
|         attachments.addAll(alts.map((ele) => PostWriteMedia(ele))); | ||||
|  | ||||
|         editingPost = post.copyWith( | ||||
|           preload: SnPostPreload( | ||||
|             attachments: alts, | ||||
|           ), | ||||
|         attachments.addAll( | ||||
|           post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? [], | ||||
|         ); | ||||
|  | ||||
|         editingPost = post; | ||||
|       } | ||||
|  | ||||
|       if (replying != null) { | ||||
|         final resp = await sn.client.get('/cgi/co/posts/$replying'); | ||||
|         final post = SnPost.fromJson(resp.data); | ||||
|         replyingPost = post.copyWith( | ||||
|           preload: SnPostPreload( | ||||
|             attachments: await attach | ||||
|                 .getMultiple(post.body['attachments']?.cast<String>() ?? []), | ||||
|           ), | ||||
|         ); | ||||
|         final post = await pt.getPost(replying); | ||||
|         replyingPost = post; | ||||
|       } | ||||
|  | ||||
|       if (reposting != null) { | ||||
|         final resp = await sn.client.get('/cgi/co/posts/$reposting'); | ||||
|         final post = SnPost.fromJson(resp.data); | ||||
|         repostingPost = post.copyWith( | ||||
|           preload: SnPostPreload( | ||||
|             attachments: await attach | ||||
|                 .getMultiple(post.body['attachments']?.cast<String>() ?? []), | ||||
|           ), | ||||
|         ); | ||||
|         final post = await pt.getPost(reposting); | ||||
|         replyingPost = post; | ||||
|       } | ||||
|     } catch (err) { | ||||
|       if (!context.mounted) return; | ||||
| @@ -256,6 +242,9 @@ class PostWriteController extends ChangeNotifier { | ||||
|           media.name, | ||||
|           'interactive', | ||||
|           null, | ||||
|           mimetype: media.raw != null && media.type == PostWriteMediaType.image | ||||
|               ? 'image/png' | ||||
|               : null, | ||||
|         ); | ||||
|  | ||||
|         final item = await attach.chunkedUploadParts( | ||||
|   | ||||
							
								
								
									
										89
									
								
								lib/firebase_options.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								lib/firebase_options.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| // File generated by FlutterFire CLI. | ||||
| // ignore_for_file: type=lint | ||||
| import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; | ||||
| import 'package:flutter/foundation.dart' | ||||
|     show defaultTargetPlatform, kIsWeb, TargetPlatform; | ||||
|  | ||||
| /// Default [FirebaseOptions] for use with your Firebase apps. | ||||
| /// | ||||
| /// Example: | ||||
| /// ```dart | ||||
| /// import 'firebase_options.dart'; | ||||
| /// // ... | ||||
| /// await Firebase.initializeApp( | ||||
| ///   options: DefaultFirebaseOptions.currentPlatform, | ||||
| /// ); | ||||
| /// ``` | ||||
| class DefaultFirebaseOptions { | ||||
|   static FirebaseOptions get currentPlatform { | ||||
|     if (kIsWeb) { | ||||
|       return web; | ||||
|     } | ||||
|     switch (defaultTargetPlatform) { | ||||
|       case TargetPlatform.android: | ||||
|         return android; | ||||
|       case TargetPlatform.iOS: | ||||
|         return ios; | ||||
|       case TargetPlatform.macOS: | ||||
|         return macos; | ||||
|       case TargetPlatform.windows: | ||||
|         return windows; | ||||
|       case TargetPlatform.linux: | ||||
|         throw UnsupportedError( | ||||
|           'DefaultFirebaseOptions have not been configured for linux - ' | ||||
|           'you can reconfigure this by running the FlutterFire CLI again.', | ||||
|         ); | ||||
|       default: | ||||
|         throw UnsupportedError( | ||||
|           'DefaultFirebaseOptions are not supported for this platform.', | ||||
|         ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static const FirebaseOptions web = FirebaseOptions( | ||||
|     apiKey: 'AIzaSyBKfIQpTouj5rXnlzkEieSlbAzepm4mgJE', | ||||
|     appId: '1:961776991058:web:b91d12f2892a5609f4188b', | ||||
|     messagingSenderId: '961776991058', | ||||
|     projectId: 'solian-0x001', | ||||
|     authDomain: 'solian-0x001.firebaseapp.com', | ||||
|     storageBucket: 'solian-0x001.firebasestorage.app', | ||||
|     measurementId: 'G-XY3HHKG0PE', | ||||
|   ); | ||||
|  | ||||
|   static const FirebaseOptions android = FirebaseOptions( | ||||
|     apiKey: 'AIzaSyDvFNudXYs29uDtcCv6pFR8h5tXBs90FYk', | ||||
|     appId: '1:961776991058:android:a8d3f7995b0b8e86f4188b', | ||||
|     messagingSenderId: '961776991058', | ||||
|     projectId: 'solian-0x001', | ||||
|     storageBucket: 'solian-0x001.firebasestorage.app', | ||||
|   ); | ||||
|  | ||||
|   static const FirebaseOptions ios = FirebaseOptions( | ||||
|     apiKey: 'AIzaSyCzQIyiYKoYHTpGXhN-IjgMML8z797WVD8', | ||||
|     appId: '1:961776991058:ios:727229d368cc47e1f4188b', | ||||
|     messagingSenderId: '961776991058', | ||||
|     projectId: 'solian-0x001', | ||||
|     storageBucket: 'solian-0x001.firebasestorage.app', | ||||
|     iosBundleId: 'dev.solsynth.solian', | ||||
|   ); | ||||
|  | ||||
|   static const FirebaseOptions macos = FirebaseOptions( | ||||
|     apiKey: 'AIzaSyCzQIyiYKoYHTpGXhN-IjgMML8z797WVD8', | ||||
|     appId: '1:961776991058:ios:727229d368cc47e1f4188b', | ||||
|     messagingSenderId: '961776991058', | ||||
|     projectId: 'solian-0x001', | ||||
|     storageBucket: 'solian-0x001.firebasestorage.app', | ||||
|     iosBundleId: 'dev.solsynth.solian', | ||||
|   ); | ||||
|  | ||||
|   static const FirebaseOptions windows = FirebaseOptions( | ||||
|     apiKey: 'AIzaSyBKfIQpTouj5rXnlzkEieSlbAzepm4mgJE', | ||||
|     appId: '1:961776991058:web:f152fd119699e13ef4188b', | ||||
|     messagingSenderId: '961776991058', | ||||
|     projectId: 'solian-0x001', | ||||
|     authDomain: 'solian-0x001.firebaseapp.com', | ||||
|     storageBucket: 'solian-0x001.firebasestorage.app', | ||||
|     measurementId: 'G-19FCN0CD9X', | ||||
|   ); | ||||
|  | ||||
| } | ||||
| @@ -1,27 +1,64 @@ | ||||
| import 'package:croppy/croppy.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:easy_localization_loader/easy_localization_loader.dart'; | ||||
| import 'package:firebase_core/firebase_core.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:relative_time/relative_time.dart'; | ||||
| import 'package:responsive_framework/responsive_framework.dart'; | ||||
| import 'package:sentry_flutter/sentry_flutter.dart'; | ||||
| import 'package:surface/firebase_options.dart'; | ||||
| import 'package:surface/providers/channel.dart'; | ||||
| import 'package:surface/providers/chat_call.dart'; | ||||
| import 'package:surface/providers/navigation.dart'; | ||||
| import 'package:surface/providers/notification.dart'; | ||||
| import 'package:surface/providers/post.dart'; | ||||
| import 'package:surface/providers/sn_attachment.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/theme.dart'; | ||||
| import 'package:surface/providers/user_directory.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
| import 'package:surface/providers/websocket.dart'; | ||||
| import 'package:surface/router.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| import 'package:surface/types/realm.dart'; | ||||
| import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy; | ||||
|  | ||||
| void main() async { | ||||
|   WidgetsFlutterBinding.ensureInitialized(); | ||||
|   await EasyLocalization.ensureInitialized(); | ||||
|  | ||||
|   await Hive.initFlutter(); | ||||
|   Hive.registerAdapter(SnChannelImplAdapter()); | ||||
|   Hive.registerAdapter(SnRealmImplAdapter()); | ||||
|   Hive.registerAdapter(SnChannelMemberImplAdapter()); | ||||
|   Hive.registerAdapter(SnChatMessageImplAdapter()); | ||||
|  | ||||
|   await Firebase.initializeApp( | ||||
|     options: DefaultFirebaseOptions.currentPlatform, | ||||
|   ); | ||||
|  | ||||
|   if (!kReleaseMode) { | ||||
|     debugInvertOversizedImages = true; | ||||
|   } | ||||
|  | ||||
|   runApp(const SolianApp()); | ||||
|   GoRouter.optionURLReflectsImperativeAPIs = true; | ||||
|   usePathUrlStrategy(); | ||||
|  | ||||
|   await SentryFlutter.init( | ||||
|     (options) { | ||||
|       options.dsn = | ||||
|           'https://c218d44126d59d69301e730498494def@o4506965897117696.ingest.us.sentry.io/4508346768228352'; | ||||
|       options.tracesSampleRate = 1.0; | ||||
|       options.profilesSampleRate = 1.0; | ||||
|       options.experimental.replay.sessionSampleRate = 1.0; | ||||
|       options.experimental.replay.onErrorSampleRate = 1.0; | ||||
|     }, | ||||
|     appRunner: () => runApp(const SolianApp()), | ||||
|   ); | ||||
| } | ||||
|  | ||||
| class SolianApp extends StatelessWidget { | ||||
| @@ -35,14 +72,24 @@ class SolianApp extends StatelessWidget { | ||||
|         supportedLocales: [Locale('en', 'US'), Locale('zh', 'CN')], | ||||
|         fallbackLocale: Locale('en', 'US'), | ||||
|         useFallbackTranslations: true, | ||||
|         useOnlyLangCode: true, | ||||
|         assetLoader: JsonAssetLoader(), | ||||
|         child: MultiProvider( | ||||
|           providers: [ | ||||
|             // Display layer | ||||
|             ChangeNotifierProvider(create: (_) => ThemeProvider()), | ||||
|             ChangeNotifierProvider(create: (ctx) => NavigationProvider()), | ||||
|  | ||||
|             // Data layer | ||||
|             Provider(create: (_) => SnNetworkProvider()), | ||||
|             Provider(create: (ctx) => SnAttachmentProvider(ctx)), | ||||
|             ChangeNotifierProvider(create: (ctx) => NavigationProvider()), | ||||
|             Provider(create: (ctx) => SnPostContentProvider(ctx)), | ||||
|             Provider(create: (ctx) => UserDirectoryProvider(ctx)), | ||||
|             ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)), | ||||
|             ChangeNotifierProvider(create: (_) => ThemeProvider()), | ||||
|             ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)), | ||||
|             ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)), | ||||
|             ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)), | ||||
|             ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)), | ||||
|           ], | ||||
|           child: AppMainContent(), | ||||
|         ), | ||||
| @@ -62,7 +109,9 @@ class AppMainContent extends StatelessWidget { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     context.read<NavigationProvider>(); | ||||
|     context.read<UserProvider>(); | ||||
|     context.read<WebSocketProvider>(); | ||||
|     context.read<ChatChannelProvider>(); | ||||
|     context.read<NotificationProvider>(); | ||||
|  | ||||
|     final th = context.watch<ThemeProvider>(); | ||||
|  | ||||
|   | ||||
| @@ -1,12 +0,0 @@ | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:native_dio_adapter/native_dio_adapter.dart'; | ||||
|  | ||||
| Dio addClientAdapter(Dio client) { | ||||
|   if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) { | ||||
|     // Switch to native implementation if possible | ||||
|     client.httpClientAdapter = NativeAdapter(); | ||||
|   } | ||||
|   return client; | ||||
| } | ||||
| @@ -1,2 +0,0 @@ | ||||
| export 'package:surface/providers/adapters/sn_network_web.dart' | ||||
|     if (dart.library.io) 'package:surface/providers/adapters/sn_network_native.dart'; | ||||
| @@ -1,5 +0,0 @@ | ||||
| import 'package:dio/dio.dart'; | ||||
|  | ||||
| Dio addClientAdapter(Dio client) { | ||||
|   return client; | ||||
| } | ||||
							
								
								
									
										121
									
								
								lib/providers/channel.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								lib/providers/channel.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| import 'package:surface/types/realm.dart'; | ||||
|  | ||||
| class ChatChannelProvider extends ChangeNotifier { | ||||
|   static const kChatChannelBoxName = 'nex_chat_channels'; | ||||
|  | ||||
|   late final SnNetworkProvider _sn; | ||||
|  | ||||
|   Box<SnChannel>? get _channelBox => Hive.box<SnChannel>(kChatChannelBoxName); | ||||
|  | ||||
|   ChatChannelProvider(BuildContext context) { | ||||
|     _sn = context.read<SnNetworkProvider>(); | ||||
|     _initializeLocalData(); | ||||
|   } | ||||
|  | ||||
|   Future<void> _initializeLocalData() async { | ||||
|     await Hive.openBox<SnChannel>(kChatChannelBoxName); | ||||
|   } | ||||
|  | ||||
|   Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async { | ||||
|     if (_channelBox == null) return; | ||||
|     await _channelBox!.putAll({ | ||||
|       for (final channel in channels) channel.key: channel, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   Future<List<SnChannel>> _fetchChannelsFromServer({ | ||||
|     String scope = 'global', | ||||
|     bool direct = false, | ||||
|     bool doNotSave = false, | ||||
|   }) async { | ||||
|     final resp = await _sn.client.get( | ||||
|       '/cgi/im/channels/$scope/me/available', | ||||
|       queryParameters: { | ||||
|         'direct': direct, | ||||
|       }, | ||||
|     ); | ||||
|     final out = List<SnChannel>.from( | ||||
|       resp.data?.map((e) => SnChannel.fromJson(e)) ?? [], | ||||
|     ); | ||||
|     if (!doNotSave) _saveChannelToLocal(out); | ||||
|     return out; | ||||
|   } | ||||
|  | ||||
|   /// The get channel method will return the channel with the given alias. | ||||
|   /// It will use the local storage as much as possible. | ||||
|   /// The alias should include the scope, formatted as `scope:alias`. | ||||
|   Future<SnChannel> getChannel(String key) async { | ||||
|     if (_channelBox != null) { | ||||
|       final local = _channelBox!.get(key); | ||||
|       if (local != null) return local; | ||||
|     } | ||||
|  | ||||
|     var resp = await _sn.client.get('/cgi/im/channels/$key'); | ||||
|     var out = SnChannel.fromJson(resp.data); | ||||
|  | ||||
|     // Preload realm of the channel | ||||
|     if (out.realmId != null) { | ||||
|       resp = await _sn.client.get('/cgi/id/realms/${out.realmId}'); | ||||
|       out = out.copyWith(realm: SnRealm.fromJson(resp.data)); | ||||
|     } | ||||
|  | ||||
|     _saveChannelToLocal([out]); | ||||
|     return out; | ||||
|   } | ||||
|  | ||||
|   /// The fetch channel method return a stream, which will emit twice. | ||||
|   /// The first time is when the data was fetched from the local storage. | ||||
|   /// And the second time is when the data was fetched from the server. | ||||
|   /// But there is some exception that will only cause one of them to be emitted. | ||||
|   /// Like the local storage is broken or the server is down. | ||||
|   Stream<List<SnChannel>> fetchChannels() async* { | ||||
|     if (_channelBox != null) yield _channelBox!.values.toList(); | ||||
|  | ||||
|     var resp = await _sn.client.get('/cgi/id/realms/me/available'); | ||||
|     final realms = List<SnRealm>.from( | ||||
|       resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], | ||||
|     ); | ||||
|     final realmMap = { | ||||
|       for (final realm in realms) realm.alias: realm, | ||||
|     }; | ||||
|  | ||||
|     final scopeToFetch = {'global', ...realms.map((e) => e.alias)}; | ||||
|  | ||||
|     final List<SnChannel> result = List.empty(growable: true); | ||||
|     final directMessages = await _fetchChannelsFromServer( | ||||
|       scope: scopeToFetch.first, | ||||
|       direct: true, | ||||
|     ); | ||||
|     result.addAll(directMessages); | ||||
|  | ||||
|     final nonBelongsChannels = await _fetchChannelsFromServer( | ||||
|       scope: scopeToFetch.first, | ||||
|       direct: false, | ||||
|     ); | ||||
|     result.addAll(nonBelongsChannels); | ||||
|  | ||||
|     for (final scope in scopeToFetch.skip(1)) { | ||||
|       final channel = await _fetchChannelsFromServer( | ||||
|         scope: scope, | ||||
|         direct: false, | ||||
|         doNotSave: true, | ||||
|       ); | ||||
|       final out = channel.map((ele) => ele.copyWith(realm: realmMap[scope])); | ||||
|       _saveChannelToLocal(out); | ||||
|       result.addAll(out); | ||||
|     } | ||||
|  | ||||
|     yield result; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _channelBox?.close(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										459
									
								
								lib/providers/chat_call.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										459
									
								
								lib/providers/chat_call.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,459 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:permission_handler/permission_handler.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| import 'package:wakelock_plus/wakelock_plus.dart'; | ||||
|  | ||||
| class ChatCallProvider extends ChangeNotifier { | ||||
|   late final SnNetworkProvider _sn; | ||||
|  | ||||
|   ChatCallProvider(BuildContext context) { | ||||
|     _sn = context.read<SnNetworkProvider>(); | ||||
|   } | ||||
|  | ||||
|   SnChatCall? _current; | ||||
|   SnChannel? _channel; | ||||
|  | ||||
|   bool _isReady = false; | ||||
|   bool _isMounted = false; | ||||
|   bool _isInitialized = false; | ||||
|   bool _isBusy = false; | ||||
|  | ||||
|   String _lastDuration = '00:00:00'; | ||||
|   Timer? _lastDurationUpdateTimer; | ||||
|  | ||||
|   String? token; | ||||
|   String? endpoint; | ||||
|  | ||||
|   StreamSubscription? hwSubscription; | ||||
|   List<MediaDevice> _audioInputs = []; | ||||
|   List<MediaDevice> _videoInputs = []; | ||||
|  | ||||
|   bool _enableAudio = true; | ||||
|   bool _enableVideo = false; | ||||
|   LocalAudioTrack? _audioTrack; | ||||
|   LocalVideoTrack? _videoTrack; | ||||
|   MediaDevice? _videoDevice; | ||||
|   MediaDevice? _audioDevice; | ||||
|  | ||||
|   late Room _room; | ||||
|   late EventsListener<RoomEvent> _listener; | ||||
|  | ||||
|   List<ParticipantTrack> _participantTracks = []; | ||||
|   ParticipantTrack? _focusTrack; | ||||
|  | ||||
|   // Getters for private fields | ||||
|   SnChatCall? get current => _current; | ||||
|   SnChannel? get channel => _channel; | ||||
|   bool get isReady => _isReady; | ||||
|   bool get isMounted => _isMounted; | ||||
|   bool get isInitialized => _isInitialized; | ||||
|   bool get isBusy => _isBusy; | ||||
|   String get lastDuration => _lastDuration; | ||||
|   List<MediaDevice> get audioInputs => _audioInputs; | ||||
|   List<MediaDevice> get videoInputs => _videoInputs; | ||||
|   bool get enableAudio => _enableAudio; | ||||
|   bool get enableVideo => _enableVideo; | ||||
|   LocalAudioTrack? get audioTrack => _audioTrack; | ||||
|   LocalVideoTrack? get videoTrack => _videoTrack; | ||||
|   MediaDevice? get videoDevice => _videoDevice; | ||||
|   MediaDevice? get audioDevice => _audioDevice; | ||||
|   List<ParticipantTrack> get participantTracks => _participantTracks; | ||||
|   ParticipantTrack? get focusTrack => _focusTrack; | ||||
|   Room get room => _room; | ||||
|  | ||||
|   void _updateDuration() { | ||||
|     if (_current == null) { | ||||
|       _lastDuration = '00:00:00'; | ||||
|     } else { | ||||
|       Duration duration = DateTime.now().difference(_current!.createdAt); | ||||
|       String twoDigits(int n) => n.toString().padLeft(2, '0'); | ||||
|       _lastDuration = '${twoDigits(duration.inHours)}:' | ||||
|           '${twoDigits(duration.inMinutes.remainder(60))}:' | ||||
|           '${twoDigits(duration.inSeconds.remainder(60))}'; | ||||
|     } | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void enableDurationUpdater() { | ||||
|     _updateDuration(); | ||||
|     _lastDurationUpdateTimer = Timer.periodic( | ||||
|       const Duration(seconds: 1), | ||||
|       (_) => _updateDuration(), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void disableDurationUpdater() { | ||||
|     _lastDurationUpdateTimer?.cancel(); | ||||
|     _lastDurationUpdateTimer = null; | ||||
|   } | ||||
|  | ||||
|   Future<void> checkPermissions() async { | ||||
|     if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     await Permission.camera.request(); | ||||
|     await Permission.microphone.request(); | ||||
|     await Permission.bluetooth.request(); | ||||
|     await Permission.bluetoothConnect.request(); | ||||
|   } | ||||
|  | ||||
|   void setCall(SnChatCall call, SnChannel related) { | ||||
|     _current = call; | ||||
|     _channel = related; | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   Future<(String, String)> getRoomToken() async { | ||||
|     final resp = await _sn.client.post( | ||||
|       '/cgi/im/channels/${_channel!.keyPath}/calls/ongoing/token', | ||||
|     ); | ||||
|     token = resp.data['token']; | ||||
|     endpoint = 'wss://${resp.data['endpoint']}'; | ||||
|     return (token!, endpoint!); | ||||
|   } | ||||
|  | ||||
|   void initHardware() { | ||||
|     if (_isReady) return; | ||||
|  | ||||
|     _isReady = true; | ||||
|     hwSubscription = Hardware.instance.onDeviceChange.stream.listen( | ||||
|       _revertDevices, | ||||
|     ); | ||||
|     Hardware.instance.enumerateDevices().then(_revertDevices); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void initRoom() { | ||||
|     initHardware(); | ||||
|     _room = Room( | ||||
|       roomOptions: const RoomOptions( | ||||
|         dynacast: true, | ||||
|         adaptiveStream: true, | ||||
|         defaultAudioPublishOptions: AudioPublishOptions( | ||||
|           name: 'call_voice', | ||||
|           stream: 'call_stream', | ||||
|         ), | ||||
|         defaultVideoPublishOptions: VideoPublishOptions( | ||||
|           name: 'call_video', | ||||
|           stream: 'call_stream', | ||||
|           simulcast: true, | ||||
|           backupVideoCodec: BackupVideoCodec(enabled: true), | ||||
|         ), | ||||
|         defaultScreenShareCaptureOptions: ScreenShareCaptureOptions( | ||||
|           useiOSBroadcastExtension: true, | ||||
|           params: VideoParametersPresets.screenShareH1080FPS30, | ||||
|         ), | ||||
|         defaultCameraCaptureOptions: CameraCaptureOptions( | ||||
|           maxFrameRate: 30, | ||||
|           params: VideoParametersPresets.h1080_169, | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|     _listener = _room.createListener(); | ||||
|     WakelockPlus.enable(); | ||||
|   } | ||||
|  | ||||
|   Future<void> joinRoom(String url, String token) async { | ||||
|     if (_isMounted) return; | ||||
|  | ||||
|     try { | ||||
|       await _room.connect( | ||||
|         url, | ||||
|         token, | ||||
|         fastConnectOptions: FastConnectOptions( | ||||
|           microphone: TrackOption(track: _audioTrack), | ||||
|           camera: TrackOption(track: _videoTrack), | ||||
|         ), | ||||
|       ); | ||||
|     } finally { | ||||
|       _isMounted = true; | ||||
|       notifyListeners(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void setupRoom() { | ||||
|     if (isInitialized) return; | ||||
|  | ||||
|     sortParticipants(); | ||||
|     _room.addListener(_onRoomDidUpdate); | ||||
|     WidgetsBindingCompatible.instance?.addPostFrameCallback( | ||||
|       (_) => autoPublish(), | ||||
|     ); | ||||
|  | ||||
|     if (lkPlatformIsMobile()) { | ||||
|       Hardware.instance.setSpeakerphoneOn(true); | ||||
|     } | ||||
|  | ||||
|     _isBusy = false; | ||||
|     _isInitialized = true; | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void autoPublish() async { | ||||
|     try { | ||||
|       if (enableVideo) { | ||||
|         await _room.localParticipant?.setCameraEnabled(true); | ||||
|       } | ||||
|       if (enableAudio) { | ||||
|         await _room.localParticipant?.setMicrophoneEnabled(true); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       rethrow; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> setEnableAudio(bool value) async { | ||||
|     _enableAudio = value; | ||||
|     if (!_enableAudio) { | ||||
|       await _audioTrack?.stop(); | ||||
|       _audioTrack = null; | ||||
|     } else { | ||||
|       await _changeLocalAudioTrack(); | ||||
|     } | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   Future<void> setEnableVideo(bool value) async { | ||||
|     _enableVideo = value; | ||||
|     if (!_enableVideo) { | ||||
|       await _videoTrack?.stop(); | ||||
|       _videoTrack = null; | ||||
|     } else { | ||||
|       await _changeLocalVideoTrack(); | ||||
|     } | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void setupRoomListeners({ | ||||
|     required Function(DisconnectReason?) onDisconnected, | ||||
|   }) { | ||||
|     _listener | ||||
|       ..on<RoomDisconnectedEvent>((event) async { | ||||
|         onDisconnected(event.reason); | ||||
|       }) | ||||
|       ..on<ParticipantEvent>((event) => sortParticipants()) | ||||
|       ..on<LocalTrackPublishedEvent>((_) => sortParticipants()) | ||||
|       ..on<LocalTrackUnpublishedEvent>((_) => sortParticipants()) | ||||
|       ..on<TrackSubscribedEvent>((_) => sortParticipants()) | ||||
|       ..on<TrackUnsubscribedEvent>((_) => sortParticipants()) | ||||
|       ..on<ParticipantNameUpdatedEvent>((event) { | ||||
|         sortParticipants(); | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   void sortParticipants() { | ||||
|     Map<String, ParticipantTrack> mediaTracks = {}; | ||||
|     for (var participant in _room.remoteParticipants.values) { | ||||
|       mediaTracks[participant.sid] = ParticipantTrack( | ||||
|         participant: participant, | ||||
|         videoTrack: null, | ||||
|         isScreenShare: false, | ||||
|       ); | ||||
|  | ||||
|       for (var t in participant.videoTrackPublications) { | ||||
|         mediaTracks[participant.sid]?.videoTrack = t.track; | ||||
|         mediaTracks[participant.sid]?.isScreenShare = t.isScreenShare; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     final newTracks = List<ParticipantTrack>.empty(growable: true); | ||||
|  | ||||
|     final mediaTrackList = mediaTracks.values.toList(); | ||||
|     mediaTrackList.sort((a, b) { | ||||
|       // Loudest people first | ||||
|       if (a.participant.isSpeaking && b.participant.isSpeaking) { | ||||
|         if (a.participant.audioLevel > b.participant.audioLevel) { | ||||
|           return -1; | ||||
|         } else { | ||||
|           return 1; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       // Last spoke first | ||||
|       final aSpokeAt = a.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0; | ||||
|       final bSpokeAt = b.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0; | ||||
|  | ||||
|       if (aSpokeAt != bSpokeAt) { | ||||
|         return aSpokeAt > bSpokeAt ? -1 : 1; | ||||
|       } | ||||
|  | ||||
|       // Has video first | ||||
|       if (a.participant.hasVideo != b.participant.hasVideo) { | ||||
|         return a.participant.hasVideo ? -1 : 1; | ||||
|       } | ||||
|  | ||||
|       // First joined people first | ||||
|       return a.participant.joinedAt.millisecondsSinceEpoch - | ||||
|           b.participant.joinedAt.millisecondsSinceEpoch; | ||||
|     }); | ||||
|  | ||||
|     newTracks.addAll(mediaTrackList); | ||||
|  | ||||
|     if (_room.localParticipant != null) { | ||||
|       ParticipantTrack localTrack = ParticipantTrack( | ||||
|         participant: _room.localParticipant!, | ||||
|         videoTrack: null, | ||||
|         isScreenShare: false, | ||||
|       ); | ||||
|  | ||||
|       final localParticipantTracks = | ||||
|           _room.localParticipant?.videoTrackPublications; | ||||
|       if (localParticipantTracks != null) { | ||||
|         for (var t in localParticipantTracks) { | ||||
|           localTrack.videoTrack = t.track; | ||||
|           localTrack.isScreenShare = t.isScreenShare; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       newTracks.add(localTrack); | ||||
|     } | ||||
|  | ||||
|     _participantTracks = newTracks; | ||||
|  | ||||
|     if (focusTrack != null) { | ||||
|       final idx = participantTracks | ||||
|           .indexWhere((x) => x.participant.sid == _focusTrack!.participant.sid); | ||||
|       if (idx == -1) { | ||||
|         _focusTrack = null; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (focusTrack == null) { | ||||
|       _focusTrack = participantTracks.firstOrNull; | ||||
|     } else { | ||||
|       final idx = participantTracks.indexWhere( | ||||
|         (x) => _focusTrack!.participant.sid == x.participant.sid, | ||||
|       ); | ||||
|       if (idx > -1) { | ||||
|         _focusTrack = participantTracks[idx]; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   Future<void> _changeLocalAudioTrack() async { | ||||
|     if (_audioTrack != null) { | ||||
|       await _audioTrack!.stop(); | ||||
|       _audioTrack = null; | ||||
|     } | ||||
|  | ||||
|     if (_audioDevice != null) { | ||||
|       _audioTrack = await LocalAudioTrack.create( | ||||
|         AudioCaptureOptions(deviceId: _audioDevice!.deviceId), | ||||
|       ); | ||||
|       await _audioTrack!.start(); | ||||
|     } | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   Future<void> _changeLocalVideoTrack() async { | ||||
|     if (_videoTrack != null) { | ||||
|       await _videoTrack!.stop(); | ||||
|       _videoTrack = null; | ||||
|     } | ||||
|  | ||||
|     if (_videoDevice != null) { | ||||
|       _videoTrack = await LocalVideoTrack.createCameraTrack( | ||||
|         CameraCaptureOptions( | ||||
|           deviceId: _videoDevice!.deviceId, | ||||
|           params: VideoParametersPresets.h1080_169, | ||||
|         ), | ||||
|       ); | ||||
|       await _videoTrack!.start(); | ||||
|     } | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void _revertDevices(List<MediaDevice> devices) { | ||||
|     _audioInputs = devices.where((d) => d.kind == 'audioinput').toList(); | ||||
|     _videoInputs = devices.where((d) => d.kind == 'videoinput').toList(); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void _onRoomDidUpdate() => sortParticipants(); | ||||
|  | ||||
|   Future<void> changeLocalAudioTrack() async { | ||||
|     if (audioTrack != null) { | ||||
|       await audioTrack!.stop(); | ||||
|       _audioTrack = null; | ||||
|     } | ||||
|  | ||||
|     if (audioDevice != null) { | ||||
|       _audioTrack = await LocalAudioTrack.create( | ||||
|         AudioCaptureOptions( | ||||
|           deviceId: audioDevice!.deviceId, | ||||
|         ), | ||||
|       ); | ||||
|       await audioTrack!.start(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> changeLocalVideoTrack() async { | ||||
|     if (videoTrack != null) { | ||||
|       await _videoTrack!.stop(); | ||||
|       _videoTrack = null; | ||||
|     } | ||||
|  | ||||
|     if (videoDevice != null) { | ||||
|       _videoTrack = await LocalVideoTrack.createCameraTrack( | ||||
|         CameraCaptureOptions( | ||||
|           deviceId: videoDevice!.deviceId, | ||||
|           params: VideoParametersPresets.h1080_169, | ||||
|         ), | ||||
|       ); | ||||
|       await videoTrack!.start(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void deactivateHardware() { | ||||
|     hwSubscription?.cancel(); | ||||
|   } | ||||
|  | ||||
|   void disposeRoom() { | ||||
|     _isBusy = false; | ||||
|     _isMounted = false; | ||||
|     _isInitialized = false; | ||||
|     _current = null; | ||||
|     _channel = null; | ||||
|     _room.removeListener(_onRoomDidUpdate); | ||||
|     _room.disconnect(); | ||||
|     _room.dispose(); | ||||
|     _listener.dispose(); | ||||
|     WakelockPlus.disable(); | ||||
|   } | ||||
|  | ||||
|   void disposeHardware() { | ||||
|     _isReady = false; | ||||
|     _audioTrack?.stop(); | ||||
|     _audioTrack = null; | ||||
|     _videoTrack?.stop(); | ||||
|     _videoTrack = null; | ||||
|   } | ||||
|  | ||||
|   void setVideoDevice(MediaDevice? value) { | ||||
|     _videoDevice = value; | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void setAudioDevice(MediaDevice? value) { | ||||
|     _audioDevice = value; | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void setFocusTrack(ParticipantTrack? value) { | ||||
|     _focusTrack = value; | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void setIsBusy(bool value) { | ||||
|     _isBusy = value; | ||||
|     notifyListeners(); | ||||
|   } | ||||
| } | ||||
| @@ -24,6 +24,14 @@ class NavigationProvider extends ChangeNotifier { | ||||
|  | ||||
|   int? get currentIndex => _currentIndex; | ||||
|  | ||||
|   static const List<String> kShowBottomNavScreen = [ | ||||
|     'home', | ||||
|     'explore', | ||||
|     'account', | ||||
|     'album', | ||||
|     'chat', | ||||
|   ]; | ||||
|  | ||||
|   static const List<AppNavDestination> kAllDestination = [ | ||||
|     AppNavDestination( | ||||
|       icon: Icon(Symbols.home, weight: 400, opticalSize: 20), | ||||
| @@ -35,26 +43,37 @@ class NavigationProvider extends ChangeNotifier { | ||||
|       screen: 'explore', | ||||
|       label: 'screenExplore', | ||||
|     ), | ||||
|     AppNavDestination( | ||||
|       icon: Icon(Symbols.chat, weight: 400, opticalSize: 20), | ||||
|       screen: 'chat', | ||||
|       label: 'screenChat', | ||||
|     ), | ||||
|     AppNavDestination( | ||||
|       icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20), | ||||
|       screen: 'account', | ||||
|       label: 'screenAccount', | ||||
|     ), | ||||
|     AppNavDestination( | ||||
|       icon: Icon(Symbols.group, weight: 400, opticalSize: 20), | ||||
|       screen: 'realm', | ||||
|       label: 'screenRealm', | ||||
|     ), | ||||
|     AppNavDestination( | ||||
|       icon: Icon(Symbols.album, weight: 400, opticalSize: 20), | ||||
|       screen: 'album', | ||||
|       label: 'screenAlbum', | ||||
|     ), | ||||
|     AppNavDestination( | ||||
|       icon: Icon(Symbols.chat, weight: 400, opticalSize: 20), | ||||
|       screen: 'chat', | ||||
|       label: 'screenChat', | ||||
|       icon: Icon(Symbols.notifications, weight: 400, opticalSize: 20), | ||||
|       screen: 'notification', | ||||
|       label: 'screenNotification', | ||||
|     ), | ||||
|   ]; | ||||
|   static const List<String> kDefaultPinnedDestination = [ | ||||
|     'home', | ||||
|     'explore', | ||||
|     'account' | ||||
|     'chat', | ||||
|     'account', | ||||
|   ]; | ||||
|  | ||||
|   List<AppNavDestination> destinations = []; | ||||
|   | ||||
							
								
								
									
										66
									
								
								lib/providers/notification.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								lib/providers/notification.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,66 @@ | ||||
| import 'dart:developer'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:firebase_messaging/firebase_messaging.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_udid/flutter_udid.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
| import 'package:surface/providers/websocket.dart'; | ||||
|  | ||||
| class NotificationProvider extends ChangeNotifier { | ||||
|   late final SnNetworkProvider _sn; | ||||
|   late final UserProvider _ua; | ||||
|   late final WebSocketProvider _ws; | ||||
|  | ||||
|   NotificationProvider(BuildContext context) { | ||||
|     _sn = context.read<SnNetworkProvider>(); | ||||
|     _ua = context.read<UserProvider>(); | ||||
|     _ws = context.read<WebSocketProvider>(); | ||||
|  | ||||
|     // Delay to wait user provider ready to use | ||||
|     Future.delayed(const Duration(milliseconds: 3000), () async { | ||||
|       if (!_ua.isAuthorized) return; | ||||
|       log("Registering push notifications..."); | ||||
|       await registerPushNotifications(); | ||||
|       log("Registered push notification subscriber successfully!"); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   Future<void> registerPushNotifications() async { | ||||
|     if (kIsWeb) return; | ||||
|     if (!_ua.isAuthorized) return; | ||||
|  | ||||
|     late final String? token; | ||||
|     late final String provider; | ||||
|     var deviceUuid = await FlutterUdid.consistentUdid; | ||||
|  | ||||
|     if (deviceUuid.isEmpty) { | ||||
|       log("Unable to active push notifications, couldn't get device uuid"); | ||||
|       return; | ||||
|     } else { | ||||
|       log('Device UUID is $deviceUuid'); | ||||
|       log('Registering device push notifications...'); | ||||
|     } | ||||
|  | ||||
|     if (Platform.isIOS || Platform.isMacOS) { | ||||
|       provider = 'apns'; | ||||
|       token = await FirebaseMessaging.instance.getAPNSToken(); | ||||
|     } else { | ||||
|       provider = 'fcm'; | ||||
|       token = await FirebaseMessaging.instance.getToken(); | ||||
|     } | ||||
|     log('Device Push Token is $token'); | ||||
|  | ||||
|     await _sn.client.post( | ||||
|       '/cgi/id/notifications/subscription', | ||||
|       data: { | ||||
|         'provider': provider, | ||||
|         'device_token': token, | ||||
|         'device_id': deviceUuid, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										119
									
								
								lib/providers/post.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								lib/providers/post.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,119 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/sn_attachment.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
|  | ||||
| class SnPostContentProvider { | ||||
|   late final SnNetworkProvider _sn; | ||||
|   late final SnAttachmentProvider _attach; | ||||
|  | ||||
|   SnPostContentProvider(BuildContext context) { | ||||
|     _sn = context.read<SnNetworkProvider>(); | ||||
|     _attach = context.read<SnAttachmentProvider>(); | ||||
|   } | ||||
|  | ||||
|   Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async { | ||||
|     Set<String> rids = {}; | ||||
|     for (var i = 0; i < out.length; i++) { | ||||
|       rids.addAll(out[i].body['attachments']?.cast<String>() ?? []); | ||||
|       if (out[i].body['thumbnail'] != null) { | ||||
|         rids.add(out[i].body['thumbnail']); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     final attachments = await _attach.getMultiple(rids.toList()); | ||||
|     for (var i = 0; i < out.length; i++) { | ||||
|       out[i] = out[i].copyWith( | ||||
|         preload: SnPostPreload( | ||||
|           thumbnail: attachments | ||||
|               .where((ele) => ele?.rid == out[i].body['thumbnail']) | ||||
|               .firstOrNull, | ||||
|           attachments: attachments | ||||
|               .where((ele) => | ||||
|                   out[i].body['attachments']?.contains(ele?.rid) ?? false) | ||||
|               .toList(), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return out; | ||||
|   } | ||||
|  | ||||
|   Future<SnPost> _preloadRelatedDataSingle(SnPost out) async { | ||||
|     Set<String> rids = {}; | ||||
|     rids.addAll(out.body['attachments']?.cast<String>() ?? []); | ||||
|     if (out.body['thumbnail'] != null) { | ||||
|       rids.add(out.body['thumbnail']); | ||||
|     } | ||||
|  | ||||
|     final attachments = await _attach.getMultiple(rids.toList()); | ||||
|     out = out.copyWith( | ||||
|       preload: SnPostPreload( | ||||
|         thumbnail: attachments | ||||
|             .where((ele) => ele?.rid == out.body['thumbnail']) | ||||
|             .firstOrNull, | ||||
|         attachments: attachments | ||||
|             .where( | ||||
|                 (ele) => out.body['attachments']?.contains(ele?.rid) ?? false) | ||||
|             .toList(), | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     return out; | ||||
|   } | ||||
|  | ||||
|   Future<(List<SnPost>, int)> listPosts({int take = 10, int offset = 0}) async { | ||||
|     final resp = await _sn.client.get('/cgi/co/posts', queryParameters: { | ||||
|       'take': take, | ||||
|       'offset': offset, | ||||
|     }); | ||||
|     final List<SnPost> out = await _preloadRelatedDataInBatch( | ||||
|       List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), | ||||
|     ); | ||||
|  | ||||
|     return (out, resp.data['count'] as int); | ||||
|   } | ||||
|  | ||||
|   Future<(List<SnPost>, int)> listPostReplies( | ||||
|     dynamic parentId, { | ||||
|     int take = 10, | ||||
|     int offset = 0, | ||||
|   }) async { | ||||
|     final resp = await _sn.client | ||||
|         .get('/cgi/co/posts/$parentId/replies', queryParameters: { | ||||
|       'take': take, | ||||
|       'offset': offset, | ||||
|     }); | ||||
|     final List<SnPost> out = await _preloadRelatedDataInBatch( | ||||
|       List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), | ||||
|     ); | ||||
|  | ||||
|     return (out, resp.data['count'] as int); | ||||
|   } | ||||
|  | ||||
|   Future<(List<SnPost>, int)> searchPosts( | ||||
|     String searchTerm, { | ||||
|     int take = 10, | ||||
|     int offset = 0, | ||||
|   }) async { | ||||
|     final resp = await _sn.client.get('/cgi/co/posts/search', queryParameters: { | ||||
|       'take': take, | ||||
|       'offset': offset, | ||||
|       'probe': searchTerm, | ||||
|     }); | ||||
|     final List<SnPost> out = await _preloadRelatedDataInBatch( | ||||
|       List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), | ||||
|     ); | ||||
|  | ||||
|     return (out, resp.data['count'] as int); | ||||
|   } | ||||
|  | ||||
|   Future<SnPost> getPost(dynamic id) async { | ||||
|     final resp = await _sn.client.get('/cgi/co/posts/$id'); | ||||
|     final out = _preloadRelatedDataSingle( | ||||
|       SnPost.fromJson(resp.data['data']), | ||||
|     ); | ||||
|     return out; | ||||
|   } | ||||
| } | ||||
| @@ -19,6 +19,14 @@ class SnAttachmentProvider { | ||||
|     _sn = context.read<SnNetworkProvider>(); | ||||
|   } | ||||
|  | ||||
|   void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) { | ||||
|     for (final item in items) { | ||||
|       if ((item.isAnalyzed && item.isUploaded) || noCheck) { | ||||
|         _cache[item.rid] = item; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<SnAttachment> getOne(String rid, {noCache = false}) async { | ||||
|     if (!noCache && _cache.containsKey(rid)) { | ||||
|       return _cache[rid]!; | ||||
| @@ -26,37 +34,49 @@ class SnAttachmentProvider { | ||||
|  | ||||
|     final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta'); | ||||
|     final out = SnAttachment.fromJson(resp.data); | ||||
|     _cache[rid] = out; | ||||
|     if (out.isAnalyzed && out.isUploaded) { | ||||
|       _cache[rid] = out; | ||||
|     } | ||||
|  | ||||
|     return out; | ||||
|   } | ||||
|  | ||||
|   Future<List<SnAttachment>> getMultiple(List<String> rids, | ||||
|   Future<List<SnAttachment?>> getMultiple(List<String> rids, | ||||
|       {noCache = false}) async { | ||||
|     final pendingFetch = | ||||
|         noCache ? rids : rids.where((rid) => !_cache.containsKey(rid)).toList(); | ||||
|     final result = List<SnAttachment?>.filled(rids.length, null); | ||||
|     final Map<String, int> randomMapping = {}; | ||||
|     for (int i = 0; i < rids.length; i++) { | ||||
|       final rid = rids[i]; | ||||
|       if (noCache || !_cache.containsKey(rid)) { | ||||
|         randomMapping[rid] = i; | ||||
|       } else { | ||||
|         result[i] = _cache[rid]!; | ||||
|       } | ||||
|     } | ||||
|     final pendingFetch = randomMapping.keys; | ||||
|  | ||||
|     if (pendingFetch.isEmpty) { | ||||
|       return rids.map((rid) => _cache[rid]!).toList(); | ||||
|     if (pendingFetch.isNotEmpty) { | ||||
|       final resp = await _sn.client.get( | ||||
|         '/cgi/uc/attachments', | ||||
|         queryParameters: { | ||||
|           'take': pendingFetch.length, | ||||
|           'id': pendingFetch.join(','), | ||||
|         }, | ||||
|       ); | ||||
|       final out = resp.data['data'] | ||||
|           .map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)) | ||||
|           .toList(); | ||||
|  | ||||
|       for (final item in out) { | ||||
|         if (item == null) continue; | ||||
|         if (item.isAnalyzed && item.isUploaded) { | ||||
|           _cache[item.rid] = item; | ||||
|         } | ||||
|         result[randomMapping[item.rid]!] = item; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     final resp = await _sn.client.get('/cgi/uc/attachments', queryParameters: { | ||||
|       'take': pendingFetch.length, | ||||
|       'id': pendingFetch.join(','), | ||||
|     }); | ||||
|     final out = resp.data['data'] | ||||
|         .where((e) => e['id'] != 0) | ||||
|         .map((e) => SnAttachment.fromJson(e)) | ||||
|         .toList(); | ||||
|  | ||||
|     for (final item in out) { | ||||
|       _cache[item.rid] = item; | ||||
|     } | ||||
|  | ||||
|     return rids | ||||
|         .where((rid) => _cache.containsKey(rid)) | ||||
|         .map((rid) => _cache[rid]!) | ||||
|         .toList(); | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   static Map<String, String> mimetypeOverrides = { | ||||
| @@ -110,8 +130,9 @@ class SnAttachmentProvider { | ||||
|     int size, | ||||
|     String filename, | ||||
|     String pool, | ||||
|     Map<String, dynamic>? metadata, | ||||
|   ) async { | ||||
|     Map<String, dynamic>? metadata, { | ||||
|     String? mimetype, | ||||
|   }) async { | ||||
|     final fileAlt = filename.contains('.') | ||||
|         ? filename.substring(0, filename.lastIndexOf('.')) | ||||
|         : filename; | ||||
| @@ -119,8 +140,10 @@ class SnAttachmentProvider { | ||||
|         filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); | ||||
|  | ||||
|     String? mimetypeOverride; | ||||
|     if (mimetypeOverrides.keys.contains(fileExt)) { | ||||
|     if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) { | ||||
|       mimetypeOverride = mimetypeOverrides[fileExt]; | ||||
|     } else { | ||||
|       mimetypeOverride = mimetype; | ||||
|     } | ||||
|  | ||||
|     final resp = await _sn.client.post('/cgi/uc/attachments/multipart', data: { | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
| import 'dart:developer'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:dio_smart_retry/dio_smart_retry.dart'; | ||||
| import 'package:flutter_secure_storage/flutter_secure_storage.dart'; | ||||
| import 'package:shared_preferences/shared_preferences.dart'; | ||||
| import 'package:surface/providers/adapters/sn_network_universal.dart'; | ||||
| import 'package:synchronized/synchronized.dart'; | ||||
|  | ||||
| const kAtkStoreKey = 'nex_user_atk'; | ||||
| const kRtkStoreKey = 'nex_user_rtk'; | ||||
| @@ -20,10 +20,9 @@ const kNetworkServerDirectory = [ | ||||
| ]; | ||||
|  | ||||
| class SnNetworkProvider { | ||||
|   late Dio client; | ||||
|   late final Dio client; | ||||
|  | ||||
|   late final SharedPreferences _prefs; | ||||
|   late final FlutterSecureStorage _storage = FlutterSecureStorage(); | ||||
|  | ||||
|   SnNetworkProvider() { | ||||
|     client = Dio(); | ||||
| @@ -44,54 +43,15 @@ class SnNetworkProvider { | ||||
|           RequestOptions options, | ||||
|           RequestInterceptorHandler handler, | ||||
|         ) async { | ||||
|           try { | ||||
|             var atk = await _storage.read(key: kAtkStoreKey); | ||||
|             if (atk != null) { | ||||
|               final atkParts = atk.split('.'); | ||||
|               if (atkParts.length != 3) { | ||||
|                 throw Exception('invalid format of access token'); | ||||
|               } | ||||
|  | ||||
|               var rawPayload = | ||||
|                   atkParts[1].replaceAll('-', '+').replaceAll('_', '/'); | ||||
|               switch (rawPayload.length % 4) { | ||||
|                 case 0: | ||||
|                   break; | ||||
|                 case 2: | ||||
|                   rawPayload += '=='; | ||||
|                   break; | ||||
|                 case 3: | ||||
|                   rawPayload += '='; | ||||
|                   break; | ||||
|                 default: | ||||
|                   throw Exception('illegal format of access token payload'); | ||||
|               } | ||||
|  | ||||
|               final b64 = utf8.fuse(base64Url); | ||||
|               final payload = b64.decode(rawPayload); | ||||
|               final exp = jsonDecode(payload)['exp']; | ||||
|               if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) { | ||||
|                 log('Access token need refresh, doing it at ${DateTime.now()}'); | ||||
|                 atk = await refreshToken(); | ||||
|               } | ||||
|  | ||||
|               if (atk != null) { | ||||
|                 options.headers['Authorization'] = 'Bearer $atk'; | ||||
|               } else { | ||||
|                 log('Access token refresh failed...'); | ||||
|               } | ||||
|             } | ||||
|           } catch (err) { | ||||
|             log('Failed to authenticate user: $err'); | ||||
|           } finally { | ||||
|             handler.next(options); | ||||
|           final atk = await getFreshAtk(); | ||||
|           if (atk != null) { | ||||
|             options.headers['Authorization'] = 'Bearer $atk'; | ||||
|           } | ||||
|           return handler.next(options); | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     client = addClientAdapter(client); | ||||
|  | ||||
|     SharedPreferences.getInstance().then((prefs) { | ||||
|       _prefs = prefs; | ||||
|       client.options.baseUrl = | ||||
| @@ -99,27 +59,82 @@ class SnNetworkProvider { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   final tkLock = Lock(); | ||||
|  | ||||
|   Completer<String?>? _refreshCompleter; | ||||
|  | ||||
|   Future<String?> getFreshAtk() async { | ||||
|     if (_refreshCompleter != null) { | ||||
|       return await _refreshCompleter!.future; | ||||
|     } else { | ||||
|       _refreshCompleter = Completer<String?>(); | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       var atk = _prefs.getString(kAtkStoreKey); | ||||
|       if (atk != null) { | ||||
|         final atkParts = atk.split('.'); | ||||
|         if (atkParts.length != 3) { | ||||
|           throw Exception('invalid format of access token'); | ||||
|         } | ||||
|  | ||||
|         var rawPayload = atkParts[1].replaceAll('-', '+').replaceAll('_', '/'); | ||||
|         switch (rawPayload.length % 4) { | ||||
|           case 0: | ||||
|             break; | ||||
|           case 2: | ||||
|             rawPayload += '=='; | ||||
|             break; | ||||
|           case 3: | ||||
|             rawPayload += '='; | ||||
|             break; | ||||
|           default: | ||||
|             throw Exception('illegal format of access token payload'); | ||||
|         } | ||||
|  | ||||
|         final b64 = utf8.fuse(base64Url); | ||||
|         final payload = b64.decode(rawPayload); | ||||
|         final exp = jsonDecode(payload)['exp']; | ||||
|         if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) { | ||||
|           log('Access token need refresh, doing it at ${DateTime.now()}'); | ||||
|           atk = await refreshToken(); | ||||
|         } | ||||
|  | ||||
|         if (atk != null) { | ||||
|           _refreshCompleter!.complete(atk); | ||||
|           return atk; | ||||
|         } else { | ||||
|           log('Access token refresh failed...'); | ||||
|           _refreshCompleter!.complete(null); | ||||
|         } | ||||
|       } | ||||
|     } catch (err) { | ||||
|       log('Failed to authenticate user: $err'); | ||||
|       _refreshCompleter!.completeError(err); | ||||
|     } finally { | ||||
|       _refreshCompleter = null; | ||||
|     } | ||||
|  | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   String getAttachmentUrl(String ky) { | ||||
|     if (ky.startsWith("http")) return ky; | ||||
|     return '${client.options.baseUrl}/cgi/uc/attachments/$ky'; | ||||
|   } | ||||
|  | ||||
|   Future<void> setTokenPair(String atk, String rtk) async { | ||||
|     await Future.wait([ | ||||
|       _storage.write(key: kAtkStoreKey, value: atk), | ||||
|       _storage.write(key: kRtkStoreKey, value: rtk), | ||||
|     ]); | ||||
|   void setTokenPair(String atk, String rtk) { | ||||
|     _prefs.setString(kAtkStoreKey, atk); | ||||
|     _prefs.setString(kRtkStoreKey, rtk); | ||||
|   } | ||||
|  | ||||
|   Future<void> clearTokenPair() async { | ||||
|     await Future.wait([ | ||||
|       _storage.delete(key: kAtkStoreKey), | ||||
|       _storage.delete(key: kRtkStoreKey), | ||||
|     ]); | ||||
|   void clearTokenPair() { | ||||
|     _prefs.remove(kAtkStoreKey); | ||||
|     _prefs.remove(kRtkStoreKey); | ||||
|   } | ||||
|  | ||||
|   Future<String?> refreshToken() async { | ||||
|     final rtk = await _storage.read(key: kRtkStoreKey); | ||||
|     final rtk = _prefs.getString(kRtkStoreKey); | ||||
|     if (rtk == null) return null; | ||||
|  | ||||
|     final dio = Dio(); | ||||
| @@ -132,7 +147,7 @@ class SnNetworkProvider { | ||||
|  | ||||
|     final atk = resp.data['access_token']; | ||||
|     final nRtk = resp.data['refresh_token']; | ||||
|     await setTokenPair(atk, nRtk); | ||||
|     setTokenPair(atk, nRtk); | ||||
|  | ||||
|     return atk; | ||||
|   } | ||||
|   | ||||
							
								
								
									
										50
									
								
								lib/providers/user_directory.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								lib/providers/user_directory.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/account.dart'; | ||||
|  | ||||
| class UserDirectoryProvider { | ||||
|   late final SnNetworkProvider _sn; | ||||
|  | ||||
|   UserDirectoryProvider(BuildContext context) { | ||||
|     _sn = context.read<SnNetworkProvider>(); | ||||
|   } | ||||
|  | ||||
|   final Map<String, int> _idCache = {}; | ||||
|   final Map<int, SnAccount> _cache = {}; | ||||
|  | ||||
|   Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async { | ||||
|     final out = await Future.wait( | ||||
|       id.map((e) => getAccount(e)), | ||||
|     ); | ||||
|     return out; | ||||
|   } | ||||
|  | ||||
|   Future<SnAccount?> getAccount(dynamic id) async { | ||||
|     if (id is String && _idCache.containsKey(id)) { | ||||
|       id = _idCache[id]; | ||||
|     } | ||||
|     if (_cache.containsKey(id)) { | ||||
|       return _cache[id]; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       final resp = await _sn.client.get('/cgi/id/users/$id'); | ||||
|       final account = SnAccount.fromJson( | ||||
|         resp.data as Map<String, dynamic>, | ||||
|       ); | ||||
|       _cache[account.id] = account; | ||||
|       if (id is String) _idCache[id] = account.id; | ||||
|       return account; | ||||
|     } catch (err) { | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   SnAccount? getAccountFromCache(dynamic id) { | ||||
|     if (id is String && _idCache.containsKey(id)) { | ||||
|       id = _idCache[id]; | ||||
|     } | ||||
|     return _cache[id]; | ||||
|   } | ||||
| } | ||||
| @@ -1,8 +1,8 @@ | ||||
| import 'dart:developer'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_secure_storage/flutter_secure_storage.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:shared_preferences/shared_preferences.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/account.dart'; | ||||
|  | ||||
| @@ -11,12 +11,17 @@ class UserProvider extends ChangeNotifier { | ||||
|   SnAccount? user; | ||||
|  | ||||
|   late final SnNetworkProvider _sn; | ||||
|   late final FlutterSecureStorage _storage = FlutterSecureStorage(); | ||||
|  | ||||
|   Future<String?> get atk async { | ||||
|     final prefs = await SharedPreferences.getInstance(); | ||||
|     return prefs.getString(kAtkStoreKey); | ||||
|   } | ||||
|  | ||||
|   UserProvider(BuildContext context) { | ||||
|     _sn = context.read<SnNetworkProvider>(); | ||||
|  | ||||
|     _storage.read(key: kAtkStoreKey).then((value) { | ||||
|     SharedPreferences.getInstance().then((prefs) { | ||||
|       final value = prefs.getString(kAtkStoreKey); | ||||
|       isAuthorized = value != null; | ||||
|       notifyListeners(); | ||||
|       refreshUser().then((value) { | ||||
| @@ -39,7 +44,7 @@ class UserProvider extends ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   void logoutUser() async { | ||||
|     await _sn.clearTokenPair(); | ||||
|     _sn.clearTokenPair(); | ||||
|     isAuthorized = false; | ||||
|     user = null; | ||||
|     notifyListeners(); | ||||
|   | ||||
							
								
								
									
										111
									
								
								lib/providers/websocket.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								lib/providers/websocket.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,111 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
| import 'dart:developer'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
| import 'package:surface/types/websocket.dart'; | ||||
| import 'package:web_socket_channel/web_socket_channel.dart'; | ||||
|  | ||||
| class WebSocketProvider extends ChangeNotifier { | ||||
|   bool isBusy = false; | ||||
|   bool isConnected = false; | ||||
|  | ||||
|   WebSocketChannel? conn; | ||||
|  | ||||
|   late final SnNetworkProvider _sn; | ||||
|   late final UserProvider _ua; | ||||
|  | ||||
|   StreamController<WebSocketPackage> stream = StreamController.broadcast(); | ||||
|  | ||||
|   WebSocketProvider(BuildContext context) { | ||||
|     _sn = context.read<SnNetworkProvider>(); | ||||
|     _ua = context.read<UserProvider>(); | ||||
|  | ||||
|     // Wait for the userinfo provide initialize authorization status | ||||
|     Future.delayed(const Duration(milliseconds: 250), () async { | ||||
|       if (_ua.isAuthorized) { | ||||
|         log('[WebSocket] Connecting to the server...'); | ||||
|         await connect(); | ||||
|       } else { | ||||
|         log('[WebSocket] Unable connect to the server, unauthorized.'); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   Future<void> connect({noRetry = false}) async { | ||||
|     if (!_ua.isAuthorized) return; | ||||
|     if (isConnected) { | ||||
|       disconnect(); | ||||
|     } | ||||
|  | ||||
|     final atk = await _sn.getFreshAtk(); | ||||
|     final uri = Uri.parse( | ||||
|       '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk', | ||||
|     ); | ||||
|  | ||||
|     isBusy = true; | ||||
|     notifyListeners(); | ||||
|  | ||||
|     try { | ||||
|       conn = WebSocketChannel.connect(uri); | ||||
|       await conn!.ready; | ||||
|       listen(); | ||||
|       log('[WebSocket] Connected to server!'); | ||||
|       isConnected = true; | ||||
|     } catch (err) { | ||||
|       if (err is WebSocketChannelException) { | ||||
|         log('Failed to connect to websocket: ${(err.inner as dynamic).message}'); | ||||
|       } else { | ||||
|         log('Failed to connect to websocket: $err'); | ||||
|       } | ||||
|  | ||||
|       if (!noRetry) { | ||||
|         log('Retry connecting to websocket in 3 seconds...'); | ||||
|         return Future.delayed( | ||||
|           const Duration(seconds: 3), | ||||
|           () => connect(noRetry: true), | ||||
|         ); | ||||
|       } | ||||
|     } finally { | ||||
|       isBusy = false; | ||||
|       notifyListeners(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void disconnect() { | ||||
|     if (conn != null) { | ||||
|       conn!.sink.close(); | ||||
|     } | ||||
|     isConnected = false; | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void listen() { | ||||
|     conn?.stream.listen( | ||||
|       (event) { | ||||
|         final packet = WebSocketPackage.fromJson(jsonDecode(event)); | ||||
|         log('Websocket incoming message: ${packet.method} ${packet.message}'); | ||||
|         stream.sink.add(packet); | ||||
|         // TODO handle notification | ||||
|         // if (packet.method == 'notifications.new') { | ||||
|         //   final NotificationProvider nty = Get.find(); | ||||
|         //   nty.notifications.add(Notification.fromJson(packet.payload!)); | ||||
|         //   nty.notificationUnread.value++; | ||||
|         // } | ||||
|       }, | ||||
|       onDone: () { | ||||
|         isConnected = false; | ||||
|         notifyListeners(); | ||||
|         Future.delayed(const Duration(seconds: 1), () => connect()); | ||||
|       }, | ||||
|       onError: (err) { | ||||
|         isConnected = false; | ||||
|         notifyListeners(); | ||||
|         Future.delayed(const Duration(seconds: 11), () => connect()); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										341
									
								
								lib/router.dart
									
									
									
									
									
								
							
							
						
						
									
										341
									
								
								lib/router.dart
									
									
									
									
									
								
							| @@ -1,3 +1,5 @@ | ||||
| import 'package:animations/animations.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:surface/screens/account.dart'; | ||||
| import 'package:surface/screens/account/profile_edit.dart'; | ||||
| @@ -8,134 +10,239 @@ import 'package:surface/screens/album.dart'; | ||||
| import 'package:surface/screens/auth/login.dart'; | ||||
| import 'package:surface/screens/auth/register.dart'; | ||||
| import 'package:surface/screens/chat.dart'; | ||||
| import 'package:surface/screens/chat/call_room.dart'; | ||||
| import 'package:surface/screens/chat/manage.dart'; | ||||
| import 'package:surface/screens/chat/room.dart'; | ||||
| import 'package:surface/screens/explore.dart'; | ||||
| import 'package:surface/screens/home.dart'; | ||||
| import 'package:surface/screens/notification.dart'; | ||||
| import 'package:surface/screens/post/post_detail.dart'; | ||||
| import 'package:surface/screens/post/post_editor.dart'; | ||||
| import 'package:surface/screens/post/post_search.dart'; | ||||
| import 'package:surface/screens/realm.dart'; | ||||
| import 'package:surface/screens/realm/manage.dart'; | ||||
| import 'package:surface/screens/settings.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
| import 'package:surface/widgets/navigation/app_background.dart'; | ||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||
|  | ||||
| final _appRoutes = [ | ||||
|   ShellRoute( | ||||
|     builder: (context, state, child) => AppPageScaffold( | ||||
|       body: child, | ||||
|       showAppBar: false, | ||||
|     ), | ||||
|     routes: [ | ||||
|       GoRoute( | ||||
|         path: '/', | ||||
|         name: 'home', | ||||
|         pageBuilder: (context, state) => NoTransitionPage( | ||||
|           child: const HomeScreen(), | ||||
|         ), | ||||
|       ), | ||||
|       GoRoute( | ||||
|         path: '/posts', | ||||
|         name: 'explore', | ||||
|         pageBuilder: (context, state) => NoTransitionPage( | ||||
|           child: const ExploreScreen(), | ||||
|         ), | ||||
|         routes: [ | ||||
|           GoRoute( | ||||
|             path: '/write/:mode', | ||||
|             name: 'postEditor', | ||||
|             builder: (context, state) => AppBackground( | ||||
|               isLessOptimization: true, | ||||
|               child: PostEditorScreen( | ||||
|                 mode: state.pathParameters['mode']!, | ||||
|                 postEditId: int.tryParse( | ||||
|                   state.uri.queryParameters['editing'] ?? '', | ||||
|                 ), | ||||
|                 postReplyId: int.tryParse( | ||||
|                   state.uri.queryParameters['replying'] ?? '', | ||||
|                 ), | ||||
|                 postRepostId: int.tryParse( | ||||
|                   state.uri.queryParameters['reposting'] ?? '', | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|           GoRoute( | ||||
|             path: '/search', | ||||
|             name: 'postSearch', | ||||
|             builder: (context, state) => const AppBackground( | ||||
|               child: PostSearchScreen(), | ||||
|             ), | ||||
|           ), | ||||
|           GoRoute( | ||||
|             path: '/:slug', | ||||
|             name: 'postDetail', | ||||
|             builder: (context, state) => AppBackground( | ||||
|               child: PostDetailScreen( | ||||
|                 slug: state.pathParameters['slug']!, | ||||
|                 preload: state.extra as SnPost?, | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|       GoRoute( | ||||
|         path: '/account', | ||||
|         name: 'account', | ||||
|         pageBuilder: (context, state) => NoTransitionPage( | ||||
|           child: const AccountScreen(), | ||||
|         ), | ||||
|       ), | ||||
|       GoRoute( | ||||
|         path: '/chat', | ||||
|         name: 'chat', | ||||
|         pageBuilder: (context, state) => NoTransitionPage( | ||||
|           child: const ChatScreen(), | ||||
|         ), | ||||
|         routes: [ | ||||
|           GoRoute( | ||||
|             path: '/:scope/:alias', | ||||
|             name: 'chatRoom', | ||||
|             builder: (context, state) => AppBackground( | ||||
|               isLessOptimization: true, | ||||
|               child: ChatRoomScreen( | ||||
|                 scope: state.pathParameters['scope']!, | ||||
|                 alias: state.pathParameters['alias']!, | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|           GoRoute( | ||||
|             path: '/:scope/:alias/call', | ||||
|             name: 'chatCallRoom', | ||||
|             builder: (context, state) => AppBackground( | ||||
|               child: CallRoomScreen( | ||||
|                 scope: state.pathParameters['scope']!, | ||||
|                 alias: state.pathParameters['alias']!, | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|           GoRoute( | ||||
|             path: '/manage', | ||||
|             name: 'chatManage', | ||||
|             pageBuilder: (context, state) => CustomTransitionPage( | ||||
|               child: ChatManageScreen(), | ||||
|               transitionsBuilder: | ||||
|                   (context, animation, secondaryAnimation, child) { | ||||
|                 return FadeThroughTransition( | ||||
|                   animation: animation, | ||||
|                   secondaryAnimation: secondaryAnimation, | ||||
|                   fillColor: Colors.transparent, | ||||
|                   child: AppBackground( | ||||
|                     isLessOptimization: true, | ||||
|                     child: child, | ||||
|                   ), | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|       GoRoute( | ||||
|         path: '/realm', | ||||
|         name: 'realm', | ||||
|         pageBuilder: (context, state) => NoTransitionPage( | ||||
|           child: const RealmScreen(), | ||||
|         ), | ||||
|         routes: [ | ||||
|           GoRoute( | ||||
|             path: '/manage', | ||||
|             name: 'realmManage', | ||||
|             pageBuilder: (context, state) => CustomTransitionPage( | ||||
|               child: RealmManageScreen( | ||||
|                 editingRealmAlias: state.uri.queryParameters['editing'], | ||||
|               ), | ||||
|               transitionsBuilder: | ||||
|                   (context, animation, secondaryAnimation, child) { | ||||
|                 return FadeThroughTransition( | ||||
|                   animation: animation, | ||||
|                   secondaryAnimation: secondaryAnimation, | ||||
|                   fillColor: Colors.transparent, | ||||
|                   child: AppBackground( | ||||
|                     isLessOptimization: true, | ||||
|                     child: child, | ||||
|                   ), | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|       GoRoute( | ||||
|         path: '/album', | ||||
|         name: 'album', | ||||
|         pageBuilder: (context, state) => NoTransitionPage( | ||||
|           child: const AlbumScreen(), | ||||
|         ), | ||||
|       ), | ||||
|       GoRoute( | ||||
|         path: '/notification', | ||||
|         name: 'notification', | ||||
|         pageBuilder: (context, state) => NoTransitionPage( | ||||
|           child: const NotificationScreen(), | ||||
|         ), | ||||
|       ), | ||||
|     ], | ||||
|   ), | ||||
|   ShellRoute( | ||||
|     builder: (context, state, child) => AppPageScaffold(body: child), | ||||
|     routes: [ | ||||
|       GoRoute( | ||||
|         path: '/auth/login', | ||||
|         name: 'authLogin', | ||||
|         builder: (context, state) => const LoginScreen(), | ||||
|       ), | ||||
|       GoRoute( | ||||
|         path: '/auth/register', | ||||
|         name: 'authRegister', | ||||
|         builder: (context, state) => const RegisterScreen(), | ||||
|       ), | ||||
|       GoRoute( | ||||
|         path: '/account/profile/edit', | ||||
|         name: 'accountProfileEdit', | ||||
|         builder: (context, state) => const ProfileEditScreen(), | ||||
|       ), | ||||
|       GoRoute( | ||||
|         path: '/account/publishers', | ||||
|         name: 'accountPublishers', | ||||
|         builder: (context, state) => const PublisherScreen(), | ||||
|       ), | ||||
|       GoRoute( | ||||
|         path: '/account/publishers/new', | ||||
|         name: 'accountPublisherNew', | ||||
|         builder: (context, state) => const AccountPublisherNewScreen(), | ||||
|       ), | ||||
|       GoRoute( | ||||
|         path: '/account/publishers/edit/:name', | ||||
|         name: 'accountPublisherEdit', | ||||
|         builder: (context, state) => AccountPublisherEditScreen( | ||||
|           name: state.pathParameters['name']!, | ||||
|         ), | ||||
|       ), | ||||
|     ], | ||||
|   ), | ||||
|   ShellRoute( | ||||
|     builder: (context, state, child) => AppPageScaffold(body: child), | ||||
|     routes: [ | ||||
|       GoRoute( | ||||
|         path: '/settings', | ||||
|         name: 'settings', | ||||
|         builder: (context, state) => const AppBackground( | ||||
|           child: SettingsScreen(), | ||||
|         ), | ||||
|       ), | ||||
|     ], | ||||
|   ), | ||||
| ]; | ||||
|  | ||||
| final appRouter = GoRouter( | ||||
|   routes: [ | ||||
|     ShellRoute( | ||||
|       builder: (context, state, child) => AppScaffold( | ||||
|         body: child, | ||||
|         showBottomNavigation: true, | ||||
|         showDrawer: true, | ||||
|       ), | ||||
|       routes: [ | ||||
|         GoRoute( | ||||
|           path: '/', | ||||
|           name: 'home', | ||||
|           builder: (context, state) => const HomeScreen(), | ||||
|         ), | ||||
|         GoRoute( | ||||
|           path: '/posts', | ||||
|           name: 'explore', | ||||
|           builder: (context, state) => const ExploreScreen(), | ||||
|         ), | ||||
|         GoRoute( | ||||
|           path: '/account', | ||||
|           name: 'account', | ||||
|           builder: (context, state) => const AccountScreen(), | ||||
|         ), | ||||
|         GoRoute( | ||||
|           path: '/chat', | ||||
|           name: 'chat', | ||||
|           builder: (context, state) => const ChatScreen(), | ||||
|         ), | ||||
|         GoRoute( | ||||
|           path: '/album', | ||||
|           name: 'album', | ||||
|           builder: (context, state) => const AlbumScreen(), | ||||
|         ), | ||||
|       ], | ||||
|     ), | ||||
|     ShellRoute( | ||||
|       builder: (context, state, child) => AppScaffold( | ||||
|         body: child, | ||||
|       ), | ||||
|       routes: [ | ||||
|         GoRoute( | ||||
|           path: '/post/write/:mode', | ||||
|           name: 'postEditor', | ||||
|           builder: (context, state) => PostEditorScreen( | ||||
|             mode: state.pathParameters['mode']!, | ||||
|             postEditId: int.tryParse( | ||||
|               state.uri.queryParameters['editing'] ?? '', | ||||
|             ), | ||||
|             postReplyId: int.tryParse( | ||||
|               state.uri.queryParameters['replying'] ?? '', | ||||
|             ), | ||||
|             postRepostId: int.tryParse( | ||||
|               state.uri.queryParameters['reposting'] ?? '', | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|         GoRoute( | ||||
|           path: '/post/:slug', | ||||
|           name: 'postDetail', | ||||
|           builder: (context, state) => PostDetailScreen( | ||||
|             slug: state.pathParameters['slug']!, | ||||
|             preload: state.extra as SnPost?, | ||||
|           ), | ||||
|         ) | ||||
|       ], | ||||
|     ), | ||||
|     ShellRoute( | ||||
|       builder: (context, state, child) => AppScaffold( | ||||
|         body: child, | ||||
|         autoImplyAppBar: true, | ||||
|         showDrawer: true, | ||||
|       ), | ||||
|       routes: [ | ||||
|         GoRoute( | ||||
|           path: '/auth/login', | ||||
|           name: 'authLogin', | ||||
|           builder: (context, state) => const LoginScreen(), | ||||
|         ), | ||||
|         GoRoute( | ||||
|           path: '/auth/register', | ||||
|           name: 'authRegister', | ||||
|           builder: (context, state) => const RegisterScreen(), | ||||
|         ), | ||||
|         GoRoute( | ||||
|           path: '/account/profile/edit', | ||||
|           name: 'accountProfileEdit', | ||||
|           builder: (context, state) => const ProfileEditScreen(), | ||||
|         ), | ||||
|         GoRoute( | ||||
|           path: '/account/publishers', | ||||
|           name: 'accountPublishers', | ||||
|           builder: (context, state) => const PublisherScreen(), | ||||
|         ), | ||||
|         GoRoute( | ||||
|           path: '/account/publishers/new', | ||||
|           name: 'accountPublisherNew', | ||||
|           builder: (context, state) => const AccountPublisherNewScreen(), | ||||
|         ), | ||||
|         GoRoute( | ||||
|           path: '/account/publishers/edit/:name', | ||||
|           name: 'accountPublisherEdit', | ||||
|           builder: (context, state) => AccountPublisherEditScreen( | ||||
|             name: state.pathParameters['name']!, | ||||
|           ), | ||||
|         ), | ||||
|       ], | ||||
|     ), | ||||
|     ShellRoute( | ||||
|       builder: (context, state, child) => AppScaffold( | ||||
|         body: child, | ||||
|         autoImplyAppBar: true, | ||||
|       ), | ||||
|       routes: [ | ||||
|         GoRoute( | ||||
|           path: '/settings', | ||||
|           name: 'settings', | ||||
|           builder: (context, state) => const SettingsScreen(), | ||||
|         ), | ||||
|       ], | ||||
|       routes: _appRoutes, | ||||
|       builder: (context, state, child) => AppRootScaffold(body: child), | ||||
|     ), | ||||
|   ], | ||||
| ); | ||||
|   | ||||
| @@ -8,7 +8,6 @@ import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||
|  | ||||
| class AccountScreen extends StatelessWidget { | ||||
|   const AccountScreen({super.key}); | ||||
| @@ -17,7 +16,7 @@ class AccountScreen extends StatelessWidget { | ||||
|   Widget build(BuildContext context) { | ||||
|     final ua = context.watch<UserProvider>(); | ||||
|  | ||||
|     return AppScaffold( | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         title: Text("screenAccount").tr(), | ||||
|         actions: [ | ||||
| @@ -27,6 +26,7 @@ class AccountScreen extends StatelessWidget { | ||||
|               GoRouter.of(context).pushNamed('settings'); | ||||
|             }, | ||||
|           ), | ||||
|           const Gap(8), | ||||
|         ], | ||||
|       ), | ||||
|       body: SingleChildScrollView( | ||||
|   | ||||
| @@ -18,7 +18,6 @@ import 'package:surface/types/post.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/loading_indicator.dart'; | ||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||
| import 'package:surface/widgets/universal_image.dart'; | ||||
|  | ||||
| class AccountPublisherEditScreen extends StatefulWidget { | ||||
| @@ -149,20 +148,14 @@ class _AccountPublisherEditScreenState | ||||
|         mimetype: 'image/png', | ||||
|       ); | ||||
|  | ||||
|       if (!mounted) return; | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       await sn.client.put( | ||||
|         '/cgi/id/users/me/$place', | ||||
|         data: {'attachment': attachment.rid}, | ||||
|       ); | ||||
|  | ||||
|       if (!mounted) return; | ||||
|       final ua = context.read<UserProvider>(); | ||||
|       await ua.refreshUser(); | ||||
|  | ||||
|       if (!mounted) return; | ||||
|       context.showSnackbar('accountProfileEditApplied'.tr()); | ||||
|       _syncWidget(); | ||||
|       switch (place) { | ||||
|         case 'avatar': | ||||
|           _avatar = attachment.rid; | ||||
|           break; | ||||
|         case 'banner': | ||||
|           _banner = attachment.rid; | ||||
|           break; | ||||
|       } | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
| @@ -189,7 +182,7 @@ class _AccountPublisherEditScreenState | ||||
|   Widget build(BuildContext context) { | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|  | ||||
|     return AppScaffold( | ||||
|     return Scaffold( | ||||
|       body: SingleChildScrollView( | ||||
|         child: Column( | ||||
|           children: [ | ||||
| @@ -287,7 +280,7 @@ class _AccountPublisherEditScreenState | ||||
|               ], | ||||
|             ) | ||||
|           ], | ||||
|         ).padding(horizontal: 16, vertical: 12), | ||||
|         ).padding(horizontal: 24, vertical: 12), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -8,7 +8,6 @@ import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||
|  | ||||
| class AccountPublisherNewScreen extends StatefulWidget { | ||||
|   const AccountPublisherNewScreen({super.key}); | ||||
| @@ -23,7 +22,7 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AppScaffold( | ||||
|     return Scaffold( | ||||
|       body: SingleChildScrollView( | ||||
|         child: Column( | ||||
|           children: [ | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/cupertino.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| @@ -11,7 +10,6 @@ import 'package:surface/types/post.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/loading_indicator.dart'; | ||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||
|  | ||||
| class PublisherScreen extends StatefulWidget { | ||||
|   const PublisherScreen({super.key}); | ||||
| @@ -55,7 +53,7 @@ class _PublisherScreenState extends State<PublisherScreen> { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AppScaffold( | ||||
|     return Scaffold( | ||||
|       body: Column( | ||||
|         children: [ | ||||
|           ListTile( | ||||
|   | ||||
| @@ -151,7 +151,7 @@ class _LoginCheckScreenState extends State<_LoginCheckScreen> { | ||||
|       }); | ||||
|       final atk = tokenResp.data['access_token']; | ||||
|       final rtk = tokenResp.data['refresh_token']; | ||||
|       await sn.setTokenPair(atk, rtk); | ||||
|       sn.setTokenPair(atk, rtk); | ||||
|       if (!mounted) return; | ||||
|       final user = context.read<UserProvider>(); | ||||
|       final userinfo = await user.refreshUser(); | ||||
|   | ||||
| @@ -1,10 +1,91 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/channel.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/loading_indicator.dart'; | ||||
|  | ||||
| class ChatScreen extends StatelessWidget { | ||||
| class ChatScreen extends StatefulWidget { | ||||
|   const ChatScreen({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<ChatScreen> createState() => _ChatScreenState(); | ||||
| } | ||||
|  | ||||
| class _ChatScreenState extends State<ChatScreen> { | ||||
|   bool _isBusy = true; | ||||
|  | ||||
|   List<SnChannel>? _channels; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     final chan = context.read<ChatChannelProvider>(); | ||||
|     chan.fetchChannels().listen((channels) { | ||||
|       if (mounted) setState(() => _channels = channels); | ||||
|     }) | ||||
|       ..onError((err) { | ||||
|         if (!mounted) return; | ||||
|         context.showErrorDialog(err); | ||||
|         setState(() => _isBusy = false); | ||||
|       }) | ||||
|       ..onDone(() { | ||||
|         if (!mounted) return; | ||||
|         setState(() => _isBusy = false); | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return const Placeholder(); | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         title: Text('screenChat').tr(), | ||||
|       ), | ||||
|       floatingActionButton: FloatingActionButton( | ||||
|         child: const Icon(Symbols.chat_add_on), | ||||
|         onPressed: () { | ||||
|           GoRouter.of(context).pushNamed('chatManage'); | ||||
|         }, | ||||
|       ), | ||||
|       body: Column( | ||||
|         children: [ | ||||
|           LoadingIndicator(isActive: _isBusy), | ||||
|           Expanded( | ||||
|             child: ListView.builder( | ||||
|               itemCount: _channels?.length ?? 0, | ||||
|               itemBuilder: (context, idx) { | ||||
|                 final channel = _channels![idx]; | ||||
|                 return ListTile( | ||||
|                   title: Text(channel.name), | ||||
|                   subtitle: Text( | ||||
|                     channel.description, | ||||
|                     maxLines: 1, | ||||
|                     overflow: TextOverflow.ellipsis, | ||||
|                   ), | ||||
|                   contentPadding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|                   leading: AccountImage( | ||||
|                     content: null, | ||||
|                     fallbackWidget: const Icon(Symbols.chat, size: 20), | ||||
|                   ), | ||||
|                   onTap: () { | ||||
|                     GoRouter.of(context).pushNamed( | ||||
|                       'chatRoom', | ||||
|                       pathParameters: { | ||||
|                         'scope': channel.realm?.alias ?? 'global', | ||||
|                         'alias': channel.alias, | ||||
|                       }, | ||||
|                     ); | ||||
|                   }, | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										324
									
								
								lib/screens/chat/call_room.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										324
									
								
								lib/screens/chat/call_room.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,324 @@ | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart' as livekit; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/chat_call.dart'; | ||||
| import 'package:surface/widgets/chat/call/call_controls.dart'; | ||||
| import 'package:surface/widgets/chat/call/call_participant.dart'; | ||||
|  | ||||
| class CallRoomScreen extends StatefulWidget { | ||||
|   final String scope; | ||||
|   final String alias; | ||||
|   const CallRoomScreen({super.key, required this.scope, required this.alias}); | ||||
|  | ||||
|   @override | ||||
|   State<CallRoomScreen> createState() => _CallRoomScreenState(); | ||||
| } | ||||
|  | ||||
| class _CallRoomScreenState extends State<CallRoomScreen> { | ||||
|   int _layoutMode = 0; | ||||
|  | ||||
|   void _switchLayout() { | ||||
|     if (_layoutMode < 1) { | ||||
|       setState(() => _layoutMode++); | ||||
|     } else { | ||||
|       setState(() => _layoutMode = 0); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Widget _buildListLayout() { | ||||
|     final call = context.read<ChatCallProvider>(); | ||||
|     return Stack( | ||||
|       children: [ | ||||
|         Container( | ||||
|           color: | ||||
|               Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75), | ||||
|           child: call.focusTrack != null | ||||
|               ? InteractiveParticipantWidget( | ||||
|                   isFixedAvatar: false, | ||||
|                   participant: call.focusTrack!, | ||||
|                   onTap: () {}, | ||||
|                 ) | ||||
|               : const SizedBox.shrink(), | ||||
|         ), | ||||
|         Positioned( | ||||
|           left: 0, | ||||
|           right: 0, | ||||
|           top: 0, | ||||
|           child: SizedBox( | ||||
|             height: 128, | ||||
|             child: ListView.builder( | ||||
|               scrollDirection: Axis.horizontal, | ||||
|               itemCount: math.max(0, call.participantTracks.length), | ||||
|               itemBuilder: (BuildContext context, int index) { | ||||
|                 final track = call.participantTracks[index]; | ||||
|                 if (track.participant.sid == call.focusTrack?.participant.sid) { | ||||
|                   return Container(); | ||||
|                 } | ||||
|  | ||||
|                 return Padding( | ||||
|                   padding: const EdgeInsets.only(top: 8, left: 8), | ||||
|                   child: ClipRRect( | ||||
|                     borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                     child: InteractiveParticipantWidget( | ||||
|                       isFixedAvatar: true, | ||||
|                       width: 120, | ||||
|                       height: 120, | ||||
|                       color: Theme.of(context).cardColor, | ||||
|                       participant: track, | ||||
|                       onTap: () { | ||||
|                         if (track.participant.sid != | ||||
|                             call.focusTrack?.participant.sid) { | ||||
|                           call.setFocusTrack(track); | ||||
|                         } | ||||
|                       }, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildGridLayout() { | ||||
|     final call = context.read<ChatCallProvider>(); | ||||
|  | ||||
|     return LayoutBuilder(builder: (context, constraints) { | ||||
|       double screenWidth = constraints.maxWidth; | ||||
|       double screenHeight = constraints.maxHeight; | ||||
|  | ||||
|       int columns = (math.sqrt(call.participantTracks.length)).ceil(); | ||||
|       int rows = (call.participantTracks.length / columns).ceil(); | ||||
|  | ||||
|       double tileWidth = screenWidth / columns; | ||||
|       double tileHeight = screenHeight / rows; | ||||
|  | ||||
|       return StyledWidget(GridView.builder( | ||||
|         gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( | ||||
|           crossAxisCount: columns, | ||||
|           childAspectRatio: tileWidth / tileHeight, | ||||
|           crossAxisSpacing: 8, | ||||
|           mainAxisSpacing: 8, | ||||
|         ), | ||||
|         itemCount: math.max(0, call.participantTracks.length), | ||||
|         itemBuilder: (BuildContext context, int index) { | ||||
|           final track = call.participantTracks[index]; | ||||
|           return Card( | ||||
|             child: ClipRRect( | ||||
|               borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|               child: InteractiveParticipantWidget( | ||||
|                 color: Theme.of(context) | ||||
|                     .colorScheme | ||||
|                     .surfaceContainerHigh | ||||
|                     .withOpacity(0.75), | ||||
|                 participant: track, | ||||
|                 onTap: () { | ||||
|                   if (track.participant.sid != | ||||
|                       call.focusTrack?.participant.sid) { | ||||
|                     call.setFocusTrack(track); | ||||
|                   } | ||||
|                 }, | ||||
|               ), | ||||
|             ), | ||||
|           ); | ||||
|         }, | ||||
|       )).padding(all: 8); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     final call = context.read<ChatCallProvider>(); | ||||
|  | ||||
|     Future.delayed(Duration.zero, () { | ||||
|       call | ||||
|         ..setupRoom() | ||||
|         ..enableDurationUpdater(); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final call = context.read<ChatCallProvider>(); | ||||
|  | ||||
|     return ListenableBuilder( | ||||
|         listenable: call, | ||||
|         builder: (context, _) { | ||||
|           return Scaffold( | ||||
|             appBar: AppBar( | ||||
|               title: RichText( | ||||
|                 textAlign: TextAlign.center, | ||||
|                 text: TextSpan(children: [ | ||||
|                   TextSpan( | ||||
|                     text: 'call'.tr(), | ||||
|                     style: Theme.of(context) | ||||
|                         .textTheme | ||||
|                         .titleLarge! | ||||
|                         .copyWith(color: Colors.white), | ||||
|                   ), | ||||
|                   const TextSpan(text: '\n'), | ||||
|                   TextSpan( | ||||
|                     text: call.lastDuration.toString(), | ||||
|                     style: Theme.of(context) | ||||
|                         .textTheme | ||||
|                         .bodySmall! | ||||
|                         .copyWith(color: Colors.white), | ||||
|                   ), | ||||
|                 ]), | ||||
|               ), | ||||
|             ), | ||||
|             body: SafeArea( | ||||
|               child: GestureDetector( | ||||
|                 behavior: HitTestBehavior.translucent, | ||||
|                 child: Column( | ||||
|                   children: [ | ||||
|                     SizedBox( | ||||
|                       width: MediaQuery.of(context).size.width, | ||||
|                       height: 64, | ||||
|                       child: Row( | ||||
|                         mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                         crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                         children: [ | ||||
|                           Builder(builder: (context) { | ||||
|                             final call = context.read<ChatCallProvider>(); | ||||
|                             final connectionQuality = | ||||
|                                 call.room.localParticipant?.connectionQuality ?? | ||||
|                                     livekit.ConnectionQuality.unknown; | ||||
|                             return Expanded( | ||||
|                               child: Column( | ||||
|                                 mainAxisSize: MainAxisSize.min, | ||||
|                                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                                 children: [ | ||||
|                                   Row( | ||||
|                                     children: [ | ||||
|                                       Text( | ||||
|                                         call.channel?.name ?? 'unknown'.tr(), | ||||
|                                         style: const TextStyle( | ||||
|                                           fontWeight: FontWeight.bold, | ||||
|                                         ), | ||||
|                                       ), | ||||
|                                       const Gap(6), | ||||
|                                       Text(call.lastDuration.toString()) | ||||
|                                     ], | ||||
|                                   ), | ||||
|                                   Row( | ||||
|                                     children: [ | ||||
|                                       Text( | ||||
|                                         { | ||||
|                                           livekit.ConnectionState.disconnected: | ||||
|                                               'callStatusDisconnected'.tr(), | ||||
|                                           livekit.ConnectionState.connected: | ||||
|                                               'callStatusConnected'.tr(), | ||||
|                                           livekit.ConnectionState.connecting: | ||||
|                                               'callStatusConnecting'.tr(), | ||||
|                                           livekit.ConnectionState.reconnecting: | ||||
|                                               'callStatusReconnecting'.tr(), | ||||
|                                         }[call.room.connectionState]!, | ||||
|                                       ), | ||||
|                                       const Gap(6), | ||||
|                                       if (connectionQuality != | ||||
|                                           livekit.ConnectionQuality.unknown) | ||||
|                                         Icon( | ||||
|                                           { | ||||
|                                             livekit.ConnectionQuality.excellent: | ||||
|                                                 Icons.signal_cellular_alt, | ||||
|                                             livekit.ConnectionQuality.good: | ||||
|                                                 Icons.signal_cellular_alt_2_bar, | ||||
|                                             livekit.ConnectionQuality.poor: | ||||
|                                                 Icons.signal_cellular_alt_1_bar, | ||||
|                                           }[connectionQuality], | ||||
|                                           color: { | ||||
|                                             livekit.ConnectionQuality.excellent: | ||||
|                                                 Colors.green, | ||||
|                                             livekit.ConnectionQuality.good: | ||||
|                                                 Colors.orange, | ||||
|                                             livekit.ConnectionQuality.poor: | ||||
|                                                 Colors.red, | ||||
|                                           }[connectionQuality], | ||||
|                                           size: 16, | ||||
|                                         ) | ||||
|                                       else | ||||
|                                         const SizedBox( | ||||
|                                           width: 12, | ||||
|                                           height: 12, | ||||
|                                           child: CircularProgressIndicator( | ||||
|                                             color: Colors.white, | ||||
|                                             strokeWidth: 2, | ||||
|                                           ), | ||||
|                                         ).padding(all: 3), | ||||
|                                     ], | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ), | ||||
|                             ); | ||||
|                           }), | ||||
|                           Row( | ||||
|                             children: [ | ||||
|                               IconButton( | ||||
|                                 icon: _layoutMode == 0 | ||||
|                                     ? const Icon(Icons.view_list) | ||||
|                                     : const Icon(Icons.grid_view), | ||||
|                                 onPressed: () { | ||||
|                                   _switchLayout(); | ||||
|                                 }, | ||||
|                               ), | ||||
|                             ], | ||||
|                           ), | ||||
|                         ], | ||||
|                       ).padding(left: 20, right: 16), | ||||
|                     ), | ||||
|                     Expanded( | ||||
|                       child: Material( | ||||
|                         color: | ||||
|                             Theme.of(context).colorScheme.surfaceContainerLow, | ||||
|                         child: Builder( | ||||
|                           builder: (context) { | ||||
|                             switch (_layoutMode) { | ||||
|                               case 1: | ||||
|                                 return _buildGridLayout(); | ||||
|                               default: | ||||
|                                 return _buildListLayout(); | ||||
|                             } | ||||
|                           }, | ||||
|                         ), | ||||
|                       ), | ||||
|                     ), | ||||
|                     if (call.room.localParticipant != null) | ||||
|                       SizedBox( | ||||
|                         width: MediaQuery.of(context).size.width, | ||||
|                         child: ControlsWidget( | ||||
|                           call.room, | ||||
|                           call.room.localParticipant!, | ||||
|                         ), | ||||
|                       ), | ||||
|                   ], | ||||
|                 ), | ||||
|                 onTap: () {}, | ||||
|               ), | ||||
|             ), | ||||
|           ); | ||||
|         }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void deactivate() { | ||||
|     final call = context.read<ChatCallProvider>(); | ||||
|     call.disableDurationUpdater(); | ||||
|     super.deactivate(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void activate() { | ||||
|     final call = context.read<ChatCallProvider>(); | ||||
|     call.enableDurationUpdater(); | ||||
|     super.activate(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										292
									
								
								lib/screens/chat/manage.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										292
									
								
								lib/screens/chat/manage.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,292 @@ | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:dropdown_button2/dropdown_button2.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.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_network.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| import 'package:surface/types/realm.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/loading_indicator.dart'; | ||||
| import 'package:uuid/uuid.dart'; | ||||
|  | ||||
| class ChatManageScreen extends StatefulWidget { | ||||
|   final String? editingChannelAlias; | ||||
|   const ChatManageScreen({super.key, this.editingChannelAlias}); | ||||
|  | ||||
|   @override | ||||
|   State<ChatManageScreen> createState() => _ChatManageScreenState(); | ||||
| } | ||||
|  | ||||
| class _ChatManageScreenState extends State<ChatManageScreen> { | ||||
|   bool _isBusy = false; | ||||
|  | ||||
|   final _aliasController = TextEditingController(); | ||||
|   final _nameController = TextEditingController(); | ||||
|   final _descriptionController = TextEditingController(); | ||||
|  | ||||
|   List<SnRealm>? _realms; | ||||
|   SnRealm? _belongToRealm; | ||||
|  | ||||
|   Future<void> _fetchRealms() async { | ||||
|     setState(() => _isBusy = true); | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = await sn.client.get('/cgi/id/realms/me/available'); | ||||
|       _realms = List<SnRealm>.from( | ||||
|         resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], | ||||
|       ); | ||||
|     } catch (err) { | ||||
|       if (mounted) context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   SnChannel? _editingChannel; | ||||
|  | ||||
|   Future<void> _fetchChannel() async { | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = await sn.client.get( | ||||
|         '/cgi/im/channels/${widget.editingChannelAlias}', | ||||
|       ); | ||||
|       _editingChannel = SnChannel.fromJson(resp.data); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _performAction() async { | ||||
|     final uuid = const Uuid(); | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|  | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     final scope = _belongToRealm != null ? _belongToRealm!.alias : 'global'; | ||||
|     final payload = { | ||||
|       'alias': _aliasController.text.isNotEmpty | ||||
|           ? _aliasController.text.toLowerCase() | ||||
|           : uuid.v4().replaceAll('-', '').substring(0, 12), | ||||
|       'name': _nameController.text, | ||||
|       'description': _descriptionController.text, | ||||
|     }; | ||||
|  | ||||
|     try { | ||||
|       final resp = await sn.client.request( | ||||
|         widget.editingChannelAlias != null | ||||
|             ? '/cgi/im/channels/$scope/${widget.editingChannelAlias}' | ||||
|             : '/cgi/im/channels/$scope', | ||||
|         data: payload, | ||||
|         options: Options( | ||||
|           method: widget.editingChannelAlias != null ? 'PUT' : 'POST', | ||||
|         ), | ||||
|       ); | ||||
|       // ignore: use_build_context_synchronously | ||||
|       if (context.mounted) Navigator.pop(context, resp.data); | ||||
|     } catch (err) { | ||||
|       // ignore: use_build_context_synchronously | ||||
|       if (context.mounted) context.showErrorDialog(err); | ||||
|     } | ||||
|  | ||||
|     setState(() => _isBusy = false); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     if (widget.editingChannelAlias != null) _fetchChannel(); | ||||
|     _fetchRealms(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     super.dispose(); | ||||
|     _aliasController.dispose(); | ||||
|     _nameController.dispose(); | ||||
|     _descriptionController.dispose(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         title: widget.editingChannelAlias != null | ||||
|             ? Text('screenChatManage').tr() | ||||
|             : Text('screenChatNew').tr(), | ||||
|       ), | ||||
|       body: SingleChildScrollView( | ||||
|         child: Column( | ||||
|           children: [ | ||||
|             LoadingIndicator(isActive: _isBusy), | ||||
|             if (_editingChannel != null) | ||||
|               MaterialBanner( | ||||
|                 leading: const Icon(Symbols.edit), | ||||
|                 leadingPadding: const EdgeInsets.only(left: 10, right: 20), | ||||
|                 dividerColor: Colors.transparent, | ||||
|                 content: Text( | ||||
|                   'channelEditingNotice' | ||||
|                       .tr(args: ['#${_editingChannel!.alias}']), | ||||
|                 ), | ||||
|                 actions: [ | ||||
|                   TextButton( | ||||
|                     child: Text('cancel').tr(), | ||||
|                     onPressed: () { | ||||
|                       Navigator.pop(context); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             DropdownButtonHideUnderline( | ||||
|               child: DropdownButton2<SnRealm>( | ||||
|                 isExpanded: true, | ||||
|                 hint: Text( | ||||
|                   'fieldChatBelongToRealm'.tr(), | ||||
|                   style: TextStyle( | ||||
|                     color: Theme.of(context).hintColor, | ||||
|                   ), | ||||
|                 ), | ||||
|                 items: [ | ||||
|                   ...(_realms?.map( | ||||
|                         (SnRealm item) => DropdownMenuItem<SnRealm>( | ||||
|                           value: item, | ||||
|                           child: Row( | ||||
|                             children: [ | ||||
|                               AccountImage( | ||||
|                                 content: item.avatar, | ||||
|                                 radius: 16, | ||||
|                                 fallbackWidget: const Icon( | ||||
|                                   Symbols.group, | ||||
|                                   size: 16, | ||||
|                                 ), | ||||
|                               ), | ||||
|                               const Gap(12), | ||||
|                               Expanded( | ||||
|                                 child: Column( | ||||
|                                   mainAxisSize: MainAxisSize.min, | ||||
|                                   crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                                   children: [ | ||||
|                                     Text(item.name).textStyle(Theme.of(context) | ||||
|                                         .textTheme | ||||
|                                         .bodyMedium!), | ||||
|                                     Text( | ||||
|                                       item.description, | ||||
|                                       maxLines: 1, | ||||
|                                       overflow: TextOverflow.ellipsis, | ||||
|                                     ).textStyle( | ||||
|                                         Theme.of(context).textTheme.bodySmall!), | ||||
|                                   ], | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ], | ||||
|                           ), | ||||
|                         ), | ||||
|                       ) ?? | ||||
|                       []), | ||||
|                   DropdownMenuItem<SnRealm>( | ||||
|                     value: null, | ||||
|                     child: Row( | ||||
|                       children: [ | ||||
|                         CircleAvatar( | ||||
|                           radius: 16, | ||||
|                           backgroundColor: Colors.transparent, | ||||
|                           foregroundColor: | ||||
|                               Theme.of(context).colorScheme.onSurface, | ||||
|                           child: const Icon(Symbols.clear), | ||||
|                         ), | ||||
|                         const Gap(12), | ||||
|                         Expanded( | ||||
|                           child: Column( | ||||
|                             mainAxisSize: MainAxisSize.min, | ||||
|                             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                             children: [ | ||||
|                               Text('fieldChatBelongToRealmUnset') | ||||
|                                   .tr() | ||||
|                                   .textStyle( | ||||
|                                     Theme.of(context).textTheme.bodyMedium!, | ||||
|                                   ), | ||||
|                             ], | ||||
|                           ), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|                 value: _belongToRealm, | ||||
|                 onChanged: (SnRealm? value) { | ||||
|                   setState(() => _belongToRealm = value); | ||||
|                 }, | ||||
|                 buttonStyleData: const ButtonStyleData( | ||||
|                   padding: EdgeInsets.only(right: 16), | ||||
|                   height: 60, | ||||
|                 ), | ||||
|                 menuItemStyleData: const MenuItemStyleData( | ||||
|                   height: 60, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             const Divider(height: 1), | ||||
|             const Gap(12), | ||||
|             Column( | ||||
|               children: [ | ||||
|                 TextField( | ||||
|                   controller: _aliasController, | ||||
|                   decoration: InputDecoration( | ||||
|                     border: const UnderlineInputBorder(), | ||||
|                     labelText: 'fieldChatAlias'.tr(), | ||||
|                     helperText: 'fieldChatAliasHint'.tr(), | ||||
|                     helperMaxLines: 2, | ||||
|                   ), | ||||
|                   onTapOutside: (_) => | ||||
|                       FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ), | ||||
|                 const Gap(4), | ||||
|                 TextField( | ||||
|                   controller: _nameController, | ||||
|                   decoration: InputDecoration( | ||||
|                     border: const UnderlineInputBorder(), | ||||
|                     labelText: 'fieldChatName'.tr(), | ||||
|                   ), | ||||
|                   onTapOutside: (_) => | ||||
|                       FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ), | ||||
|                 const Gap(4), | ||||
|                 TextField( | ||||
|                   controller: _descriptionController, | ||||
|                   maxLines: null, | ||||
|                   minLines: 3, | ||||
|                   decoration: InputDecoration( | ||||
|                     border: const UnderlineInputBorder(), | ||||
|                     labelText: 'fieldChatDescription'.tr(), | ||||
|                   ), | ||||
|                   onTapOutside: (_) => | ||||
|                       FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ), | ||||
|                 const Gap(12), | ||||
|                 Row( | ||||
|                   mainAxisAlignment: MainAxisAlignment.end, | ||||
|                   children: [ | ||||
|                     ElevatedButton.icon( | ||||
|                       onPressed: _isBusy ? null : _performAction, | ||||
|                       icon: const Icon(Symbols.save), | ||||
|                       label: Text('apply').tr(), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ], | ||||
|             ).padding(horizontal: 24), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										305
									
								
								lib/screens/chat/room.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										305
									
								
								lib/screens/chat/room.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,305 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
| import 'dart:developer'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/controllers/chat_message_controller.dart'; | ||||
| import 'package:surface/providers/channel.dart'; | ||||
| import 'package:surface/providers/chat_call.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/websocket.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| import 'package:surface/widgets/chat/call/call_prejoin.dart'; | ||||
| import 'package:surface/widgets/chat/chat_message.dart'; | ||||
| import 'package:surface/widgets/chat/chat_message_input.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/loading_indicator.dart'; | ||||
| import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | ||||
|  | ||||
| class ChatRoomScreen extends StatefulWidget { | ||||
|   final String scope; | ||||
|   final String alias; | ||||
|   const ChatRoomScreen({super.key, required this.scope, required this.alias}); | ||||
|  | ||||
|   @override | ||||
|   State<ChatRoomScreen> createState() => _ChatRoomScreenState(); | ||||
| } | ||||
|  | ||||
| class _ChatRoomScreenState extends State<ChatRoomScreen> { | ||||
|   bool _isBusy = false; | ||||
|   bool _isCalling = false; | ||||
|  | ||||
|   SnChannel? _channel; | ||||
|   SnChatCall? _ongoingCall; | ||||
|  | ||||
|   final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey(); | ||||
|   late final ChatMessageController _messageController; | ||||
|  | ||||
|   StreamSubscription? _wsSubscription; | ||||
|  | ||||
|   Future<void> _fetchChannel() async { | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     try { | ||||
|       final chan = context.read<ChatChannelProvider>(); | ||||
|       _channel = await chan.getChannel('${widget.scope}:${widget.alias}'); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _fetchOngoingCall() async { | ||||
|     setState(() => _isCalling = true); | ||||
|  | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = await sn.client.get( | ||||
|         '/cgi/im/channels/${_messageController.channel!.keyPath}/calls/ongoing', | ||||
|         options: Options( | ||||
|           validateStatus: (status) => status != null && status < 500, | ||||
|         ), | ||||
|       ); | ||||
|       if (resp.statusCode == 200) { | ||||
|         _ongoingCall = SnChatCall.fromJson(resp.data); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isCalling = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _makeCall() async { | ||||
|     setState(() => _isCalling = true); | ||||
|  | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = await sn.client.post( | ||||
|         '/cgi/im/channels/${_messageController.channel!.keyPath}/calls', | ||||
|         options: Options( | ||||
|           sendTimeout: const Duration(seconds: 30), | ||||
|           receiveTimeout: const Duration(seconds: 30), | ||||
|         ), | ||||
|       ); | ||||
|       log(jsonDecode(resp.data)); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isCalling = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _endCall() async { | ||||
|     setState(() => _isCalling = true); | ||||
|  | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       await sn.client.delete( | ||||
|         '/cgi/im/channels/${_messageController.channel!.keyPath}/calls/ongoing', | ||||
|       ); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isCalling = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _onCallJoin() async { | ||||
|     await showModalBottomSheet( | ||||
|       context: context, | ||||
|       builder: (context) => ChatCallPrejoinPopup( | ||||
|         ongoingCall: _ongoingCall!, | ||||
|         channel: _channel!, | ||||
|         onJoin: _onCallResume, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void _onCallResume() { | ||||
|     GoRouter.of(context).pushNamed( | ||||
|       'chatCallRoom', | ||||
|       pathParameters: { | ||||
|         'scope': _channel!.realm!.alias, | ||||
|         'alias': _channel!.alias, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   bool _checkMessageMergeable(SnChatMessage? a, SnChatMessage? b) { | ||||
|     if (a == null || b == null) return false; | ||||
|     if (a.sender.accountId != b.sender.accountId) return false; | ||||
|     return a.createdAt.difference(b.createdAt).inMinutes <= 3; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _messageController = ChatMessageController(context); | ||||
|     _fetchChannel().then((_) async { | ||||
|       await _messageController.initialize(_channel!); | ||||
|       await _messageController.checkUpdate(); | ||||
|       await _fetchOngoingCall(); | ||||
|     }); | ||||
|  | ||||
|     final ws = context.read<WebSocketProvider>(); | ||||
|     _wsSubscription = ws.stream.stream.listen((event) { | ||||
|       switch (event.method) { | ||||
|         case 'calls.new': | ||||
|           final payload = SnChatCall.fromJson(event.payload!); | ||||
|           if (payload.channelId == _channel?.id) { | ||||
|             setState(() => _ongoingCall = payload); | ||||
|           } | ||||
|           break; | ||||
|         case 'calls.end': | ||||
|           final payload = SnChatCall.fromJson(event.payload!); | ||||
|           if (payload.channelId == _channel?.id) { | ||||
|             setState(() => _ongoingCall = null); | ||||
|           } | ||||
|           break; | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _wsSubscription?.cancel(); | ||||
|     _messageController.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final call = context.watch<ChatCallProvider>(); | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         title: Text(_channel?.name ?? 'loading'.tr()), | ||||
|         actions: [ | ||||
|           IconButton( | ||||
|             icon: _ongoingCall == null | ||||
|                 ? const Icon(Symbols.call) | ||||
|                 : const Icon(Symbols.call_end), | ||||
|             onPressed: _isCalling | ||||
|                 ? null | ||||
|                 : _ongoingCall == null | ||||
|                     ? _makeCall | ||||
|                     : _endCall, | ||||
|           ), | ||||
|           IconButton( | ||||
|             icon: const Icon(Symbols.more_vert), | ||||
|             onPressed: () {}, | ||||
|           ), | ||||
|           const Gap(8), | ||||
|         ], | ||||
|       ), | ||||
|       body: ListenableBuilder( | ||||
|         listenable: _messageController, | ||||
|         builder: (context, _) { | ||||
|           return Column( | ||||
|             children: [ | ||||
|               LoadingIndicator(isActive: _isBusy), | ||||
|               SingleChildScrollView( | ||||
|                 physics: const NeverScrollableScrollPhysics(), | ||||
|                 child: MaterialBanner( | ||||
|                   dividerColor: Colors.transparent, | ||||
|                   leading: const Icon(Symbols.call_received), | ||||
|                   content: Text('callOngoingNotice').tr().padding(top: 2), | ||||
|                   actions: [ | ||||
|                     if (call.current == null) | ||||
|                       TextButton( | ||||
|                         onPressed: _onCallJoin, | ||||
|                         child: Text('callJoin').tr(), | ||||
|                       ) | ||||
|                     else if (call.current?.channelId == _channel?.id) | ||||
|                       TextButton( | ||||
|                         onPressed: _onCallResume, | ||||
|                         child: Text('callResume').tr(), | ||||
|                       ) | ||||
|                   ], | ||||
|                 ), | ||||
|               ).height(_ongoingCall != null ? 54 : 0, animate: true).animate( | ||||
|                   const Duration(milliseconds: 300), | ||||
|                   Curves.fastLinearToSlowEaseIn), | ||||
|               if (_messageController.isPending) | ||||
|                 Expanded( | ||||
|                   child: const CircularProgressIndicator().center(), | ||||
|                 ), | ||||
|               if (!_messageController.isPending) | ||||
|                 Expanded( | ||||
|                   child: InfiniteList( | ||||
|                     reverse: true, | ||||
|                     padding: const EdgeInsets.only( | ||||
|                       left: 12, | ||||
|                       right: 12, | ||||
|                       top: 12, | ||||
|                     ), | ||||
|                     hasReachedMax: _messageController.isAllLoaded, | ||||
|                     itemCount: _messageController.messages.length, | ||||
|                     isLoading: _messageController.isLoading, | ||||
|                     onFetchData: () { | ||||
|                       _messageController.loadMessages(); | ||||
|                     }, | ||||
|                     itemBuilder: (context, idx) { | ||||
|                       final message = _messageController.messages[idx]; | ||||
|  | ||||
|                       bool canMerge = false, canMergePrevious = false; | ||||
|                       if (idx > 0) { | ||||
|                         canMergePrevious = _checkMessageMergeable( | ||||
|                           _messageController.messages[idx - 1], | ||||
|                           _messageController.messages[idx], | ||||
|                         ); | ||||
|                       } | ||||
|                       if (idx + 1 < _messageController.messages.length) { | ||||
|                         canMerge = _checkMessageMergeable( | ||||
|                           _messageController.messages[idx], | ||||
|                           _messageController.messages[idx + 1], | ||||
|                         ); | ||||
|                       } | ||||
|  | ||||
|                       return ChatMessage( | ||||
|                         data: message, | ||||
|                         isMerged: canMerge, | ||||
|                         hasMerged: canMergePrevious, | ||||
|                         isPending: _messageController.unconfirmedMessages | ||||
|                             .contains(message.uuid), | ||||
|                         onReply: (value) { | ||||
|                           _inputGlobalKey.currentState?.setReply(value); | ||||
|                         }, | ||||
|                         onEdit: (value) { | ||||
|                           _inputGlobalKey.currentState?.setEdit(value); | ||||
|                         }, | ||||
|                         onDelete: (value) { | ||||
|                           _inputGlobalKey.currentState?.deleteMessage(value); | ||||
|                         }, | ||||
|                       ); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ), | ||||
|               if (!_messageController.isPending) | ||||
|                 Material( | ||||
|                   elevation: 2, | ||||
|                   child: ChatMessageInput( | ||||
|                     key: _inputGlobalKey, | ||||
|                     controller: _messageController, | ||||
|                   ).padding(bottom: MediaQuery.of(context).padding.bottom), | ||||
|                 ), | ||||
|             ], | ||||
|           ); | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -5,10 +5,8 @@ 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:surface/providers/sn_attachment.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/post.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||
| import 'package:surface/widgets/post/post_item.dart'; | ||||
| import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | ||||
|  | ||||
| @@ -32,35 +30,13 @@ class _ExploreScreenState extends State<ExploreScreen> { | ||||
|  | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|     final resp = await sn.client.get('/cgi/co/posts', queryParameters: { | ||||
|       'take': 10, | ||||
|       'offset': _posts.length, | ||||
|     }); | ||||
|     final List<SnPost> out = | ||||
|         List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []); | ||||
|  | ||||
|     Set<String> rids = {}; | ||||
|     for (var i = 0; i < out.length; i++) { | ||||
|       rids.addAll(out[i].body['attachments']?.cast<String>() ?? []); | ||||
|     } | ||||
|     final pt = context.read<SnPostContentProvider>(); | ||||
|     final result = await pt.listPosts(take: 10, offset: _posts.length); | ||||
|     final out = result.$1; | ||||
|  | ||||
|     if (!mounted) return; | ||||
|     final attach = context.read<SnAttachmentProvider>(); | ||||
|     final attachments = await attach.getMultiple(rids.toList()); | ||||
|     for (var i = 0; i < out.length; i++) { | ||||
|       out[i] = out[i].copyWith( | ||||
|         preload: SnPostPreload( | ||||
|           attachments: attachments | ||||
|               .where( | ||||
|                 (ele) => out[i].body['attachments']?.contains(ele.rid) ?? false, | ||||
|               ) | ||||
|               .toList(), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     _postCount = resp.data['count']; | ||||
|     _postCount = result.$2; | ||||
|     _posts.addAll(out); | ||||
|  | ||||
|     if (mounted) setState(() => _isBusy = false); | ||||
| @@ -74,7 +50,7 @@ class _ExploreScreenState extends State<ExploreScreen> { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AppScaffold( | ||||
|     return Scaffold( | ||||
|       floatingActionButtonLocation: ExpandableFab.location, | ||||
|       floatingActionButton: ExpandableFab( | ||||
|         key: _fabKey, | ||||
| @@ -164,6 +140,15 @@ class _ExploreScreenState extends State<ExploreScreen> { | ||||
|               title: Text('screenExplore').tr(), | ||||
|               floating: true, | ||||
|               snap: true, | ||||
|               actions: [ | ||||
|                 IconButton( | ||||
|                   icon: const Icon(Symbols.search), | ||||
|                   onPressed: () { | ||||
|                     GoRouter.of(context).pushNamed('postSearch'); | ||||
|                   }, | ||||
|                 ), | ||||
|                 const Gap(8), | ||||
|               ], | ||||
|             ), | ||||
|             SliverInfiniteList( | ||||
|               itemCount: _posts.length, | ||||
| @@ -173,7 +158,13 @@ class _ExploreScreenState extends State<ExploreScreen> { | ||||
|               onFetchData: _fetchPosts, | ||||
|               itemBuilder: (context, idx) { | ||||
|                 return GestureDetector( | ||||
|                   child: PostItem(data: _posts[idx]), | ||||
|                   child: PostItem( | ||||
|                     data: _posts[idx], | ||||
|                     maxWidth: 640, | ||||
|                     onChanged: (data) { | ||||
|                       setState(() => _posts[idx] = data); | ||||
|                     }, | ||||
|                   ), | ||||
|                   onTap: () { | ||||
|                     GoRouter.of(context).pushNamed( | ||||
|                       'postDetail', | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
|  | ||||
| class HomeScreen extends StatefulWidget { | ||||
| @@ -14,7 +13,7 @@ class HomeScreen extends StatefulWidget { | ||||
| class _HomeScreenState extends State<HomeScreen> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AppScaffold( | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         title: Text("screenHome").tr(), | ||||
|       ), | ||||
|   | ||||
							
								
								
									
										258
									
								
								lib/screens/notification.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										258
									
								
								lib/screens/notification.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,258 @@ | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.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:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/notification.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/loading_indicator.dart'; | ||||
| import 'package:surface/widgets/markdown_content.dart'; | ||||
| import 'package:surface/widgets/post/post_item.dart'; | ||||
| import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | ||||
|  | ||||
| class NotificationScreen extends StatefulWidget { | ||||
|   const NotificationScreen({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<NotificationScreen> createState() => _NotificationScreenState(); | ||||
| } | ||||
|  | ||||
| class _NotificationScreenState extends State<NotificationScreen> { | ||||
|   bool _isBusy = false; | ||||
|   bool _isFirstLoading = true; | ||||
|   bool _isSubmitting = false; | ||||
|  | ||||
|   final List<SnNotification> _notifications = List.empty(growable: true); | ||||
|   int? _totalCount; | ||||
|  | ||||
|   static const Map<String, IconData> kNotificationTopicIcons = { | ||||
|     'passport.security.alert': Symbols.gpp_maybe, | ||||
|     'interactive.subscription': Symbols.subscriptions, | ||||
|     'interactive.feedback': Symbols.add_reaction, | ||||
|     'messaging.callStart': Symbols.call_received, | ||||
|   }; | ||||
|  | ||||
|   Future<void> _fetchNotifications() async { | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = await sn.client.get('/cgi/id/notifications?take=10'); | ||||
|       _totalCount = resp.data['count']; | ||||
|       _notifications.addAll( | ||||
|         resp.data['data'] | ||||
|                 ?.map((e) => SnNotification.fromJson(e)) | ||||
|                 .cast<SnNotification>() ?? | ||||
|             [], | ||||
|       ); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       _isFirstLoading = false; | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _markAllAsRead() async { | ||||
|     if (_notifications.isEmpty) return; | ||||
|  | ||||
|     final confirm = await context.showConfirmDialog( | ||||
|       'notificationMarkAllRead'.tr(), | ||||
|       'notificationMarkAllReadDescription'.tr(), | ||||
|     ); | ||||
|     if (!confirm) return; | ||||
|  | ||||
|     if (!mounted) return; | ||||
|     setState(() => _isSubmitting = true); | ||||
|  | ||||
|     List<int> markList = List.empty(growable: true); | ||||
|     for (final element in _notifications) { | ||||
|       if (element.id <= 0) continue; | ||||
|       if (element.readAt != null) continue; | ||||
|       markList.add(element.id); | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       await sn.client.put('/cgi/id/notifications/read', data: { | ||||
|         'messages': markList, | ||||
|       }); | ||||
|       _notifications.clear(); | ||||
|       _fetchNotifications(); | ||||
|  | ||||
|       if (!mounted) return; | ||||
|       context.showSnackbar( | ||||
|         'notificationMarkAllReadPrompt'.plural(markList.length), | ||||
|       ); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isSubmitting = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _markOneAsRead(SnNotification notification) async { | ||||
|     if (notification.readAt != null) return; | ||||
|  | ||||
|     setState(() => _isSubmitting = true); | ||||
|  | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       await sn.client.put('/cgi/id/notifications/read/${notification.id}'); | ||||
|       _notifications.clear(); | ||||
|       _fetchNotifications(); | ||||
|  | ||||
|       if (!mounted) return; | ||||
|       context.showSnackbar( | ||||
|         'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']), | ||||
|       ); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isSubmitting = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _fetchNotifications(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         title: Text('screenNotification').tr(), | ||||
|         actions: [ | ||||
|           IconButton( | ||||
|             icon: const Icon(Symbols.checklist), | ||||
|             onPressed: _isSubmitting ? null : _markAllAsRead, | ||||
|           ), | ||||
|           const Gap(8), | ||||
|         ], | ||||
|       ), | ||||
|       body: Column( | ||||
|         children: [ | ||||
|           LoadingIndicator(isActive: _isFirstLoading), | ||||
|           Expanded( | ||||
|             child: RefreshIndicator( | ||||
|               onRefresh: () { | ||||
|                 _notifications.clear(); | ||||
|                 return _fetchNotifications(); | ||||
|               }, | ||||
|               child: InfiniteList( | ||||
|                 padding: EdgeInsets.only( | ||||
|                   top: 16, | ||||
|                   bottom: math.max(MediaQuery.of(context).padding.bottom, 16), | ||||
|                 ), | ||||
|                 itemCount: _notifications.length, | ||||
|                 onFetchData: () { | ||||
|                   _fetchNotifications(); | ||||
|                 }, | ||||
|                 isLoading: _isBusy, | ||||
|                 hasReachedMax: _totalCount != null && | ||||
|                     _notifications.length >= _totalCount!, | ||||
|                 itemBuilder: (context, idx) { | ||||
|                   final nty = _notifications[idx]; | ||||
|                   return Row( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       Icon(kNotificationTopicIcons[nty.topic]), | ||||
|                       const Gap(16), | ||||
|                       Expanded( | ||||
|                         child: Column( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                           children: [ | ||||
|                             if (nty.readAt == null) | ||||
|                               StyledWidget(Badge( | ||||
|                                 label: Text('notificationUnread').tr(), | ||||
|                               )).padding(bottom: 4), | ||||
|                             Text( | ||||
|                               nty.title, | ||||
|                               style: Theme.of(context).textTheme.titleMedium, | ||||
|                             ), | ||||
|                             if (nty.subtitle != null) | ||||
|                               Text( | ||||
|                                 nty.subtitle!, | ||||
|                                 style: Theme.of(context).textTheme.titleSmall, | ||||
|                               ), | ||||
|                             if (nty.subtitle != null) const Gap(4), | ||||
|                             MarkdownTextContent( | ||||
|                               content: nty.body, | ||||
|                               isAutoWarp: true, | ||||
|                               isSelectable: true, | ||||
|                             ), | ||||
|                             if ([ | ||||
|                                   'interactive.feedback', | ||||
|                                   'interactive.subscription' | ||||
|                                 ].contains(nty.topic) && | ||||
|                                 nty.metadata['related_post'] != null) | ||||
|                               StyledWidget(Container( | ||||
|                                 decoration: BoxDecoration( | ||||
|                                   borderRadius: const BorderRadius.all( | ||||
|                                       Radius.circular(8)), | ||||
|                                   border: Border.all( | ||||
|                                     color: Theme.of(context).dividerColor, | ||||
|                                     width: 1, | ||||
|                                   ), | ||||
|                                 ), | ||||
|                                 child: PostItem( | ||||
|                                   data: SnPost.fromJson( | ||||
|                                     nty.metadata['related_post']!, | ||||
|                                   ), | ||||
|                                   showComments: false, | ||||
|                                   showReactions: false, | ||||
|                                   showMenu: false, | ||||
|                                 ), | ||||
|                               )).padding(top: 8), | ||||
|                             const Gap(8), | ||||
|                             Row( | ||||
|                               children: [ | ||||
|                                 Text( | ||||
|                                   DateFormat('yy/MM/dd').format(nty.createdAt), | ||||
|                                 ).fontSize(12), | ||||
|                                 const Gap(4), | ||||
|                                 Text( | ||||
|                                   '·', | ||||
|                                   style: TextStyle(fontSize: 12), | ||||
|                                 ), | ||||
|                                 const Gap(4), | ||||
|                                 Text( | ||||
|                                   RelativeTime(context).format(nty.createdAt), | ||||
|                                 ).fontSize(12), | ||||
|                               ], | ||||
|                             ).opacity(0.75), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ), | ||||
|                       const Gap(16), | ||||
|                       IconButton( | ||||
|                         icon: const Icon(Symbols.check), | ||||
|                         padding: EdgeInsets.all(0), | ||||
|                         visualDensity: | ||||
|                             const VisualDensity(horizontal: -4, vertical: -4), | ||||
|                         onPressed: | ||||
|                             _isSubmitting ? null : () => _markOneAsRead(nty), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ).padding(horizontal: 16); | ||||
|                 }, | ||||
|                 separatorBuilder: (_, __) => const Divider(), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -7,12 +7,11 @@ 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/sn_attachment.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/post.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/loading_indicator.dart'; | ||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||
| import 'package:surface/widgets/post/post_comment_list.dart'; | ||||
| import 'package:surface/widgets/post/post_item.dart'; | ||||
| import 'package:surface/widgets/post/post_mini_editor.dart'; | ||||
| @@ -39,19 +38,10 @@ class _PostDetailScreenState extends State<PostDetailScreen> { | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final attach = context.read<SnAttachmentProvider>(); | ||||
|       final resp = await sn.client.get('/cgi/co/posts/${widget.slug}'); | ||||
|       final pt = context.read<SnPostContentProvider>(); | ||||
|       final post = await pt.getPost(widget.slug); | ||||
|       if (!mounted) return; | ||||
|       final attachments = await attach.getMultiple( | ||||
|         resp.data['body']['attachments']?.cast<String>() ?? [], | ||||
|       ); | ||||
|       if (!mounted) return; | ||||
|       _data = SnPost.fromJson(resp.data).copyWith( | ||||
|         preload: SnPostPreload( | ||||
|           attachments: attachments, | ||||
|         ), | ||||
|       ); | ||||
|       _data = post; | ||||
|     } catch (err) { | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
| @@ -72,34 +62,42 @@ class _PostDetailScreenState extends State<PostDetailScreen> { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final ua = context.watch<UserProvider>(); | ||||
|     final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; | ||||
|  | ||||
|     return AppScaffold( | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         leading: BackButton( | ||||
|           onPressed: () { | ||||
|             if (GoRouter.of(context).canPop()) { | ||||
|               Navigator.pop(context); | ||||
|               GoRouter.of(context).pop(context); | ||||
|               return; | ||||
|             } | ||||
|             GoRouter.of(context).replaceNamed('explore'); | ||||
|           }, | ||||
|         ), | ||||
|         flexibleSpace: Column( | ||||
|           mainAxisAlignment: MainAxisAlignment.center, | ||||
|           children: [ | ||||
|             if (_data?.body['title'] != null) | ||||
|               Text(_data?.body['title'] ?? 'postNoun'.tr()) | ||||
|                   .textStyle(Theme.of(context).textTheme.titleLarge!) | ||||
|                   .textColor(Colors.white), | ||||
|             if (_data?.body['title'] != null) | ||||
|               Text('postDetail'.tr()) | ||||
|                   .textColor(Colors.white.withAlpha((255 * 0.9).round())) | ||||
|             else | ||||
|               Text('postDetail'.tr()) | ||||
|                   .textStyle(Theme.of(context).textTheme.titleLarge!) | ||||
|                   .textColor(Colors.white), | ||||
|           ], | ||||
|         ).padding(top: math.max(MediaQuery.of(context).padding.top, 8)), | ||||
|         title: _data?.body['title'] != null | ||||
|             ? RichText( | ||||
|                 textAlign: TextAlign.center, | ||||
|                 text: TextSpan(children: [ | ||||
|                   TextSpan( | ||||
|                     text: _data?.body['title'] ?? 'postNoun'.tr(), | ||||
|                     style: Theme.of(context) | ||||
|                         .textTheme | ||||
|                         .titleLarge! | ||||
|                         .copyWith(color: Colors.white), | ||||
|                   ), | ||||
|                   const TextSpan(text: '\n'), | ||||
|                   TextSpan( | ||||
|                     text: 'postDetail'.tr(), | ||||
|                     style: Theme.of(context) | ||||
|                         .textTheme | ||||
|                         .bodySmall! | ||||
|                         .copyWith(color: Colors.white), | ||||
|                   ), | ||||
|                 ]), | ||||
|               ) | ||||
|             : Text('postDetail').tr(), | ||||
|       ), | ||||
|       body: CustomScrollView( | ||||
|         slivers: [ | ||||
| @@ -110,27 +108,35 @@ class _PostDetailScreenState extends State<PostDetailScreen> { | ||||
|             SliverToBoxAdapter( | ||||
|               child: PostItem( | ||||
|                 data: _data!, | ||||
|                 maxWidth: 640, | ||||
|                 showComments: false, | ||||
|                 onChanged: (data) { | ||||
|                   setState(() => _data = data); | ||||
|                 }, | ||||
|               ), | ||||
|             ), | ||||
|           const SliverToBoxAdapter(child: Divider(height: 1)), | ||||
|           if (_data != null) | ||||
|             SliverToBoxAdapter( | ||||
|               child: Row( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                 children: [ | ||||
|                   const Icon(Symbols.comment, size: 24), | ||||
|                   const Gap(16), | ||||
|                   Text('postCommentsDetailed') | ||||
|                       .plural(_data!.metric.replyCount) | ||||
|                       .textStyle(Theme.of(context).textTheme.titleLarge!), | ||||
|                 ], | ||||
|               ).padding(horizontal: 20, vertical: 12), | ||||
|               child: Container( | ||||
|                 constraints: const BoxConstraints(maxWidth: 640), | ||||
|                 child: Row( | ||||
|                   crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                   children: [ | ||||
|                     const Icon(Symbols.comment, size: 24), | ||||
|                     const Gap(16), | ||||
|                     Text('postCommentsDetailed') | ||||
|                         .plural(_data!.metric.replyCount) | ||||
|                         .textStyle(Theme.of(context).textTheme.titleLarge!), | ||||
|                   ], | ||||
|                 ).padding(horizontal: 20, vertical: 12).center(), | ||||
|               ), | ||||
|             ), | ||||
|           if (_data != null) | ||||
|           if (_data != null && ua.isAuthorized) | ||||
|             SliverToBoxAdapter( | ||||
|               child: Container( | ||||
|                 height: 240, | ||||
|                 constraints: const BoxConstraints(maxWidth: 640), | ||||
|                 decoration: BoxDecoration( | ||||
|                   border: Border.symmetric( | ||||
|                     horizontal: BorderSide( | ||||
| @@ -142,7 +148,6 @@ class _PostDetailScreenState extends State<PostDetailScreen> { | ||||
|                 child: PostMiniEditor( | ||||
|                   postReplyId: _data!.id, | ||||
|                   onPost: () { | ||||
|                     _childListKey.currentState!.refresh(); | ||||
|                     setState(() { | ||||
|                       _data = _data!.copyWith( | ||||
|                         metric: _data!.metric.copyWith( | ||||
| @@ -150,14 +155,16 @@ class _PostDetailScreenState extends State<PostDetailScreen> { | ||||
|                         ), | ||||
|                       ); | ||||
|                     }); | ||||
|                     _childListKey.currentState!.refresh(); | ||||
|                   }, | ||||
|                 ), | ||||
|               ), | ||||
|               ).center(), | ||||
|             ), | ||||
|           if (_data != null) | ||||
|             PostCommentSliverList( | ||||
|               key: _childListKey, | ||||
|               parentPostId: _data!.id, | ||||
|               maxWidth: 640, | ||||
|             ), | ||||
|           SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)), | ||||
|         ], | ||||
|   | ||||
| @@ -1,5 +1,3 @@ | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:dropdown_button2/dropdown_button2.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| @@ -9,13 +7,13 @@ import 'package:gap/gap.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:image_picker/image_picker.dart'; | ||||
| 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/sn_network.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| import 'package:surface/widgets/loading_indicator.dart'; | ||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||
| import 'package:surface/widgets/post/post_item.dart'; | ||||
| import 'package:surface/widgets/post/post_media_pending_list.dart'; | ||||
| import 'package:surface/widgets/post/post_meta_editor.dart'; | ||||
| @@ -81,7 +79,18 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | ||||
|     _writeController.addAttachments( | ||||
|       result.map((e) => PostWriteMedia.fromFile(e)), | ||||
|     ); | ||||
|     setState(() {}); | ||||
|   } | ||||
|  | ||||
|   void _pasteMedia() async { | ||||
|     final imageBytes = await Pasteboard.image; | ||||
|     if (imageBytes == null) return; | ||||
|     _writeController.addAttachments([ | ||||
|       PostWriteMedia.fromBytes( | ||||
|         imageBytes, | ||||
|         'attachmentPastedImage'.tr(), | ||||
|         PostWriteMediaType.image, | ||||
|       ), | ||||
|     ]); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -111,30 +120,41 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | ||||
|     return ListenableBuilder( | ||||
|       listenable: _writeController, | ||||
|       builder: (context, _) { | ||||
|         return AppScaffold( | ||||
|         return Scaffold( | ||||
|           appBar: AppBar( | ||||
|             leading: BackButton( | ||||
|               onPressed: () { | ||||
|                 Navigator.pop(context); | ||||
|               }, | ||||
|             ), | ||||
|             flexibleSpace: Column( | ||||
|               children: [ | ||||
|                 Text(_writeController.title.isNotEmpty | ||||
|                         ? _writeController.title | ||||
|                         : 'untitled'.tr()) | ||||
|                     .textStyle(Theme.of(context).textTheme.titleLarge!) | ||||
|                     .textColor(Colors.white), | ||||
|                 Text(PostWriteController.kTitleMap[widget.mode]!) | ||||
|                     .tr() | ||||
|                     .textColor(Colors.white.withAlpha((255 * 0.9).round())), | ||||
|               ], | ||||
|             ).padding(top: math.max(MediaQuery.of(context).padding.top, 8)), | ||||
|             title: RichText( | ||||
|               textAlign: TextAlign.center, | ||||
|               text: TextSpan(children: [ | ||||
|                 TextSpan( | ||||
|                   text: _writeController.title.isNotEmpty | ||||
|                       ? _writeController.title | ||||
|                       : 'untitled'.tr(), | ||||
|                   style: Theme.of(context) | ||||
|                       .textTheme | ||||
|                       .titleLarge! | ||||
|                       .copyWith(color: Colors.white), | ||||
|                 ), | ||||
|                 const TextSpan(text: '\n'), | ||||
|                 TextSpan( | ||||
|                   text: PostWriteController.kTitleMap[widget.mode]!, | ||||
|                   style: Theme.of(context) | ||||
|                       .textTheme | ||||
|                       .bodySmall! | ||||
|                       .copyWith(color: Colors.white), | ||||
|                 ), | ||||
|               ]), | ||||
|             ), | ||||
|             actions: [ | ||||
|               IconButton( | ||||
|                 icon: const Icon(Symbols.tune), | ||||
|                 onPressed: _writeController.isBusy ? null : _updateMeta, | ||||
|               ), | ||||
|               const Gap(8), | ||||
|             ], | ||||
|           ), | ||||
|           body: Column( | ||||
| @@ -281,7 +301,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | ||||
|                                 ]), | ||||
|                                 children: <Widget>[ | ||||
|                                   PostItem( | ||||
|                                       data: _writeController.repostingPost!) | ||||
|                                     data: _writeController.repostingPost!, | ||||
|                                   ) | ||||
|                                 ], | ||||
|                               ), | ||||
|                             ), | ||||
| @@ -343,7 +364,25 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | ||||
|               ), | ||||
|               if (_writeController.attachments.isNotEmpty) | ||||
|                 PostMediaPendingList( | ||||
|                   controller: _writeController, | ||||
|                   attachments: _writeController.attachments, | ||||
|                   isBusy: _writeController.isBusy, | ||||
|                   onUpdate: (int idx, PostWriteMedia updatedMedia) async { | ||||
|                     _writeController.setIsBusy(true); | ||||
|                     try { | ||||
|                       _writeController.setAttachmentAt(idx, updatedMedia); | ||||
|                     } finally { | ||||
|                       _writeController.setIsBusy(false); | ||||
|                     } | ||||
|                   }, | ||||
|                   onRemove: (int idx) async { | ||||
|                     _writeController.setIsBusy(true); | ||||
|                     try { | ||||
|                       _writeController.removeAttachmentAt(idx); | ||||
|                     } finally { | ||||
|                       _writeController.setIsBusy(false); | ||||
|                     } | ||||
|                   }, | ||||
|                   onUpdateBusy: (state) => _writeController.setIsBusy(state), | ||||
|                 ).padding(bottom: 8), | ||||
|               Material( | ||||
|                 elevation: 2, | ||||
| @@ -371,15 +410,39 @@ class _PostEditorScreenState extends State<PostEditorScreen> { | ||||
|                               scrollDirection: Axis.vertical, | ||||
|                               child: Row( | ||||
|                                 children: [ | ||||
|                                   IconButton( | ||||
|                                     onPressed: _writeController.isBusy | ||||
|                                         ? null | ||||
|                                         : _selectMedia, | ||||
|                                   PopupMenuButton( | ||||
|                                     icon: Icon( | ||||
|                                       Symbols.add_photo_alternate, | ||||
|                                       color: | ||||
|                                           Theme.of(context).colorScheme.primary, | ||||
|                                     ), | ||||
|                                     itemBuilder: (context) => [ | ||||
|                                       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(); | ||||
|                                         }, | ||||
|                                       ), | ||||
|                                     ], | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ), | ||||
|   | ||||
							
								
								
									
										184
									
								
								lib/screens/post/post_search.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								lib/screens/post/post_search.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,184 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package: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/types/post.dart'; | ||||
| import 'package:surface/widgets/post/post_item.dart'; | ||||
| import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | ||||
|  | ||||
| class PostSearchScreen extends StatefulWidget { | ||||
|   const PostSearchScreen({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<PostSearchScreen> createState() => _PostSearchScreenState(); | ||||
| } | ||||
|  | ||||
| class _PostSearchScreenState extends State<PostSearchScreen> { | ||||
|   bool _isBusy = false; | ||||
|  | ||||
|   final List<SnPost> _posts = List.empty(growable: true); | ||||
|   int? _postCount; | ||||
|  | ||||
|   String _searchTerm = ''; | ||||
|   Duration? _lastTook; | ||||
|  | ||||
|   Future<void> _fetchPosts() async { | ||||
|     if (_searchTerm.isEmpty) return; | ||||
|     if (_postCount != null && _posts.length >= _postCount!) return; | ||||
|  | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     final stopwatch = Stopwatch()..start(); | ||||
|  | ||||
|     final pt = context.read<SnPostContentProvider>(); | ||||
|     final result = await pt.searchPosts( | ||||
|       _searchTerm, | ||||
|       take: 10, | ||||
|       offset: _posts.length, | ||||
|     ); | ||||
|     final List<SnPost> out = result.$1; | ||||
|  | ||||
|     if (!mounted) return; | ||||
|  | ||||
|     stopwatch.stop(); | ||||
|  | ||||
|     _lastTook = stopwatch.elapsed; | ||||
|     _postCount = result.$2; | ||||
|     _posts.addAll(out); | ||||
|  | ||||
|     if (mounted) setState(() => _isBusy = false); | ||||
|   } | ||||
|  | ||||
|   void _showAdvancedSearchTune() { | ||||
|     showModalBottomSheet( | ||||
|       context: context, | ||||
|       builder: (context) => Column( | ||||
|         children: [], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     const labelShadows = <Shadow>[ | ||||
|       Shadow( | ||||
|         offset: Offset(1, 1), | ||||
|         blurRadius: 8.0, | ||||
|         color: Color.fromARGB(255, 0, 0, 0), | ||||
|       ), | ||||
|     ]; | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         title: Text('screenPostSearch').tr(), | ||||
|         actions: [ | ||||
|           IconButton( | ||||
|             icon: const Icon(Symbols.tune), | ||||
|             onPressed: _showAdvancedSearchTune, | ||||
|           ), | ||||
|           const Gap(8), | ||||
|         ], | ||||
|       ), | ||||
|       body: Stack( | ||||
|         children: [ | ||||
|           InfiniteList( | ||||
|             padding: const EdgeInsets.only(top: 100), | ||||
|             itemCount: _posts.length, | ||||
|             isLoading: _isBusy, | ||||
|             hasReachedMax: _postCount != null && _posts.length >= _postCount!, | ||||
|             onFetchData: () { | ||||
|               _fetchPosts(); | ||||
|             }, | ||||
|             itemBuilder: (context, idx) { | ||||
|               return GestureDetector( | ||||
|                 child: PostItem( | ||||
|                   data: _posts[idx], | ||||
|                   maxWidth: 640, | ||||
|                   onChanged: (data) { | ||||
|                     setState(() => _posts[idx] = data); | ||||
|                   }, | ||||
|                 ), | ||||
|                 onTap: () { | ||||
|                   GoRouter.of(context).pushNamed( | ||||
|                     'postDetail', | ||||
|                     pathParameters: {'slug': _posts[idx].id.toString()}, | ||||
|                     extra: _posts[idx], | ||||
|                   ); | ||||
|                 }, | ||||
|               ); | ||||
|             }, | ||||
|             separatorBuilder: (context, index) => const Divider(height: 1), | ||||
|           ), | ||||
|           Positioned( | ||||
|             top: 16, | ||||
|             left: 16, | ||||
|             right: 16, | ||||
|             child: Column( | ||||
|               children: [ | ||||
|                 SearchBar( | ||||
|                   elevation: const WidgetStatePropertyAll(1), | ||||
|                   leading: const Icon(Symbols.search), | ||||
|                   padding: const WidgetStatePropertyAll( | ||||
|                     EdgeInsets.symmetric(horizontal: 24), | ||||
|                   ), | ||||
|                   onChanged: (value) { | ||||
|                     _searchTerm = value; | ||||
|                   }, | ||||
|                   onSubmitted: (value) { | ||||
|                     setState(() => _posts.clear()); | ||||
|  | ||||
|                     _searchTerm = value; | ||||
|                     _fetchPosts(); | ||||
|                   }, | ||||
|                 ), | ||||
|                 if (_lastTook != null) | ||||
|                   Row( | ||||
|                     mainAxisAlignment: MainAxisAlignment.center, | ||||
|                     children: [ | ||||
|                       Icon( | ||||
|                         Symbols.summarize, | ||||
|                         color: Colors.white, | ||||
|                         shadows: labelShadows, | ||||
|                         size: 16, | ||||
|                       ), | ||||
|                       const Gap(4), | ||||
|                       Text( | ||||
|                         'postSearchResult'.plural(_postCount ?? 0), | ||||
|                         style: TextStyle( | ||||
|                           color: Colors.white, | ||||
|                           shadows: labelShadows, | ||||
|                           fontSize: 13, | ||||
|                         ), | ||||
|                       ), | ||||
|                       const Gap(8), | ||||
|                       Icon( | ||||
|                         Symbols.pace, | ||||
|                         color: Colors.white, | ||||
|                         shadows: labelShadows, | ||||
|                         size: 16, | ||||
|                       ), | ||||
|                       const Gap(4), | ||||
|                       Text( | ||||
|                         'postSearchTook'.tr(args: [ | ||||
|                           '${(_lastTook!.inMilliseconds / 1000).toStringAsFixed(3)}s', | ||||
|                         ]), | ||||
|                         style: TextStyle( | ||||
|                           color: Colors.white, | ||||
|                           shadows: labelShadows, | ||||
|                           fontSize: 13, | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ).padding(vertical: 8), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										221
									
								
								lib/screens/realm.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								lib/screens/realm.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,221 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/realm.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/loading_indicator.dart'; | ||||
| import 'package:surface/widgets/universal_image.dart'; | ||||
|  | ||||
| class RealmScreen extends StatefulWidget { | ||||
|   const RealmScreen({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<RealmScreen> createState() => _RealmScreenState(); | ||||
| } | ||||
|  | ||||
| class _RealmScreenState extends State<RealmScreen> { | ||||
|   bool _isBusy = false; | ||||
|   bool _isCompactView = false; | ||||
|  | ||||
|   List<SnRealm>? _realms; | ||||
|  | ||||
|   Future<void> _fetchRealms() async { | ||||
|     setState(() => _isBusy = true); | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = await sn.client.get('/cgi/id/realms/me/available'); | ||||
|       _realms = List<SnRealm>.from( | ||||
|         resp.data?.map((e) => SnRealm.fromJson(e)) ?? [], | ||||
|       ); | ||||
|     } catch (err) { | ||||
|       if (mounted) context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _deleteRealm(SnRealm realm) async { | ||||
|     final confirm = await context.showConfirmDialog( | ||||
|       'realmDelete'.tr(args: ['#${realm.alias}']), | ||||
|       'realmDeleteDescription'.tr(), | ||||
|     ); | ||||
|     if (!confirm) return; | ||||
|  | ||||
|     if (!mounted) return; | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|  | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     try { | ||||
|       await sn.client.delete('/cgi/id/realms/${realm.alias}'); | ||||
|       if (!mounted) return; | ||||
|       context.showSnackbar('realmDeleted'.tr(args: ['#${realm.alias}'])); | ||||
|       _fetchRealms(); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _fetchRealms(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         title: Text('screenRealm').tr(), | ||||
|         actions: [ | ||||
|           IconButton( | ||||
|             icon: !_isCompactView | ||||
|                 ? const Icon(Symbols.view_list) | ||||
|                 : const Icon(Symbols.view_module), | ||||
|             onPressed: () { | ||||
|               setState(() => _isCompactView = !_isCompactView); | ||||
|             }, | ||||
|           ), | ||||
|           const Gap(8), | ||||
|         ], | ||||
|       ), | ||||
|       floatingActionButton: FloatingActionButton( | ||||
|         child: const Icon(Symbols.group_add), | ||||
|         onPressed: () { | ||||
|           GoRouter.of(context).pushNamed('realmManage'); | ||||
|         }, | ||||
|       ), | ||||
|       body: Column( | ||||
|         children: [ | ||||
|           LoadingIndicator(isActive: _isBusy), | ||||
|           Expanded( | ||||
|             child: RefreshIndicator( | ||||
|               onRefresh: _fetchRealms, | ||||
|               child: ListView.builder( | ||||
|                 itemCount: _realms?.length ?? 0, | ||||
|                 itemBuilder: (context, idx) { | ||||
|                   final realm = _realms![idx]; | ||||
|                   if (_isCompactView) { | ||||
|                     return ListTile( | ||||
|                       contentPadding: | ||||
|                           const EdgeInsets.symmetric(horizontal: 16), | ||||
|                       leading: AccountImage( | ||||
|                         content: realm.avatar, | ||||
|                         fallbackWidget: const Icon(Symbols.group, size: 20), | ||||
|                       ), | ||||
|                       title: Text(realm.name), | ||||
|                       subtitle: Text( | ||||
|                         realm.description, | ||||
|                         maxLines: 1, | ||||
|                         overflow: TextOverflow.ellipsis, | ||||
|                       ), | ||||
|                       trailing: PopupMenuButton( | ||||
|                         itemBuilder: (BuildContext context) => [ | ||||
|                           PopupMenuItem( | ||||
|                             child: Row( | ||||
|                               children: [ | ||||
|                                 const Icon(Symbols.edit), | ||||
|                                 const Gap(16), | ||||
|                                 Text('edit').tr(), | ||||
|                               ], | ||||
|                             ), | ||||
|                             onTap: () { | ||||
|                               GoRouter.of(context).pushNamed( | ||||
|                                 'realmManage', | ||||
|                                 queryParameters: {'editing': realm.alias}, | ||||
|                               ).then((value) { | ||||
|                                 if (value != null) { | ||||
|                                   _fetchRealms(); | ||||
|                                 } | ||||
|                               }); | ||||
|                             }, | ||||
|                           ), | ||||
|                           PopupMenuItem( | ||||
|                             child: Row( | ||||
|                               children: [ | ||||
|                                 const Icon(Symbols.delete), | ||||
|                                 const Gap(16), | ||||
|                                 Text('delete').tr(), | ||||
|                               ], | ||||
|                             ), | ||||
|                             onTap: () { | ||||
|                               _deleteRealm(realm); | ||||
|                             }, | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ); | ||||
|                   } | ||||
|  | ||||
|                   return Card( | ||||
|                     margin: const EdgeInsets.all(12), | ||||
|                     child: InkWell( | ||||
|                       borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           AspectRatio( | ||||
|                             aspectRatio: 16 / 7, | ||||
|                             child: Stack( | ||||
|                               clipBehavior: Clip.none, | ||||
|                               fit: StackFit.expand, | ||||
|                               children: [ | ||||
|                                 Container( | ||||
|                                   color: Theme.of(context) | ||||
|                                       .colorScheme | ||||
|                                       .surfaceContainer, | ||||
|                                   child: (realm.banner?.isEmpty ?? true) | ||||
|                                       ? const SizedBox.shrink() | ||||
|                                       : AutoResizeUniversalImage( | ||||
|                                           sn.getAttachmentUrl(realm.banner!), | ||||
|                                           fit: BoxFit.cover, | ||||
|                                         ), | ||||
|                                 ), | ||||
|                                 Positioned( | ||||
|                                   bottom: -30, | ||||
|                                   left: 18, | ||||
|                                   child: AccountImage( | ||||
|                                     content: realm.avatar, | ||||
|                                     radius: 24, | ||||
|                                     fallbackWidget: | ||||
|                                         const Icon(Symbols.group, size: 24), | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ], | ||||
|                             ), | ||||
|                           ), | ||||
|                           const Gap(20 + 12), | ||||
|                           Column( | ||||
|                             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                             children: [ | ||||
|                               Text(realm.name).textStyle( | ||||
|                                   Theme.of(context).textTheme.titleMedium!), | ||||
|                               Text(realm.description).textStyle( | ||||
|                                   Theme.of(context).textTheme.bodySmall!), | ||||
|                             ], | ||||
|                           ).padding(horizontal: 24, bottom: 14), | ||||
|                         ], | ||||
|                       ), | ||||
|                       onTap: () {}, | ||||
|                     ), | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										312
									
								
								lib/screens/realm/manage.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										312
									
								
								lib/screens/realm/manage.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,312 @@ | ||||
| import 'dart:io'; | ||||
| import 'dart:ui'; | ||||
|  | ||||
| import 'package:croppy/croppy.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.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:path/path.dart' show basename; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/sn_attachment.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/realm.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/loading_indicator.dart'; | ||||
| import 'package:surface/widgets/universal_image.dart'; | ||||
| import 'package:uuid/uuid.dart'; | ||||
|  | ||||
| class RealmManageScreen extends StatefulWidget { | ||||
|   final String? editingRealmAlias; | ||||
|   const RealmManageScreen({super.key, this.editingRealmAlias}); | ||||
|  | ||||
|   @override | ||||
|   State<RealmManageScreen> createState() => _RealmManageScreenState(); | ||||
| } | ||||
|  | ||||
| class _RealmManageScreenState extends State<RealmManageScreen> { | ||||
|   bool _isBusy = false; | ||||
|  | ||||
|   SnRealm? _editingRealm; | ||||
|  | ||||
|   Future<void> _fetchRealm() async { | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|  | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     try { | ||||
|       final resp = | ||||
|           await sn.client.get('/cgi/id/realms/${widget.editingRealmAlias}'); | ||||
|       final out = SnRealm.fromJson(resp.data); | ||||
|       _editingRealm = out; | ||||
|       _avatar = out.avatar; | ||||
|       _banner = out.banner; | ||||
|       _aliasController.text = out.alias; | ||||
|       _nameController.text = out.name; | ||||
|       _descriptionController.text = out.description; | ||||
|     } catch (err) { | ||||
|       // ignore: use_build_context_synchronously | ||||
|       if (context.mounted) context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   String? _avatar; | ||||
|   String? _banner; | ||||
|  | ||||
|   final _aliasController = TextEditingController(); | ||||
|   final _nameController = TextEditingController(); | ||||
|   final _descriptionController = TextEditingController(); | ||||
|  | ||||
|   final _imagePicker = ImagePicker(); | ||||
|  | ||||
|   Future<void> _updateImage(String place) async { | ||||
|     final image = await _imagePicker.pickImage(source: ImageSource.gallery); | ||||
|     if (image == null) return; | ||||
|     if (!mounted) return; | ||||
|  | ||||
|     final ImageProvider imageProvider = | ||||
|         kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)); | ||||
|     final aspectRatios = place == 'banner' | ||||
|         ? [CropAspectRatio(width: 16, height: 7)] | ||||
|         : [CropAspectRatio(width: 1, height: 1)]; | ||||
|     final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) | ||||
|         ? await showCupertinoImageCropper( | ||||
|             // ignore: use_build_context_synchronously | ||||
|             context, | ||||
|             allowedAspectRatios: aspectRatios, | ||||
|             imageProvider: imageProvider, | ||||
|           ) | ||||
|         : await showMaterialImageCropper( | ||||
|             // ignore: use_build_context_synchronously | ||||
|             context, | ||||
|             allowedAspectRatios: aspectRatios, | ||||
|             imageProvider: imageProvider, | ||||
|           ); | ||||
|  | ||||
|     if (result == null) return; | ||||
|  | ||||
|     if (!mounted) return; | ||||
|     final attach = context.read<SnAttachmentProvider>(); | ||||
|  | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     final rawBytes = | ||||
|         (await result.uiImage.toByteData(format: ImageByteFormat.png))! | ||||
|             .buffer | ||||
|             .asUint8List(); | ||||
|  | ||||
|     try { | ||||
|       final attachment = await attach.directUploadOne( | ||||
|         rawBytes, | ||||
|         basename(image.path), | ||||
|         'avatar', | ||||
|         null, | ||||
|         mimetype: 'image/png', | ||||
|       ); | ||||
|  | ||||
|       switch (place) { | ||||
|         case 'avatar': | ||||
|           _avatar = attachment.rid; | ||||
|           break; | ||||
|         case 'banner': | ||||
|           _banner = attachment.rid; | ||||
|           break; | ||||
|       } | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _performAction() async { | ||||
|     final uuid = const Uuid(); | ||||
|     final payload = { | ||||
|       'alias': _aliasController.text.isNotEmpty | ||||
|           ? _aliasController.text.toLowerCase() | ||||
|           : uuid.v4().replaceAll('-', '').substring(0, 12), | ||||
|       'name': _nameController.text, | ||||
|       'description': _descriptionController.text, | ||||
|       'avatar': _avatar, | ||||
|       'banner': _banner, | ||||
|     }; | ||||
|  | ||||
|     try { | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = await sn.client.request( | ||||
|         widget.editingRealmAlias != null | ||||
|             ? '/cgi/id/realms/${widget.editingRealmAlias}' | ||||
|             : '/cgi/id/realms', | ||||
|         data: payload, | ||||
|         options: Options( | ||||
|           method: widget.editingRealmAlias != null ? 'PUT' : 'POST', | ||||
|         ), | ||||
|       ); | ||||
|       final out = SnRealm.fromJson(resp.data); | ||||
|       // ignore: use_build_context_synchronously | ||||
|       if (context.mounted) Navigator.pop(context, out); | ||||
|     } catch (err) { | ||||
|       // ignore: use_build_context_synchronously | ||||
|       if (context.mounted) context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     if (widget.editingRealmAlias != null) _fetchRealm(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _aliasController.dispose(); | ||||
|     _nameController.dispose(); | ||||
|     _descriptionController.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         title: widget.editingRealmAlias != null | ||||
|             ? Text('screenRealmManage').tr() | ||||
|             : Text('screenRealmNew').tr(), | ||||
|       ), | ||||
|       body: SingleChildScrollView( | ||||
|         child: Column( | ||||
|           children: [ | ||||
|             LoadingIndicator(isActive: _isBusy), | ||||
|             if (_editingRealm != null) | ||||
|               MaterialBanner( | ||||
|                 leading: const Icon(Symbols.edit), | ||||
|                 leadingPadding: const EdgeInsets.only(left: 10, right: 20), | ||||
|                 dividerColor: Colors.transparent, | ||||
|                 content: Text( | ||||
|                   'realmEditingNotice'.tr(args: ['#${_editingRealm!.alias}']), | ||||
|                 ), | ||||
|                 actions: [ | ||||
|                   TextButton( | ||||
|                     child: Text('cancel').tr(), | ||||
|                     onPressed: () { | ||||
|                       Navigator.pop(context); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             const Gap(24), | ||||
|             Stack( | ||||
|               clipBehavior: Clip.none, | ||||
|               children: [ | ||||
|                 Material( | ||||
|                   elevation: 0, | ||||
|                   child: InkWell( | ||||
|                     child: ClipRRect( | ||||
|                       borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                       child: AspectRatio( | ||||
|                         aspectRatio: 16 / 9, | ||||
|                         child: Container( | ||||
|                           color: Theme.of(context) | ||||
|                               .colorScheme | ||||
|                               .surfaceContainerHigh, | ||||
|                           child: _banner != null | ||||
|                               ? AutoResizeUniversalImage( | ||||
|                                   sn.getAttachmentUrl(_banner!), | ||||
|                                   fit: BoxFit.cover, | ||||
|                                 ) | ||||
|                               : const SizedBox.shrink(), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ), | ||||
|                     onTap: () { | ||||
|                       _updateImage('banner'); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ), | ||||
|                 Positioned( | ||||
|                   bottom: -28, | ||||
|                   left: 16, | ||||
|                   child: Material( | ||||
|                     elevation: 2, | ||||
|                     borderRadius: const BorderRadius.all(Radius.circular(40)), | ||||
|                     child: InkWell( | ||||
|                       child: AccountImage( | ||||
|                         content: _avatar, | ||||
|                         radius: 40, | ||||
|                         fallbackWidget: const Icon(Symbols.group, size: 40), | ||||
|                       ), | ||||
|                       onTap: () { | ||||
|                         _updateImage('avatar'); | ||||
|                       }, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ).padding(horizontal: 24), | ||||
|             const Gap(8 + 28), | ||||
|             Column( | ||||
|               children: [ | ||||
|                 TextField( | ||||
|                   controller: _aliasController, | ||||
|                   decoration: InputDecoration( | ||||
|                     border: const UnderlineInputBorder(), | ||||
|                     labelText: 'fieldRealmAlias'.tr(), | ||||
|                     helperText: 'fieldRealmAliasHint'.tr(), | ||||
|                     helperMaxLines: 2, | ||||
|                   ), | ||||
|                   onTapOutside: (_) => | ||||
|                       FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ), | ||||
|                 const Gap(4), | ||||
|                 TextField( | ||||
|                   controller: _nameController, | ||||
|                   decoration: InputDecoration( | ||||
|                     border: const UnderlineInputBorder(), | ||||
|                     labelText: 'fieldRealmName'.tr(), | ||||
|                   ), | ||||
|                   onTapOutside: (_) => | ||||
|                       FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ), | ||||
|                 const Gap(4), | ||||
|                 TextField( | ||||
|                   controller: _descriptionController, | ||||
|                   maxLines: null, | ||||
|                   minLines: 3, | ||||
|                   decoration: InputDecoration( | ||||
|                     border: const UnderlineInputBorder(), | ||||
|                     labelText: 'fieldRealmDescription'.tr(), | ||||
|                   ), | ||||
|                   onTapOutside: (_) => | ||||
|                       FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ), | ||||
|                 const Gap(12), | ||||
|                 Row( | ||||
|                   mainAxisAlignment: MainAxisAlignment.end, | ||||
|                   children: [ | ||||
|                     ElevatedButton.icon( | ||||
|                       onPressed: _isBusy ? null : _performAction, | ||||
|                       icon: const Icon(Symbols.save), | ||||
|                       label: Text('apply').tr(), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ], | ||||
|             ).padding(horizontal: 24 + 8), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -15,7 +15,6 @@ import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/theme.dart'; | ||||
| import 'package:surface/theme.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/navigation/app_scaffold.dart'; | ||||
|  | ||||
| class SettingsScreen extends StatefulWidget { | ||||
|   const SettingsScreen({super.key}); | ||||
| @@ -58,7 +57,7 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|   Widget build(BuildContext context) { | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|  | ||||
|     return AppScaffold( | ||||
|     return Scaffold( | ||||
|       body: SingleChildScrollView( | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|   | ||||
| @@ -39,6 +39,9 @@ Future<ThemeData> createAppTheme( | ||||
|       opticalSize: 20, | ||||
|       color: colorScheme.onSurface, | ||||
|     ), | ||||
|     appBarTheme: AppBarTheme( | ||||
|       centerTitle: true, | ||||
|     ), | ||||
|     scaffoldBackgroundColor: Colors.transparent, | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -1,29 +1,32 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
|  | ||||
| part 'account.freezed.dart'; | ||||
| part 'account.g.dart'; | ||||
|  | ||||
| @freezed | ||||
| class SnAccount with _$SnAccount { | ||||
|   const SnAccount._(); | ||||
|  | ||||
|   const factory SnAccount({ | ||||
|     required int id, | ||||
|     required int? affiliatedId, | ||||
|     required int? affiliatedTo, | ||||
|     required int? automatedBy, | ||||
|     required int? automatedId, | ||||
|     @HiveField(0) required int id, | ||||
|     required DateTime createdAt, | ||||
|     required DateTime updatedAt, | ||||
|     required DateTime? deletedAt, | ||||
|     required DateTime? confirmedAt, | ||||
|     required List<SnAccountContact>? contacts, | ||||
|     required String avatar, | ||||
|     required String banner, | ||||
|     required DateTime? confirmedAt, | ||||
|     required List<SnAccountContact> contacts, | ||||
|     required DateTime createdAt, | ||||
|     required DateTime? deletedAt, | ||||
|     required String description, | ||||
|     required String name, | ||||
|     required String nick, | ||||
|     required Map<String, dynamic> permNodes, | ||||
|     required SnAccountProfile? profile, | ||||
|     required DateTime? suspendedAt, | ||||
|     required DateTime updatedAt, | ||||
|     required int? affiliatedId, | ||||
|     required int? affiliatedTo, | ||||
|     required int? automatedBy, | ||||
|     required int? automatedId, | ||||
|   }) = _SnAccount; | ||||
|  | ||||
|   factory SnAccount.fromJson(Map<String, Object?> json) => | ||||
|   | ||||
| @@ -20,24 +20,25 @@ SnAccount _$SnAccountFromJson(Map<String, dynamic> json) { | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SnAccount { | ||||
|   @HiveField(0) | ||||
|   int get id => throw _privateConstructorUsedError; | ||||
|   int? get affiliatedId => throw _privateConstructorUsedError; | ||||
|   int? get affiliatedTo => throw _privateConstructorUsedError; | ||||
|   int? get automatedBy => throw _privateConstructorUsedError; | ||||
|   int? get automatedId => throw _privateConstructorUsedError; | ||||
|   DateTime get createdAt => throw _privateConstructorUsedError; | ||||
|   DateTime get updatedAt => throw _privateConstructorUsedError; | ||||
|   DateTime? get deletedAt => throw _privateConstructorUsedError; | ||||
|   DateTime? get confirmedAt => throw _privateConstructorUsedError; | ||||
|   List<SnAccountContact>? get contacts => throw _privateConstructorUsedError; | ||||
|   String get avatar => throw _privateConstructorUsedError; | ||||
|   String get banner => throw _privateConstructorUsedError; | ||||
|   DateTime? get confirmedAt => throw _privateConstructorUsedError; | ||||
|   List<SnAccountContact> get contacts => throw _privateConstructorUsedError; | ||||
|   DateTime get createdAt => throw _privateConstructorUsedError; | ||||
|   DateTime? get deletedAt => throw _privateConstructorUsedError; | ||||
|   String get description => throw _privateConstructorUsedError; | ||||
|   String get name => throw _privateConstructorUsedError; | ||||
|   String get nick => throw _privateConstructorUsedError; | ||||
|   Map<String, dynamic> get permNodes => throw _privateConstructorUsedError; | ||||
|   SnAccountProfile? get profile => throw _privateConstructorUsedError; | ||||
|   DateTime? get suspendedAt => throw _privateConstructorUsedError; | ||||
|   DateTime get updatedAt => throw _privateConstructorUsedError; | ||||
|   int? get affiliatedId => throw _privateConstructorUsedError; | ||||
|   int? get affiliatedTo => throw _privateConstructorUsedError; | ||||
|   int? get automatedBy => throw _privateConstructorUsedError; | ||||
|   int? get automatedId => throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Serializes this SnAccount to a JSON map. | ||||
|   Map<String, dynamic> toJson() => throw _privateConstructorUsedError; | ||||
| @@ -55,24 +56,24 @@ abstract class $SnAccountCopyWith<$Res> { | ||||
|       _$SnAccountCopyWithImpl<$Res, SnAccount>; | ||||
|   @useResult | ||||
|   $Res call( | ||||
|       {int id, | ||||
|       int? affiliatedId, | ||||
|       int? affiliatedTo, | ||||
|       int? automatedBy, | ||||
|       int? automatedId, | ||||
|       {@HiveField(0) int id, | ||||
|       DateTime createdAt, | ||||
|       DateTime updatedAt, | ||||
|       DateTime? deletedAt, | ||||
|       DateTime? confirmedAt, | ||||
|       List<SnAccountContact>? contacts, | ||||
|       String avatar, | ||||
|       String banner, | ||||
|       DateTime? confirmedAt, | ||||
|       List<SnAccountContact> contacts, | ||||
|       DateTime createdAt, | ||||
|       DateTime? deletedAt, | ||||
|       String description, | ||||
|       String name, | ||||
|       String nick, | ||||
|       Map<String, dynamic> permNodes, | ||||
|       SnAccountProfile? profile, | ||||
|       DateTime? suspendedAt, | ||||
|       DateTime updatedAt}); | ||||
|       int? affiliatedId, | ||||
|       int? affiliatedTo, | ||||
|       int? automatedBy, | ||||
|       int? automatedId}); | ||||
|  | ||||
|   $SnAccountProfileCopyWith<$Res>? get profile; | ||||
| } | ||||
| @@ -93,45 +94,49 @@ class _$SnAccountCopyWithImpl<$Res, $Val extends SnAccount> | ||||
|   @override | ||||
|   $Res call({ | ||||
|     Object? id = null, | ||||
|     Object? affiliatedId = freezed, | ||||
|     Object? affiliatedTo = freezed, | ||||
|     Object? automatedBy = freezed, | ||||
|     Object? automatedId = freezed, | ||||
|     Object? createdAt = null, | ||||
|     Object? updatedAt = null, | ||||
|     Object? deletedAt = freezed, | ||||
|     Object? confirmedAt = freezed, | ||||
|     Object? contacts = freezed, | ||||
|     Object? avatar = null, | ||||
|     Object? banner = null, | ||||
|     Object? confirmedAt = freezed, | ||||
|     Object? contacts = null, | ||||
|     Object? createdAt = null, | ||||
|     Object? deletedAt = freezed, | ||||
|     Object? description = null, | ||||
|     Object? name = null, | ||||
|     Object? nick = null, | ||||
|     Object? permNodes = null, | ||||
|     Object? profile = freezed, | ||||
|     Object? suspendedAt = freezed, | ||||
|     Object? updatedAt = null, | ||||
|     Object? affiliatedId = freezed, | ||||
|     Object? affiliatedTo = freezed, | ||||
|     Object? automatedBy = freezed, | ||||
|     Object? automatedId = freezed, | ||||
|   }) { | ||||
|     return _then(_value.copyWith( | ||||
|       id: null == id | ||||
|           ? _value.id | ||||
|           : id // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       affiliatedId: freezed == affiliatedId | ||||
|           ? _value.affiliatedId | ||||
|           : affiliatedId // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|       affiliatedTo: freezed == affiliatedTo | ||||
|           ? _value.affiliatedTo | ||||
|           : affiliatedTo // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|       automatedBy: freezed == automatedBy | ||||
|           ? _value.automatedBy | ||||
|           : automatedBy // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|       automatedId: freezed == automatedId | ||||
|           ? _value.automatedId | ||||
|           : automatedId // 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 DateTime?, | ||||
|       confirmedAt: freezed == confirmedAt | ||||
|           ? _value.confirmedAt | ||||
|           : confirmedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime?, | ||||
|       contacts: freezed == contacts | ||||
|           ? _value.contacts | ||||
|           : contacts // ignore: cast_nullable_to_non_nullable | ||||
|               as List<SnAccountContact>?, | ||||
|       avatar: null == avatar | ||||
|           ? _value.avatar | ||||
|           : avatar // ignore: cast_nullable_to_non_nullable | ||||
| @@ -140,22 +145,6 @@ class _$SnAccountCopyWithImpl<$Res, $Val extends SnAccount> | ||||
|           ? _value.banner | ||||
|           : banner // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       confirmedAt: freezed == confirmedAt | ||||
|           ? _value.confirmedAt | ||||
|           : confirmedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime?, | ||||
|       contacts: null == contacts | ||||
|           ? _value.contacts | ||||
|           : contacts // ignore: cast_nullable_to_non_nullable | ||||
|               as List<SnAccountContact>, | ||||
|       createdAt: null == createdAt | ||||
|           ? _value.createdAt | ||||
|           : createdAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime, | ||||
|       deletedAt: freezed == deletedAt | ||||
|           ? _value.deletedAt | ||||
|           : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime?, | ||||
|       description: null == description | ||||
|           ? _value.description | ||||
|           : description // ignore: cast_nullable_to_non_nullable | ||||
| @@ -180,10 +169,22 @@ class _$SnAccountCopyWithImpl<$Res, $Val extends SnAccount> | ||||
|           ? _value.suspendedAt | ||||
|           : suspendedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime?, | ||||
|       updatedAt: null == updatedAt | ||||
|           ? _value.updatedAt | ||||
|           : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime, | ||||
|       affiliatedId: freezed == affiliatedId | ||||
|           ? _value.affiliatedId | ||||
|           : affiliatedId // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|       affiliatedTo: freezed == affiliatedTo | ||||
|           ? _value.affiliatedTo | ||||
|           : affiliatedTo // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|       automatedBy: freezed == automatedBy | ||||
|           ? _value.automatedBy | ||||
|           : automatedBy // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|       automatedId: freezed == automatedId | ||||
|           ? _value.automatedId | ||||
|           : automatedId // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|     ) as $Val); | ||||
|   } | ||||
|  | ||||
| @@ -211,24 +212,24 @@ abstract class _$$SnAccountImplCopyWith<$Res> | ||||
|   @override | ||||
|   @useResult | ||||
|   $Res call( | ||||
|       {int id, | ||||
|       int? affiliatedId, | ||||
|       int? affiliatedTo, | ||||
|       int? automatedBy, | ||||
|       int? automatedId, | ||||
|       {@HiveField(0) int id, | ||||
|       DateTime createdAt, | ||||
|       DateTime updatedAt, | ||||
|       DateTime? deletedAt, | ||||
|       DateTime? confirmedAt, | ||||
|       List<SnAccountContact>? contacts, | ||||
|       String avatar, | ||||
|       String banner, | ||||
|       DateTime? confirmedAt, | ||||
|       List<SnAccountContact> contacts, | ||||
|       DateTime createdAt, | ||||
|       DateTime? deletedAt, | ||||
|       String description, | ||||
|       String name, | ||||
|       String nick, | ||||
|       Map<String, dynamic> permNodes, | ||||
|       SnAccountProfile? profile, | ||||
|       DateTime? suspendedAt, | ||||
|       DateTime updatedAt}); | ||||
|       int? affiliatedId, | ||||
|       int? affiliatedTo, | ||||
|       int? automatedBy, | ||||
|       int? automatedId}); | ||||
|  | ||||
|   @override | ||||
|   $SnAccountProfileCopyWith<$Res>? get profile; | ||||
| @@ -248,45 +249,49 @@ class __$$SnAccountImplCopyWithImpl<$Res> | ||||
|   @override | ||||
|   $Res call({ | ||||
|     Object? id = null, | ||||
|     Object? affiliatedId = freezed, | ||||
|     Object? affiliatedTo = freezed, | ||||
|     Object? automatedBy = freezed, | ||||
|     Object? automatedId = freezed, | ||||
|     Object? createdAt = null, | ||||
|     Object? updatedAt = null, | ||||
|     Object? deletedAt = freezed, | ||||
|     Object? confirmedAt = freezed, | ||||
|     Object? contacts = freezed, | ||||
|     Object? avatar = null, | ||||
|     Object? banner = null, | ||||
|     Object? confirmedAt = freezed, | ||||
|     Object? contacts = null, | ||||
|     Object? createdAt = null, | ||||
|     Object? deletedAt = freezed, | ||||
|     Object? description = null, | ||||
|     Object? name = null, | ||||
|     Object? nick = null, | ||||
|     Object? permNodes = null, | ||||
|     Object? profile = freezed, | ||||
|     Object? suspendedAt = freezed, | ||||
|     Object? updatedAt = null, | ||||
|     Object? affiliatedId = freezed, | ||||
|     Object? affiliatedTo = freezed, | ||||
|     Object? automatedBy = freezed, | ||||
|     Object? automatedId = freezed, | ||||
|   }) { | ||||
|     return _then(_$SnAccountImpl( | ||||
|       id: null == id | ||||
|           ? _value.id | ||||
|           : id // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       affiliatedId: freezed == affiliatedId | ||||
|           ? _value.affiliatedId | ||||
|           : affiliatedId // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|       affiliatedTo: freezed == affiliatedTo | ||||
|           ? _value.affiliatedTo | ||||
|           : affiliatedTo // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|       automatedBy: freezed == automatedBy | ||||
|           ? _value.automatedBy | ||||
|           : automatedBy // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|       automatedId: freezed == automatedId | ||||
|           ? _value.automatedId | ||||
|           : automatedId // 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 DateTime?, | ||||
|       confirmedAt: freezed == confirmedAt | ||||
|           ? _value.confirmedAt | ||||
|           : confirmedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime?, | ||||
|       contacts: freezed == contacts | ||||
|           ? _value._contacts | ||||
|           : contacts // ignore: cast_nullable_to_non_nullable | ||||
|               as List<SnAccountContact>?, | ||||
|       avatar: null == avatar | ||||
|           ? _value.avatar | ||||
|           : avatar // ignore: cast_nullable_to_non_nullable | ||||
| @@ -295,22 +300,6 @@ class __$$SnAccountImplCopyWithImpl<$Res> | ||||
|           ? _value.banner | ||||
|           : banner // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       confirmedAt: freezed == confirmedAt | ||||
|           ? _value.confirmedAt | ||||
|           : confirmedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime?, | ||||
|       contacts: null == contacts | ||||
|           ? _value._contacts | ||||
|           : contacts // ignore: cast_nullable_to_non_nullable | ||||
|               as List<SnAccountContact>, | ||||
|       createdAt: null == createdAt | ||||
|           ? _value.createdAt | ||||
|           : createdAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime, | ||||
|       deletedAt: freezed == deletedAt | ||||
|           ? _value.deletedAt | ||||
|           : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime?, | ||||
|       description: null == description | ||||
|           ? _value.description | ||||
|           : description // ignore: cast_nullable_to_non_nullable | ||||
| @@ -335,71 +324,81 @@ class __$$SnAccountImplCopyWithImpl<$Res> | ||||
|           ? _value.suspendedAt | ||||
|           : suspendedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime?, | ||||
|       updatedAt: null == updatedAt | ||||
|           ? _value.updatedAt | ||||
|           : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime, | ||||
|       affiliatedId: freezed == affiliatedId | ||||
|           ? _value.affiliatedId | ||||
|           : affiliatedId // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|       affiliatedTo: freezed == affiliatedTo | ||||
|           ? _value.affiliatedTo | ||||
|           : affiliatedTo // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|       automatedBy: freezed == automatedBy | ||||
|           ? _value.automatedBy | ||||
|           : automatedBy // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|       automatedId: freezed == automatedId | ||||
|           ? _value.automatedId | ||||
|           : automatedId // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|     )); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
| class _$SnAccountImpl implements _SnAccount { | ||||
| class _$SnAccountImpl extends _SnAccount { | ||||
|   const _$SnAccountImpl( | ||||
|       {required this.id, | ||||
|       required this.affiliatedId, | ||||
|       required this.affiliatedTo, | ||||
|       required this.automatedBy, | ||||
|       required this.automatedId, | ||||
|       {@HiveField(0) required this.id, | ||||
|       required this.createdAt, | ||||
|       required this.updatedAt, | ||||
|       required this.deletedAt, | ||||
|       required this.confirmedAt, | ||||
|       required final List<SnAccountContact>? contacts, | ||||
|       required this.avatar, | ||||
|       required this.banner, | ||||
|       required this.confirmedAt, | ||||
|       required final List<SnAccountContact> contacts, | ||||
|       required this.createdAt, | ||||
|       required this.deletedAt, | ||||
|       required this.description, | ||||
|       required this.name, | ||||
|       required this.nick, | ||||
|       required final Map<String, dynamic> permNodes, | ||||
|       required this.profile, | ||||
|       required this.suspendedAt, | ||||
|       required this.updatedAt}) | ||||
|       required this.affiliatedId, | ||||
|       required this.affiliatedTo, | ||||
|       required this.automatedBy, | ||||
|       required this.automatedId}) | ||||
|       : _contacts = contacts, | ||||
|         _permNodes = permNodes; | ||||
|         _permNodes = permNodes, | ||||
|         super._(); | ||||
|  | ||||
|   factory _$SnAccountImpl.fromJson(Map<String, dynamic> json) => | ||||
|       _$$SnAccountImplFromJson(json); | ||||
|  | ||||
|   @override | ||||
|   @HiveField(0) | ||||
|   final int id; | ||||
|   @override | ||||
|   final int? affiliatedId; | ||||
|   final DateTime createdAt; | ||||
|   @override | ||||
|   final int? affiliatedTo; | ||||
|   final DateTime updatedAt; | ||||
|   @override | ||||
|   final int? automatedBy; | ||||
|   final DateTime? deletedAt; | ||||
|   @override | ||||
|   final int? automatedId; | ||||
|   final DateTime? confirmedAt; | ||||
|   final List<SnAccountContact>? _contacts; | ||||
|   @override | ||||
|   List<SnAccountContact>? get contacts { | ||||
|     final value = _contacts; | ||||
|     if (value == null) return null; | ||||
|     if (_contacts is EqualUnmodifiableListView) return _contacts; | ||||
|     // ignore: implicit_dynamic_type | ||||
|     return EqualUnmodifiableListView(value); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   final String avatar; | ||||
|   @override | ||||
|   final String banner; | ||||
|   @override | ||||
|   final DateTime? confirmedAt; | ||||
|   final List<SnAccountContact> _contacts; | ||||
|   @override | ||||
|   List<SnAccountContact> get contacts { | ||||
|     if (_contacts is EqualUnmodifiableListView) return _contacts; | ||||
|     // ignore: implicit_dynamic_type | ||||
|     return EqualUnmodifiableListView(_contacts); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   final DateTime createdAt; | ||||
|   @override | ||||
|   final DateTime? deletedAt; | ||||
|   @override | ||||
|   final String description; | ||||
|   @override | ||||
|   final String name; | ||||
| @@ -418,11 +417,17 @@ class _$SnAccountImpl implements _SnAccount { | ||||
|   @override | ||||
|   final DateTime? suspendedAt; | ||||
|   @override | ||||
|   final DateTime updatedAt; | ||||
|   final int? affiliatedId; | ||||
|   @override | ||||
|   final int? affiliatedTo; | ||||
|   @override | ||||
|   final int? automatedBy; | ||||
|   @override | ||||
|   final int? automatedId; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'SnAccount(id: $id, affiliatedId: $affiliatedId, affiliatedTo: $affiliatedTo, automatedBy: $automatedBy, automatedId: $automatedId, avatar: $avatar, banner: $banner, confirmedAt: $confirmedAt, contacts: $contacts, createdAt: $createdAt, deletedAt: $deletedAt, description: $description, name: $name, nick: $nick, permNodes: $permNodes, profile: $profile, suspendedAt: $suspendedAt, updatedAt: $updatedAt)'; | ||||
|     return 'SnAccount(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, confirmedAt: $confirmedAt, contacts: $contacts, avatar: $avatar, banner: $banner, description: $description, name: $name, nick: $nick, permNodes: $permNodes, profile: $profile, suspendedAt: $suspendedAt, affiliatedId: $affiliatedId, affiliatedTo: $affiliatedTo, automatedBy: $automatedBy, automatedId: $automatedId)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -431,23 +436,17 @@ class _$SnAccountImpl implements _SnAccount { | ||||
|         (other.runtimeType == runtimeType && | ||||
|             other is _$SnAccountImpl && | ||||
|             (identical(other.id, id) || other.id == id) && | ||||
|             (identical(other.affiliatedId, affiliatedId) || | ||||
|                 other.affiliatedId == affiliatedId) && | ||||
|             (identical(other.affiliatedTo, affiliatedTo) || | ||||
|                 other.affiliatedTo == affiliatedTo) && | ||||
|             (identical(other.automatedBy, automatedBy) || | ||||
|                 other.automatedBy == automatedBy) && | ||||
|             (identical(other.automatedId, automatedId) || | ||||
|                 other.automatedId == automatedId) && | ||||
|             (identical(other.avatar, avatar) || other.avatar == avatar) && | ||||
|             (identical(other.banner, banner) || other.banner == banner) && | ||||
|             (identical(other.createdAt, createdAt) || | ||||
|                 other.createdAt == createdAt) && | ||||
|             (identical(other.updatedAt, updatedAt) || | ||||
|                 other.updatedAt == updatedAt) && | ||||
|             (identical(other.deletedAt, deletedAt) || | ||||
|                 other.deletedAt == deletedAt) && | ||||
|             (identical(other.confirmedAt, confirmedAt) || | ||||
|                 other.confirmedAt == confirmedAt) && | ||||
|             const DeepCollectionEquality().equals(other._contacts, _contacts) && | ||||
|             (identical(other.createdAt, createdAt) || | ||||
|                 other.createdAt == createdAt) && | ||||
|             (identical(other.deletedAt, deletedAt) || | ||||
|                 other.deletedAt == deletedAt) && | ||||
|             (identical(other.avatar, avatar) || other.avatar == avatar) && | ||||
|             (identical(other.banner, banner) || other.banner == banner) && | ||||
|             (identical(other.description, description) || | ||||
|                 other.description == description) && | ||||
|             (identical(other.name, name) || other.name == name) && | ||||
| @@ -457,8 +456,14 @@ class _$SnAccountImpl implements _SnAccount { | ||||
|             (identical(other.profile, profile) || other.profile == profile) && | ||||
|             (identical(other.suspendedAt, suspendedAt) || | ||||
|                 other.suspendedAt == suspendedAt) && | ||||
|             (identical(other.updatedAt, updatedAt) || | ||||
|                 other.updatedAt == updatedAt)); | ||||
|             (identical(other.affiliatedId, affiliatedId) || | ||||
|                 other.affiliatedId == affiliatedId) && | ||||
|             (identical(other.affiliatedTo, affiliatedTo) || | ||||
|                 other.affiliatedTo == affiliatedTo) && | ||||
|             (identical(other.automatedBy, automatedBy) || | ||||
|                 other.automatedBy == automatedBy) && | ||||
|             (identical(other.automatedId, automatedId) || | ||||
|                 other.automatedId == automatedId)); | ||||
|   } | ||||
|  | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -466,23 +471,23 @@ class _$SnAccountImpl implements _SnAccount { | ||||
|   int get hashCode => Object.hash( | ||||
|       runtimeType, | ||||
|       id, | ||||
|       affiliatedId, | ||||
|       affiliatedTo, | ||||
|       automatedBy, | ||||
|       automatedId, | ||||
|       avatar, | ||||
|       banner, | ||||
|       createdAt, | ||||
|       updatedAt, | ||||
|       deletedAt, | ||||
|       confirmedAt, | ||||
|       const DeepCollectionEquality().hash(_contacts), | ||||
|       createdAt, | ||||
|       deletedAt, | ||||
|       avatar, | ||||
|       banner, | ||||
|       description, | ||||
|       name, | ||||
|       nick, | ||||
|       const DeepCollectionEquality().hash(_permNodes), | ||||
|       profile, | ||||
|       suspendedAt, | ||||
|       updatedAt); | ||||
|       affiliatedId, | ||||
|       affiliatedTo, | ||||
|       automatedBy, | ||||
|       automatedId); | ||||
|  | ||||
|   /// Create a copy of SnAccount | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
| @@ -500,53 +505,49 @@ class _$SnAccountImpl implements _SnAccount { | ||||
|   } | ||||
| } | ||||
|  | ||||
| abstract class _SnAccount implements SnAccount { | ||||
| abstract class _SnAccount extends SnAccount { | ||||
|   const factory _SnAccount( | ||||
|       {required final int id, | ||||
|       required final int? affiliatedId, | ||||
|       required final int? affiliatedTo, | ||||
|       required final int? automatedBy, | ||||
|       required final int? automatedId, | ||||
|       {@HiveField(0) required final int id, | ||||
|       required final DateTime createdAt, | ||||
|       required final DateTime updatedAt, | ||||
|       required final DateTime? deletedAt, | ||||
|       required final DateTime? confirmedAt, | ||||
|       required final List<SnAccountContact>? contacts, | ||||
|       required final String avatar, | ||||
|       required final String banner, | ||||
|       required final DateTime? confirmedAt, | ||||
|       required final List<SnAccountContact> contacts, | ||||
|       required final DateTime createdAt, | ||||
|       required final DateTime? deletedAt, | ||||
|       required final String description, | ||||
|       required final String name, | ||||
|       required final String nick, | ||||
|       required final Map<String, dynamic> permNodes, | ||||
|       required final SnAccountProfile? profile, | ||||
|       required final DateTime? suspendedAt, | ||||
|       required final DateTime updatedAt}) = _$SnAccountImpl; | ||||
|       required final int? affiliatedId, | ||||
|       required final int? affiliatedTo, | ||||
|       required final int? automatedBy, | ||||
|       required final int? automatedId}) = _$SnAccountImpl; | ||||
|   const _SnAccount._() : super._(); | ||||
|  | ||||
|   factory _SnAccount.fromJson(Map<String, dynamic> json) = | ||||
|       _$SnAccountImpl.fromJson; | ||||
|  | ||||
|   @override | ||||
|   @HiveField(0) | ||||
|   int get id; | ||||
|   @override | ||||
|   int? get affiliatedId; | ||||
|   DateTime get createdAt; | ||||
|   @override | ||||
|   int? get affiliatedTo; | ||||
|   DateTime get updatedAt; | ||||
|   @override | ||||
|   int? get automatedBy; | ||||
|   DateTime? get deletedAt; | ||||
|   @override | ||||
|   int? get automatedId; | ||||
|   DateTime? get confirmedAt; | ||||
|   @override | ||||
|   List<SnAccountContact>? get contacts; | ||||
|   @override | ||||
|   String get avatar; | ||||
|   @override | ||||
|   String get banner; | ||||
|   @override | ||||
|   DateTime? get confirmedAt; | ||||
|   @override | ||||
|   List<SnAccountContact> get contacts; | ||||
|   @override | ||||
|   DateTime get createdAt; | ||||
|   @override | ||||
|   DateTime? get deletedAt; | ||||
|   @override | ||||
|   String get description; | ||||
|   @override | ||||
|   String get name; | ||||
| @@ -559,7 +560,13 @@ abstract class _SnAccount implements SnAccount { | ||||
|   @override | ||||
|   DateTime? get suspendedAt; | ||||
|   @override | ||||
|   DateTime get updatedAt; | ||||
|   int? get affiliatedId; | ||||
|   @override | ||||
|   int? get affiliatedTo; | ||||
|   @override | ||||
|   int? get automatedBy; | ||||
|   @override | ||||
|   int? get automatedId; | ||||
|  | ||||
|   /// Create a copy of SnAccount | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   | ||||
| @@ -9,22 +9,19 @@ part of 'account.dart'; | ||||
| _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) => | ||||
|     _$SnAccountImpl( | ||||
|       id: (json['id'] as num).toInt(), | ||||
|       affiliatedId: (json['affiliated_id'] as num?)?.toInt(), | ||||
|       affiliatedTo: (json['affiliated_to'] as num?)?.toInt(), | ||||
|       automatedBy: (json['automated_by'] as num?)?.toInt(), | ||||
|       automatedId: (json['automated_id'] as num?)?.toInt(), | ||||
|       avatar: json['avatar'] as String, | ||||
|       banner: json['banner'] as String, | ||||
|       confirmedAt: json['confirmed_at'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['confirmed_at'] as String), | ||||
|       contacts: (json['contacts'] as List<dynamic>) | ||||
|           .map((e) => SnAccountContact.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList(), | ||||
|       createdAt: DateTime.parse(json['created_at'] as String), | ||||
|       updatedAt: DateTime.parse(json['updated_at'] as String), | ||||
|       deletedAt: json['deleted_at'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['deleted_at'] as String), | ||||
|       confirmedAt: json['confirmed_at'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['confirmed_at'] as String), | ||||
|       contacts: (json['contacts'] as List<dynamic>?) | ||||
|           ?.map((e) => SnAccountContact.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList(), | ||||
|       avatar: json['avatar'] as String, | ||||
|       banner: json['banner'] as String, | ||||
|       description: json['description'] as String, | ||||
|       name: json['name'] as String, | ||||
|       nick: json['nick'] as String, | ||||
| @@ -35,29 +32,32 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) => | ||||
|       suspendedAt: json['suspended_at'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['suspended_at'] as String), | ||||
|       updatedAt: DateTime.parse(json['updated_at'] as String), | ||||
|       affiliatedId: (json['affiliated_id'] as num?)?.toInt(), | ||||
|       affiliatedTo: (json['affiliated_to'] as num?)?.toInt(), | ||||
|       automatedBy: (json['automated_by'] as num?)?.toInt(), | ||||
|       automatedId: (json['automated_id'] as num?)?.toInt(), | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'affiliated_id': instance.affiliatedId, | ||||
|       'affiliated_to': instance.affiliatedTo, | ||||
|       'automated_by': instance.automatedBy, | ||||
|       'automated_id': instance.automatedId, | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
|       'confirmed_at': instance.confirmedAt?.toIso8601String(), | ||||
|       'contacts': instance.contacts?.map((e) => e.toJson()).toList(), | ||||
|       'avatar': instance.avatar, | ||||
|       'banner': instance.banner, | ||||
|       'confirmed_at': instance.confirmedAt?.toIso8601String(), | ||||
|       'contacts': instance.contacts.map((e) => e.toJson()).toList(), | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
|       'description': instance.description, | ||||
|       'name': instance.name, | ||||
|       'nick': instance.nick, | ||||
|       'perm_nodes': instance.permNodes, | ||||
|       'profile': instance.profile?.toJson(), | ||||
|       'suspended_at': instance.suspendedAt?.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'affiliated_id': instance.affiliatedId, | ||||
|       'affiliated_to': instance.affiliatedTo, | ||||
|       'automated_by': instance.automatedBy, | ||||
|       'automated_id': instance.automatedId, | ||||
|     }; | ||||
|  | ||||
| _$SnAccountContactImpl _$$SnAccountContactImplFromJson( | ||||
|   | ||||
							
								
								
									
										144
									
								
								lib/types/chat.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								lib/types/chat.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,144 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:surface/types/account.dart'; | ||||
| import 'package:surface/types/attachment.dart'; | ||||
| import 'package:surface/types/realm.dart'; | ||||
|  | ||||
| part 'chat.freezed.dart'; | ||||
| part 'chat.g.dart'; | ||||
|  | ||||
| @freezed | ||||
| class SnChannel with _$SnChannel { | ||||
|   const SnChannel._(); | ||||
|  | ||||
|   @HiveType(typeId: 2) | ||||
|   const factory SnChannel({ | ||||
|     @HiveField(0) required int id, | ||||
|     @HiveField(1) required DateTime createdAt, | ||||
|     @HiveField(2) required DateTime updatedAt, | ||||
|     @HiveField(3) required dynamic deletedAt, | ||||
|     @HiveField(4) required String alias, | ||||
|     @HiveField(5) required String name, | ||||
|     @HiveField(6) required String description, | ||||
|     @HiveField(7) required List<dynamic>? members, | ||||
|     List<SnChatMessage>? messages, | ||||
|     dynamic calls, | ||||
|     @HiveField(8) required int type, | ||||
|     @HiveField(9) required int accountId, | ||||
|     @HiveField(10) required SnRealm? realm, | ||||
|     @HiveField(11) required int? realmId, | ||||
|     @HiveField(12) required bool isPublic, | ||||
|     @HiveField(13) required bool isCommunity, | ||||
|   }) = _SnChannel; | ||||
|  | ||||
|   factory SnChannel.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnChannelFromJson(json); | ||||
|  | ||||
|   String get key => '${realm?.alias ?? 'global'}:$alias'; | ||||
|   String get keyPath => '${realm?.alias ?? 'global'}/$alias'; | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| class SnChannelMember with _$SnChannelMember { | ||||
|   const SnChannelMember._(); | ||||
|  | ||||
|   @HiveType(typeId: 3) | ||||
|   const factory SnChannelMember({ | ||||
|     @HiveField(0) required int id, | ||||
|     @HiveField(1) required DateTime createdAt, | ||||
|     @HiveField(2) required DateTime updatedAt, | ||||
|     @HiveField(3) required DateTime? deletedAt, | ||||
|     @HiveField(4) required int channelId, | ||||
|     @HiveField(5) required int accountId, | ||||
|     @HiveField(6) required String? nick, | ||||
|     @HiveField(7) required SnChannel? channel, | ||||
|     @HiveField(8) required SnAccount? account, | ||||
|     @Default(0) int notify, | ||||
|     @HiveField(9) required int powerLevel, | ||||
|     dynamic calls, | ||||
|     dynamic events, | ||||
|   }) = _SnChannelMember; | ||||
|  | ||||
|   factory SnChannelMember.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnChannelMemberFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| class SnChatMessage with _$SnChatMessage { | ||||
|   const SnChatMessage._(); | ||||
|  | ||||
|   @HiveType(typeId: 4) | ||||
|   const factory SnChatMessage({ | ||||
|     @HiveField(0) required int id, | ||||
|     @HiveField(1) required DateTime createdAt, | ||||
|     @HiveField(2) required DateTime updatedAt, | ||||
|     @HiveField(3) required DateTime? deletedAt, | ||||
|     @HiveField(4) required String uuid, | ||||
|     @HiveField(5) @Default({}) Map<String, dynamic> body, | ||||
|     @HiveField(6) required String type, | ||||
|     @HiveField(7) required SnChannel channel, | ||||
|     @HiveField(8) required SnChannelMember sender, | ||||
|     @HiveField(9) required int channelId, | ||||
|     @HiveField(10) required int senderId, | ||||
|     @HiveField(11) required int? quoteEventId, | ||||
|     @HiveField(12) required int? relatedEventId, | ||||
|     SnChatMessagePreload? preload, | ||||
|   }) = _SnChatMessage; | ||||
|  | ||||
|   factory SnChatMessage.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnChatMessageFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| class SnChatMessagePreload with _$SnChatMessagePreload { | ||||
|   const SnChatMessagePreload._(); | ||||
|  | ||||
|   const factory SnChatMessagePreload({ | ||||
|     List<SnAttachment?>? attachments, | ||||
|     SnChatMessage? quoteEvent, | ||||
|   }) = _SnChatMessagePreload; | ||||
|  | ||||
|   factory SnChatMessagePreload.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnChatMessagePreloadFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| class SnChatCall with _$SnChatCall { | ||||
|   const factory SnChatCall({ | ||||
|     required int id, | ||||
|     required DateTime createdAt, | ||||
|     required DateTime updatedAt, | ||||
|     required DateTime? deletedAt, | ||||
|     required DateTime? endedAt, | ||||
|     required String externalId, | ||||
|     required int founderId, | ||||
|     required int channelId, | ||||
|     required SnChannelMember founder, | ||||
|     @Default([]) List<dynamic> participants, | ||||
|   }) = _SnChatCall; | ||||
|  | ||||
|   factory SnChatCall.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnChatCallFromJson(json); | ||||
| } | ||||
|  | ||||
| // Call stuff | ||||
|  | ||||
| enum ParticipantStatsType { | ||||
|   unknown, | ||||
|   localAudioSender, | ||||
|   localVideoSender, | ||||
|   remoteAudioReceiver, | ||||
|   remoteVideoReceiver, | ||||
| } | ||||
|  | ||||
| class ParticipantTrack { | ||||
|   ParticipantTrack( | ||||
|       {required this.participant, | ||||
|       required this.videoTrack, | ||||
|       required this.isScreenShare}); | ||||
|  | ||||
|   VideoTrack? videoTrack; | ||||
|   Participant participant; | ||||
|   bool isScreenShare; | ||||
| } | ||||
							
								
								
									
										2163
									
								
								lib/types/chat.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2163
									
								
								lib/types/chat.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										395
									
								
								lib/types/chat.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										395
									
								
								lib/types/chat.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,395 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'chat.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // TypeAdapterGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| class SnChannelImplAdapter extends TypeAdapter<_$SnChannelImpl> { | ||||
|   @override | ||||
|   final int typeId = 2; | ||||
|  | ||||
|   @override | ||||
|   _$SnChannelImpl read(BinaryReader reader) { | ||||
|     final numOfFields = reader.readByte(); | ||||
|     final fields = <int, dynamic>{ | ||||
|       for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), | ||||
|     }; | ||||
|     return _$SnChannelImpl( | ||||
|       id: fields[0] as int, | ||||
|       createdAt: fields[1] as DateTime, | ||||
|       updatedAt: fields[2] as DateTime, | ||||
|       deletedAt: fields[3] as dynamic, | ||||
|       alias: fields[4] as String, | ||||
|       name: fields[5] as String, | ||||
|       description: fields[6] as String, | ||||
|       members: (fields[7] as List?)?.cast<dynamic>(), | ||||
|       type: fields[8] as int, | ||||
|       accountId: fields[9] as int, | ||||
|       realm: fields[10] as SnRealm?, | ||||
|       realmId: fields[11] as int?, | ||||
|       isPublic: fields[12] as bool, | ||||
|       isCommunity: fields[13] as bool, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void write(BinaryWriter writer, _$SnChannelImpl obj) { | ||||
|     writer | ||||
|       ..writeByte(14) | ||||
|       ..writeByte(0) | ||||
|       ..write(obj.id) | ||||
|       ..writeByte(1) | ||||
|       ..write(obj.createdAt) | ||||
|       ..writeByte(2) | ||||
|       ..write(obj.updatedAt) | ||||
|       ..writeByte(3) | ||||
|       ..write(obj.deletedAt) | ||||
|       ..writeByte(4) | ||||
|       ..write(obj.alias) | ||||
|       ..writeByte(5) | ||||
|       ..write(obj.name) | ||||
|       ..writeByte(6) | ||||
|       ..write(obj.description) | ||||
|       ..writeByte(8) | ||||
|       ..write(obj.type) | ||||
|       ..writeByte(9) | ||||
|       ..write(obj.accountId) | ||||
|       ..writeByte(10) | ||||
|       ..write(obj.realm) | ||||
|       ..writeByte(11) | ||||
|       ..write(obj.realmId) | ||||
|       ..writeByte(12) | ||||
|       ..write(obj.isPublic) | ||||
|       ..writeByte(13) | ||||
|       ..write(obj.isCommunity) | ||||
|       ..writeByte(7) | ||||
|       ..write(obj.members); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => typeId.hashCode; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) => | ||||
|       identical(this, other) || | ||||
|       other is SnChannelImplAdapter && | ||||
|           runtimeType == other.runtimeType && | ||||
|           typeId == other.typeId; | ||||
| } | ||||
|  | ||||
| class SnChannelMemberImplAdapter extends TypeAdapter<_$SnChannelMemberImpl> { | ||||
|   @override | ||||
|   final int typeId = 3; | ||||
|  | ||||
|   @override | ||||
|   _$SnChannelMemberImpl read(BinaryReader reader) { | ||||
|     final numOfFields = reader.readByte(); | ||||
|     final fields = <int, dynamic>{ | ||||
|       for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), | ||||
|     }; | ||||
|     return _$SnChannelMemberImpl( | ||||
|       id: fields[0] as int, | ||||
|       createdAt: fields[1] as DateTime, | ||||
|       updatedAt: fields[2] as DateTime, | ||||
|       deletedAt: fields[3] as DateTime?, | ||||
|       channelId: fields[4] as int, | ||||
|       accountId: fields[5] as int, | ||||
|       nick: fields[6] as String?, | ||||
|       channel: fields[7] as SnChannel?, | ||||
|       account: fields[8] as SnAccount?, | ||||
|       powerLevel: fields[9] as int, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void write(BinaryWriter writer, _$SnChannelMemberImpl obj) { | ||||
|     writer | ||||
|       ..writeByte(10) | ||||
|       ..writeByte(0) | ||||
|       ..write(obj.id) | ||||
|       ..writeByte(1) | ||||
|       ..write(obj.createdAt) | ||||
|       ..writeByte(2) | ||||
|       ..write(obj.updatedAt) | ||||
|       ..writeByte(3) | ||||
|       ..write(obj.deletedAt) | ||||
|       ..writeByte(4) | ||||
|       ..write(obj.channelId) | ||||
|       ..writeByte(5) | ||||
|       ..write(obj.accountId) | ||||
|       ..writeByte(6) | ||||
|       ..write(obj.nick) | ||||
|       ..writeByte(7) | ||||
|       ..write(obj.channel) | ||||
|       ..writeByte(8) | ||||
|       ..write(obj.account) | ||||
|       ..writeByte(9) | ||||
|       ..write(obj.powerLevel); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => typeId.hashCode; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) => | ||||
|       identical(this, other) || | ||||
|       other is SnChannelMemberImplAdapter && | ||||
|           runtimeType == other.runtimeType && | ||||
|           typeId == other.typeId; | ||||
| } | ||||
|  | ||||
| class SnChatMessageImplAdapter extends TypeAdapter<_$SnChatMessageImpl> { | ||||
|   @override | ||||
|   final int typeId = 4; | ||||
|  | ||||
|   @override | ||||
|   _$SnChatMessageImpl read(BinaryReader reader) { | ||||
|     final numOfFields = reader.readByte(); | ||||
|     final fields = <int, dynamic>{ | ||||
|       for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), | ||||
|     }; | ||||
|     return _$SnChatMessageImpl( | ||||
|       id: fields[0] as int, | ||||
|       createdAt: fields[1] as DateTime, | ||||
|       updatedAt: fields[2] as DateTime, | ||||
|       deletedAt: fields[3] as DateTime?, | ||||
|       uuid: fields[4] as String, | ||||
|       body: (fields[5] as Map).cast<String, dynamic>(), | ||||
|       type: fields[6] as String, | ||||
|       channel: fields[7] as SnChannel, | ||||
|       sender: fields[8] as SnChannelMember, | ||||
|       channelId: fields[9] as int, | ||||
|       senderId: fields[10] as int, | ||||
|       quoteEventId: fields[11] as int?, | ||||
|       relatedEventId: fields[12] as int?, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void write(BinaryWriter writer, _$SnChatMessageImpl obj) { | ||||
|     writer | ||||
|       ..writeByte(13) | ||||
|       ..writeByte(0) | ||||
|       ..write(obj.id) | ||||
|       ..writeByte(1) | ||||
|       ..write(obj.createdAt) | ||||
|       ..writeByte(2) | ||||
|       ..write(obj.updatedAt) | ||||
|       ..writeByte(3) | ||||
|       ..write(obj.deletedAt) | ||||
|       ..writeByte(4) | ||||
|       ..write(obj.uuid) | ||||
|       ..writeByte(6) | ||||
|       ..write(obj.type) | ||||
|       ..writeByte(7) | ||||
|       ..write(obj.channel) | ||||
|       ..writeByte(8) | ||||
|       ..write(obj.sender) | ||||
|       ..writeByte(9) | ||||
|       ..write(obj.channelId) | ||||
|       ..writeByte(10) | ||||
|       ..write(obj.senderId) | ||||
|       ..writeByte(11) | ||||
|       ..write(obj.quoteEventId) | ||||
|       ..writeByte(12) | ||||
|       ..write(obj.relatedEventId) | ||||
|       ..writeByte(5) | ||||
|       ..write(obj.body); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => typeId.hashCode; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) => | ||||
|       identical(this, other) || | ||||
|       other is SnChatMessageImplAdapter && | ||||
|           runtimeType == other.runtimeType && | ||||
|           typeId == other.typeId; | ||||
| } | ||||
|  | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| _$SnChannelImpl _$$SnChannelImplFromJson(Map<String, dynamic> json) => | ||||
|     _$SnChannelImpl( | ||||
|       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, | ||||
|       members: json['members'] as List<dynamic>?, | ||||
|       messages: (json['messages'] as List<dynamic>?) | ||||
|           ?.map((e) => SnChatMessage.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList(), | ||||
|       calls: json['calls'], | ||||
|       type: (json['type'] as num).toInt(), | ||||
|       accountId: (json['account_id'] as num).toInt(), | ||||
|       realm: json['realm'] == null | ||||
|           ? null | ||||
|           : SnRealm.fromJson(json['realm'] as Map<String, dynamic>), | ||||
|       realmId: (json['realm_id'] as num?)?.toInt(), | ||||
|       isPublic: json['is_public'] as bool, | ||||
|       isCommunity: json['is_community'] as bool, | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$$SnChannelImplToJson(_$SnChannelImpl 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, | ||||
|       'members': instance.members, | ||||
|       'messages': instance.messages?.map((e) => e.toJson()).toList(), | ||||
|       'calls': instance.calls, | ||||
|       'type': instance.type, | ||||
|       'account_id': instance.accountId, | ||||
|       'realm': instance.realm?.toJson(), | ||||
|       'realm_id': instance.realmId, | ||||
|       'is_public': instance.isPublic, | ||||
|       'is_community': instance.isCommunity, | ||||
|     }; | ||||
|  | ||||
| _$SnChannelMemberImpl _$$SnChannelMemberImplFromJson( | ||||
|         Map<String, dynamic> json) => | ||||
|     _$SnChannelMemberImpl( | ||||
|       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'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['deleted_at'] as String), | ||||
|       channelId: (json['channel_id'] as num).toInt(), | ||||
|       accountId: (json['account_id'] as num).toInt(), | ||||
|       nick: json['nick'] as String?, | ||||
|       channel: json['channel'] == null | ||||
|           ? null | ||||
|           : SnChannel.fromJson(json['channel'] as Map<String, dynamic>), | ||||
|       account: json['account'] == null | ||||
|           ? null | ||||
|           : SnAccount.fromJson(json['account'] as Map<String, dynamic>), | ||||
|       notify: (json['notify'] as num?)?.toInt() ?? 0, | ||||
|       powerLevel: (json['power_level'] as num).toInt(), | ||||
|       calls: json['calls'], | ||||
|       events: json['events'], | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$$SnChannelMemberImplToJson( | ||||
|         _$SnChannelMemberImpl instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
|       'channel_id': instance.channelId, | ||||
|       'account_id': instance.accountId, | ||||
|       'nick': instance.nick, | ||||
|       'channel': instance.channel?.toJson(), | ||||
|       'account': instance.account?.toJson(), | ||||
|       'notify': instance.notify, | ||||
|       'power_level': instance.powerLevel, | ||||
|       'calls': instance.calls, | ||||
|       'events': instance.events, | ||||
|     }; | ||||
|  | ||||
| _$SnChatMessageImpl _$$SnChatMessageImplFromJson(Map<String, dynamic> json) => | ||||
|     _$SnChatMessageImpl( | ||||
|       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'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['deleted_at'] as String), | ||||
|       uuid: json['uuid'] as String, | ||||
|       body: json['body'] as Map<String, dynamic>? ?? const {}, | ||||
|       type: json['type'] as String, | ||||
|       channel: SnChannel.fromJson(json['channel'] as Map<String, dynamic>), | ||||
|       sender: SnChannelMember.fromJson(json['sender'] as Map<String, dynamic>), | ||||
|       channelId: (json['channel_id'] as num).toInt(), | ||||
|       senderId: (json['sender_id'] as num).toInt(), | ||||
|       quoteEventId: (json['quote_event_id'] as num?)?.toInt(), | ||||
|       relatedEventId: (json['related_event_id'] as num?)?.toInt(), | ||||
|       preload: json['preload'] == null | ||||
|           ? null | ||||
|           : SnChatMessagePreload.fromJson( | ||||
|               json['preload'] as Map<String, dynamic>), | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$$SnChatMessageImplToJson(_$SnChatMessageImpl instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
|       'uuid': instance.uuid, | ||||
|       'body': instance.body, | ||||
|       'type': instance.type, | ||||
|       'channel': instance.channel.toJson(), | ||||
|       'sender': instance.sender.toJson(), | ||||
|       'channel_id': instance.channelId, | ||||
|       'sender_id': instance.senderId, | ||||
|       'quote_event_id': instance.quoteEventId, | ||||
|       'related_event_id': instance.relatedEventId, | ||||
|       'preload': instance.preload?.toJson(), | ||||
|     }; | ||||
|  | ||||
| _$SnChatMessagePreloadImpl _$$SnChatMessagePreloadImplFromJson( | ||||
|         Map<String, dynamic> json) => | ||||
|     _$SnChatMessagePreloadImpl( | ||||
|       attachments: (json['attachments'] as List<dynamic>?) | ||||
|           ?.map((e) => e == null | ||||
|               ? null | ||||
|               : SnAttachment.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList(), | ||||
|       quoteEvent: json['quote_event'] == null | ||||
|           ? null | ||||
|           : SnChatMessage.fromJson(json['quote_event'] as Map<String, dynamic>), | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$$SnChatMessagePreloadImplToJson( | ||||
|         _$SnChatMessagePreloadImpl instance) => | ||||
|     <String, dynamic>{ | ||||
|       'attachments': instance.attachments?.map((e) => e?.toJson()).toList(), | ||||
|       'quote_event': instance.quoteEvent?.toJson(), | ||||
|     }; | ||||
|  | ||||
| _$SnChatCallImpl _$$SnChatCallImplFromJson(Map<String, dynamic> json) => | ||||
|     _$SnChatCallImpl( | ||||
|       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'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['deleted_at'] as String), | ||||
|       endedAt: json['ended_at'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['ended_at'] as String), | ||||
|       externalId: json['external_id'] as String, | ||||
|       founderId: (json['founder_id'] as num).toInt(), | ||||
|       channelId: (json['channel_id'] as num).toInt(), | ||||
|       founder: | ||||
|           SnChannelMember.fromJson(json['founder'] as Map<String, dynamic>), | ||||
|       participants: json['participants'] as List<dynamic>? ?? const [], | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$$SnChatCallImplToJson(_$SnChatCallImpl instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
|       'ended_at': instance.endedAt?.toIso8601String(), | ||||
|       'external_id': instance.externalId, | ||||
|       'founder_id': instance.founderId, | ||||
|       'channel_id': instance.channelId, | ||||
|       'founder': instance.founder.toJson(), | ||||
|       'participants': instance.participants, | ||||
|     }; | ||||
							
								
								
									
										26
									
								
								lib/types/notification.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								lib/types/notification.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
|  | ||||
| part 'notification.freezed.dart'; | ||||
| part 'notification.g.dart'; | ||||
|  | ||||
| @freezed | ||||
| class SnNotification with _$SnNotification { | ||||
|   const factory SnNotification({ | ||||
|     required int id, | ||||
|     required DateTime createdAt, | ||||
|     required DateTime updatedAt, | ||||
|     required DateTime? deletedAt, | ||||
|     required String topic, | ||||
|     required String title, | ||||
|     required String? subtitle, | ||||
|     required String body, | ||||
|     @Default({}) Map<String, dynamic> metadata, | ||||
|     required int priority, | ||||
|     required int? senderId, | ||||
|     required int accountId, | ||||
|     required DateTime? readAt, | ||||
|   }) = _SnNotification; | ||||
|  | ||||
|   factory SnNotification.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnNotificationFromJson(json); | ||||
| } | ||||
							
								
								
									
										438
									
								
								lib/types/notification.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										438
									
								
								lib/types/notification.freezed.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,438 @@ | ||||
| // coverage:ignore-file | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark | ||||
|  | ||||
| part of 'notification.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // FreezedGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| T _$identity<T>(T value) => value; | ||||
|  | ||||
| final _privateConstructorUsedError = UnsupportedError( | ||||
|     'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); | ||||
|  | ||||
| SnNotification _$SnNotificationFromJson(Map<String, dynamic> json) { | ||||
|   return _SnNotification.fromJson(json); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SnNotification { | ||||
|   int get id => throw _privateConstructorUsedError; | ||||
|   DateTime get createdAt => throw _privateConstructorUsedError; | ||||
|   DateTime get updatedAt => throw _privateConstructorUsedError; | ||||
|   DateTime? get deletedAt => throw _privateConstructorUsedError; | ||||
|   String get topic => throw _privateConstructorUsedError; | ||||
|   String get title => throw _privateConstructorUsedError; | ||||
|   String? get subtitle => throw _privateConstructorUsedError; | ||||
|   String get body => throw _privateConstructorUsedError; | ||||
|   Map<String, dynamic> get metadata => throw _privateConstructorUsedError; | ||||
|   int get priority => throw _privateConstructorUsedError; | ||||
|   int? get senderId => throw _privateConstructorUsedError; | ||||
|   int get accountId => throw _privateConstructorUsedError; | ||||
|   DateTime? get readAt => throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Serializes this SnNotification to a JSON map. | ||||
|   Map<String, dynamic> toJson() => throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Create a copy of SnNotification | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   $SnNotificationCopyWith<SnNotification> get copyWith => | ||||
|       throw _privateConstructorUsedError; | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract class $SnNotificationCopyWith<$Res> { | ||||
|   factory $SnNotificationCopyWith( | ||||
|           SnNotification value, $Res Function(SnNotification) then) = | ||||
|       _$SnNotificationCopyWithImpl<$Res, SnNotification>; | ||||
|   @useResult | ||||
|   $Res call( | ||||
|       {int id, | ||||
|       DateTime createdAt, | ||||
|       DateTime updatedAt, | ||||
|       DateTime? deletedAt, | ||||
|       String topic, | ||||
|       String title, | ||||
|       String? subtitle, | ||||
|       String body, | ||||
|       Map<String, dynamic> metadata, | ||||
|       int priority, | ||||
|       int? senderId, | ||||
|       int accountId, | ||||
|       DateTime? readAt}); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| class _$SnNotificationCopyWithImpl<$Res, $Val extends SnNotification> | ||||
|     implements $SnNotificationCopyWith<$Res> { | ||||
|   _$SnNotificationCopyWithImpl(this._value, this._then); | ||||
|  | ||||
|   // ignore: unused_field | ||||
|   final $Val _value; | ||||
|   // ignore: unused_field | ||||
|   final $Res Function($Val) _then; | ||||
|  | ||||
|   /// Create a copy of SnNotification | ||||
|   /// 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? topic = null, | ||||
|     Object? title = null, | ||||
|     Object? subtitle = freezed, | ||||
|     Object? body = null, | ||||
|     Object? metadata = null, | ||||
|     Object? priority = null, | ||||
|     Object? senderId = freezed, | ||||
|     Object? accountId = null, | ||||
|     Object? readAt = 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 DateTime?, | ||||
|       topic: null == topic | ||||
|           ? _value.topic | ||||
|           : topic // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       title: null == title | ||||
|           ? _value.title | ||||
|           : title // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       subtitle: freezed == subtitle | ||||
|           ? _value.subtitle | ||||
|           : subtitle // ignore: cast_nullable_to_non_nullable | ||||
|               as String?, | ||||
|       body: null == body | ||||
|           ? _value.body | ||||
|           : body // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       metadata: null == metadata | ||||
|           ? _value.metadata | ||||
|           : metadata // ignore: cast_nullable_to_non_nullable | ||||
|               as Map<String, dynamic>, | ||||
|       priority: null == priority | ||||
|           ? _value.priority | ||||
|           : priority // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       senderId: freezed == senderId | ||||
|           ? _value.senderId | ||||
|           : senderId // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|       accountId: null == accountId | ||||
|           ? _value.accountId | ||||
|           : accountId // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       readAt: freezed == readAt | ||||
|           ? _value.readAt | ||||
|           : readAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime?, | ||||
|     ) as $Val); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract class _$$SnNotificationImplCopyWith<$Res> | ||||
|     implements $SnNotificationCopyWith<$Res> { | ||||
|   factory _$$SnNotificationImplCopyWith(_$SnNotificationImpl value, | ||||
|           $Res Function(_$SnNotificationImpl) then) = | ||||
|       __$$SnNotificationImplCopyWithImpl<$Res>; | ||||
|   @override | ||||
|   @useResult | ||||
|   $Res call( | ||||
|       {int id, | ||||
|       DateTime createdAt, | ||||
|       DateTime updatedAt, | ||||
|       DateTime? deletedAt, | ||||
|       String topic, | ||||
|       String title, | ||||
|       String? subtitle, | ||||
|       String body, | ||||
|       Map<String, dynamic> metadata, | ||||
|       int priority, | ||||
|       int? senderId, | ||||
|       int accountId, | ||||
|       DateTime? readAt}); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| class __$$SnNotificationImplCopyWithImpl<$Res> | ||||
|     extends _$SnNotificationCopyWithImpl<$Res, _$SnNotificationImpl> | ||||
|     implements _$$SnNotificationImplCopyWith<$Res> { | ||||
|   __$$SnNotificationImplCopyWithImpl( | ||||
|       _$SnNotificationImpl _value, $Res Function(_$SnNotificationImpl) _then) | ||||
|       : super(_value, _then); | ||||
|  | ||||
|   /// Create a copy of SnNotification | ||||
|   /// 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? topic = null, | ||||
|     Object? title = null, | ||||
|     Object? subtitle = freezed, | ||||
|     Object? body = null, | ||||
|     Object? metadata = null, | ||||
|     Object? priority = null, | ||||
|     Object? senderId = freezed, | ||||
|     Object? accountId = null, | ||||
|     Object? readAt = freezed, | ||||
|   }) { | ||||
|     return _then(_$SnNotificationImpl( | ||||
|       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 DateTime?, | ||||
|       topic: null == topic | ||||
|           ? _value.topic | ||||
|           : topic // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       title: null == title | ||||
|           ? _value.title | ||||
|           : title // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       subtitle: freezed == subtitle | ||||
|           ? _value.subtitle | ||||
|           : subtitle // ignore: cast_nullable_to_non_nullable | ||||
|               as String?, | ||||
|       body: null == body | ||||
|           ? _value.body | ||||
|           : body // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       metadata: null == metadata | ||||
|           ? _value._metadata | ||||
|           : metadata // ignore: cast_nullable_to_non_nullable | ||||
|               as Map<String, dynamic>, | ||||
|       priority: null == priority | ||||
|           ? _value.priority | ||||
|           : priority // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       senderId: freezed == senderId | ||||
|           ? _value.senderId | ||||
|           : senderId // ignore: cast_nullable_to_non_nullable | ||||
|               as int?, | ||||
|       accountId: null == accountId | ||||
|           ? _value.accountId | ||||
|           : accountId // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       readAt: freezed == readAt | ||||
|           ? _value.readAt | ||||
|           : readAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime?, | ||||
|     )); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
| class _$SnNotificationImpl implements _SnNotification { | ||||
|   const _$SnNotificationImpl( | ||||
|       {required this.id, | ||||
|       required this.createdAt, | ||||
|       required this.updatedAt, | ||||
|       required this.deletedAt, | ||||
|       required this.topic, | ||||
|       required this.title, | ||||
|       required this.subtitle, | ||||
|       required this.body, | ||||
|       final Map<String, dynamic> metadata = const {}, | ||||
|       required this.priority, | ||||
|       required this.senderId, | ||||
|       required this.accountId, | ||||
|       required this.readAt}) | ||||
|       : _metadata = metadata; | ||||
|  | ||||
|   factory _$SnNotificationImpl.fromJson(Map<String, dynamic> json) => | ||||
|       _$$SnNotificationImplFromJson(json); | ||||
|  | ||||
|   @override | ||||
|   final int id; | ||||
|   @override | ||||
|   final DateTime createdAt; | ||||
|   @override | ||||
|   final DateTime updatedAt; | ||||
|   @override | ||||
|   final DateTime? deletedAt; | ||||
|   @override | ||||
|   final String topic; | ||||
|   @override | ||||
|   final String title; | ||||
|   @override | ||||
|   final String? subtitle; | ||||
|   @override | ||||
|   final String body; | ||||
|   final Map<String, dynamic> _metadata; | ||||
|   @override | ||||
|   @JsonKey() | ||||
|   Map<String, dynamic> get metadata { | ||||
|     if (_metadata is EqualUnmodifiableMapView) return _metadata; | ||||
|     // ignore: implicit_dynamic_type | ||||
|     return EqualUnmodifiableMapView(_metadata); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   final int priority; | ||||
|   @override | ||||
|   final int? senderId; | ||||
|   @override | ||||
|   final int accountId; | ||||
|   @override | ||||
|   final DateTime? readAt; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'SnNotification(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, topic: $topic, title: $title, subtitle: $subtitle, body: $body, metadata: $metadata, priority: $priority, senderId: $senderId, accountId: $accountId, readAt: $readAt)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return identical(this, other) || | ||||
|         (other.runtimeType == runtimeType && | ||||
|             other is _$SnNotificationImpl && | ||||
|             (identical(other.id, id) || other.id == id) && | ||||
|             (identical(other.createdAt, createdAt) || | ||||
|                 other.createdAt == createdAt) && | ||||
|             (identical(other.updatedAt, updatedAt) || | ||||
|                 other.updatedAt == updatedAt) && | ||||
|             (identical(other.deletedAt, deletedAt) || | ||||
|                 other.deletedAt == deletedAt) && | ||||
|             (identical(other.topic, topic) || other.topic == topic) && | ||||
|             (identical(other.title, title) || other.title == title) && | ||||
|             (identical(other.subtitle, subtitle) || | ||||
|                 other.subtitle == subtitle) && | ||||
|             (identical(other.body, body) || other.body == body) && | ||||
|             const DeepCollectionEquality().equals(other._metadata, _metadata) && | ||||
|             (identical(other.priority, priority) || | ||||
|                 other.priority == priority) && | ||||
|             (identical(other.senderId, senderId) || | ||||
|                 other.senderId == senderId) && | ||||
|             (identical(other.accountId, accountId) || | ||||
|                 other.accountId == accountId) && | ||||
|             (identical(other.readAt, readAt) || other.readAt == readAt)); | ||||
|   } | ||||
|  | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   int get hashCode => Object.hash( | ||||
|       runtimeType, | ||||
|       id, | ||||
|       createdAt, | ||||
|       updatedAt, | ||||
|       deletedAt, | ||||
|       topic, | ||||
|       title, | ||||
|       subtitle, | ||||
|       body, | ||||
|       const DeepCollectionEquality().hash(_metadata), | ||||
|       priority, | ||||
|       senderId, | ||||
|       accountId, | ||||
|       readAt); | ||||
|  | ||||
|   /// Create a copy of SnNotification | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   @pragma('vm:prefer-inline') | ||||
|   _$$SnNotificationImplCopyWith<_$SnNotificationImpl> get copyWith => | ||||
|       __$$SnNotificationImplCopyWithImpl<_$SnNotificationImpl>( | ||||
|           this, _$identity); | ||||
|  | ||||
|   @override | ||||
|   Map<String, dynamic> toJson() { | ||||
|     return _$$SnNotificationImplToJson( | ||||
|       this, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| abstract class _SnNotification implements SnNotification { | ||||
|   const factory _SnNotification( | ||||
|       {required final int id, | ||||
|       required final DateTime createdAt, | ||||
|       required final DateTime updatedAt, | ||||
|       required final DateTime? deletedAt, | ||||
|       required final String topic, | ||||
|       required final String title, | ||||
|       required final String? subtitle, | ||||
|       required final String body, | ||||
|       final Map<String, dynamic> metadata, | ||||
|       required final int priority, | ||||
|       required final int? senderId, | ||||
|       required final int accountId, | ||||
|       required final DateTime? readAt}) = _$SnNotificationImpl; | ||||
|  | ||||
|   factory _SnNotification.fromJson(Map<String, dynamic> json) = | ||||
|       _$SnNotificationImpl.fromJson; | ||||
|  | ||||
|   @override | ||||
|   int get id; | ||||
|   @override | ||||
|   DateTime get createdAt; | ||||
|   @override | ||||
|   DateTime get updatedAt; | ||||
|   @override | ||||
|   DateTime? get deletedAt; | ||||
|   @override | ||||
|   String get topic; | ||||
|   @override | ||||
|   String get title; | ||||
|   @override | ||||
|   String? get subtitle; | ||||
|   @override | ||||
|   String get body; | ||||
|   @override | ||||
|   Map<String, dynamic> get metadata; | ||||
|   @override | ||||
|   int get priority; | ||||
|   @override | ||||
|   int? get senderId; | ||||
|   @override | ||||
|   int get accountId; | ||||
|   @override | ||||
|   DateTime? get readAt; | ||||
|  | ||||
|   /// Create a copy of SnNotification | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @override | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   _$$SnNotificationImplCopyWith<_$SnNotificationImpl> get copyWith => | ||||
|       throw _privateConstructorUsedError; | ||||
| } | ||||
							
								
								
									
										46
									
								
								lib/types/notification.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								lib/types/notification.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'notification.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| _$SnNotificationImpl _$$SnNotificationImplFromJson(Map<String, dynamic> json) => | ||||
|     _$SnNotificationImpl( | ||||
|       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'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['deleted_at'] as String), | ||||
|       topic: json['topic'] as String, | ||||
|       title: json['title'] as String, | ||||
|       subtitle: json['subtitle'] as String?, | ||||
|       body: json['body'] as String, | ||||
|       metadata: json['metadata'] as Map<String, dynamic>? ?? const {}, | ||||
|       priority: (json['priority'] as num).toInt(), | ||||
|       senderId: (json['sender_id'] as num?)?.toInt(), | ||||
|       accountId: (json['account_id'] as num).toInt(), | ||||
|       readAt: json['read_at'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['read_at'] as String), | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$$SnNotificationImplToJson( | ||||
|         _$SnNotificationImpl instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
|       'topic': instance.topic, | ||||
|       'title': instance.title, | ||||
|       'subtitle': instance.subtitle, | ||||
|       'body': instance.body, | ||||
|       'metadata': instance.metadata, | ||||
|       'priority': instance.priority, | ||||
|       'sender_id': instance.senderId, | ||||
|       'account_id': instance.accountId, | ||||
|       'read_at': instance.readAt?.toIso8601String(), | ||||
|     }; | ||||
| @@ -18,8 +18,8 @@ class SnPost with _$SnPost { | ||||
|     required String language, | ||||
|     required String? alias, | ||||
|     required String? aliasPrefix, | ||||
|     required List<dynamic> tags, | ||||
|     required List<dynamic> categories, | ||||
|     @Default([]) List<dynamic> tags, | ||||
|     @Default([]) List<dynamic> categories, | ||||
|     required List<SnPost>? replies, | ||||
|     required int? replyId, | ||||
|     required int? repostId, | ||||
| @@ -53,7 +53,8 @@ class SnPost with _$SnPost { | ||||
| @freezed | ||||
| class SnPostPreload with _$SnPostPreload { | ||||
|   const factory SnPostPreload({ | ||||
|     required List<SnAttachment>? attachments, | ||||
|     required SnAttachment? thumbnail, | ||||
|     required List<SnAttachment?>? attachments, | ||||
|   }) = _SnPostPreload; | ||||
|  | ||||
|   factory SnPostPreload.fromJson(Map<String, Object?> json) => | ||||
|   | ||||
| @@ -583,8 +583,8 @@ class _$SnPostImpl extends _SnPost { | ||||
|       required this.language, | ||||
|       required this.alias, | ||||
|       required this.aliasPrefix, | ||||
|       required final List<dynamic> tags, | ||||
|       required final List<dynamic> categories, | ||||
|       final List<dynamic> tags = const [], | ||||
|       final List<dynamic> categories = const [], | ||||
|       required final List<SnPost>? replies, | ||||
|       required this.replyId, | ||||
|       required this.repostId, | ||||
| @@ -642,6 +642,7 @@ class _$SnPostImpl extends _SnPost { | ||||
|   final String? aliasPrefix; | ||||
|   final List<dynamic> _tags; | ||||
|   @override | ||||
|   @JsonKey() | ||||
|   List<dynamic> get tags { | ||||
|     if (_tags is EqualUnmodifiableListView) return _tags; | ||||
|     // ignore: implicit_dynamic_type | ||||
| @@ -650,6 +651,7 @@ class _$SnPostImpl extends _SnPost { | ||||
|  | ||||
|   final List<dynamic> _categories; | ||||
|   @override | ||||
|   @JsonKey() | ||||
|   List<dynamic> get categories { | ||||
|     if (_categories is EqualUnmodifiableListView) return _categories; | ||||
|     // ignore: implicit_dynamic_type | ||||
| @@ -850,8 +852,8 @@ abstract class _SnPost extends SnPost { | ||||
|       required final String language, | ||||
|       required final String? alias, | ||||
|       required final String? aliasPrefix, | ||||
|       required final List<dynamic> tags, | ||||
|       required final List<dynamic> categories, | ||||
|       final List<dynamic> tags, | ||||
|       final List<dynamic> categories, | ||||
|       required final List<SnPost>? replies, | ||||
|       required final int? replyId, | ||||
|       required final int? repostId, | ||||
| @@ -953,7 +955,8 @@ SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) { | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SnPostPreload { | ||||
|   List<SnAttachment>? get attachments => throw _privateConstructorUsedError; | ||||
|   SnAttachment? get thumbnail => throw _privateConstructorUsedError; | ||||
|   List<SnAttachment?>? get attachments => throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Serializes this SnPostPreload to a JSON map. | ||||
|   Map<String, dynamic> toJson() => throw _privateConstructorUsedError; | ||||
| @@ -971,7 +974,9 @@ abstract class $SnPostPreloadCopyWith<$Res> { | ||||
|           SnPostPreload value, $Res Function(SnPostPreload) then) = | ||||
|       _$SnPostPreloadCopyWithImpl<$Res, SnPostPreload>; | ||||
|   @useResult | ||||
|   $Res call({List<SnAttachment>? attachments}); | ||||
|   $Res call({SnAttachment? thumbnail, List<SnAttachment?>? attachments}); | ||||
|  | ||||
|   $SnAttachmentCopyWith<$Res>? get thumbnail; | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @@ -989,15 +994,34 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload> | ||||
|   @pragma('vm:prefer-inline') | ||||
|   @override | ||||
|   $Res call({ | ||||
|     Object? thumbnail = freezed, | ||||
|     Object? attachments = freezed, | ||||
|   }) { | ||||
|     return _then(_value.copyWith( | ||||
|       thumbnail: freezed == thumbnail | ||||
|           ? _value.thumbnail | ||||
|           : thumbnail // ignore: cast_nullable_to_non_nullable | ||||
|               as SnAttachment?, | ||||
|       attachments: freezed == attachments | ||||
|           ? _value.attachments | ||||
|           : attachments // ignore: cast_nullable_to_non_nullable | ||||
|               as List<SnAttachment>?, | ||||
|               as List<SnAttachment?>?, | ||||
|     ) as $Val); | ||||
|   } | ||||
|  | ||||
|   /// Create a copy of SnPostPreload | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @override | ||||
|   @pragma('vm:prefer-inline') | ||||
|   $SnAttachmentCopyWith<$Res>? get thumbnail { | ||||
|     if (_value.thumbnail == null) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     return $SnAttachmentCopyWith<$Res>(_value.thumbnail!, (value) { | ||||
|       return _then(_value.copyWith(thumbnail: value) as $Val); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @@ -1008,7 +1032,10 @@ abstract class _$$SnPostPreloadImplCopyWith<$Res> | ||||
|       __$$SnPostPreloadImplCopyWithImpl<$Res>; | ||||
|   @override | ||||
|   @useResult | ||||
|   $Res call({List<SnAttachment>? attachments}); | ||||
|   $Res call({SnAttachment? thumbnail, List<SnAttachment?>? attachments}); | ||||
|  | ||||
|   @override | ||||
|   $SnAttachmentCopyWith<$Res>? get thumbnail; | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @@ -1024,13 +1051,18 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res> | ||||
|   @pragma('vm:prefer-inline') | ||||
|   @override | ||||
|   $Res call({ | ||||
|     Object? thumbnail = freezed, | ||||
|     Object? attachments = freezed, | ||||
|   }) { | ||||
|     return _then(_$SnPostPreloadImpl( | ||||
|       thumbnail: freezed == thumbnail | ||||
|           ? _value.thumbnail | ||||
|           : thumbnail // ignore: cast_nullable_to_non_nullable | ||||
|               as SnAttachment?, | ||||
|       attachments: freezed == attachments | ||||
|           ? _value._attachments | ||||
|           : attachments // ignore: cast_nullable_to_non_nullable | ||||
|               as List<SnAttachment>?, | ||||
|               as List<SnAttachment?>?, | ||||
|     )); | ||||
|   } | ||||
| } | ||||
| @@ -1038,15 +1070,19 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res> | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
| class _$SnPostPreloadImpl implements _SnPostPreload { | ||||
|   const _$SnPostPreloadImpl({required final List<SnAttachment>? attachments}) | ||||
|   const _$SnPostPreloadImpl( | ||||
|       {required this.thumbnail, | ||||
|       required final List<SnAttachment?>? attachments}) | ||||
|       : _attachments = attachments; | ||||
|  | ||||
|   factory _$SnPostPreloadImpl.fromJson(Map<String, dynamic> json) => | ||||
|       _$$SnPostPreloadImplFromJson(json); | ||||
|  | ||||
|   final List<SnAttachment>? _attachments; | ||||
|   @override | ||||
|   List<SnAttachment>? get attachments { | ||||
|   final SnAttachment? thumbnail; | ||||
|   final List<SnAttachment?>? _attachments; | ||||
|   @override | ||||
|   List<SnAttachment?>? get attachments { | ||||
|     final value = _attachments; | ||||
|     if (value == null) return null; | ||||
|     if (_attachments is EqualUnmodifiableListView) return _attachments; | ||||
| @@ -1056,7 +1092,7 @@ class _$SnPostPreloadImpl implements _SnPostPreload { | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'SnPostPreload(attachments: $attachments)'; | ||||
|     return 'SnPostPreload(thumbnail: $thumbnail, attachments: $attachments)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -1064,14 +1100,16 @@ class _$SnPostPreloadImpl implements _SnPostPreload { | ||||
|     return identical(this, other) || | ||||
|         (other.runtimeType == runtimeType && | ||||
|             other is _$SnPostPreloadImpl && | ||||
|             (identical(other.thumbnail, thumbnail) || | ||||
|                 other.thumbnail == thumbnail) && | ||||
|             const DeepCollectionEquality() | ||||
|                 .equals(other._attachments, _attachments)); | ||||
|   } | ||||
|  | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   int get hashCode => Object.hash( | ||||
|       runtimeType, const DeepCollectionEquality().hash(_attachments)); | ||||
|   int get hashCode => Object.hash(runtimeType, thumbnail, | ||||
|       const DeepCollectionEquality().hash(_attachments)); | ||||
|  | ||||
|   /// Create a copy of SnPostPreload | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
| @@ -1091,13 +1129,16 @@ class _$SnPostPreloadImpl implements _SnPostPreload { | ||||
|  | ||||
| abstract class _SnPostPreload implements SnPostPreload { | ||||
|   const factory _SnPostPreload( | ||||
|       {required final List<SnAttachment>? attachments}) = _$SnPostPreloadImpl; | ||||
|       {required final SnAttachment? thumbnail, | ||||
|       required final List<SnAttachment?>? attachments}) = _$SnPostPreloadImpl; | ||||
|  | ||||
|   factory _SnPostPreload.fromJson(Map<String, dynamic> json) = | ||||
|       _$SnPostPreloadImpl.fromJson; | ||||
|  | ||||
|   @override | ||||
|   List<SnAttachment>? get attachments; | ||||
|   SnAttachment? get thumbnail; | ||||
|   @override | ||||
|   List<SnAttachment?>? get attachments; | ||||
|  | ||||
|   /// Create a copy of SnPostPreload | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   | ||||
| @@ -18,8 +18,8 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl( | ||||
|       language: json['language'] as String, | ||||
|       alias: json['alias'] as String?, | ||||
|       aliasPrefix: json['alias_prefix'] as String?, | ||||
|       tags: json['tags'] as List<dynamic>, | ||||
|       categories: json['categories'] as List<dynamic>, | ||||
|       tags: json['tags'] as List<dynamic>? ?? const [], | ||||
|       categories: json['categories'] as List<dynamic>? ?? const [], | ||||
|       replies: (json['replies'] as List<dynamic>?) | ||||
|           ?.map((e) => SnPost.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList(), | ||||
| @@ -102,14 +102,20 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) => | ||||
|  | ||||
| _$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) => | ||||
|     _$SnPostPreloadImpl( | ||||
|       thumbnail: json['thumbnail'] == null | ||||
|           ? null | ||||
|           : SnAttachment.fromJson(json['thumbnail'] as Map<String, dynamic>), | ||||
|       attachments: (json['attachments'] as List<dynamic>?) | ||||
|           ?.map((e) => SnAttachment.fromJson(e as Map<String, dynamic>)) | ||||
|           ?.map((e) => e == null | ||||
|               ? null | ||||
|               : SnAttachment.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList(), | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$$SnPostPreloadImplToJson(_$SnPostPreloadImpl instance) => | ||||
|     <String, dynamic>{ | ||||
|       'attachments': instance.attachments?.map((e) => e.toJson()).toList(), | ||||
|       'thumbnail': instance.thumbnail?.toJson(), | ||||
|       'attachments': instance.attachments?.map((e) => e?.toJson()).toList(), | ||||
|     }; | ||||
|  | ||||
| _$SnBodyImpl _$$SnBodyImplFromJson(Map<String, dynamic> json) => _$SnBodyImpl( | ||||
|   | ||||
							
								
								
									
										50
									
								
								lib/types/realm.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								lib/types/realm.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:surface/types/account.dart'; | ||||
|  | ||||
| part 'realm.freezed.dart'; | ||||
| part 'realm.g.dart'; | ||||
|  | ||||
| @freezed | ||||
| class SnRealmMember with _$SnRealmMember { | ||||
|   const factory SnRealmMember({ | ||||
|     required int id, | ||||
|     required DateTime createdAt, | ||||
|     required DateTime updatedAt, | ||||
|     required DateTime? deletedAt, | ||||
|     required int realmId, | ||||
|     required int accountId, | ||||
|     required SnRealm realm, | ||||
|     required SnAccount account, | ||||
|     required int powerLevel, | ||||
|   }) = _SnRealmMember; | ||||
|  | ||||
|   factory SnRealmMember.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnRealmMemberFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| class SnRealm with _$SnRealm { | ||||
|   const SnRealm._(); | ||||
|  | ||||
|   @HiveType(typeId: 1) | ||||
|   const factory SnRealm({ | ||||
|     @HiveField(0) required int id, | ||||
|     @HiveField(1) required DateTime createdAt, | ||||
|     @HiveField(2) required DateTime updatedAt, | ||||
|     @HiveField(3) required DateTime? deletedAt, | ||||
|     @HiveField(4) required String alias, | ||||
|     @HiveField(5) required String name, | ||||
|     @HiveField(6) required String description, | ||||
|     List<SnRealmMember>? members, | ||||
|     @HiveField(7) required String? avatar, | ||||
|     @HiveField(8) required String? banner, | ||||
|     @HiveField(9) required Map<String, dynamic>? accessPolicy, | ||||
|     @HiveField(10) required int accountId, | ||||
|     @HiveField(11) required bool isPublic, | ||||
|     @HiveField(12) required bool isCommunity, | ||||
|   }) = _SnRealm; | ||||
|  | ||||
|   factory SnRealm.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnRealmFromJson(json); | ||||
| } | ||||
							
								
								
									
										854
									
								
								lib/types/realm.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										854
									
								
								lib/types/realm.freezed.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,854 @@ | ||||
| // coverage:ignore-file | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark | ||||
|  | ||||
| part of 'realm.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // FreezedGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| T _$identity<T>(T value) => value; | ||||
|  | ||||
| final _privateConstructorUsedError = UnsupportedError( | ||||
|     'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); | ||||
|  | ||||
| SnRealmMember _$SnRealmMemberFromJson(Map<String, dynamic> json) { | ||||
|   return _SnRealmMember.fromJson(json); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SnRealmMember { | ||||
|   int get id => throw _privateConstructorUsedError; | ||||
|   DateTime get createdAt => throw _privateConstructorUsedError; | ||||
|   DateTime get updatedAt => throw _privateConstructorUsedError; | ||||
|   DateTime? get deletedAt => throw _privateConstructorUsedError; | ||||
|   int get realmId => throw _privateConstructorUsedError; | ||||
|   int get accountId => throw _privateConstructorUsedError; | ||||
|   SnRealm get realm => throw _privateConstructorUsedError; | ||||
|   SnAccount get account => throw _privateConstructorUsedError; | ||||
|   int get powerLevel => throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Serializes this SnRealmMember to a JSON map. | ||||
|   Map<String, dynamic> toJson() => throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Create a copy of SnRealmMember | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   $SnRealmMemberCopyWith<SnRealmMember> get copyWith => | ||||
|       throw _privateConstructorUsedError; | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract class $SnRealmMemberCopyWith<$Res> { | ||||
|   factory $SnRealmMemberCopyWith( | ||||
|           SnRealmMember value, $Res Function(SnRealmMember) then) = | ||||
|       _$SnRealmMemberCopyWithImpl<$Res, SnRealmMember>; | ||||
|   @useResult | ||||
|   $Res call( | ||||
|       {int id, | ||||
|       DateTime createdAt, | ||||
|       DateTime updatedAt, | ||||
|       DateTime? deletedAt, | ||||
|       int realmId, | ||||
|       int accountId, | ||||
|       SnRealm realm, | ||||
|       SnAccount account, | ||||
|       int powerLevel}); | ||||
|  | ||||
|   $SnRealmCopyWith<$Res> get realm; | ||||
|   $SnAccountCopyWith<$Res> get account; | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| class _$SnRealmMemberCopyWithImpl<$Res, $Val extends SnRealmMember> | ||||
|     implements $SnRealmMemberCopyWith<$Res> { | ||||
|   _$SnRealmMemberCopyWithImpl(this._value, this._then); | ||||
|  | ||||
|   // ignore: unused_field | ||||
|   final $Val _value; | ||||
|   // ignore: unused_field | ||||
|   final $Res Function($Val) _then; | ||||
|  | ||||
|   /// Create a copy of SnRealmMember | ||||
|   /// 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? realmId = null, | ||||
|     Object? accountId = null, | ||||
|     Object? realm = null, | ||||
|     Object? account = null, | ||||
|     Object? powerLevel = null, | ||||
|   }) { | ||||
|     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 DateTime?, | ||||
|       realmId: null == realmId | ||||
|           ? _value.realmId | ||||
|           : realmId // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       accountId: null == accountId | ||||
|           ? _value.accountId | ||||
|           : accountId // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       realm: null == realm | ||||
|           ? _value.realm | ||||
|           : realm // ignore: cast_nullable_to_non_nullable | ||||
|               as SnRealm, | ||||
|       account: null == account | ||||
|           ? _value.account | ||||
|           : account // ignore: cast_nullable_to_non_nullable | ||||
|               as SnAccount, | ||||
|       powerLevel: null == powerLevel | ||||
|           ? _value.powerLevel | ||||
|           : powerLevel // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|     ) as $Val); | ||||
|   } | ||||
|  | ||||
|   /// Create a copy of SnRealmMember | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @override | ||||
|   @pragma('vm:prefer-inline') | ||||
|   $SnRealmCopyWith<$Res> get realm { | ||||
|     return $SnRealmCopyWith<$Res>(_value.realm, (value) { | ||||
|       return _then(_value.copyWith(realm: value) as $Val); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /// Create a copy of SnRealmMember | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @override | ||||
|   @pragma('vm:prefer-inline') | ||||
|   $SnAccountCopyWith<$Res> get account { | ||||
|     return $SnAccountCopyWith<$Res>(_value.account, (value) { | ||||
|       return _then(_value.copyWith(account: value) as $Val); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract class _$$SnRealmMemberImplCopyWith<$Res> | ||||
|     implements $SnRealmMemberCopyWith<$Res> { | ||||
|   factory _$$SnRealmMemberImplCopyWith( | ||||
|           _$SnRealmMemberImpl value, $Res Function(_$SnRealmMemberImpl) then) = | ||||
|       __$$SnRealmMemberImplCopyWithImpl<$Res>; | ||||
|   @override | ||||
|   @useResult | ||||
|   $Res call( | ||||
|       {int id, | ||||
|       DateTime createdAt, | ||||
|       DateTime updatedAt, | ||||
|       DateTime? deletedAt, | ||||
|       int realmId, | ||||
|       int accountId, | ||||
|       SnRealm realm, | ||||
|       SnAccount account, | ||||
|       int powerLevel}); | ||||
|  | ||||
|   @override | ||||
|   $SnRealmCopyWith<$Res> get realm; | ||||
|   @override | ||||
|   $SnAccountCopyWith<$Res> get account; | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| class __$$SnRealmMemberImplCopyWithImpl<$Res> | ||||
|     extends _$SnRealmMemberCopyWithImpl<$Res, _$SnRealmMemberImpl> | ||||
|     implements _$$SnRealmMemberImplCopyWith<$Res> { | ||||
|   __$$SnRealmMemberImplCopyWithImpl( | ||||
|       _$SnRealmMemberImpl _value, $Res Function(_$SnRealmMemberImpl) _then) | ||||
|       : super(_value, _then); | ||||
|  | ||||
|   /// Create a copy of SnRealmMember | ||||
|   /// 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? realmId = null, | ||||
|     Object? accountId = null, | ||||
|     Object? realm = null, | ||||
|     Object? account = null, | ||||
|     Object? powerLevel = null, | ||||
|   }) { | ||||
|     return _then(_$SnRealmMemberImpl( | ||||
|       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 DateTime?, | ||||
|       realmId: null == realmId | ||||
|           ? _value.realmId | ||||
|           : realmId // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       accountId: null == accountId | ||||
|           ? _value.accountId | ||||
|           : accountId // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       realm: null == realm | ||||
|           ? _value.realm | ||||
|           : realm // ignore: cast_nullable_to_non_nullable | ||||
|               as SnRealm, | ||||
|       account: null == account | ||||
|           ? _value.account | ||||
|           : account // ignore: cast_nullable_to_non_nullable | ||||
|               as SnAccount, | ||||
|       powerLevel: null == powerLevel | ||||
|           ? _value.powerLevel | ||||
|           : powerLevel // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|     )); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
| class _$SnRealmMemberImpl implements _SnRealmMember { | ||||
|   const _$SnRealmMemberImpl( | ||||
|       {required this.id, | ||||
|       required this.createdAt, | ||||
|       required this.updatedAt, | ||||
|       required this.deletedAt, | ||||
|       required this.realmId, | ||||
|       required this.accountId, | ||||
|       required this.realm, | ||||
|       required this.account, | ||||
|       required this.powerLevel}); | ||||
|  | ||||
|   factory _$SnRealmMemberImpl.fromJson(Map<String, dynamic> json) => | ||||
|       _$$SnRealmMemberImplFromJson(json); | ||||
|  | ||||
|   @override | ||||
|   final int id; | ||||
|   @override | ||||
|   final DateTime createdAt; | ||||
|   @override | ||||
|   final DateTime updatedAt; | ||||
|   @override | ||||
|   final DateTime? deletedAt; | ||||
|   @override | ||||
|   final int realmId; | ||||
|   @override | ||||
|   final int accountId; | ||||
|   @override | ||||
|   final SnRealm realm; | ||||
|   @override | ||||
|   final SnAccount account; | ||||
|   @override | ||||
|   final int powerLevel; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'SnRealmMember(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, realmId: $realmId, accountId: $accountId, realm: $realm, account: $account, powerLevel: $powerLevel)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return identical(this, other) || | ||||
|         (other.runtimeType == runtimeType && | ||||
|             other is _$SnRealmMemberImpl && | ||||
|             (identical(other.id, id) || other.id == id) && | ||||
|             (identical(other.createdAt, createdAt) || | ||||
|                 other.createdAt == createdAt) && | ||||
|             (identical(other.updatedAt, updatedAt) || | ||||
|                 other.updatedAt == updatedAt) && | ||||
|             (identical(other.deletedAt, deletedAt) || | ||||
|                 other.deletedAt == deletedAt) && | ||||
|             (identical(other.realmId, realmId) || other.realmId == realmId) && | ||||
|             (identical(other.accountId, accountId) || | ||||
|                 other.accountId == accountId) && | ||||
|             (identical(other.realm, realm) || other.realm == realm) && | ||||
|             (identical(other.account, account) || other.account == account) && | ||||
|             (identical(other.powerLevel, powerLevel) || | ||||
|                 other.powerLevel == powerLevel)); | ||||
|   } | ||||
|  | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt, | ||||
|       deletedAt, realmId, accountId, realm, account, powerLevel); | ||||
|  | ||||
|   /// Create a copy of SnRealmMember | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   @pragma('vm:prefer-inline') | ||||
|   _$$SnRealmMemberImplCopyWith<_$SnRealmMemberImpl> get copyWith => | ||||
|       __$$SnRealmMemberImplCopyWithImpl<_$SnRealmMemberImpl>(this, _$identity); | ||||
|  | ||||
|   @override | ||||
|   Map<String, dynamic> toJson() { | ||||
|     return _$$SnRealmMemberImplToJson( | ||||
|       this, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| abstract class _SnRealmMember implements SnRealmMember { | ||||
|   const factory _SnRealmMember( | ||||
|       {required final int id, | ||||
|       required final DateTime createdAt, | ||||
|       required final DateTime updatedAt, | ||||
|       required final DateTime? deletedAt, | ||||
|       required final int realmId, | ||||
|       required final int accountId, | ||||
|       required final SnRealm realm, | ||||
|       required final SnAccount account, | ||||
|       required final int powerLevel}) = _$SnRealmMemberImpl; | ||||
|  | ||||
|   factory _SnRealmMember.fromJson(Map<String, dynamic> json) = | ||||
|       _$SnRealmMemberImpl.fromJson; | ||||
|  | ||||
|   @override | ||||
|   int get id; | ||||
|   @override | ||||
|   DateTime get createdAt; | ||||
|   @override | ||||
|   DateTime get updatedAt; | ||||
|   @override | ||||
|   DateTime? get deletedAt; | ||||
|   @override | ||||
|   int get realmId; | ||||
|   @override | ||||
|   int get accountId; | ||||
|   @override | ||||
|   SnRealm get realm; | ||||
|   @override | ||||
|   SnAccount get account; | ||||
|   @override | ||||
|   int get powerLevel; | ||||
|  | ||||
|   /// Create a copy of SnRealmMember | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @override | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   _$$SnRealmMemberImplCopyWith<_$SnRealmMemberImpl> get copyWith => | ||||
|       throw _privateConstructorUsedError; | ||||
| } | ||||
|  | ||||
| SnRealm _$SnRealmFromJson(Map<String, dynamic> json) { | ||||
|   return _SnRealm.fromJson(json); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SnRealm { | ||||
|   @HiveField(0) | ||||
|   int get id => throw _privateConstructorUsedError; | ||||
|   @HiveField(1) | ||||
|   DateTime get createdAt => throw _privateConstructorUsedError; | ||||
|   @HiveField(2) | ||||
|   DateTime get updatedAt => throw _privateConstructorUsedError; | ||||
|   @HiveField(3) | ||||
|   DateTime? get deletedAt => throw _privateConstructorUsedError; | ||||
|   @HiveField(4) | ||||
|   String get alias => throw _privateConstructorUsedError; | ||||
|   @HiveField(5) | ||||
|   String get name => throw _privateConstructorUsedError; | ||||
|   @HiveField(6) | ||||
|   String get description => throw _privateConstructorUsedError; | ||||
|   List<SnRealmMember>? get members => throw _privateConstructorUsedError; | ||||
|   @HiveField(7) | ||||
|   String? get avatar => throw _privateConstructorUsedError; | ||||
|   @HiveField(8) | ||||
|   String? get banner => throw _privateConstructorUsedError; | ||||
|   @HiveField(9) | ||||
|   Map<String, dynamic>? get accessPolicy => throw _privateConstructorUsedError; | ||||
|   @HiveField(10) | ||||
|   int get accountId => throw _privateConstructorUsedError; | ||||
|   @HiveField(11) | ||||
|   bool get isPublic => throw _privateConstructorUsedError; | ||||
|   @HiveField(12) | ||||
|   bool get isCommunity => throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Serializes this SnRealm to a JSON map. | ||||
|   Map<String, dynamic> toJson() => throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Create a copy of SnRealm | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   $SnRealmCopyWith<SnRealm> get copyWith => throw _privateConstructorUsedError; | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract class $SnRealmCopyWith<$Res> { | ||||
|   factory $SnRealmCopyWith(SnRealm value, $Res Function(SnRealm) then) = | ||||
|       _$SnRealmCopyWithImpl<$Res, SnRealm>; | ||||
|   @useResult | ||||
|   $Res call( | ||||
|       {@HiveField(0) int id, | ||||
|       @HiveField(1) DateTime createdAt, | ||||
|       @HiveField(2) DateTime updatedAt, | ||||
|       @HiveField(3) DateTime? deletedAt, | ||||
|       @HiveField(4) String alias, | ||||
|       @HiveField(5) String name, | ||||
|       @HiveField(6) String description, | ||||
|       List<SnRealmMember>? members, | ||||
|       @HiveField(7) String? avatar, | ||||
|       @HiveField(8) String? banner, | ||||
|       @HiveField(9) Map<String, dynamic>? accessPolicy, | ||||
|       @HiveField(10) int accountId, | ||||
|       @HiveField(11) bool isPublic, | ||||
|       @HiveField(12) bool isCommunity}); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| class _$SnRealmCopyWithImpl<$Res, $Val extends SnRealm> | ||||
|     implements $SnRealmCopyWith<$Res> { | ||||
|   _$SnRealmCopyWithImpl(this._value, this._then); | ||||
|  | ||||
|   // ignore: unused_field | ||||
|   final $Val _value; | ||||
|   // ignore: unused_field | ||||
|   final $Res Function($Val) _then; | ||||
|  | ||||
|   /// Create a copy of SnRealm | ||||
|   /// 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? members = freezed, | ||||
|     Object? avatar = freezed, | ||||
|     Object? banner = freezed, | ||||
|     Object? accessPolicy = freezed, | ||||
|     Object? accountId = null, | ||||
|     Object? isPublic = null, | ||||
|     Object? isCommunity = null, | ||||
|   }) { | ||||
|     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 DateTime?, | ||||
|       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, | ||||
|       members: freezed == members | ||||
|           ? _value.members | ||||
|           : members // ignore: cast_nullable_to_non_nullable | ||||
|               as List<SnRealmMember>?, | ||||
|       avatar: freezed == avatar | ||||
|           ? _value.avatar | ||||
|           : avatar // ignore: cast_nullable_to_non_nullable | ||||
|               as String?, | ||||
|       banner: freezed == banner | ||||
|           ? _value.banner | ||||
|           : banner // ignore: cast_nullable_to_non_nullable | ||||
|               as String?, | ||||
|       accessPolicy: freezed == accessPolicy | ||||
|           ? _value.accessPolicy | ||||
|           : accessPolicy // ignore: cast_nullable_to_non_nullable | ||||
|               as Map<String, dynamic>?, | ||||
|       accountId: null == accountId | ||||
|           ? _value.accountId | ||||
|           : accountId // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       isPublic: null == isPublic | ||||
|           ? _value.isPublic | ||||
|           : isPublic // ignore: cast_nullable_to_non_nullable | ||||
|               as bool, | ||||
|       isCommunity: null == isCommunity | ||||
|           ? _value.isCommunity | ||||
|           : isCommunity // ignore: cast_nullable_to_non_nullable | ||||
|               as bool, | ||||
|     ) as $Val); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract class _$$SnRealmImplCopyWith<$Res> implements $SnRealmCopyWith<$Res> { | ||||
|   factory _$$SnRealmImplCopyWith( | ||||
|           _$SnRealmImpl value, $Res Function(_$SnRealmImpl) then) = | ||||
|       __$$SnRealmImplCopyWithImpl<$Res>; | ||||
|   @override | ||||
|   @useResult | ||||
|   $Res call( | ||||
|       {@HiveField(0) int id, | ||||
|       @HiveField(1) DateTime createdAt, | ||||
|       @HiveField(2) DateTime updatedAt, | ||||
|       @HiveField(3) DateTime? deletedAt, | ||||
|       @HiveField(4) String alias, | ||||
|       @HiveField(5) String name, | ||||
|       @HiveField(6) String description, | ||||
|       List<SnRealmMember>? members, | ||||
|       @HiveField(7) String? avatar, | ||||
|       @HiveField(8) String? banner, | ||||
|       @HiveField(9) Map<String, dynamic>? accessPolicy, | ||||
|       @HiveField(10) int accountId, | ||||
|       @HiveField(11) bool isPublic, | ||||
|       @HiveField(12) bool isCommunity}); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| class __$$SnRealmImplCopyWithImpl<$Res> | ||||
|     extends _$SnRealmCopyWithImpl<$Res, _$SnRealmImpl> | ||||
|     implements _$$SnRealmImplCopyWith<$Res> { | ||||
|   __$$SnRealmImplCopyWithImpl( | ||||
|       _$SnRealmImpl _value, $Res Function(_$SnRealmImpl) _then) | ||||
|       : super(_value, _then); | ||||
|  | ||||
|   /// Create a copy of SnRealm | ||||
|   /// 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? members = freezed, | ||||
|     Object? avatar = freezed, | ||||
|     Object? banner = freezed, | ||||
|     Object? accessPolicy = freezed, | ||||
|     Object? accountId = null, | ||||
|     Object? isPublic = null, | ||||
|     Object? isCommunity = null, | ||||
|   }) { | ||||
|     return _then(_$SnRealmImpl( | ||||
|       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 DateTime?, | ||||
|       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, | ||||
|       members: freezed == members | ||||
|           ? _value._members | ||||
|           : members // ignore: cast_nullable_to_non_nullable | ||||
|               as List<SnRealmMember>?, | ||||
|       avatar: freezed == avatar | ||||
|           ? _value.avatar | ||||
|           : avatar // ignore: cast_nullable_to_non_nullable | ||||
|               as String?, | ||||
|       banner: freezed == banner | ||||
|           ? _value.banner | ||||
|           : banner // ignore: cast_nullable_to_non_nullable | ||||
|               as String?, | ||||
|       accessPolicy: freezed == accessPolicy | ||||
|           ? _value._accessPolicy | ||||
|           : accessPolicy // ignore: cast_nullable_to_non_nullable | ||||
|               as Map<String, dynamic>?, | ||||
|       accountId: null == accountId | ||||
|           ? _value.accountId | ||||
|           : accountId // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       isPublic: null == isPublic | ||||
|           ? _value.isPublic | ||||
|           : isPublic // ignore: cast_nullable_to_non_nullable | ||||
|               as bool, | ||||
|       isCommunity: null == isCommunity | ||||
|           ? _value.isCommunity | ||||
|           : isCommunity // ignore: cast_nullable_to_non_nullable | ||||
|               as bool, | ||||
|     )); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
| @HiveType(typeId: 1) | ||||
| class _$SnRealmImpl extends _SnRealm { | ||||
|   const _$SnRealmImpl( | ||||
|       {@HiveField(0) required this.id, | ||||
|       @HiveField(1) required this.createdAt, | ||||
|       @HiveField(2) required this.updatedAt, | ||||
|       @HiveField(3) required this.deletedAt, | ||||
|       @HiveField(4) required this.alias, | ||||
|       @HiveField(5) required this.name, | ||||
|       @HiveField(6) required this.description, | ||||
|       final List<SnRealmMember>? members, | ||||
|       @HiveField(7) required this.avatar, | ||||
|       @HiveField(8) required this.banner, | ||||
|       @HiveField(9) required final Map<String, dynamic>? accessPolicy, | ||||
|       @HiveField(10) required this.accountId, | ||||
|       @HiveField(11) required this.isPublic, | ||||
|       @HiveField(12) required this.isCommunity}) | ||||
|       : _members = members, | ||||
|         _accessPolicy = accessPolicy, | ||||
|         super._(); | ||||
|  | ||||
|   factory _$SnRealmImpl.fromJson(Map<String, dynamic> json) => | ||||
|       _$$SnRealmImplFromJson(json); | ||||
|  | ||||
|   @override | ||||
|   @HiveField(0) | ||||
|   final int id; | ||||
|   @override | ||||
|   @HiveField(1) | ||||
|   final DateTime createdAt; | ||||
|   @override | ||||
|   @HiveField(2) | ||||
|   final DateTime updatedAt; | ||||
|   @override | ||||
|   @HiveField(3) | ||||
|   final DateTime? deletedAt; | ||||
|   @override | ||||
|   @HiveField(4) | ||||
|   final String alias; | ||||
|   @override | ||||
|   @HiveField(5) | ||||
|   final String name; | ||||
|   @override | ||||
|   @HiveField(6) | ||||
|   final String description; | ||||
|   final List<SnRealmMember>? _members; | ||||
|   @override | ||||
|   List<SnRealmMember>? get members { | ||||
|     final value = _members; | ||||
|     if (value == null) return null; | ||||
|     if (_members is EqualUnmodifiableListView) return _members; | ||||
|     // ignore: implicit_dynamic_type | ||||
|     return EqualUnmodifiableListView(value); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   @HiveField(7) | ||||
|   final String? avatar; | ||||
|   @override | ||||
|   @HiveField(8) | ||||
|   final String? banner; | ||||
|   final Map<String, dynamic>? _accessPolicy; | ||||
|   @override | ||||
|   @HiveField(9) | ||||
|   Map<String, dynamic>? get accessPolicy { | ||||
|     final value = _accessPolicy; | ||||
|     if (value == null) return null; | ||||
|     if (_accessPolicy is EqualUnmodifiableMapView) return _accessPolicy; | ||||
|     // ignore: implicit_dynamic_type | ||||
|     return EqualUnmodifiableMapView(value); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   @HiveField(10) | ||||
|   final int accountId; | ||||
|   @override | ||||
|   @HiveField(11) | ||||
|   final bool isPublic; | ||||
|   @override | ||||
|   @HiveField(12) | ||||
|   final bool isCommunity; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'SnRealm(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, alias: $alias, name: $name, description: $description, members: $members, avatar: $avatar, banner: $banner, accessPolicy: $accessPolicy, accountId: $accountId, isPublic: $isPublic, isCommunity: $isCommunity)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return identical(this, other) || | ||||
|         (other.runtimeType == runtimeType && | ||||
|             other is _$SnRealmImpl && | ||||
|             (identical(other.id, id) || other.id == id) && | ||||
|             (identical(other.createdAt, createdAt) || | ||||
|                 other.createdAt == createdAt) && | ||||
|             (identical(other.updatedAt, updatedAt) || | ||||
|                 other.updatedAt == updatedAt) && | ||||
|             (identical(other.deletedAt, deletedAt) || | ||||
|                 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._members, _members) && | ||||
|             (identical(other.avatar, avatar) || other.avatar == avatar) && | ||||
|             (identical(other.banner, banner) || other.banner == banner) && | ||||
|             const DeepCollectionEquality() | ||||
|                 .equals(other._accessPolicy, _accessPolicy) && | ||||
|             (identical(other.accountId, accountId) || | ||||
|                 other.accountId == accountId) && | ||||
|             (identical(other.isPublic, isPublic) || | ||||
|                 other.isPublic == isPublic) && | ||||
|             (identical(other.isCommunity, isCommunity) || | ||||
|                 other.isCommunity == isCommunity)); | ||||
|   } | ||||
|  | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   int get hashCode => Object.hash( | ||||
|       runtimeType, | ||||
|       id, | ||||
|       createdAt, | ||||
|       updatedAt, | ||||
|       deletedAt, | ||||
|       alias, | ||||
|       name, | ||||
|       description, | ||||
|       const DeepCollectionEquality().hash(_members), | ||||
|       avatar, | ||||
|       banner, | ||||
|       const DeepCollectionEquality().hash(_accessPolicy), | ||||
|       accountId, | ||||
|       isPublic, | ||||
|       isCommunity); | ||||
|  | ||||
|   /// Create a copy of SnRealm | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   @pragma('vm:prefer-inline') | ||||
|   _$$SnRealmImplCopyWith<_$SnRealmImpl> get copyWith => | ||||
|       __$$SnRealmImplCopyWithImpl<_$SnRealmImpl>(this, _$identity); | ||||
|  | ||||
|   @override | ||||
|   Map<String, dynamic> toJson() { | ||||
|     return _$$SnRealmImplToJson( | ||||
|       this, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| abstract class _SnRealm extends SnRealm { | ||||
|   const factory _SnRealm( | ||||
|       {@HiveField(0) required final int id, | ||||
|       @HiveField(1) required final DateTime createdAt, | ||||
|       @HiveField(2) required final DateTime updatedAt, | ||||
|       @HiveField(3) required final DateTime? deletedAt, | ||||
|       @HiveField(4) required final String alias, | ||||
|       @HiveField(5) required final String name, | ||||
|       @HiveField(6) required final String description, | ||||
|       final List<SnRealmMember>? members, | ||||
|       @HiveField(7) required final String? avatar, | ||||
|       @HiveField(8) required final String? banner, | ||||
|       @HiveField(9) required final Map<String, dynamic>? accessPolicy, | ||||
|       @HiveField(10) required final int accountId, | ||||
|       @HiveField(11) required final bool isPublic, | ||||
|       @HiveField(12) required final bool isCommunity}) = _$SnRealmImpl; | ||||
|   const _SnRealm._() : super._(); | ||||
|  | ||||
|   factory _SnRealm.fromJson(Map<String, dynamic> json) = _$SnRealmImpl.fromJson; | ||||
|  | ||||
|   @override | ||||
|   @HiveField(0) | ||||
|   int get id; | ||||
|   @override | ||||
|   @HiveField(1) | ||||
|   DateTime get createdAt; | ||||
|   @override | ||||
|   @HiveField(2) | ||||
|   DateTime get updatedAt; | ||||
|   @override | ||||
|   @HiveField(3) | ||||
|   DateTime? get deletedAt; | ||||
|   @override | ||||
|   @HiveField(4) | ||||
|   String get alias; | ||||
|   @override | ||||
|   @HiveField(5) | ||||
|   String get name; | ||||
|   @override | ||||
|   @HiveField(6) | ||||
|   String get description; | ||||
|   @override | ||||
|   List<SnRealmMember>? get members; | ||||
|   @override | ||||
|   @HiveField(7) | ||||
|   String? get avatar; | ||||
|   @override | ||||
|   @HiveField(8) | ||||
|   String? get banner; | ||||
|   @override | ||||
|   @HiveField(9) | ||||
|   Map<String, dynamic>? get accessPolicy; | ||||
|   @override | ||||
|   @HiveField(10) | ||||
|   int get accountId; | ||||
|   @override | ||||
|   @HiveField(11) | ||||
|   bool get isPublic; | ||||
|   @override | ||||
|   @HiveField(12) | ||||
|   bool get isCommunity; | ||||
|  | ||||
|   /// Create a copy of SnRealm | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @override | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   _$$SnRealmImplCopyWith<_$SnRealmImpl> get copyWith => | ||||
|       throw _privateConstructorUsedError; | ||||
| } | ||||
							
								
								
									
										149
									
								
								lib/types/realm.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								lib/types/realm.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,149 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'realm.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // TypeAdapterGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| class SnRealmImplAdapter extends TypeAdapter<_$SnRealmImpl> { | ||||
|   @override | ||||
|   final int typeId = 1; | ||||
|  | ||||
|   @override | ||||
|   _$SnRealmImpl read(BinaryReader reader) { | ||||
|     final numOfFields = reader.readByte(); | ||||
|     final fields = <int, dynamic>{ | ||||
|       for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), | ||||
|     }; | ||||
|     return _$SnRealmImpl( | ||||
|       id: fields[0] as int, | ||||
|       createdAt: fields[1] as DateTime, | ||||
|       updatedAt: fields[2] as DateTime, | ||||
|       deletedAt: fields[3] as DateTime?, | ||||
|       alias: fields[4] as String, | ||||
|       name: fields[5] as String, | ||||
|       description: fields[6] as String, | ||||
|       avatar: fields[7] as String?, | ||||
|       banner: fields[8] as String?, | ||||
|       accessPolicy: (fields[9] as Map?)?.cast<String, dynamic>(), | ||||
|       accountId: fields[10] as int, | ||||
|       isPublic: fields[11] as bool, | ||||
|       isCommunity: fields[12] as bool, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void write(BinaryWriter writer, _$SnRealmImpl obj) { | ||||
|     writer | ||||
|       ..writeByte(13) | ||||
|       ..writeByte(0) | ||||
|       ..write(obj.id) | ||||
|       ..writeByte(1) | ||||
|       ..write(obj.createdAt) | ||||
|       ..writeByte(2) | ||||
|       ..write(obj.updatedAt) | ||||
|       ..writeByte(3) | ||||
|       ..write(obj.deletedAt) | ||||
|       ..writeByte(4) | ||||
|       ..write(obj.alias) | ||||
|       ..writeByte(5) | ||||
|       ..write(obj.name) | ||||
|       ..writeByte(6) | ||||
|       ..write(obj.description) | ||||
|       ..writeByte(7) | ||||
|       ..write(obj.avatar) | ||||
|       ..writeByte(8) | ||||
|       ..write(obj.banner) | ||||
|       ..writeByte(10) | ||||
|       ..write(obj.accountId) | ||||
|       ..writeByte(11) | ||||
|       ..write(obj.isPublic) | ||||
|       ..writeByte(12) | ||||
|       ..write(obj.isCommunity) | ||||
|       ..writeByte(9) | ||||
|       ..write(obj.accessPolicy); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => typeId.hashCode; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) => | ||||
|       identical(this, other) || | ||||
|       other is SnRealmImplAdapter && | ||||
|           runtimeType == other.runtimeType && | ||||
|           typeId == other.typeId; | ||||
| } | ||||
|  | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| _$SnRealmMemberImpl _$$SnRealmMemberImplFromJson(Map<String, dynamic> json) => | ||||
|     _$SnRealmMemberImpl( | ||||
|       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'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['deleted_at'] as String), | ||||
|       realmId: (json['realm_id'] as num).toInt(), | ||||
|       accountId: (json['account_id'] as num).toInt(), | ||||
|       realm: SnRealm.fromJson(json['realm'] as Map<String, dynamic>), | ||||
|       account: SnAccount.fromJson(json['account'] as Map<String, dynamic>), | ||||
|       powerLevel: (json['power_level'] as num).toInt(), | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$$SnRealmMemberImplToJson(_$SnRealmMemberImpl instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
|       'realm_id': instance.realmId, | ||||
|       'account_id': instance.accountId, | ||||
|       'realm': instance.realm.toJson(), | ||||
|       'account': instance.account.toJson(), | ||||
|       'power_level': instance.powerLevel, | ||||
|     }; | ||||
|  | ||||
| _$SnRealmImpl _$$SnRealmImplFromJson(Map<String, dynamic> json) => | ||||
|     _$SnRealmImpl( | ||||
|       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'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['deleted_at'] as String), | ||||
|       alias: json['alias'] as String, | ||||
|       name: json['name'] as String, | ||||
|       description: json['description'] as String, | ||||
|       members: (json['members'] as List<dynamic>?) | ||||
|           ?.map((e) => SnRealmMember.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList(), | ||||
|       avatar: json['avatar'] as String?, | ||||
|       banner: json['banner'] as String?, | ||||
|       accessPolicy: json['access_policy'] as Map<String, dynamic>?, | ||||
|       accountId: (json['account_id'] as num).toInt(), | ||||
|       isPublic: json['is_public'] as bool, | ||||
|       isCommunity: json['is_community'] as bool, | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$$SnRealmImplToJson(_$SnRealmImpl instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
|       'alias': instance.alias, | ||||
|       'name': instance.name, | ||||
|       'description': instance.description, | ||||
|       'members': instance.members?.map((e) => e.toJson()).toList(), | ||||
|       'avatar': instance.avatar, | ||||
|       'banner': instance.banner, | ||||
|       'access_policy': instance.accessPolicy, | ||||
|       'account_id': instance.accountId, | ||||
|       'is_public': instance.isPublic, | ||||
|       'is_community': instance.isCommunity, | ||||
|     }; | ||||
							
								
								
									
										17
									
								
								lib/types/websocket.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								lib/types/websocket.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
|  | ||||
| part 'websocket.freezed.dart'; | ||||
| part 'websocket.g.dart'; | ||||
|  | ||||
| @freezed | ||||
| class WebSocketPackage with _$WebSocketPackage { | ||||
|   const factory WebSocketPackage({ | ||||
|     @JsonKey(name: 'w') @Default('unknown') String method, | ||||
|     @JsonKey(name: 'e') String? endpoint, | ||||
|     @JsonKey(name: 'm') String? message, | ||||
|     @JsonKey(name: 'p') @Default({}) Map<String, dynamic>? payload, | ||||
|   }) = _WebSocketPackage; | ||||
|  | ||||
|   factory WebSocketPackage.fromJson(Map<String, dynamic> json) => | ||||
|       _$WebSocketPackageFromJson(json); | ||||
| } | ||||
							
								
								
									
										252
									
								
								lib/types/websocket.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										252
									
								
								lib/types/websocket.freezed.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,252 @@ | ||||
| // coverage:ignore-file | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark | ||||
|  | ||||
| part of 'websocket.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // FreezedGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| T _$identity<T>(T value) => value; | ||||
|  | ||||
| final _privateConstructorUsedError = UnsupportedError( | ||||
|     'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); | ||||
|  | ||||
| WebSocketPackage _$WebSocketPackageFromJson(Map<String, dynamic> json) { | ||||
|   return _WebSocketPackage.fromJson(json); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$WebSocketPackage { | ||||
|   @JsonKey(name: 'w') | ||||
|   String get method => throw _privateConstructorUsedError; | ||||
|   @JsonKey(name: 'e') | ||||
|   String? get endpoint => throw _privateConstructorUsedError; | ||||
|   @JsonKey(name: 'm') | ||||
|   String? get message => throw _privateConstructorUsedError; | ||||
|   @JsonKey(name: 'p') | ||||
|   Map<String, dynamic>? get payload => throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Serializes this WebSocketPackage to a JSON map. | ||||
|   Map<String, dynamic> toJson() => throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Create a copy of WebSocketPackage | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   $WebSocketPackageCopyWith<WebSocketPackage> get copyWith => | ||||
|       throw _privateConstructorUsedError; | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract class $WebSocketPackageCopyWith<$Res> { | ||||
|   factory $WebSocketPackageCopyWith( | ||||
|           WebSocketPackage value, $Res Function(WebSocketPackage) then) = | ||||
|       _$WebSocketPackageCopyWithImpl<$Res, WebSocketPackage>; | ||||
|   @useResult | ||||
|   $Res call( | ||||
|       {@JsonKey(name: 'w') String method, | ||||
|       @JsonKey(name: 'e') String? endpoint, | ||||
|       @JsonKey(name: 'm') String? message, | ||||
|       @JsonKey(name: 'p') Map<String, dynamic>? payload}); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| class _$WebSocketPackageCopyWithImpl<$Res, $Val extends WebSocketPackage> | ||||
|     implements $WebSocketPackageCopyWith<$Res> { | ||||
|   _$WebSocketPackageCopyWithImpl(this._value, this._then); | ||||
|  | ||||
|   // ignore: unused_field | ||||
|   final $Val _value; | ||||
|   // ignore: unused_field | ||||
|   final $Res Function($Val) _then; | ||||
|  | ||||
|   /// Create a copy of WebSocketPackage | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @pragma('vm:prefer-inline') | ||||
|   @override | ||||
|   $Res call({ | ||||
|     Object? method = null, | ||||
|     Object? endpoint = freezed, | ||||
|     Object? message = freezed, | ||||
|     Object? payload = freezed, | ||||
|   }) { | ||||
|     return _then(_value.copyWith( | ||||
|       method: null == method | ||||
|           ? _value.method | ||||
|           : method // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       endpoint: freezed == endpoint | ||||
|           ? _value.endpoint | ||||
|           : endpoint // ignore: cast_nullable_to_non_nullable | ||||
|               as String?, | ||||
|       message: freezed == message | ||||
|           ? _value.message | ||||
|           : message // ignore: cast_nullable_to_non_nullable | ||||
|               as String?, | ||||
|       payload: freezed == payload | ||||
|           ? _value.payload | ||||
|           : payload // ignore: cast_nullable_to_non_nullable | ||||
|               as Map<String, dynamic>?, | ||||
|     ) as $Val); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract class _$$WebSocketPackageImplCopyWith<$Res> | ||||
|     implements $WebSocketPackageCopyWith<$Res> { | ||||
|   factory _$$WebSocketPackageImplCopyWith(_$WebSocketPackageImpl value, | ||||
|           $Res Function(_$WebSocketPackageImpl) then) = | ||||
|       __$$WebSocketPackageImplCopyWithImpl<$Res>; | ||||
|   @override | ||||
|   @useResult | ||||
|   $Res call( | ||||
|       {@JsonKey(name: 'w') String method, | ||||
|       @JsonKey(name: 'e') String? endpoint, | ||||
|       @JsonKey(name: 'm') String? message, | ||||
|       @JsonKey(name: 'p') Map<String, dynamic>? payload}); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| class __$$WebSocketPackageImplCopyWithImpl<$Res> | ||||
|     extends _$WebSocketPackageCopyWithImpl<$Res, _$WebSocketPackageImpl> | ||||
|     implements _$$WebSocketPackageImplCopyWith<$Res> { | ||||
|   __$$WebSocketPackageImplCopyWithImpl(_$WebSocketPackageImpl _value, | ||||
|       $Res Function(_$WebSocketPackageImpl) _then) | ||||
|       : super(_value, _then); | ||||
|  | ||||
|   /// Create a copy of WebSocketPackage | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @pragma('vm:prefer-inline') | ||||
|   @override | ||||
|   $Res call({ | ||||
|     Object? method = null, | ||||
|     Object? endpoint = freezed, | ||||
|     Object? message = freezed, | ||||
|     Object? payload = freezed, | ||||
|   }) { | ||||
|     return _then(_$WebSocketPackageImpl( | ||||
|       method: null == method | ||||
|           ? _value.method | ||||
|           : method // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       endpoint: freezed == endpoint | ||||
|           ? _value.endpoint | ||||
|           : endpoint // ignore: cast_nullable_to_non_nullable | ||||
|               as String?, | ||||
|       message: freezed == message | ||||
|           ? _value.message | ||||
|           : message // ignore: cast_nullable_to_non_nullable | ||||
|               as String?, | ||||
|       payload: freezed == payload | ||||
|           ? _value._payload | ||||
|           : payload // ignore: cast_nullable_to_non_nullable | ||||
|               as Map<String, dynamic>?, | ||||
|     )); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
| class _$WebSocketPackageImpl implements _WebSocketPackage { | ||||
|   const _$WebSocketPackageImpl( | ||||
|       {@JsonKey(name: 'w') this.method = 'unknown', | ||||
|       @JsonKey(name: 'e') this.endpoint, | ||||
|       @JsonKey(name: 'm') this.message, | ||||
|       @JsonKey(name: 'p') final Map<String, dynamic>? payload = const {}}) | ||||
|       : _payload = payload; | ||||
|  | ||||
|   factory _$WebSocketPackageImpl.fromJson(Map<String, dynamic> json) => | ||||
|       _$$WebSocketPackageImplFromJson(json); | ||||
|  | ||||
|   @override | ||||
|   @JsonKey(name: 'w') | ||||
|   final String method; | ||||
|   @override | ||||
|   @JsonKey(name: 'e') | ||||
|   final String? endpoint; | ||||
|   @override | ||||
|   @JsonKey(name: 'm') | ||||
|   final String? message; | ||||
|   final Map<String, dynamic>? _payload; | ||||
|   @override | ||||
|   @JsonKey(name: 'p') | ||||
|   Map<String, dynamic>? get payload { | ||||
|     final value = _payload; | ||||
|     if (value == null) return null; | ||||
|     if (_payload is EqualUnmodifiableMapView) return _payload; | ||||
|     // ignore: implicit_dynamic_type | ||||
|     return EqualUnmodifiableMapView(value); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'WebSocketPackage(method: $method, endpoint: $endpoint, message: $message, payload: $payload)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return identical(this, other) || | ||||
|         (other.runtimeType == runtimeType && | ||||
|             other is _$WebSocketPackageImpl && | ||||
|             (identical(other.method, method) || other.method == method) && | ||||
|             (identical(other.endpoint, endpoint) || | ||||
|                 other.endpoint == endpoint) && | ||||
|             (identical(other.message, message) || other.message == message) && | ||||
|             const DeepCollectionEquality().equals(other._payload, _payload)); | ||||
|   } | ||||
|  | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   int get hashCode => Object.hash(runtimeType, method, endpoint, message, | ||||
|       const DeepCollectionEquality().hash(_payload)); | ||||
|  | ||||
|   /// Create a copy of WebSocketPackage | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   @pragma('vm:prefer-inline') | ||||
|   _$$WebSocketPackageImplCopyWith<_$WebSocketPackageImpl> get copyWith => | ||||
|       __$$WebSocketPackageImplCopyWithImpl<_$WebSocketPackageImpl>( | ||||
|           this, _$identity); | ||||
|  | ||||
|   @override | ||||
|   Map<String, dynamic> toJson() { | ||||
|     return _$$WebSocketPackageImplToJson( | ||||
|       this, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| abstract class _WebSocketPackage implements WebSocketPackage { | ||||
|   const factory _WebSocketPackage( | ||||
|           {@JsonKey(name: 'w') final String method, | ||||
|           @JsonKey(name: 'e') final String? endpoint, | ||||
|           @JsonKey(name: 'm') final String? message, | ||||
|           @JsonKey(name: 'p') final Map<String, dynamic>? payload}) = | ||||
|       _$WebSocketPackageImpl; | ||||
|  | ||||
|   factory _WebSocketPackage.fromJson(Map<String, dynamic> json) = | ||||
|       _$WebSocketPackageImpl.fromJson; | ||||
|  | ||||
|   @override | ||||
|   @JsonKey(name: 'w') | ||||
|   String get method; | ||||
|   @override | ||||
|   @JsonKey(name: 'e') | ||||
|   String? get endpoint; | ||||
|   @override | ||||
|   @JsonKey(name: 'm') | ||||
|   String? get message; | ||||
|   @override | ||||
|   @JsonKey(name: 'p') | ||||
|   Map<String, dynamic>? get payload; | ||||
|  | ||||
|   /// Create a copy of WebSocketPackage | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @override | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   _$$WebSocketPackageImplCopyWith<_$WebSocketPackageImpl> get copyWith => | ||||
|       throw _privateConstructorUsedError; | ||||
| } | ||||
							
								
								
									
										25
									
								
								lib/types/websocket.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								lib/types/websocket.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'websocket.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| _$WebSocketPackageImpl _$$WebSocketPackageImplFromJson( | ||||
|         Map<String, dynamic> json) => | ||||
|     _$WebSocketPackageImpl( | ||||
|       method: json['w'] as String? ?? 'unknown', | ||||
|       endpoint: json['e'] as String?, | ||||
|       message: json['m'] as String?, | ||||
|       payload: json['p'] as Map<String, dynamic>? ?? const {}, | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$$WebSocketPackageImplToJson( | ||||
|         _$WebSocketPackageImpl instance) => | ||||
|     <String, dynamic>{ | ||||
|       'w': instance.method, | ||||
|       'e': instance.endpoint, | ||||
|       'm': instance.message, | ||||
|       'p': instance.payload, | ||||
|     }; | ||||
| @@ -1,16 +1,37 @@ | ||||
| import 'package:dismissible_page/dismissible_page.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:photo_view/photo_view.dart'; | ||||
| import 'package:photo_view/photo_view_gallery.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/attachment.dart'; | ||||
| import 'package:surface/widgets/universal_image.dart'; | ||||
| import 'package:uuid/uuid.dart'; | ||||
|  | ||||
| class AttachmentDetailPopup extends StatelessWidget { | ||||
|   final SnAttachment data; | ||||
|   final String? heroTag; | ||||
|   const AttachmentDetailPopup({super.key, required this.data, this.heroTag}); | ||||
| class AttachmentZoomView extends StatefulWidget { | ||||
|   final Iterable<SnAttachment> data; | ||||
|   final int? initialIndex; | ||||
|   final List<String?>? heroTags; | ||||
|   const AttachmentZoomView({ | ||||
|     super.key, | ||||
|     required this.data, | ||||
|     this.initialIndex, | ||||
|     this.heroTags, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<AttachmentZoomView> createState() => _AttachmentZoomViewState(); | ||||
| } | ||||
|  | ||||
| class _AttachmentZoomViewState extends State<AttachmentZoomView> { | ||||
|   late final PageController _pageController = | ||||
|       PageController(initialPage: widget.initialIndex ?? 0); | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _pageController.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
| @@ -24,18 +45,51 @@ class AttachmentDetailPopup extends StatelessWidget { | ||||
|       direction: DismissiblePageDismissDirection.down, | ||||
|       backgroundColor: Colors.transparent, | ||||
|       isFullScreen: true, | ||||
|       child: Hero( | ||||
|         tag: 'attachment-${data.rid}-${heroTag ?? uuid.v4()}', | ||||
|         child: PhotoView( | ||||
|           key: Key('attachment-detail-${data.rid}-$heroTag'), | ||||
|           backgroundDecoration: BoxDecoration( | ||||
|             color: Colors.black.withOpacity(0.7), | ||||
|       child: Builder(builder: (context) { | ||||
|         if (widget.data.length == 1) { | ||||
|           final heroTag = widget.heroTags?.first ?? uuid.v4(); | ||||
|           return Hero( | ||||
|             tag: 'attachment-${widget.data.first.rid}-$heroTag', | ||||
|             child: PhotoView( | ||||
|               key: Key('attachment-detail-${widget.data.first.rid}-$heroTag'), | ||||
|               backgroundDecoration: BoxDecoration(color: Colors.transparent), | ||||
|               imageProvider: UniversalImage.provider( | ||||
|                 sn.getAttachmentUrl(widget.data.first.rid), | ||||
|               ), | ||||
|             ), | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         return PhotoViewGallery.builder( | ||||
|           pageController: _pageController, | ||||
|           scrollPhysics: const BouncingScrollPhysics(), | ||||
|           builder: (context, idx) { | ||||
|             final heroTag = widget.heroTags?.elementAt(idx) ?? uuid.v4(); | ||||
|             return PhotoViewGalleryPageOptions( | ||||
|               imageProvider: UniversalImage.provider( | ||||
|                 sn.getAttachmentUrl(widget.data.elementAt(idx).rid), | ||||
|               ), | ||||
|               heroAttributes: PhotoViewHeroAttributes( | ||||
|                 tag: 'attachment-${widget.data.first.rid}-$heroTag', | ||||
|               ), | ||||
|             ); | ||||
|           }, | ||||
|           itemCount: widget.data.length, | ||||
|           loadingBuilder: (context, event) => Center( | ||||
|             child: SizedBox( | ||||
|               width: 20.0, | ||||
|               height: 20.0, | ||||
|               child: CircularProgressIndicator( | ||||
|                 value: event == null | ||||
|                     ? 0 | ||||
|                     : event.cumulativeBytesLoaded / | ||||
|                         (event.expectedTotalBytes ?? 1), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|           imageProvider: UniversalImage.provider( | ||||
|             sn.getAttachmentUrl(data.rid), | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|           backgroundDecoration: BoxDecoration(color: Colors.transparent), | ||||
|         ); | ||||
|       }), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,40 +1,58 @@ | ||||
| import 'dart:ui'; | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:dismissible_page/dismissible_page.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:google_fonts/google_fonts.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:media_kit/media_kit.dart'; | ||||
| import 'package:media_kit_video/media_kit_video.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/attachment.dart'; | ||||
| import 'package:surface/widgets/attachment/attachment_detail.dart'; | ||||
| import 'package:surface/widgets/universal_image.dart'; | ||||
| import 'package:uuid/uuid.dart'; | ||||
|  | ||||
| class AttachmentItem extends StatelessWidget { | ||||
|   final SnAttachment data; | ||||
|   final bool isExpandable; | ||||
|   final SnAttachment? data; | ||||
|   final String? heroTag; | ||||
|   const AttachmentItem({ | ||||
|     super.key, | ||||
|     required this.data, | ||||
|     this.isExpandable = false, | ||||
|     required this.heroTag, | ||||
|   }); | ||||
|  | ||||
|   Widget _buildContent(BuildContext context, String heroTag) { | ||||
|     final tp = data.mimetype.split('/').firstOrNull; | ||||
|   Widget _buildContent(BuildContext context) { | ||||
|     final tag = heroTag ?? Uuid().v4(); | ||||
|  | ||||
|     if (data == null) { | ||||
|       return const Icon(Symbols.cancel).center(); | ||||
|     } | ||||
|  | ||||
|     final tp = data!.mimetype.split('/').firstOrNull; | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|     switch (tp) { | ||||
|       case 'image': | ||||
|         return Hero( | ||||
|           tag: 'attachment-${data.rid}-$heroTag', | ||||
|           tag: 'attachment-${data!.rid}-$tag', | ||||
|           child: AutoResizeUniversalImage( | ||||
|             sn.getAttachmentUrl(data.rid), | ||||
|             key: Key('attachment-${data.rid}-$heroTag'), | ||||
|             sn.getAttachmentUrl(data!.rid), | ||||
|             key: Key('attachment-${data!.rid}-$tag'), | ||||
|             fit: BoxFit.cover, | ||||
|           ), | ||||
|         ); | ||||
|       case 'video': | ||||
|         return _AttachmentItemContentVideo( | ||||
|           data: data!, | ||||
|           isAutoload: false, | ||||
|         ); | ||||
|       case 'audio': | ||||
|         return _AttachmentItemContentAudio( | ||||
|           data: data!, | ||||
|           isAutoload: false, | ||||
|         ); | ||||
|       default: | ||||
|         return const Placeholder(); | ||||
|     } | ||||
| @@ -42,28 +60,13 @@ class AttachmentItem extends StatelessWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final uuid = Uuid(); | ||||
|     final heroTag = uuid.v4(); | ||||
|  | ||||
|     if (data.isMature) { | ||||
|     if (data!.isMature) { | ||||
|       return _AttachmentItemSensitiveBlur( | ||||
|         child: _buildContent(context, heroTag), | ||||
|         child: _buildContent(context), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     if (isExpandable) { | ||||
|       return GestureDetector( | ||||
|         child: _buildContent(context, heroTag), | ||||
|         onTap: () { | ||||
|           context.pushTransparentRoute( | ||||
|             AttachmentDetailPopup(data: data, heroTag: heroTag), | ||||
|             rootNavigator: true, | ||||
|           ); | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return _buildContent(context, heroTag); | ||||
|     return _buildContent(context); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -91,35 +94,38 @@ class _AttachmentItemSensitiveBlurState | ||||
|             child: Container( | ||||
|               color: Colors.black.withOpacity(0.5), | ||||
|               alignment: Alignment.center, | ||||
|               child: Column( | ||||
|                 mainAxisAlignment: MainAxisAlignment.center, | ||||
|                 children: [ | ||||
|                   const Icon( | ||||
|                     Symbols.visibility_off, | ||||
|                     color: Colors.white, | ||||
|                     size: 32, | ||||
|                   ), | ||||
|                   const Gap(8), | ||||
|                   Text('sensitiveContent') | ||||
|                       .tr() | ||||
|                       .fontSize(20) | ||||
|                       .textColor(Colors.white) | ||||
|                       .bold(), | ||||
|                   Text('sensitiveContentDescription') | ||||
|                       .tr() | ||||
|                       .fontSize(14) | ||||
|                       .textColor(Colors.white.withOpacity(0.8)), | ||||
|                   const Gap(16), | ||||
|                   InkWell( | ||||
|                     child: Text('sensitiveContentReveal') | ||||
|               child: Container( | ||||
|                 constraints: const BoxConstraints(maxWidth: 280), | ||||
|                 child: Column( | ||||
|                   mainAxisAlignment: MainAxisAlignment.center, | ||||
|                   children: [ | ||||
|                     const Icon( | ||||
|                       Symbols.visibility_off, | ||||
|                       color: Colors.white, | ||||
|                       size: 32, | ||||
|                     ), | ||||
|                     const Gap(8), | ||||
|                     Text('sensitiveContent') | ||||
|                         .tr() | ||||
|                         .textColor(Colors.white), | ||||
|                     onTap: () { | ||||
|                       setState(() => _doesShow = !_doesShow); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|                         .fontSize(20) | ||||
|                         .textColor(Colors.white) | ||||
|                         .bold(), | ||||
|                     Text('sensitiveContentDescription') | ||||
|                         .tr() | ||||
|                         .fontSize(14) | ||||
|                         .textColor(Colors.white.withOpacity(0.8)), | ||||
|                     const Gap(16), | ||||
|                     InkWell( | ||||
|                       child: Text('sensitiveContentReveal') | ||||
|                           .tr() | ||||
|                           .textColor(Colors.white), | ||||
|                       onTap: () { | ||||
|                         setState(() => _doesShow = !_doesShow); | ||||
|                       }, | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ).center(), | ||||
|             ), | ||||
|           ), | ||||
|         ) | ||||
| @@ -150,3 +156,431 @@ class _AttachmentItemSensitiveBlurState | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _AttachmentItemContentVideo extends StatefulWidget { | ||||
|   final SnAttachment data; | ||||
|   final bool isAutoload; | ||||
|   const _AttachmentItemContentVideo({ | ||||
|     super.key, | ||||
|     required this.data, | ||||
|     this.isAutoload = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<_AttachmentItemContentVideo> createState() => | ||||
|       _AttachmentItemContentVideoState(); | ||||
| } | ||||
|  | ||||
| class _AttachmentItemContentVideoState | ||||
|     extends State<_AttachmentItemContentVideo> { | ||||
|   bool _showContent = false; | ||||
|  | ||||
|   Player? _videoPlayer; | ||||
|   VideoController? _videoController; | ||||
|  | ||||
|   Future<void> _startLoad() async { | ||||
|     setState(() => _showContent = true); | ||||
|     MediaKit.ensureInitialized(); | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|     final url = sn.getAttachmentUrl(widget.data.rid); | ||||
|     _videoPlayer = Player(); | ||||
|     _videoController = VideoController(_videoPlayer!); | ||||
|     _videoPlayer!.open(Media(url), play: !widget.isAutoload); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     if (widget.isAutoload) _startLoad(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     const labelShadows = <Shadow>[ | ||||
|       Shadow( | ||||
|         offset: Offset(1, 1), | ||||
|         blurRadius: 5.0, | ||||
|         color: Color.fromARGB(255, 0, 0, 0), | ||||
|       ), | ||||
|     ]; | ||||
|  | ||||
|     final ratio = widget.data.metadata['ratio'] ?? 16 / 9; | ||||
|  | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|  | ||||
|     if (!_showContent) { | ||||
|       return GestureDetector( | ||||
|         behavior: HitTestBehavior.opaque, | ||||
|         child: Stack( | ||||
|           children: [ | ||||
|             if (widget.data.metadata['thumbnail'] != null) | ||||
|               AutoResizeUniversalImage( | ||||
|                 sn.getAttachmentUrl(widget.data.metadata['thumbnail']), | ||||
|                 fit: BoxFit.cover, | ||||
|               ) | ||||
|             else | ||||
|               const Center( | ||||
|                 child: Icon(Symbols.movie, size: 64), | ||||
|               ), | ||||
|             Align( | ||||
|               alignment: Alignment.bottomCenter, | ||||
|               child: IgnorePointer( | ||||
|                 child: Container( | ||||
|                   height: 56, | ||||
|                   decoration: BoxDecoration( | ||||
|                     gradient: LinearGradient( | ||||
|                       begin: Alignment.bottomCenter, | ||||
|                       end: Alignment.topCenter, | ||||
|                       colors: [ | ||||
|                         Theme.of(context).colorScheme.surface, | ||||
|                         Theme.of(context).colorScheme.surface.withOpacity(0), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             Positioned( | ||||
|               bottom: 4, | ||||
|               left: 16, | ||||
|               right: 16, | ||||
|               child: SizedBox( | ||||
|                 height: 45, | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
|                     Expanded( | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Text( | ||||
|                             widget.data.alt, | ||||
|                             maxLines: 1, | ||||
|                             overflow: TextOverflow.ellipsis, | ||||
|                             style: const TextStyle( | ||||
|                               shadows: labelShadows, | ||||
|                               color: Colors.white, | ||||
|                             ), | ||||
|                           ), | ||||
|                           Text( | ||||
|                             Duration( | ||||
|                               milliseconds: | ||||
|                                   (widget.data.metadata['duration'] ?? 0) | ||||
|                                           .toInt() * | ||||
|                                       1000, | ||||
|                             ).toString(), | ||||
|                             style: GoogleFonts.robotoMono( | ||||
|                               fontSize: 12, | ||||
|                               shadows: labelShadows, | ||||
|                               color: Colors.white, | ||||
|                             ), | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|                     const Icon( | ||||
|                       Symbols.play_arrow, | ||||
|                       shadows: labelShadows, | ||||
|                       color: Colors.white, | ||||
|                     ).padding(bottom: 4, right: 8), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|         onTap: () { | ||||
|           _startLoad(); | ||||
|         }, | ||||
|       ); | ||||
|     } else if (_videoController == null) { | ||||
|       return const Center( | ||||
|         child: CircularProgressIndicator(), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Video( | ||||
|       controller: _videoController!, | ||||
|       aspectRatio: ratio, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _videoPlayer?.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _AttachmentItemContentAudio extends StatefulWidget { | ||||
|   final SnAttachment data; | ||||
|   final bool isAutoload; | ||||
|   const _AttachmentItemContentAudio({ | ||||
|     super.key, | ||||
|     required this.data, | ||||
|     this.isAutoload = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<_AttachmentItemContentAudio> createState() => | ||||
|       _AttachmentItemContentAudioState(); | ||||
| } | ||||
|  | ||||
| class _AttachmentItemContentAudioState | ||||
|     extends State<_AttachmentItemContentAudio> { | ||||
|   bool _showContent = false; | ||||
|  | ||||
|   double? _draggingValue; | ||||
|   bool _isPlaying = false; | ||||
|   Duration _duration = Duration.zero; | ||||
|   Duration _position = Duration.zero; | ||||
|   Duration _bufferedPosition = Duration.zero; | ||||
|  | ||||
|   Player? _audioPlayer; | ||||
|  | ||||
|   Future<void> _startLoad() async { | ||||
|     setState(() => _showContent = true); | ||||
|     MediaKit.ensureInitialized(); | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|     final url = sn.getAttachmentUrl(widget.data.rid); | ||||
|     _audioPlayer = Player(); | ||||
|     await _audioPlayer!.open(Media(url), play: !widget.isAutoload); | ||||
|     _audioPlayer!.stream.playing.listen((v) => setState(() => _isPlaying = v)); | ||||
|     _audioPlayer!.stream.position.listen((v) => setState(() => _position = v)); | ||||
|     _audioPlayer!.stream.duration.listen((v) => setState(() => _duration = v)); | ||||
|     _audioPlayer!.stream.buffer.listen( | ||||
|       (v) => setState(() => _bufferedPosition = v), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     if (widget.isAutoload) _startLoad(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     const labelShadows = <Shadow>[ | ||||
|       Shadow( | ||||
|         offset: Offset(1, 1), | ||||
|         blurRadius: 5.0, | ||||
|         color: Color.fromARGB(255, 0, 0, 0), | ||||
|       ), | ||||
|     ]; | ||||
|  | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|  | ||||
|     if (!_showContent) { | ||||
|       return GestureDetector( | ||||
|         behavior: HitTestBehavior.opaque, | ||||
|         child: Stack( | ||||
|           children: [ | ||||
|             if (widget.data.metadata['thumbnail'] != null) | ||||
|               AspectRatio( | ||||
|                 aspectRatio: 16 / 9, | ||||
|                 child: AutoResizeUniversalImage( | ||||
|                   sn.getAttachmentUrl(widget.data.metadata['thumbnail']), | ||||
|                   fit: BoxFit.cover, | ||||
|                 ), | ||||
|               ) | ||||
|             else | ||||
|               const Center( | ||||
|                 child: Icon(Symbols.radio, size: 64), | ||||
|               ), | ||||
|             Align( | ||||
|               alignment: Alignment.bottomCenter, | ||||
|               child: IgnorePointer( | ||||
|                 child: Container( | ||||
|                   height: 56, | ||||
|                   decoration: BoxDecoration( | ||||
|                     gradient: LinearGradient( | ||||
|                       begin: Alignment.bottomCenter, | ||||
|                       end: Alignment.topCenter, | ||||
|                       colors: [ | ||||
|                         Theme.of(context).colorScheme.surface, | ||||
|                         Theme.of(context).colorScheme.surface.withOpacity(0), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             Positioned( | ||||
|               bottom: 4, | ||||
|               left: 16, | ||||
|               right: 16, | ||||
|               child: SizedBox( | ||||
|                 height: 45, | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
|                     Expanded( | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Text( | ||||
|                             widget.data.alt, | ||||
|                             maxLines: 1, | ||||
|                             overflow: TextOverflow.ellipsis, | ||||
|                             style: const TextStyle( | ||||
|                               shadows: labelShadows, | ||||
|                               color: Colors.white, | ||||
|                             ), | ||||
|                           ), | ||||
|                           Text( | ||||
|                             widget.data.size.toString(), | ||||
|                             style: GoogleFonts.robotoMono( | ||||
|                               fontSize: 12, | ||||
|                               shadows: labelShadows, | ||||
|                               color: Colors.white, | ||||
|                             ), | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|                     const Icon( | ||||
|                       Symbols.play_arrow, | ||||
|                       shadows: labelShadows, | ||||
|                       color: Colors.white, | ||||
|                     ).padding(bottom: 4, right: 8), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|         onTap: () { | ||||
|           _startLoad(); | ||||
|         }, | ||||
|       ); | ||||
|     } else if (_audioPlayer == null) { | ||||
|       return const Center( | ||||
|         child: CircularProgressIndicator(), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Stack( | ||||
|       children: [ | ||||
|         if (widget.data.metadata['thumbnail'] != null) | ||||
|           AspectRatio( | ||||
|             aspectRatio: 16 / 9, | ||||
|             child: AutoResizeUniversalImage( | ||||
|               sn.getAttachmentUrl(widget.data.metadata['thumbnail']), | ||||
|               fit: BoxFit.cover, | ||||
|             ), | ||||
|           ), | ||||
|         Container( | ||||
|           constraints: const BoxConstraints(maxWidth: 320), | ||||
|           child: Column( | ||||
|             mainAxisAlignment: MainAxisAlignment.center, | ||||
|             children: [ | ||||
|               const Icon(Symbols.audio_file, size: 32), | ||||
|               const Gap(8), | ||||
|               Text( | ||||
|                 widget.data.alt, | ||||
|                 style: const TextStyle(fontSize: 13), | ||||
|                 textAlign: TextAlign.center, | ||||
|               ), | ||||
|               const Gap(12), | ||||
|               Row( | ||||
|                 children: [ | ||||
|                   Expanded( | ||||
|                     child: Column( | ||||
|                       children: [ | ||||
|                         SliderTheme( | ||||
|                           data: SliderThemeData( | ||||
|                             trackHeight: 2, | ||||
|                             trackShape: _PlayerProgressTrackShape(), | ||||
|                             thumbShape: const RoundSliderThumbShape( | ||||
|                               enabledThumbRadius: 8, | ||||
|                             ), | ||||
|                             overlayShape: SliderComponentShape.noOverlay, | ||||
|                           ), | ||||
|                           child: Slider( | ||||
|                             secondaryTrackValue: _bufferedPosition | ||||
|                                 .inMilliseconds | ||||
|                                 .abs() | ||||
|                                 .toDouble(), | ||||
|                             value: _draggingValue?.abs() ?? | ||||
|                                 _position.inMilliseconds.toDouble().abs(), | ||||
|                             min: 0, | ||||
|                             max: math | ||||
|                                 .max( | ||||
|                                   _bufferedPosition.inMilliseconds.abs(), | ||||
|                                   math.max( | ||||
|                                     _position.inMilliseconds.abs(), | ||||
|                                     _duration.inMilliseconds.abs(), | ||||
|                                   ), | ||||
|                                 ) | ||||
|                                 .toDouble(), | ||||
|                             onChanged: (value) { | ||||
|                               setState(() => _draggingValue = value); | ||||
|                             }, | ||||
|                             onChangeEnd: (value) { | ||||
|                               _audioPlayer!.seek( | ||||
|                                 Duration(milliseconds: value.toInt()), | ||||
|                               ); | ||||
|                               setState(() => _draggingValue = null); | ||||
|                             }, | ||||
|                           ), | ||||
|                         ), | ||||
|                         Row( | ||||
|                           mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                           children: [ | ||||
|                             Text( | ||||
|                               _position.toString(), | ||||
|                               style: GoogleFonts.robotoMono(fontSize: 12), | ||||
|                             ), | ||||
|                             Text( | ||||
|                               _duration.toString(), | ||||
|                               style: GoogleFonts.robotoMono(fontSize: 12), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ).padding(horizontal: 8, vertical: 4), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                   const Gap(16), | ||||
|                   IconButton.filled( | ||||
|                     icon: _isPlaying | ||||
|                         ? const Icon(Symbols.pause) | ||||
|                         : const Icon(Symbols.play_arrow), | ||||
|                     onPressed: () { | ||||
|                       _audioPlayer!.playOrPause(); | ||||
|                     }, | ||||
|                     visualDensity: const VisualDensity( | ||||
|                       horizontal: -4, | ||||
|                       vertical: 0, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ).center(), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _audioPlayer?.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _PlayerProgressTrackShape extends RoundedRectSliderTrackShape { | ||||
|   @override | ||||
|   Rect getPreferredRect({ | ||||
|     required RenderBox parentBox, | ||||
|     Offset offset = Offset.zero, | ||||
|     required SliderThemeData sliderTheme, | ||||
|     bool isEnabled = false, | ||||
|     bool isDiscrete = false, | ||||
|   }) { | ||||
|     final trackHeight = sliderTheme.trackHeight; | ||||
|     final trackLeft = offset.dx; | ||||
|     final trackTop = offset.dy + (parentBox.size.height - trackHeight!) / 2; | ||||
|     final trackWidth = parentBox.size.width; | ||||
|     return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,100 +1,173 @@ | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:dismissible_page/dismissible_page.dart'; | ||||
| import 'package:flutter/gestures.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:responsive_framework/responsive_framework.dart'; | ||||
| import 'package:surface/types/attachment.dart'; | ||||
| import 'package:surface/widgets/attachment/attachment_detail.dart'; | ||||
| import 'package:surface/widgets/attachment/attachment_item.dart'; | ||||
| import 'package:uuid/uuid.dart'; | ||||
|  | ||||
| class AttachmentList extends StatelessWidget { | ||||
|   final List<SnAttachment> data; | ||||
|   final bool? bordered; | ||||
| class AttachmentList extends StatefulWidget { | ||||
|   final List<SnAttachment?> data; | ||||
|   final bool bordered; | ||||
|   final bool noGrow; | ||||
|   final double? maxHeight; | ||||
|   final EdgeInsets? listPadding; | ||||
|   const AttachmentList({ | ||||
|     super.key, | ||||
|     required this.data, | ||||
|     this.bordered, | ||||
|     this.bordered = false, | ||||
|     this.noGrow = false, | ||||
|     this.maxHeight, | ||||
|     this.listPadding, | ||||
|   }); | ||||
|  | ||||
|   static const double kMaxItemWidth = 520; | ||||
|   static const BorderRadius kDefaultRadius = | ||||
|       BorderRadius.all(Radius.circular(8)); | ||||
|  | ||||
|   @override | ||||
|   State<AttachmentList> createState() => _AttachmentListState(); | ||||
| } | ||||
|  | ||||
| class _AttachmentListState extends State<AttachmentList> { | ||||
|   late final List<String> heroTags = List.generate( | ||||
|     widget.data.length, | ||||
|     (_) => const Uuid().v4(), | ||||
|   ); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final borderSide = (bordered ?? false) | ||||
|     final borderSide = widget.bordered | ||||
|         ? BorderSide(width: 1, color: Theme.of(context).dividerColor) | ||||
|         : BorderSide.none; | ||||
|     final backgroundColor = Theme.of(context).colorScheme.surfaceContainer; | ||||
|     final constraints = BoxConstraints( | ||||
|       minWidth: 80, | ||||
|       maxHeight: widget.maxHeight ?? double.infinity, | ||||
|     ); | ||||
|  | ||||
|     if (data.isEmpty) return const SizedBox.shrink(); | ||||
|     if (data.length == 1) { | ||||
|       if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) { | ||||
|         return Container( | ||||
|           constraints: BoxConstraints( | ||||
|             maxHeight: maxHeight ?? double.infinity, | ||||
|             maxWidth: math.min( | ||||
|               MediaQuery.of(context).size.width - 20, | ||||
|               kMaxItemWidth, | ||||
|             ), | ||||
|           ), | ||||
|           decoration: BoxDecoration( | ||||
|             border: Border(top: borderSide, bottom: borderSide), | ||||
|             borderRadius: kDefaultRadius, | ||||
|           ), | ||||
|           child: AspectRatio( | ||||
|             aspectRatio: data[0].metadata['ratio']?.toDouble() ?? 1, | ||||
|             child: ClipRRect( | ||||
|               borderRadius: kDefaultRadius, | ||||
|               child: AttachmentItem(data: data[0], isExpandable: true), | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       return Container( | ||||
|         decoration: BoxDecoration( | ||||
|           border: Border(top: borderSide, bottom: borderSide), | ||||
|         ), | ||||
|         child: AspectRatio( | ||||
|           aspectRatio: data[0].metadata['ratio']?.toDouble() ?? 1, | ||||
|           child: AttachmentItem(data: data[0], isExpandable: true), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Container( | ||||
|       constraints: BoxConstraints(maxHeight: maxHeight ?? 320), | ||||
|       child: ScrollConfiguration( | ||||
|         behavior: _AttachmentListScrollBehavior(), | ||||
|         child: ListView.separated( | ||||
|           shrinkWrap: true, | ||||
|           itemCount: data.length, | ||||
|           itemBuilder: (context, idx) { | ||||
|             return Container( | ||||
|               constraints: BoxConstraints( | ||||
|                 maxHeight: maxHeight ?? double.infinity, | ||||
|                 maxWidth: math.min( | ||||
|                   MediaQuery.of(context).size.width - 20, | ||||
|                   kMaxItemWidth, | ||||
|     if (widget.data.isEmpty) return const SizedBox.shrink(); | ||||
|     if (widget.data.length == 1) { | ||||
|       return GestureDetector( | ||||
|         child: Builder( | ||||
|           builder: (context) { | ||||
|             if (ResponsiveBreakpoints.of(context).largerThan(MOBILE) || | ||||
|                 widget.noGrow) { | ||||
|               return Padding( | ||||
|                 // Single child list-like displaying | ||||
|                 padding: widget.listPadding ?? EdgeInsets.zero, | ||||
|                 child: Container( | ||||
|                   constraints: constraints, | ||||
|                   decoration: BoxDecoration( | ||||
|                     color: backgroundColor, | ||||
|                     border: Border(top: borderSide, bottom: borderSide), | ||||
|                     borderRadius: AttachmentList.kDefaultRadius, | ||||
|                   ), | ||||
|                   child: AspectRatio( | ||||
|                     aspectRatio: widget.data[0]?.metadata['ratio'] | ||||
|                             ?.toDouble() ?? | ||||
|                         switch ( | ||||
|                             widget.data[0]?.mimetype.split('/').firstOrNull) { | ||||
|                           'audio' => 16 / 9, | ||||
|                           'video' => 16 / 9, | ||||
|                           _ => 1, | ||||
|                         }, | ||||
|                     child: ClipRRect( | ||||
|                       borderRadius: AttachmentList.kDefaultRadius, | ||||
|                       child: AttachmentItem( | ||||
|                         data: widget.data[0], | ||||
|                         heroTag: heroTags[0], | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|               ); | ||||
|             } | ||||
|  | ||||
|             return Container( | ||||
|               decoration: BoxDecoration( | ||||
|                 color: backgroundColor, | ||||
|                 border: Border(top: borderSide, bottom: borderSide), | ||||
|                 borderRadius: kDefaultRadius, | ||||
|               ), | ||||
|               child: AspectRatio( | ||||
|                 aspectRatio: data[idx].metadata['ratio']?.toDouble() ?? 1, | ||||
|                 child: ClipRRect( | ||||
|                   borderRadius: kDefaultRadius, | ||||
|                   child: AttachmentItem(data: data[idx], isExpandable: true), | ||||
|                 aspectRatio: widget.data[0]?.metadata['ratio']?.toDouble() ?? 1, | ||||
|                 child: AttachmentItem( | ||||
|                   data: widget.data[0], | ||||
|                   heroTag: heroTags.first, | ||||
|                 ), | ||||
|               ), | ||||
|             ); | ||||
|           }, | ||||
|         ), | ||||
|         onTap: () { | ||||
|           context.pushTransparentRoute( | ||||
|             AttachmentZoomView( | ||||
|               data: widget.data.where((ele) => ele != null).cast(), | ||||
|               initialIndex: 0, | ||||
|               heroTags: heroTags, | ||||
|             ), | ||||
|             backgroundColor: Colors.black.withOpacity(0.7), | ||||
|             rootNavigator: true, | ||||
|           ); | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Container( | ||||
|       constraints: BoxConstraints(maxHeight: widget.maxHeight ?? 320), | ||||
|       child: ScrollConfiguration( | ||||
|         behavior: _AttachmentListScrollBehavior(), | ||||
|         child: ListView.separated( | ||||
|           shrinkWrap: true, | ||||
|           itemCount: widget.data.length, | ||||
|           itemBuilder: (context, idx) { | ||||
|             return GestureDetector( | ||||
|               onTap: () { | ||||
|                 context.pushTransparentRoute( | ||||
|                   AttachmentZoomView( | ||||
|                     data: widget.data.where((ele) => ele != null).cast(), | ||||
|                     initialIndex: idx, | ||||
|                     heroTags: heroTags, | ||||
|                   ), | ||||
|                   backgroundColor: Colors.black.withOpacity(0.7), | ||||
|                   rootNavigator: true, | ||||
|                 ); | ||||
|               }, | ||||
|               child: Stack( | ||||
|                 children: [ | ||||
|                   Container( | ||||
|                     constraints: constraints, | ||||
|                     decoration: BoxDecoration( | ||||
|                       color: backgroundColor, | ||||
|                       border: Border(top: borderSide, bottom: borderSide), | ||||
|                       borderRadius: AttachmentList.kDefaultRadius, | ||||
|                     ), | ||||
|                     child: AspectRatio( | ||||
|                       aspectRatio: | ||||
|                           widget.data[idx]?.metadata['ratio']?.toDouble() ?? 1, | ||||
|                       child: ClipRRect( | ||||
|                         borderRadius: AttachmentList.kDefaultRadius, | ||||
|                         child: AttachmentItem( | ||||
|                           data: widget.data[idx], | ||||
|                           heroTag: heroTags[idx], | ||||
|                         ), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                   Positioned( | ||||
|                     right: 12, | ||||
|                     bottom: 12, | ||||
|                     child: Chip( | ||||
|                       label: Text('${idx + 1}/${widget.data.length}'), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ); | ||||
|           }, | ||||
|           separatorBuilder: (context, index) => const Gap(8), | ||||
|           padding: const EdgeInsets.symmetric(horizontal: 12), | ||||
|           padding: widget.listPadding, | ||||
|           physics: const BouncingScrollPhysics(), | ||||
|           scrollDirection: Axis.horizontal, | ||||
|         ), | ||||
|   | ||||
							
								
								
									
										369
									
								
								lib/widgets/chat/call/call_controls.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										369
									
								
								lib/widgets/chat/call/call_controls.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,369 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_webrtc/flutter_webrtc.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/chat_call.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
|  | ||||
| class ControlsWidget extends StatefulWidget { | ||||
|   final Room room; | ||||
|   final LocalParticipant participant; | ||||
|  | ||||
|   const ControlsWidget( | ||||
|     this.room, | ||||
|     this.participant, { | ||||
|     super.key, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<StatefulWidget> createState() => _ControlsWidgetState(); | ||||
| } | ||||
|  | ||||
| class _ControlsWidgetState extends State<ControlsWidget> { | ||||
|   CameraPosition _position = CameraPosition.front; | ||||
|  | ||||
|   List<MediaDevice>? _audioInputs; | ||||
|   List<MediaDevice>? _audioOutputs; | ||||
|   List<MediaDevice>? _videoInputs; | ||||
|  | ||||
|   StreamSubscription? _subscription; | ||||
|  | ||||
|   bool _speakerphoneOn = false; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _participant.addListener(onChange); | ||||
|     _subscription = Hardware.instance.onDeviceChange.stream | ||||
|         .listen((List<MediaDevice> devices) { | ||||
|       _revertDevices(devices); | ||||
|     }); | ||||
|     Hardware.instance.enumerateDevices().then(_revertDevices); | ||||
|     _speakerphoneOn = Hardware.instance.speakerOn ?? false; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _subscription?.cancel(); | ||||
|     _participant.removeListener(onChange); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   LocalParticipant get _participant => widget.participant; | ||||
|  | ||||
|   void _revertDevices(List<MediaDevice> devices) async { | ||||
|     _audioInputs = devices.where((d) => d.kind == 'audioinput').toList(); | ||||
|     _audioOutputs = devices.where((d) => d.kind == 'audiooutput').toList(); | ||||
|     _videoInputs = devices.where((d) => d.kind == 'videoinput').toList(); | ||||
|     setState(() {}); | ||||
|   } | ||||
|  | ||||
|   void onChange() => setState(() {}); | ||||
|  | ||||
|   bool get isMuted => _participant.isMuted; | ||||
|  | ||||
|   Future<bool?> showDisconnectDialog() { | ||||
|     return showDialog<bool>( | ||||
|       context: context, | ||||
|       builder: (ctx) => AlertDialog( | ||||
|         title: Text('callDisconnect').tr(), | ||||
|         content: Text('callDisconnectDescription').tr(), | ||||
|         actions: [ | ||||
|           TextButton( | ||||
|             onPressed: () => Navigator.pop(ctx, false), | ||||
|             child: Text('cancel').tr(), | ||||
|           ), | ||||
|           TextButton( | ||||
|             onPressed: () => Navigator.pop(ctx, true), | ||||
|             child: Text('dialogConfirm').tr(), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void _disconnect() async { | ||||
|     if (await showDisconnectDialog() != true) return; | ||||
|     if (!mounted) return; | ||||
|  | ||||
|     final call = context.read<ChatCallProvider>(); | ||||
|     if (call.current != null) { | ||||
|       call.disposeRoom(); | ||||
|       if (Navigator.canPop(context)) { | ||||
|         Navigator.pop(context); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _disableAudio() async { | ||||
|     await _participant.setMicrophoneEnabled(false); | ||||
|   } | ||||
|  | ||||
|   void _enableAudio() async { | ||||
|     await _participant.setMicrophoneEnabled(true); | ||||
|   } | ||||
|  | ||||
|   void _disableVideo() async { | ||||
|     await _participant.setCameraEnabled(false); | ||||
|   } | ||||
|  | ||||
|   void _enableVideo() async { | ||||
|     await _participant.setCameraEnabled(true); | ||||
|   } | ||||
|  | ||||
|   void _selectAudioOutput(MediaDevice device) async { | ||||
|     await widget.room.setAudioOutputDevice(device); | ||||
|     setState(() {}); | ||||
|   } | ||||
|  | ||||
|   void _selectAudioInput(MediaDevice device) async { | ||||
|     await widget.room.setAudioInputDevice(device); | ||||
|     setState(() {}); | ||||
|   } | ||||
|  | ||||
|   void _selectVideoInput(MediaDevice device) async { | ||||
|     await widget.room.setVideoInputDevice(device); | ||||
|     setState(() {}); | ||||
|   } | ||||
|  | ||||
|   void _toggleSpeakerphoneOn() { | ||||
|     _speakerphoneOn = !_speakerphoneOn; | ||||
|     Hardware.instance.setSpeakerphoneOn(_speakerphoneOn); | ||||
|     setState(() {}); | ||||
|   } | ||||
|  | ||||
|   void _toggleCamera() async { | ||||
|     final track = _participant.videoTrackPublications.firstOrNull?.track; | ||||
|     if (track == null) return; | ||||
|  | ||||
|     try { | ||||
|       final newPosition = _position.switched(); | ||||
|       await track.setCameraPosition(newPosition); | ||||
|       setState(() { | ||||
|         _position = newPosition; | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       return; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _enableScreenShare() async { | ||||
|     if (lkPlatformIsDesktop()) { | ||||
|       try { | ||||
|         final source = await showDialog<DesktopCapturerSource>( | ||||
|           context: context, | ||||
|           builder: (context) => ScreenSelectDialog(), | ||||
|         ); | ||||
|         if (source == null) { | ||||
|           return; | ||||
|         } | ||||
|         var track = await LocalVideoTrack.createScreenShareTrack( | ||||
|           ScreenShareCaptureOptions( | ||||
|             captureScreenAudio: true, | ||||
|             sourceId: source.id, | ||||
|             maxFrameRate: 30.0, | ||||
|           ), | ||||
|         ); | ||||
|         await _participant.publishVideoTrack(track); | ||||
|       } catch (err) { | ||||
|         if (!mounted) return; | ||||
|         context.showErrorDialog(err); | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
|     if (lkPlatformIs(PlatformType.iOS)) { | ||||
|       var track = await LocalVideoTrack.createScreenShareTrack( | ||||
|         const ScreenShareCaptureOptions( | ||||
|           useiOSBroadcastExtension: true, | ||||
|           captureScreenAudio: true, | ||||
|           maxFrameRate: 30.0, | ||||
|         ), | ||||
|       ); | ||||
|       await _participant.publishVideoTrack(track); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (lkPlatformIsWebMobile()) { | ||||
|       ScaffoldMessenger.of(context).showSnackBar(const SnackBar( | ||||
|         content: Text('Screen share is not supported mobile platform.'), | ||||
|       )); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     await _participant.setScreenShareEnabled(true, captureScreenAudio: true); | ||||
|   } | ||||
|  | ||||
|   void _disableScreenShare() async { | ||||
|     await _participant.setScreenShareEnabled(false); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Padding( | ||||
|       padding: const EdgeInsets.symmetric( | ||||
|         vertical: 10, | ||||
|       ), | ||||
|       child: Wrap( | ||||
|         alignment: WrapAlignment.center, | ||||
|         spacing: 5, | ||||
|         runSpacing: 5, | ||||
|         children: [ | ||||
|           IconButton( | ||||
|             icon: const Icon(Symbols.exit_to_app), | ||||
|             color: Theme.of(context).colorScheme.onSurface, | ||||
|             onPressed: _disconnect, | ||||
|           ), | ||||
|           if (_participant.isMicrophoneEnabled()) | ||||
|             if (lkPlatformIs(PlatformType.android)) | ||||
|               IconButton( | ||||
|                 onPressed: _disableAudio, | ||||
|                 icon: const Icon(Symbols.mic), | ||||
|                 color: Theme.of(context).colorScheme.onSurface, | ||||
|                 tooltip: 'callMicrophoneOff'.tr(), | ||||
|               ) | ||||
|             else | ||||
|               PopupMenuButton<MediaDevice>( | ||||
|                 icon: const Icon(Symbols.settings_voice), | ||||
|                 itemBuilder: (BuildContext context) { | ||||
|                   return [ | ||||
|                     PopupMenuItem<MediaDevice>( | ||||
|                       value: null, | ||||
|                       onTap: isMuted ? _enableAudio : _disableAudio, | ||||
|                       child: ListTile( | ||||
|                         leading: const Icon(Symbols.mic_off), | ||||
|                         title: Text(isMuted | ||||
|                             ? 'callMicrophoneOn'.tr() | ||||
|                             : 'callMicrophoneOff'.tr()), | ||||
|                       ), | ||||
|                     ), | ||||
|                     if (_audioInputs != null) | ||||
|                       ..._audioInputs!.map((device) { | ||||
|                         return PopupMenuItem<MediaDevice>( | ||||
|                           value: device, | ||||
|                           child: ListTile( | ||||
|                             leading: (device.deviceId == | ||||
|                                     widget.room.selectedAudioInputDeviceId) | ||||
|                                 ? const Icon(Symbols.check_box) | ||||
|                                 : const Icon(Symbols.check_box_outline_blank), | ||||
|                             title: Text(device.label), | ||||
|                           ), | ||||
|                           onTap: () => _selectAudioInput(device), | ||||
|                         ); | ||||
|                       }) | ||||
|                   ]; | ||||
|                 }, | ||||
|               ) | ||||
|           else | ||||
|             IconButton( | ||||
|               onPressed: _enableAudio, | ||||
|               icon: const Icon(Symbols.mic_off), | ||||
|               color: Theme.of(context).colorScheme.onSurface, | ||||
|               tooltip: 'callMicrophoneOn'.tr(), | ||||
|             ), | ||||
|           if (_participant.isCameraEnabled()) | ||||
|             PopupMenuButton<MediaDevice>( | ||||
|               icon: const Icon(Symbols.videocam_sharp), | ||||
|               itemBuilder: (BuildContext context) { | ||||
|                 return [ | ||||
|                   PopupMenuItem<MediaDevice>( | ||||
|                     value: null, | ||||
|                     onTap: _disableVideo, | ||||
|                     child: ListTile( | ||||
|                       leading: const Icon(Symbols.videocam_off), | ||||
|                       title: Text('callCameraOff'.tr()), | ||||
|                     ), | ||||
|                   ), | ||||
|                   if (_videoInputs != null) | ||||
|                     ..._videoInputs!.map((device) { | ||||
|                       return PopupMenuItem<MediaDevice>( | ||||
|                         value: device, | ||||
|                         child: ListTile( | ||||
|                           leading: (device.deviceId == | ||||
|                                   widget.room.selectedVideoInputDeviceId) | ||||
|                               ? const Icon(Symbols.check_box) | ||||
|                               : const Icon(Symbols.check_box_outline_blank), | ||||
|                           title: Text(device.label), | ||||
|                         ), | ||||
|                         onTap: () => _selectVideoInput(device), | ||||
|                       ); | ||||
|                     }) | ||||
|                 ]; | ||||
|               }, | ||||
|             ) | ||||
|           else | ||||
|             IconButton( | ||||
|               onPressed: _enableVideo, | ||||
|               icon: const Icon(Symbols.videocam_off), | ||||
|               color: Theme.of(context).colorScheme.onSurface, | ||||
|               tooltip: 'callCameraOn'.tr(), | ||||
|             ), | ||||
|           IconButton( | ||||
|             icon: Icon(_position == CameraPosition.back | ||||
|                 ? Symbols.video_camera_back | ||||
|                 : Symbols.video_camera_front), | ||||
|             color: Theme.of(context).colorScheme.onSurface, | ||||
|             onPressed: () => _toggleCamera(), | ||||
|             tooltip: 'callVideoFlip'.tr(), | ||||
|           ), | ||||
|           if (!lkPlatformIs(PlatformType.iOS)) | ||||
|             PopupMenuButton<MediaDevice>( | ||||
|               icon: const Icon(Symbols.volume_up), | ||||
|               itemBuilder: (BuildContext context) { | ||||
|                 return [ | ||||
|                   PopupMenuItem<MediaDevice>( | ||||
|                     value: null, | ||||
|                     child: ListTile( | ||||
|                       leading: const Icon(Symbols.speaker), | ||||
|                       title: Text('callSpeakerSelect').tr(), | ||||
|                     ), | ||||
|                   ), | ||||
|                   if (_audioOutputs != null) | ||||
|                     ..._audioOutputs!.map((device) { | ||||
|                       return PopupMenuItem<MediaDevice>( | ||||
|                         value: device, | ||||
|                         child: ListTile( | ||||
|                           leading: (device.deviceId == | ||||
|                                   widget.room.selectedAudioOutputDeviceId) | ||||
|                               ? const Icon(Symbols.check_box) | ||||
|                               : const Icon(Symbols.check_box_outline_blank), | ||||
|                           title: Text(device.label), | ||||
|                         ), | ||||
|                         onTap: () => _selectAudioOutput(device), | ||||
|                       ); | ||||
|                     }) | ||||
|                 ]; | ||||
|               }, | ||||
|             ), | ||||
|           if (!kIsWeb && Hardware.instance.canSwitchSpeakerphone) | ||||
|             IconButton( | ||||
|               onPressed: _toggleSpeakerphoneOn, | ||||
|               color: Theme.of(context).colorScheme.onSurface, | ||||
|               icon: _speakerphoneOn | ||||
|                   ? Icon(Symbols.volume_up) | ||||
|                   : Icon(Symbols.volume_down), | ||||
|               tooltip: 'callSpeakerphoneToggle'.tr(), | ||||
|             ), | ||||
|           if (_participant.isScreenShareEnabled()) | ||||
|             IconButton( | ||||
|               icon: const Icon(Symbols.stop_screen_share), | ||||
|               color: Theme.of(context).colorScheme.onSurface, | ||||
|               onPressed: () => _disableScreenShare(), | ||||
|               tooltip: 'callScreenOff'.tr(), | ||||
|             ) | ||||
|           else | ||||
|             IconButton( | ||||
|               icon: const Icon(Symbols.screen_share), | ||||
|               color: Theme.of(context).colorScheme.onSurface, | ||||
|               onPressed: () => _enableScreenShare(), | ||||
|               tooltip: 'callScreenOn'.tr(), | ||||
|             ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										92
									
								
								lib/widgets/chat/call/call_no_content.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								lib/widgets/chat/call/call_no_content.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_animate/flutter_animate.dart'; | ||||
| import 'package:surface/types/account.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
|  | ||||
| class NoContentWidget extends StatefulWidget { | ||||
|   final SnAccount? userinfo; | ||||
|   final bool isSpeaking; | ||||
|   final bool isFixed; | ||||
|  | ||||
|   const NoContentWidget({ | ||||
|     super.key, | ||||
|     this.userinfo, | ||||
|     this.isFixed = false, | ||||
|     required this.isSpeaking, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<NoContentWidget> createState() => _NoContentWidgetState(); | ||||
| } | ||||
|  | ||||
| class _NoContentWidgetState extends State<NoContentWidget> | ||||
|     with SingleTickerProviderStateMixin { | ||||
|   late final AnimationController _animationController; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _animationController = AnimationController(vsync: this); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void didUpdateWidget(NoContentWidget old) { | ||||
|     super.didUpdateWidget(old); | ||||
|     if (widget.isSpeaking) { | ||||
|       _animationController.repeat(reverse: true); | ||||
|     } else { | ||||
|       _animationController | ||||
|           .animateTo(0, duration: 300.ms) | ||||
|           .then((_) => _animationController.reset()); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final double radius = widget.isFixed | ||||
|         ? 32 | ||||
|         : math.min( | ||||
|             MediaQuery.of(context).size.width * 0.1, | ||||
|             MediaQuery.of(context).size.height * 0.1, | ||||
|           ); | ||||
|  | ||||
|     return Container( | ||||
|       alignment: Alignment.center, | ||||
|       child: Center( | ||||
|         child: Animate( | ||||
|           autoPlay: false, | ||||
|           controller: _animationController, | ||||
|           effects: [ | ||||
|             CustomEffect( | ||||
|               begin: widget.isSpeaking ? 2 : 0, | ||||
|               end: 8, | ||||
|               curve: Curves.easeInOut, | ||||
|               duration: 1250.ms, | ||||
|               builder: (context, value, child) => Container( | ||||
|                 decoration: BoxDecoration( | ||||
|                   borderRadius: BorderRadius.all(Radius.circular(radius + 8)), | ||||
|                   border: value > 0 | ||||
|                       ? Border.all(color: Colors.green, width: value) | ||||
|                       : null, | ||||
|                 ), | ||||
|                 child: child, | ||||
|               ), | ||||
|             ) | ||||
|           ], | ||||
|           child: AccountImage( | ||||
|             content: widget.userinfo?.avatar, | ||||
|             radius: radius, | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _animationController.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										242
									
								
								lib/widgets/chat/call/call_participant.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										242
									
								
								lib/widgets/chat/call/call_participant.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,242 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_webrtc/flutter_webrtc.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:surface/types/account.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| import 'package:surface/widgets/chat/call/call_no_content.dart'; | ||||
| import 'package:surface/widgets/chat/call/call_participant_info.dart'; | ||||
| import 'package:surface/widgets/chat/call/call_participant_menu.dart'; | ||||
| import 'package:surface/widgets/chat/call/call_participant_stats.dart'; | ||||
|  | ||||
| abstract class ParticipantWidget extends StatefulWidget { | ||||
|   static ParticipantWidget widgetFor(ParticipantTrack participantTrack, | ||||
|       {bool isFixed = false, bool showStatsLayer = false}) { | ||||
|     if (participantTrack.participant is LocalParticipant) { | ||||
|       return LocalParticipantWidget( | ||||
|         participantTrack.participant as LocalParticipant, | ||||
|         participantTrack.videoTrack, | ||||
|         isFixed, | ||||
|         participantTrack.isScreenShare, | ||||
|         showStatsLayer, | ||||
|       ); | ||||
|     } else if (participantTrack.participant is RemoteParticipant) { | ||||
|       return RemoteParticipantWidget( | ||||
|         participantTrack.participant as RemoteParticipant, | ||||
|         participantTrack.videoTrack, | ||||
|         isFixed, | ||||
|         participantTrack.isScreenShare, | ||||
|         showStatsLayer, | ||||
|       ); | ||||
|     } | ||||
|     throw UnimplementedError('Unknown participant type'); | ||||
|   } | ||||
|  | ||||
|   abstract final Participant participant; | ||||
|   abstract final VideoTrack? videoTrack; | ||||
|   abstract final bool isScreenShare; | ||||
|   abstract final bool isFixed; | ||||
|   abstract final bool showStatsLayer; | ||||
|   final VideoQuality quality; | ||||
|  | ||||
|   const ParticipantWidget({ | ||||
|     super.key, | ||||
|     this.quality = VideoQuality.MEDIUM, | ||||
|   }); | ||||
| } | ||||
|  | ||||
| class LocalParticipantWidget extends ParticipantWidget { | ||||
|   @override | ||||
|   final LocalParticipant participant; | ||||
|   @override | ||||
|   final VideoTrack? videoTrack; | ||||
|   @override | ||||
|   final bool isFixed; | ||||
|   @override | ||||
|   final bool isScreenShare; | ||||
|   @override | ||||
|   final bool showStatsLayer; | ||||
|  | ||||
|   const LocalParticipantWidget( | ||||
|     this.participant, | ||||
|     this.videoTrack, | ||||
|     this.isFixed, | ||||
|     this.isScreenShare, | ||||
|     this.showStatsLayer, { | ||||
|     super.key, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<StatefulWidget> createState() => _LocalParticipantWidgetState(); | ||||
| } | ||||
|  | ||||
| class RemoteParticipantWidget extends ParticipantWidget { | ||||
|   @override | ||||
|   final RemoteParticipant participant; | ||||
|   @override | ||||
|   final VideoTrack? videoTrack; | ||||
|   @override | ||||
|   final bool isFixed; | ||||
|   @override | ||||
|   final bool isScreenShare; | ||||
|   @override | ||||
|   final bool showStatsLayer; | ||||
|  | ||||
|   const RemoteParticipantWidget( | ||||
|     this.participant, | ||||
|     this.videoTrack, | ||||
|     this.isFixed, | ||||
|     this.isScreenShare, | ||||
|     this.showStatsLayer, { | ||||
|     super.key, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<StatefulWidget> createState() => _RemoteParticipantWidgetState(); | ||||
| } | ||||
|  | ||||
| abstract class _ParticipantWidgetState<T extends ParticipantWidget> | ||||
|     extends State<T> { | ||||
|   VideoTrack? get _activeVideoTrack; | ||||
|  | ||||
|   TrackPublication? get _firstAudioPublication; | ||||
|  | ||||
|   SnAccount? _userinfoMetadata; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     widget.participant.addListener(onParticipantChanged); | ||||
|     onParticipantChanged(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     widget.participant.removeListener(onParticipantChanged); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void didUpdateWidget(covariant T oldWidget) { | ||||
|     oldWidget.participant.removeListener(onParticipantChanged); | ||||
|     widget.participant.addListener(onParticipantChanged); | ||||
|     onParticipantChanged(); | ||||
|     super.didUpdateWidget(oldWidget); | ||||
|   } | ||||
|  | ||||
|   void onParticipantChanged() { | ||||
|     setState(() { | ||||
|       if (widget.participant.metadata != null) { | ||||
|         _userinfoMetadata = SnAccount.fromJson( | ||||
|           jsonDecode(widget.participant.metadata!), | ||||
|         ); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext ctx) { | ||||
|     return Stack( | ||||
|       children: [ | ||||
|         _activeVideoTrack != null && !_activeVideoTrack!.muted | ||||
|             ? VideoTrackRenderer( | ||||
|                 _activeVideoTrack!, | ||||
|                 fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, | ||||
|               ) | ||||
|             : NoContentWidget( | ||||
|                 userinfo: _userinfoMetadata, | ||||
|                 isFixed: widget.isFixed, | ||||
|                 isSpeaking: widget.participant.isSpeaking, | ||||
|               ), | ||||
|         if (widget.showStatsLayer) | ||||
|           Positioned( | ||||
|             top: 30, | ||||
|             right: 30, | ||||
|             child: ParticipantStatsWidget(participant: widget.participant), | ||||
|           ), | ||||
|         Align( | ||||
|           alignment: Alignment.bottomCenter, | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|             mainAxisSize: MainAxisSize.min, | ||||
|             children: [ | ||||
|               ParticipantInfoWidget( | ||||
|                 title: widget.participant.name.isNotEmpty | ||||
|                     ? widget.participant.name | ||||
|                     : widget.participant.identity, | ||||
|                 audioAvailable: _firstAudioPublication?.muted == false && | ||||
|                     _firstAudioPublication?.subscribed == true, | ||||
|                 connectionQuality: widget.participant.connectionQuality, | ||||
|                 isScreenShare: widget.isScreenShare, | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _LocalParticipantWidgetState | ||||
|     extends _ParticipantWidgetState<LocalParticipantWidget> { | ||||
|   @override | ||||
|   LocalTrackPublication<LocalAudioTrack>? get _firstAudioPublication => | ||||
|       widget.participant.audioTrackPublications.firstOrNull; | ||||
|  | ||||
|   @override | ||||
|   VideoTrack? get _activeVideoTrack => widget.videoTrack; | ||||
| } | ||||
|  | ||||
| class _RemoteParticipantWidgetState | ||||
|     extends _ParticipantWidgetState<RemoteParticipantWidget> { | ||||
|   @override | ||||
|   RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication => | ||||
|       widget.participant.audioTrackPublications.firstOrNull; | ||||
|  | ||||
|   @override | ||||
|   VideoTrack? get _activeVideoTrack => widget.videoTrack; | ||||
| } | ||||
|  | ||||
| class InteractiveParticipantWidget extends StatelessWidget { | ||||
|   final double? width; | ||||
|   final double? height; | ||||
|   final Color? color; | ||||
|   final bool isFixedAvatar; | ||||
|   final ParticipantTrack participant; | ||||
|   final Function() onTap; | ||||
|  | ||||
|   const InteractiveParticipantWidget({ | ||||
|     super.key, | ||||
|     this.width, | ||||
|     this.height, | ||||
|     this.color, | ||||
|     this.isFixedAvatar = false, | ||||
|     required this.participant, | ||||
|     required this.onTap, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return GestureDetector( | ||||
|       child: Container( | ||||
|         width: width, | ||||
|         height: height, | ||||
|         color: color, | ||||
|         child: ParticipantWidget.widgetFor(participant, isFixed: isFixedAvatar), | ||||
|       ), | ||||
|       onTap: () => onTap(), | ||||
|       onLongPress: () { | ||||
|         if (participant.participant is LocalParticipant) return; | ||||
|         showModalBottomSheet( | ||||
|           context: context, | ||||
|           builder: (context) => ParticipantMenu( | ||||
|             participant: participant.participant as RemoteParticipant, | ||||
|             videoTrack: participant.videoTrack, | ||||
|             isScreenShare: participant.isScreenShare, | ||||
|           ), | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										79
									
								
								lib/widgets/chat/call/call_participant_info.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								lib/widgets/chat/call/call_participant_info.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class ParticipantInfoWidget extends StatelessWidget { | ||||
|   final String? title; | ||||
|   final bool audioAvailable; | ||||
|   final ConnectionQuality connectionQuality; | ||||
|   final bool isScreenShare; | ||||
|  | ||||
|   const ParticipantInfoWidget({ | ||||
|     super.key, | ||||
|     this.title, | ||||
|     this.audioAvailable = true, | ||||
|     this.connectionQuality = ConnectionQuality.unknown, | ||||
|     this.isScreenShare = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) => Container( | ||||
|         color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75), | ||||
|         padding: const EdgeInsets.symmetric( | ||||
|           vertical: 7, | ||||
|           horizontal: 10, | ||||
|         ), | ||||
|         child: Row( | ||||
|           mainAxisAlignment: MainAxisAlignment.end, | ||||
|           crossAxisAlignment: CrossAxisAlignment.center, | ||||
|           children: [ | ||||
|             if (title != null) | ||||
|               Flexible( | ||||
|                 child: Text( | ||||
|                   title!, | ||||
|                   overflow: TextOverflow.ellipsis, | ||||
|                   style: const TextStyle(color: Colors.white), | ||||
|                 ), | ||||
|               ), | ||||
|             const Gap(5), | ||||
|             isScreenShare | ||||
|                 ? const Icon( | ||||
|                     Symbols.monitor, | ||||
|                     color: Colors.white, | ||||
|                     size: 16, | ||||
|                   ) | ||||
|                 : Icon( | ||||
|                     audioAvailable ? Symbols.mic : Symbols.mic_off, | ||||
|                     color: audioAvailable ? Colors.white : Colors.red, | ||||
|                     size: 16, | ||||
|                   ), | ||||
|             const Gap(3), | ||||
|             if (connectionQuality != ConnectionQuality.unknown) | ||||
|               Icon( | ||||
|                 { | ||||
|                   ConnectionQuality.excellent: Symbols.signal_cellular_alt, | ||||
|                   ConnectionQuality.good: Symbols.signal_cellular_alt_2_bar, | ||||
|                   ConnectionQuality.poor: Symbols.signal_cellular_alt_1_bar, | ||||
|                 }[connectionQuality], | ||||
|                 color: { | ||||
|                   ConnectionQuality.excellent: Colors.green, | ||||
|                   ConnectionQuality.good: Colors.orange, | ||||
|                   ConnectionQuality.poor: Colors.red, | ||||
|                 }[connectionQuality], | ||||
|                 size: 16, | ||||
|               ) | ||||
|             else | ||||
|               const SizedBox( | ||||
|                 width: 12, | ||||
|                 height: 12, | ||||
|                 child: CircularProgressIndicator( | ||||
|                   color: Colors.white, | ||||
|                   strokeWidth: 2, | ||||
|                 ), | ||||
|               ).padding(all: 3), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
| } | ||||
							
								
								
									
										161
									
								
								lib/widgets/chat/call/call_participant_menu.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								lib/widgets/chat/call/call_participant_menu.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,161 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
|  | ||||
| class ParticipantMenu extends StatefulWidget { | ||||
|   final RemoteParticipant participant; | ||||
|   final VideoTrack? videoTrack; | ||||
|   final bool isScreenShare; | ||||
|   final bool showStatsLayer; | ||||
|  | ||||
|   const ParticipantMenu({ | ||||
|     super.key, | ||||
|     required this.participant, | ||||
|     this.videoTrack, | ||||
|     this.isScreenShare = false, | ||||
|     this.showStatsLayer = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<ParticipantMenu> createState() => _ParticipantMenuState(); | ||||
| } | ||||
|  | ||||
| class _ParticipantMenuState extends State<ParticipantMenu> { | ||||
|   RemoteTrackPublication<RemoteVideoTrack>? get _videoPublication => | ||||
|       widget.participant.videoTrackPublications | ||||
|           .where((element) => element.sid == widget.videoTrack?.sid) | ||||
|           .firstOrNull; | ||||
|  | ||||
|   RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication => | ||||
|       widget.participant.audioTrackPublications.firstOrNull; | ||||
|  | ||||
|   void tookAction() { | ||||
|     if (Navigator.canPop(context)) { | ||||
|       Navigator.pop(context); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         Container( | ||||
|           padding: | ||||
|               const EdgeInsets.only(left: 8, right: 8, top: 20, bottom: 12), | ||||
|           child: Padding( | ||||
|             padding: const EdgeInsets.symmetric( | ||||
|               horizontal: 8, | ||||
|               vertical: 12, | ||||
|             ), | ||||
|             child: Text( | ||||
|               'callParticipantAction', | ||||
|               style: Theme.of(context).textTheme.headlineSmall, | ||||
|             ).tr(), | ||||
|           ), | ||||
|         ), | ||||
|         Expanded( | ||||
|           child: ListView( | ||||
|             children: [ | ||||
|               if (_firstAudioPublication != null && !widget.isScreenShare) | ||||
|                 ListTile( | ||||
|                   leading: Icon( | ||||
|                     Symbols.volume_up, | ||||
|                     color: { | ||||
|                       TrackSubscriptionState.notAllowed: | ||||
|                           Theme.of(context).colorScheme.error, | ||||
|                       TrackSubscriptionState.unsubscribed: Theme.of(context) | ||||
|                           .colorScheme | ||||
|                           .onSurface | ||||
|                           .withOpacity(0.6), | ||||
|                       TrackSubscriptionState.subscribed: | ||||
|                           Theme.of(context).colorScheme.primary, | ||||
|                     }[_firstAudioPublication!.subscriptionState], | ||||
|                   ), | ||||
|                   title: Text( | ||||
|                     _firstAudioPublication!.subscribed | ||||
|                         ? 'callParticipantMicrophoneOff'.tr() | ||||
|                         : 'callParticipantMicrophoneOn'.tr(), | ||||
|                   ), | ||||
|                   onTap: () { | ||||
|                     if (_firstAudioPublication!.subscribed) { | ||||
|                       _firstAudioPublication!.unsubscribe(); | ||||
|                     } else { | ||||
|                       _firstAudioPublication!.subscribe(); | ||||
|                     } | ||||
|                     tookAction(); | ||||
|                   }, | ||||
|                 ), | ||||
|               if (_videoPublication != null) | ||||
|                 ListTile( | ||||
|                   leading: Icon( | ||||
|                     widget.isScreenShare ? Symbols.monitor : Symbols.videocam, | ||||
|                     color: { | ||||
|                       TrackSubscriptionState.notAllowed: | ||||
|                           Theme.of(context).colorScheme.error, | ||||
|                       TrackSubscriptionState.unsubscribed: Theme.of(context) | ||||
|                           .colorScheme | ||||
|                           .onSurface | ||||
|                           .withOpacity(0.6), | ||||
|                       TrackSubscriptionState.subscribed: | ||||
|                           Theme.of(context).colorScheme.primary, | ||||
|                     }[_videoPublication!.subscriptionState], | ||||
|                   ), | ||||
|                   title: Text( | ||||
|                     _videoPublication!.subscribed | ||||
|                         ? 'callParticipantVideoOff'.tr() | ||||
|                         : 'callParticipantVideoOn'.tr(), | ||||
|                   ), | ||||
|                   onTap: () { | ||||
|                     if (_videoPublication!.subscribed) { | ||||
|                       _videoPublication!.unsubscribe(); | ||||
|                     } else { | ||||
|                       _videoPublication!.subscribe(); | ||||
|                     } | ||||
|                     tookAction(); | ||||
|                   }, | ||||
|                 ), | ||||
|               if (_videoPublication != null) const Divider(thickness: 0.3), | ||||
|               if (_videoPublication != null) | ||||
|                 ...[30, 15, 8].map( | ||||
|                   (x) => ListTile( | ||||
|                     leading: Icon( | ||||
|                       _videoPublication?.fps == x | ||||
|                           ? Symbols.check_box | ||||
|                           : Symbols.check_box_outline_blank, | ||||
|                     ), | ||||
|                     title: Text('Set preferred frame-per-second to $x'), | ||||
|                     onTap: () { | ||||
|                       _videoPublication!.setVideoFPS(x); | ||||
|                       tookAction(); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ), | ||||
|               if (_videoPublication != null) const Divider(thickness: 0.3), | ||||
|               if (_videoPublication != null) | ||||
|                 ...[ | ||||
|                   ('High', VideoQuality.HIGH), | ||||
|                   ('Medium', VideoQuality.MEDIUM), | ||||
|                   ('Low', VideoQuality.LOW), | ||||
|                 ].map( | ||||
|                   (x) => ListTile( | ||||
|                     leading: Icon( | ||||
|                       _videoPublication?.videoQuality == x.$2 | ||||
|                           ? Symbols.check_box | ||||
|                           : Symbols.check_box_outline_blank, | ||||
|                     ), | ||||
|                     title: Text('Set preferred quality to ${x.$1}'), | ||||
|                     onTap: () { | ||||
|                       _videoPublication!.setVideoQuality(x.$2); | ||||
|                       tookAction(); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										133
									
								
								lib/widgets/chat/call/call_participant_stats.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								lib/widgets/chat/call/call_participant_stats.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
|  | ||||
| class ParticipantStatsWidget extends StatefulWidget { | ||||
|   const ParticipantStatsWidget({super.key, required this.participant}); | ||||
|  | ||||
|   final Participant participant; | ||||
|  | ||||
|   @override | ||||
|   State<StatefulWidget> createState() => _ParticipantStatsWidgetState(); | ||||
| } | ||||
|  | ||||
| class _ParticipantStatsWidgetState extends State<ParticipantStatsWidget> { | ||||
|   List<EventsListener<TrackEvent>> listeners = []; | ||||
|   ParticipantStatsType statsType = ParticipantStatsType.unknown; | ||||
|   Map<String, String> stats = {}; | ||||
|  | ||||
|   void _setUpListener(Track track) { | ||||
|     var listener = track.createListener(); | ||||
|     listeners.add(listener); | ||||
|     if (track is LocalVideoTrack) { | ||||
|       statsType = ParticipantStatsType.localVideoSender; | ||||
|       listener.on<VideoSenderStatsEvent>((event) { | ||||
|         setState(() { | ||||
|           stats['video tx'] = 'total sent ${event.currentBitrate.toInt()} kpbs'; | ||||
|           event.stats.forEach((key, value) { | ||||
|             stats['layer-$key'] = | ||||
|                 '${value.frameWidth ?? 0}x${value.frameHeight ?? 0} ${value.framesPerSecond?.toDouble() ?? 0} fps, ${event.bitrateForLayers[key] ?? 0} kbps'; | ||||
|           }); | ||||
|           var firstStats = | ||||
|               event.stats['f'] ?? event.stats['h'] ?? event.stats['q']; | ||||
|           if (firstStats != null) { | ||||
|             stats['encoder'] = firstStats.encoderImplementation ?? ''; | ||||
|             stats['video codec'] = | ||||
|                 '${firstStats.mimeType}, ${firstStats.clockRate}hz, pt: ${firstStats.payloadType}'; | ||||
|             stats['qualityLimitationReason'] = | ||||
|                 firstStats.qualityLimitationReason ?? ''; | ||||
|           } | ||||
|         }); | ||||
|       }); | ||||
|     } else if (track is RemoteVideoTrack) { | ||||
|       statsType = ParticipantStatsType.remoteVideoReceiver; | ||||
|       listener.on<VideoReceiverStatsEvent>((event) { | ||||
|         setState(() { | ||||
|           stats['video rx'] = '${event.currentBitrate.toInt()} kpbs'; | ||||
|           stats['video codec'] = | ||||
|               '${event.stats.mimeType}, ${event.stats.clockRate}hz, pt: ${event.stats.payloadType}'; | ||||
|           stats['video size'] = | ||||
|               '${event.stats.frameWidth}x${event.stats.frameHeight} ${event.stats.framesPerSecond?.toDouble()}fps'; | ||||
|           stats['video jitter'] = '${event.stats.jitter} s'; | ||||
|           stats['video decoder'] = '${event.stats.decoderImplementation}'; | ||||
|           stats['video packets lost'] = '${event.stats.packetsLost}'; | ||||
|           stats['video packets received'] = '${event.stats.packetsReceived}'; | ||||
|           stats['video frames received'] = '${event.stats.framesReceived}'; | ||||
|           stats['video frames decoded'] = '${event.stats.framesDecoded}'; | ||||
|           stats['video frames dropped'] = '${event.stats.framesDropped}'; | ||||
|         }); | ||||
|       }); | ||||
|     } else if (track is LocalAudioTrack) { | ||||
|       statsType = ParticipantStatsType.localAudioSender; | ||||
|       listener.on<AudioSenderStatsEvent>((event) { | ||||
|         setState(() { | ||||
|           stats['audio tx'] = '${event.currentBitrate.toInt()} kpbs'; | ||||
|           stats['audio codec'] = | ||||
|               '${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}'; | ||||
|         }); | ||||
|       }); | ||||
|     } else if (track is RemoteAudioTrack) { | ||||
|       statsType = ParticipantStatsType.remoteAudioReceiver; | ||||
|       listener.on<AudioReceiverStatsEvent>((event) { | ||||
|         setState(() { | ||||
|           stats['audio rx'] = '${event.currentBitrate.toInt()} kpbs'; | ||||
|           stats['audio codec'] = | ||||
|               '${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}'; | ||||
|           stats['audio jitter'] = '${event.stats.jitter} s'; | ||||
|           stats['audio concealed samples'] = | ||||
|               '${event.stats.concealedSamples} / ${event.stats.concealmentEvents}'; | ||||
|           stats['audio packets lost'] = '${event.stats.packetsLost}'; | ||||
|           stats['audio packets received'] = '${event.stats.packetsReceived}'; | ||||
|         }); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   onParticipantChanged() { | ||||
|     for (var element in listeners) { | ||||
|       element.dispose(); | ||||
|     } | ||||
|     listeners.clear(); | ||||
|     for (var track in [ | ||||
|       ...widget.participant.videoTrackPublications, | ||||
|       ...widget.participant.audioTrackPublications | ||||
|     ]) { | ||||
|       if (track.track != null) { | ||||
|         _setUpListener(track.track!); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     widget.participant.addListener(onParticipantChanged); | ||||
|     onParticipantChanged(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void deactivate() { | ||||
|     for (var element in listeners) { | ||||
|       element.dispose(); | ||||
|     } | ||||
|     widget.participant.removeListener(onParticipantChanged); | ||||
|     super.deactivate(); | ||||
|   } | ||||
|  | ||||
|   num sendBitrate = 0; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Container( | ||||
|       color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75), | ||||
|       padding: const EdgeInsets.symmetric( | ||||
|         vertical: 8, | ||||
|         horizontal: 8, | ||||
|       ), | ||||
|       child: Column( | ||||
|         children: | ||||
|             stats.entries.map((e) => Text('${e.key}: ${e.value}')).toList(), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										191
									
								
								lib/widgets/chat/call/call_prejoin.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								lib/widgets/chat/call/call_prejoin.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,191 @@ | ||||
| import 'package:dropdown_button2/dropdown_button2.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/chat_call.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
|  | ||||
| class ChatCallPrejoinPopup extends StatefulWidget { | ||||
|   final SnChatCall ongoingCall; | ||||
|   final SnChannel channel; | ||||
|   final void Function() onJoin; | ||||
|  | ||||
|   const ChatCallPrejoinPopup({ | ||||
|     super.key, | ||||
|     required this.ongoingCall, | ||||
|     required this.channel, | ||||
|     required this.onJoin, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<ChatCallPrejoinPopup> createState() => _ChatCallPrejoinPopupState(); | ||||
| } | ||||
|  | ||||
| class _ChatCallPrejoinPopupState extends State<ChatCallPrejoinPopup> { | ||||
|   bool _isBusy = false; | ||||
|  | ||||
|   late final ChatCallProvider _call = context.read<ChatCallProvider>(); | ||||
|  | ||||
|   void _performJoin() async { | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     _call.setCall(widget.ongoingCall, widget.channel); | ||||
|     _call.setIsBusy(true); | ||||
|  | ||||
|     try { | ||||
|       final resp = await _call.getRoomToken(); | ||||
|       final token = resp.$1; | ||||
|       final endpoint = resp.$2; | ||||
|  | ||||
|       _call.initRoom(); | ||||
|       _call.setupRoomListeners( | ||||
|         onDisconnected: (reason) { | ||||
|           context.showSnackbar( | ||||
|             'callDisconnected'.tr(args: [reason.toString()]), | ||||
|           ); | ||||
|         }, | ||||
|       ); | ||||
|  | ||||
|       await _call.joinRoom(endpoint, token); | ||||
|       widget.onJoin(); | ||||
|  | ||||
|       if (!mounted) return; | ||||
|       Navigator.pop(context); | ||||
|     } catch (e) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(e); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     final call = context.read<ChatCallProvider>(); | ||||
|     call.checkPermissions().then((_) { | ||||
|       call.initHardware(); | ||||
|     }); | ||||
|  | ||||
|     super.initState(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final call = context.read<ChatCallProvider>(); | ||||
|     return ListenableBuilder( | ||||
|       listenable: call, | ||||
|       builder: (context, _) { | ||||
|         return Center( | ||||
|           child: Container( | ||||
|             constraints: const BoxConstraints(maxWidth: 320), | ||||
|             child: Column( | ||||
|               mainAxisAlignment: MainAxisAlignment.center, | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Row( | ||||
|                   mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                   children: [ | ||||
|                     Text('callMicrophone').tr(), | ||||
|                     Switch( | ||||
|                       value: call.enableAudio, | ||||
|                       onChanged: null, | ||||
|                     ), | ||||
|                   ], | ||||
|                 ).padding(bottom: 5), | ||||
|                 DropdownButtonHideUnderline( | ||||
|                   child: DropdownButton2<MediaDevice>( | ||||
|                     isExpanded: true, | ||||
|                     disabledHint: Text('callMicrophoneDisabled').tr(), | ||||
|                     hint: Text('callMicrophoneSelect').tr(), | ||||
|                     items: call.enableAudio | ||||
|                         ? call.audioInputs | ||||
|                             .map( | ||||
|                               (item) => DropdownMenuItem<MediaDevice>( | ||||
|                                 value: item, | ||||
|                                 child: Text(item.label), | ||||
|                               ), | ||||
|                             ) | ||||
|                             .toList() | ||||
|                             .cast<DropdownMenuItem<MediaDevice>>() | ||||
|                         : [], | ||||
|                     value: call.audioDevice, | ||||
|                     onChanged: (MediaDevice? value) async { | ||||
|                       if (value != null) { | ||||
|                         call.setAudioDevice(value); | ||||
|                         await call.changeLocalAudioTrack(); | ||||
|                       } | ||||
|                     }, | ||||
|                     buttonStyleData: const ButtonStyleData( | ||||
|                       height: 40, | ||||
|                       width: 320, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ).padding(bottom: 25), | ||||
|                 Row( | ||||
|                   mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                   children: [ | ||||
|                     Text('callCamera').tr(), | ||||
|                     Switch( | ||||
|                       value: call.enableVideo, | ||||
|                       onChanged: (value) => call.setEnableAudio(value), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ).padding(bottom: 5), | ||||
|                 DropdownButtonHideUnderline( | ||||
|                   child: DropdownButton2<MediaDevice>( | ||||
|                     isExpanded: true, | ||||
|                     disabledHint: Text('callCameraDisabled').tr(), | ||||
|                     hint: Text('callCameraSelect').tr(), | ||||
|                     items: call.enableVideo | ||||
|                         ? call.videoInputs | ||||
|                             .map( | ||||
|                               (item) => DropdownMenuItem<MediaDevice>( | ||||
|                                 value: item, | ||||
|                                 child: Text(item.label), | ||||
|                               ), | ||||
|                             ) | ||||
|                             .toList() | ||||
|                             .cast<DropdownMenuItem<MediaDevice>>() | ||||
|                         : [], | ||||
|                     value: call.videoDevice, | ||||
|                     onChanged: (MediaDevice? value) async { | ||||
|                       if (value != null) { | ||||
|                         call.setVideoDevice(value); | ||||
|                         await call.changeLocalVideoTrack(); | ||||
|                       } | ||||
|                     }, | ||||
|                     buttonStyleData: const ButtonStyleData( | ||||
|                       height: 40, | ||||
|                       width: 320, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ).padding(bottom: 25), | ||||
|                 if (_isBusy) | ||||
|                   const Center(child: CircularProgressIndicator()) | ||||
|                 else | ||||
|                   ElevatedButton( | ||||
|                     style: ElevatedButton.styleFrom( | ||||
|                       minimumSize: const Size(320, 56), | ||||
|                     ), | ||||
|                     onPressed: _isBusy ? null : _performJoin, | ||||
|                     child: Text('callJoin').tr(), | ||||
|                   ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _call | ||||
|       ..deactivateHardware() | ||||
|       ..disposeHardware(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										280
									
								
								lib/widgets/chat/chat_message.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										280
									
								
								lib/widgets/chat/chat_message.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,280 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_context_menu/flutter_context_menu.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/user_directory.dart'; | ||||
| 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/markdown_content.dart'; | ||||
| import 'package:swipe_to/swipe_to.dart'; | ||||
|  | ||||
| class ChatMessage extends StatelessWidget { | ||||
|   final SnChatMessage data; | ||||
|   final bool isCompact; | ||||
|   final bool isMerged; | ||||
|   final bool hasMerged; | ||||
|   final bool isPending; | ||||
|   final Function(SnChatMessage)? onReply; | ||||
|   final Function(SnChatMessage)? onEdit; | ||||
|   final Function(SnChatMessage)? onDelete; | ||||
|   const ChatMessage({ | ||||
|     super.key, | ||||
|     required this.data, | ||||
|     this.isCompact = false, | ||||
|     this.isMerged = false, | ||||
|     this.hasMerged = false, | ||||
|     this.isPending = false, | ||||
|     this.onReply, | ||||
|     this.onEdit, | ||||
|     this.onDelete, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final ua = context.read<UserProvider>(); | ||||
|     final ud = context.read<UserDirectoryProvider>(); | ||||
|     final user = ud.getAccountFromCache(data.sender.accountId); | ||||
|  | ||||
|     final isOwner = ua.isAuthorized && data.sender.accountId == ua.user?.id; | ||||
|  | ||||
|     final dateFormatter = DateFormat('MM/dd HH:mm'); | ||||
|  | ||||
|     return SwipeTo( | ||||
|       key: Key('chat-message-${data.id}'), | ||||
|       iconOnLeftSwipe: Symbols.reply, | ||||
|       iconOnRightSwipe: Symbols.edit, | ||||
|       swipeSensitivity: 20, | ||||
|       onLeftSwipe: onReply != null ? (_) => onReply!(data) : null, | ||||
|       onRightSwipe: onEdit != null ? (_) => onEdit!(data) : null, | ||||
|       child: ContextMenuRegion( | ||||
|         contextMenu: ContextMenu( | ||||
|           entries: [ | ||||
|             MenuHeader(text: "eventResourceTag".tr(args: ['#${data.id}'])), | ||||
|             if (onReply != null) | ||||
|               MenuItem( | ||||
|                 label: 'reply'.tr(), | ||||
|                 icon: Symbols.reply, | ||||
|                 onSelected: () { | ||||
|                   onReply!(data); | ||||
|                 }, | ||||
|               ), | ||||
|             if (isOwner && onEdit != null) | ||||
|               MenuItem( | ||||
|                 label: 'edit'.tr(), | ||||
|                 icon: Symbols.edit, | ||||
|                 onSelected: () { | ||||
|                   onEdit!(data); | ||||
|                 }, | ||||
|               ), | ||||
|             if (isOwner && onDelete != null) | ||||
|               MenuItem( | ||||
|                 label: 'delete'.tr(), | ||||
|                 icon: Symbols.delete, | ||||
|                 onSelected: () { | ||||
|                   onDelete!(data); | ||||
|                 }, | ||||
|               ), | ||||
|           ], | ||||
|         ), | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             Row( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 if (!isMerged && !isCompact) | ||||
|                   AccountImage( | ||||
|                     content: user?.avatar, | ||||
|                   ) | ||||
|                 else if (isMerged) | ||||
|                   const Gap(40), | ||||
|                 const Gap(8), | ||||
|                 Expanded( | ||||
|                   child: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       if (!isMerged) | ||||
|                         Row( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.baseline, | ||||
|                           textBaseline: TextBaseline.alphabetic, | ||||
|                           children: [ | ||||
|                             if (isCompact) | ||||
|                               AccountImage( | ||||
|                                 content: user?.avatar, | ||||
|                                 radius: 12, | ||||
|                               ).padding(right: 6), | ||||
|                             Text( | ||||
|                               (data.sender.nick?.isNotEmpty ?? false) | ||||
|                                   ? data.sender.nick! | ||||
|                                   : user?.nick ?? 'unknown', | ||||
|                             ).bold(), | ||||
|                             const Gap(6), | ||||
|                             Text( | ||||
|                               dateFormatter.format(data.createdAt.toLocal()), | ||||
|                             ).fontSize(13), | ||||
|                           ], | ||||
|                         ), | ||||
|                       if (isCompact) const Gap(4), | ||||
|                       if (data.preload?.quoteEvent != null) | ||||
|                         StyledWidget(Container( | ||||
|                           decoration: BoxDecoration( | ||||
|                             borderRadius: | ||||
|                                 const BorderRadius.all(Radius.circular(8)), | ||||
|                             border: Border.all( | ||||
|                               color: Theme.of(context).dividerColor, | ||||
|                               width: 1, | ||||
|                             ), | ||||
|                           ), | ||||
|                           padding: const EdgeInsets.only( | ||||
|                             left: 4, | ||||
|                             right: 4, | ||||
|                             top: 8, | ||||
|                             bottom: 6, | ||||
|                           ), | ||||
|                           child: ChatMessage( | ||||
|                             data: data.preload!.quoteEvent!, | ||||
|                             isCompact: true, | ||||
|                             onReply: onReply, | ||||
|                             onEdit: onEdit, | ||||
|                             onDelete: onDelete, | ||||
|                           ), | ||||
|                         )).padding(bottom: 4, top: isMerged ? 4 : 2), | ||||
|                       switch (data.type) { | ||||
|                         'messages.new' => _ChatMessageText(data: data), | ||||
|                         _ => _ChatMessageSystemNotify(data: data), | ||||
|                       }, | ||||
|                     ], | ||||
|                   ), | ||||
|                 ) | ||||
|               ], | ||||
|             ).opacity(isPending ? 0.5 : 1), | ||||
|             if (data.preload?.attachments?.isNotEmpty ?? false) | ||||
|               AttachmentList( | ||||
|                 data: data.preload!.attachments!, | ||||
|                 bordered: true, | ||||
|                 noGrow: true, | ||||
|                 maxHeight: 520, | ||||
|                 listPadding: const EdgeInsets.only(top: 8), | ||||
|               ), | ||||
|             if (!hasMerged && !isCompact) | ||||
|               const Gap(12) | ||||
|             else if (!isCompact) | ||||
|               const Gap(6), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _ChatMessageText extends StatelessWidget { | ||||
|   final SnChatMessage data; | ||||
|   const _ChatMessageText({super.key, required this.data}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (data.body['text'] != null && data.body['text'].isNotEmpty) { | ||||
|       return Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           MarkdownTextContent( | ||||
|             content: data.body['text'], | ||||
|             isAutoWarp: true, | ||||
|           ), | ||||
|           if (data.updatedAt != data.createdAt) | ||||
|             Text( | ||||
|               'messageEditedHint'.tr(), | ||||
|             ).fontSize(13).opacity(0.75), | ||||
|         ], | ||||
|       ); | ||||
|     } else if (data.body['attachments']?.isNotEmpty) { | ||||
|       return Row( | ||||
|         children: [ | ||||
|           const Icon(Symbols.file_present, size: 20), | ||||
|           const Gap(4), | ||||
|           Text( | ||||
|             'messageFileHint'.plural( | ||||
|               data.body['attachments']!.length, | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ).opacity(0.75); | ||||
|     } | ||||
|  | ||||
|     return const SizedBox.shrink(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _ChatMessageSystemNotify extends StatelessWidget { | ||||
|   final SnChatMessage data; | ||||
|   const _ChatMessageSystemNotify({super.key, required this.data}); | ||||
|  | ||||
|   String _formatDuration(Duration duration) { | ||||
|     String negativeSign = duration.isNegative ? '-' : ''; | ||||
|     String twoDigits(int n) => n.toString().padLeft(2, '0'); | ||||
|     String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60).abs()); | ||||
|     String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).abs()); | ||||
|     return '$negativeSign${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     switch (data.type) { | ||||
|       case 'messages.edit': | ||||
|         return Row( | ||||
|           children: [ | ||||
|             const Icon(Symbols.edit, size: 20), | ||||
|             const Gap(4), | ||||
|             Text( | ||||
|               'messageEdited'.tr(args: ['#${data.relatedEventId}']), | ||||
|             ), | ||||
|           ], | ||||
|         ).opacity(0.75); | ||||
|       case 'messages.delete': | ||||
|         return Row( | ||||
|           children: [ | ||||
|             const Icon(Symbols.delete, size: 20), | ||||
|             const Gap(4), | ||||
|             Text( | ||||
|               'messageDeleted'.tr(args: ['#${data.relatedEventId}']), | ||||
|             ), | ||||
|           ], | ||||
|         ).opacity(0.75); | ||||
|       case 'calls.start': | ||||
|         return Row( | ||||
|           children: [ | ||||
|             const Icon(Symbols.call, size: 20), | ||||
|             const Gap(4), | ||||
|             Text( | ||||
|               'callMessageStarted'.tr(), | ||||
|             ), | ||||
|           ], | ||||
|         ).opacity(0.75); | ||||
|       case 'calls.end': | ||||
|         return Row( | ||||
|           children: [ | ||||
|             const Icon(Symbols.call_end, size: 20), | ||||
|             const Gap(4), | ||||
|             Text( | ||||
|               'callMessageEnded'.tr(args: [ | ||||
|                 _formatDuration(Duration(seconds: data.body['last'])), | ||||
|               ]), | ||||
|             ), | ||||
|           ], | ||||
|         ).opacity(0.75); | ||||
|       default: | ||||
|         return Row( | ||||
|           children: [ | ||||
|             const Icon(Symbols.info, size: 20), | ||||
|             const Gap(4), | ||||
|             Text('messageUnsupported'.tr(args: [data.type])), | ||||
|           ], | ||||
|         ).opacity(0.75); | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										338
									
								
								lib/widgets/chat/chat_message_input.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										338
									
								
								lib/widgets/chat/chat_message_input.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,338 @@ | ||||
| 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:pasteboard/pasteboard.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/controllers/chat_message_controller.dart'; | ||||
| import 'package:surface/controllers/post_write_controller.dart'; | ||||
| import 'package:surface/providers/sn_attachment.dart'; | ||||
| import 'package:surface/types/chat.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/markdown_content.dart'; | ||||
| import 'package:surface/widgets/post/post_media_pending_list.dart'; | ||||
|  | ||||
| class ChatMessageInput extends StatefulWidget { | ||||
|   final ChatMessageController controller; | ||||
|   const ChatMessageInput({super.key, required this.controller}); | ||||
|  | ||||
|   @override | ||||
|   State<ChatMessageInput> createState() => ChatMessageInputState(); | ||||
| } | ||||
|  | ||||
| class ChatMessageInputState extends State<ChatMessageInput> { | ||||
|   bool _isBusy = false; | ||||
|   double? _progress; | ||||
|  | ||||
|   SnChatMessage? _replyingMessage, _editingMessage; | ||||
|  | ||||
|   final TextEditingController _contentController = TextEditingController(); | ||||
|   final FocusNode _focusNode = FocusNode(); | ||||
|  | ||||
|   void setReply(SnChatMessage? value) { | ||||
|     setState(() => _replyingMessage = value); | ||||
|   } | ||||
|  | ||||
|   void setEdit(SnChatMessage? value) { | ||||
|     _contentController.text = value?.body['text'] ?? ''; | ||||
|     setState(() => _editingMessage = value); | ||||
|   } | ||||
|  | ||||
|   Future<void> deleteMessage(SnChatMessage message) async { | ||||
|     final confirm = await context.showConfirmDialog( | ||||
|       'messageDelete'.tr(args: ['#${message.id}']), | ||||
|       'messageDeleteDescription'.tr(), | ||||
|     ); | ||||
|     if (!confirm) return; | ||||
|  | ||||
|     if (!mounted) return; | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     await widget.controller.deleteMessage(message); | ||||
|  | ||||
|     if (!mounted) return; | ||||
|     setState(() => _isBusy = false); | ||||
|   } | ||||
|  | ||||
|   Future<void> _sendMessage() async { | ||||
|     if (_isBusy) return; | ||||
|  | ||||
|     final attach = context.read<SnAttachmentProvider>(); | ||||
|  | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     try { | ||||
|       for (int i = 0; i < _attachments.length; i++) { | ||||
|         final media = _attachments[i]; | ||||
|         if (media.attachment != null) continue; // Already uploaded, skip | ||||
|         if (media.isEmpty) continue; // Nothing to do, skip | ||||
|  | ||||
|         final place = await attach.chunkedUploadInitialize( | ||||
|           (await media.length())!, | ||||
|           media.name, | ||||
|           'interactive', | ||||
|           null, | ||||
|           mimetype: media.raw != null && media.type == PostWriteMediaType.image | ||||
|               ? 'image/png' | ||||
|               : null, | ||||
|         ); | ||||
|  | ||||
|         final item = await attach.chunkedUploadParts( | ||||
|           media.toFile()!, | ||||
|           place.$1, | ||||
|           place.$2, | ||||
|           onProgress: (progress) { | ||||
|             // Calculate overall progress for attachments | ||||
|             setState(() { | ||||
|               progress = (i + progress) / _attachments.length; | ||||
|             }); | ||||
|           }, | ||||
|         ); | ||||
|  | ||||
|         _attachments[i] = PostWriteMedia(item); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       setState(() => _isBusy = false); | ||||
|       context.showErrorDialog(err); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     attach.putCache( | ||||
|       _attachments.where((e) => e.attachment != null).map((e) => e.attachment!), | ||||
|       noCheck: true, | ||||
|     ); | ||||
|  | ||||
|     // Send the message | ||||
|     // NOTICE This future should not be awaited, so that the message can be sent in the background and the user can continue to type | ||||
|     widget.controller.sendMessage( | ||||
|       'messages.new', | ||||
|       _contentController.text, | ||||
|       attachments: _attachments | ||||
|           .where((e) => e.attachment != null) | ||||
|           .map((e) => e.attachment!.rid) | ||||
|           .toList(), | ||||
|       relatedId: _editingMessage?.id, | ||||
|       quoteId: _replyingMessage?.id, | ||||
|       editingMessage: _editingMessage, | ||||
|     ); | ||||
|     _contentController.clear(); | ||||
|     _attachments.clear(); | ||||
|     _editingMessage = null; | ||||
|     _replyingMessage = null; | ||||
|  | ||||
|     setState(() => _isBusy = false); | ||||
|   } | ||||
|  | ||||
|   final List<PostWriteMedia> _attachments = List.empty(growable: true); | ||||
|   final _imagePicker = ImagePicker(); | ||||
|  | ||||
|   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() { | ||||
|     _contentController.dispose(); | ||||
|     _focusNode.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Column( | ||||
|       mainAxisAlignment: MainAxisAlignment.center, | ||||
|       children: [ | ||||
|         if (_isBusy && _progress != null) | ||||
|           TweenAnimationBuilder<double>( | ||||
|             tween: Tween(begin: 0, end: _progress), | ||||
|             duration: Duration(milliseconds: 300), | ||||
|             builder: (context, value, _) => | ||||
|                 LinearProgressIndicator(value: value, minHeight: 2), | ||||
|           ) | ||||
|         else if (_isBusy) | ||||
|           const LinearProgressIndicator(value: null, minHeight: 2), | ||||
|         Padding( | ||||
|           padding: _attachments.isNotEmpty | ||||
|               ? const EdgeInsets.only(top: 8) | ||||
|               : EdgeInsets.zero, | ||||
|           child: PostMediaPendingList( | ||||
|             attachments: _attachments, | ||||
|             isBusy: _isBusy, | ||||
|             onUpdate: (idx, updatedMedia) async { | ||||
|               setState(() => _attachments[idx] = updatedMedia); | ||||
|             }, | ||||
|             onRemove: (idx) async { | ||||
|               setState(() => _attachments.removeAt(idx)); | ||||
|             }, | ||||
|             onUpdateBusy: (state) => setState(() => _isBusy = state), | ||||
|           ), | ||||
|         ).height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true).animate( | ||||
|             const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut), | ||||
|         SingleChildScrollView( | ||||
|           physics: const NeverScrollableScrollPhysics(), | ||||
|           child: Padding( | ||||
|             padding: _replyingMessage != null | ||||
|                 ? const EdgeInsets.only(top: 8) | ||||
|                 : EdgeInsets.zero, | ||||
|             child: _replyingMessage != null | ||||
|                 ? MaterialBanner( | ||||
|                     padding: const EdgeInsets.only(left: 16.0), | ||||
|                     leading: const Icon(Symbols.reply), | ||||
|                     content: SingleChildScrollView( | ||||
|                       physics: const NeverScrollableScrollPhysics(), | ||||
|                       child: Column( | ||||
|                         mainAxisAlignment: MainAxisAlignment.center, | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           if (_replyingMessage?.body['text'] != null) | ||||
|                             MarkdownTextContent( | ||||
|                               content: _replyingMessage?.body['text'], | ||||
|                             ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|                     actions: [ | ||||
|                       TextButton( | ||||
|                         child: Text('cancel'.tr()), | ||||
|                         onPressed: () { | ||||
|                           setState(() => _replyingMessage = null); | ||||
|                         }, | ||||
|                       ), | ||||
|                     ], | ||||
|                   ) | ||||
|                 : const SizedBox.shrink(), | ||||
|           ), | ||||
|         ).height(_replyingMessage != null ? 54 + 8 : 0, animate: true).animate( | ||||
|             const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut), | ||||
|         SingleChildScrollView( | ||||
|           physics: const NeverScrollableScrollPhysics(), | ||||
|           child: Padding( | ||||
|             padding: _editingMessage != null | ||||
|                 ? const EdgeInsets.only(top: 8) | ||||
|                 : EdgeInsets.zero, | ||||
|             child: _editingMessage != null | ||||
|                 ? MaterialBanner( | ||||
|                     padding: const EdgeInsets.only(left: 16.0), | ||||
|                     leading: const Icon(Symbols.edit), | ||||
|                     content: SingleChildScrollView( | ||||
|                       physics: const NeverScrollableScrollPhysics(), | ||||
|                       child: Column( | ||||
|                         mainAxisAlignment: MainAxisAlignment.center, | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           if (_editingMessage?.body['text'] != null) | ||||
|                             MarkdownTextContent( | ||||
|                               content: _editingMessage?.body['text'], | ||||
|                             ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|                     actions: [ | ||||
|                       TextButton( | ||||
|                         child: Text('cancel'.tr()), | ||||
|                         onPressed: () { | ||||
|                           setState(() => _editingMessage = null); | ||||
|                         }, | ||||
|                       ), | ||||
|                     ], | ||||
|                   ) | ||||
|                 : const SizedBox.shrink(), | ||||
|           ), | ||||
|         ).height(_editingMessage != null ? 54 + 8 : 0, animate: true).animate( | ||||
|             const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut), | ||||
|         SizedBox( | ||||
|           height: 56, | ||||
|           child: Row( | ||||
|             children: [ | ||||
|               Expanded( | ||||
|                 child: TextField( | ||||
|                   focusNode: _focusNode, | ||||
|                   controller: _contentController, | ||||
|                   decoration: InputDecoration( | ||||
|                     isCollapsed: true, | ||||
|                     hintText: 'fieldChatMessage'.tr(args: [ | ||||
|                       widget.controller.channel?.name ?? 'loading'.tr() | ||||
|                     ]), | ||||
|                     border: InputBorder.none, | ||||
|                   ), | ||||
|                   onTapOutside: (_) => | ||||
|                       FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                   onSubmitted: (_) { | ||||
|                     if (_isBusy) return; | ||||
|                     _sendMessage(); | ||||
|                     _focusNode.requestFocus(); | ||||
|                   }, | ||||
|                 ), | ||||
|               ), | ||||
|               const Gap(8), | ||||
|               PopupMenuButton( | ||||
|                 icon: Icon( | ||||
|                   Symbols.add_photo_alternate, | ||||
|                   color: Theme.of(context).colorScheme.primary, | ||||
|                 ), | ||||
|                 itemBuilder: (context) => [ | ||||
|                   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(); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|               IconButton( | ||||
|                 onPressed: _isBusy ? null : _sendMessage, | ||||
|                 icon: Icon( | ||||
|                   Symbols.send, | ||||
|                   color: Theme.of(context).colorScheme.primary, | ||||
|                 ), | ||||
|                 visualDensity: const VisualDensity( | ||||
|                   horizontal: -4, | ||||
|                   vertical: -4, | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ).padding(horizontal: 16), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										62
									
								
								lib/widgets/connection_indicator.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								lib/widgets/connection_indicator.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
| import 'package:surface/providers/websocket.dart'; | ||||
|  | ||||
| class ConnectionIndicator extends StatelessWidget { | ||||
|   const ConnectionIndicator({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final ws = context.watch<WebSocketProvider>(); | ||||
|  | ||||
|     return ListenableBuilder( | ||||
|       listenable: ws, | ||||
|       builder: (context, _) { | ||||
|         final ua = context.read<UserProvider>(); | ||||
|  | ||||
|         return GestureDetector( | ||||
|           child: Container( | ||||
|             padding: EdgeInsets.only( | ||||
|               bottom: 8, | ||||
|               top: MediaQuery.of(context).padding.top + 8, | ||||
|               left: 24, | ||||
|               right: 24, | ||||
|             ), | ||||
|             color: Theme.of(context).colorScheme.secondaryContainer, | ||||
|             child: ua.isAuthorized | ||||
|                 ? Row( | ||||
|                     mainAxisAlignment: MainAxisAlignment.center, | ||||
|                     crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                     children: [ | ||||
|                       if (ws.isBusy) | ||||
|                         Text('serverConnecting').tr().textColor( | ||||
|                             Theme.of(context).colorScheme.onSecondaryContainer) | ||||
|                       else if (!ws.isConnected) | ||||
|                         Text('serverDisconnected').tr().textColor( | ||||
|                             Theme.of(context).colorScheme.onSecondaryContainer), | ||||
|                     ], | ||||
|                   ) | ||||
|                 : const SizedBox.shrink(), | ||||
|           ) | ||||
|               .height( | ||||
|                   (ws.isBusy || !ws.isConnected) && ua.isAuthorized | ||||
|                       ? MediaQuery.of(context).padding.top + 36 | ||||
|                       : 0, | ||||
|                   animate: true) | ||||
|               .animate( | ||||
|                 const Duration(milliseconds: 300), | ||||
|                 Curves.easeInOut, | ||||
|               ), | ||||
|           onTap: () { | ||||
|             if (!ws.isConnected && !ws.isBusy) { | ||||
|               ws.connect(); | ||||
|             } | ||||
|           }, | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -3,55 +3,98 @@ import 'dart:io'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| import 'package:responsive_framework/responsive_framework.dart'; | ||||
|  | ||||
| class AppBackground extends StatelessWidget { | ||||
|   final Widget child; | ||||
|   const AppBackground({super.key, required this.child}); | ||||
|   final bool isLessOptimization; | ||||
|   final bool isRoot; | ||||
|   const AppBackground({ | ||||
|     super.key, | ||||
|     required this.child, | ||||
|     this.isLessOptimization = false, | ||||
|     this.isRoot = false, | ||||
|   }); | ||||
|  | ||||
|   Widget _buildWithBackgroundImage( | ||||
|     BuildContext context, | ||||
|     File imageFile, | ||||
|     Widget child, | ||||
|   ) { | ||||
|     final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; | ||||
|  | ||||
|     if (isLessOptimization) { | ||||
|       final size = MediaQuery.of(context).size; | ||||
|       return Container( | ||||
|         color: Theme.of(context).colorScheme.surface, | ||||
|         child: Container( | ||||
|           decoration: BoxDecoration( | ||||
|             backgroundBlendMode: BlendMode.darken, | ||||
|             color: Theme.of(context).colorScheme.surface, | ||||
|             image: DecorationImage( | ||||
|               opacity: 0.2, | ||||
|               image: ResizeImage( | ||||
|                 FileImage(imageFile), | ||||
|                 width: (size.width * devicePixelRatio).round(), | ||||
|                 height: (size.height * devicePixelRatio).round(), | ||||
|                 policy: ResizeImagePolicy.fit, | ||||
|               ), | ||||
|               fit: BoxFit.cover, | ||||
|             ), | ||||
|           ), | ||||
|           child: child, | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Container( | ||||
|       color: Theme.of(context).colorScheme.surface, | ||||
|       child: LayoutBuilder( | ||||
|         builder: (context, constraints) { | ||||
|           return Container( | ||||
|             decoration: BoxDecoration( | ||||
|               backgroundBlendMode: BlendMode.darken, | ||||
|               color: Theme.of(context).colorScheme.surface, | ||||
|               image: DecorationImage( | ||||
|                 opacity: 0.2, | ||||
|                 image: ResizeImage( | ||||
|                   FileImage(imageFile), | ||||
|                   width: (constraints.maxWidth * devicePixelRatio).round(), | ||||
|                   height: (constraints.maxHeight * devicePixelRatio).round(), | ||||
|                   policy: ResizeImagePolicy.fit, | ||||
|                 ), | ||||
|                 fit: BoxFit.cover, | ||||
|               ), | ||||
|             ), | ||||
|             child: child, | ||||
|           ); | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; | ||||
|  | ||||
|     return ScaffoldMessenger( | ||||
|       child: FutureBuilder( | ||||
|         future: | ||||
|             kIsWeb ? Future.value(null) : getApplicationDocumentsDirectory(), | ||||
|         builder: (context, snapshot) { | ||||
|           if (snapshot.hasData) { | ||||
|             final path = '${snapshot.data!.path}/app_background_image'; | ||||
|             final file = File(path); | ||||
|             if (file.existsSync()) { | ||||
|               return Container( | ||||
|                 color: Theme.of(context).colorScheme.surface, | ||||
|                 child: LayoutBuilder( | ||||
|                   builder: (context, constraints) { | ||||
|                     return Container( | ||||
|                       decoration: BoxDecoration( | ||||
|                         backgroundBlendMode: BlendMode.darken, | ||||
|                         color: Theme.of(context).colorScheme.surface, | ||||
|                         image: DecorationImage( | ||||
|                           opacity: 0.2, | ||||
|                           image: ResizeImage( | ||||
|                             FileImage(file), | ||||
|                             width: (constraints.maxWidth * devicePixelRatio) | ||||
|                                 .round(), | ||||
|                             height: (constraints.maxHeight * devicePixelRatio) | ||||
|                                 .round(), | ||||
|                             policy: ResizeImagePolicy.fit, | ||||
|                           ), | ||||
|                           fit: BoxFit.cover, | ||||
|                         ), | ||||
|                       ), | ||||
|                       child: child, | ||||
|                     ); | ||||
|                   }, | ||||
|                 ), | ||||
|               ); | ||||
|           if (isRoot || | ||||
|               ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)) { | ||||
|             if (snapshot.hasData) { | ||||
|               final path = '${snapshot.data!.path}/app_background_image'; | ||||
|               final file = File(path); | ||||
|               if (file.existsSync()) { | ||||
|                 return _buildWithBackgroundImage(context, file, child); | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|  | ||||
|           return Material( | ||||
|             color: Theme.of(context).colorScheme.surface, | ||||
|             color: isRoot | ||||
|                 ? Theme.of(context).colorScheme.surface | ||||
|                 : Colors.transparent, | ||||
|             child: child, | ||||
|           ); | ||||
|         }, | ||||
|   | ||||
| @@ -1,5 +1,3 @@ | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| @@ -9,7 +7,8 @@ import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/navigation.dart'; | ||||
|  | ||||
| class AppNavigationDrawer extends StatefulWidget { | ||||
|   const AppNavigationDrawer({super.key}); | ||||
|   final double? elevation; | ||||
|   const AppNavigationDrawer({super.key, this.elevation}); | ||||
|  | ||||
|   @override | ||||
|   State<AppNavigationDrawer> createState() => _AppNavigationDrawerState(); | ||||
| @@ -30,8 +29,8 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> { | ||||
|   Widget build(BuildContext context) { | ||||
|     final nav = context.watch<NavigationProvider>(); | ||||
|  | ||||
|     final backgroundColor = ResponsiveBreakpoints.of(context).largerThan(MOBILE) | ||||
|         ? Theme.of(context).colorScheme.surface | ||||
|     final backgroundColor = ResponsiveBreakpoints.of(context).largerThan(TABLET) | ||||
|         ? Colors.transparent | ||||
|         : null; | ||||
|  | ||||
|     return ListenableBuilder( | ||||
| @@ -43,6 +42,7 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> { | ||||
|         ]; | ||||
|  | ||||
|         return NavigationDrawer( | ||||
|           elevation: widget.elevation, | ||||
|           backgroundColor: backgroundColor, | ||||
|           selectedIndex: nav.currentIndex, | ||||
|           children: [ | ||||
| @@ -51,13 +51,13 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> { | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Text('Solar Network').bold(), | ||||
|                 Text('Solar Network 2.0α').fontSize(12).textColor( | ||||
|                 Text('Canary Preview 2.0α').fontSize(12).textColor( | ||||
|                     Theme.of(context).colorScheme.onSurface.withOpacity(0.5)), | ||||
|               ], | ||||
|             ).padding( | ||||
|               horizontal: 32, | ||||
|               top: math.max(MediaQuery.of(context).padding.top, 16), | ||||
|               bottom: 16, | ||||
|               top: MediaQuery.of(context).padding.top > 16 ? 8 : 16, | ||||
|               bottom: 8, | ||||
|             ), | ||||
|             ...destinations.where((ele) => ele.isPinned).map((ele) { | ||||
|               return NavigationDrawerDestination( | ||||
|   | ||||
							
								
								
									
										68
									
								
								lib/widgets/navigation/app_rail_navigation.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								lib/widgets/navigation/app_rail_navigation.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.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/navigation.dart'; | ||||
|  | ||||
| class AppRailNavigation extends StatefulWidget { | ||||
|   const AppRailNavigation({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<AppRailNavigation> createState() => _AppRailNavigationState(); | ||||
| } | ||||
|  | ||||
| class _AppRailNavigationState extends State<AppRailNavigation> { | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     WidgetsBinding.instance.addPostFrameCallback((_) { | ||||
|       context | ||||
|           .read<NavigationProvider>() | ||||
|           .autoDetectIndex(GoRouter.maybeOf(context)); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final nav = context.watch<NavigationProvider>(); | ||||
|  | ||||
|     return ListenableBuilder( | ||||
|       listenable: nav, | ||||
|       builder: (context, _) { | ||||
|         final destinations = | ||||
|             nav.destinations.where((ele) => ele.isPinned).toList(); | ||||
|  | ||||
|         return NavigationRail( | ||||
|           selectedIndex: nav.currentIndex, | ||||
|           destinations: [ | ||||
|             ...destinations.where((ele) => ele.isPinned).map((ele) { | ||||
|               return NavigationRailDestination( | ||||
|                 icon: ele.icon, | ||||
|                 label: Text(ele.label).tr(), | ||||
|               ); | ||||
|             }), | ||||
|           ], | ||||
|           trailing: Expanded( | ||||
|             child: Align( | ||||
|               alignment: Alignment.bottomCenter, | ||||
|               child: StyledWidget( | ||||
|                 IconButton( | ||||
|                   icon: const Icon(Symbols.menu), | ||||
|                   onPressed: () { | ||||
|                     Scaffold.of(context).openDrawer(); | ||||
|                   }, | ||||
|                 ), | ||||
|               ).padding(bottom: 16), | ||||
|             ), | ||||
|           ), | ||||
|           onDestinationSelected: (idx) { | ||||
|             nav.setIndex(idx); | ||||
|             GoRouter.of(context).goNamed(destinations[idx].screen); | ||||
|           }, | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -2,76 +2,104 @@ import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:responsive_framework/responsive_framework.dart'; | ||||
| import 'package:surface/providers/navigation.dart'; | ||||
| import 'package:surface/widgets/connection_indicator.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:surface/widgets/navigation/app_background.dart'; | ||||
| import 'package:surface/widgets/navigation/app_bottom_navigation.dart'; | ||||
| import 'package:surface/widgets/navigation/app_drawer_navigation.dart'; | ||||
| import 'package:surface/widgets/navigation/app_rail_navigation.dart'; | ||||
|  | ||||
| class AppScaffold extends StatelessWidget { | ||||
|   final PreferredSizeWidget? appBar; | ||||
|   final FloatingActionButtonLocation? floatingActionButtonLocation; | ||||
|   final Widget? floatingActionButton; | ||||
| class AppPageScaffold extends StatelessWidget { | ||||
|   final String? title; | ||||
|   final Widget? body; | ||||
|   final bool autoImplyAppBar; | ||||
|   final bool showAppBar; | ||||
|   final bool showBottomNavigation; | ||||
|   final bool showDrawer; | ||||
|   const AppScaffold({ | ||||
|   const AppPageScaffold({ | ||||
|     super.key, | ||||
|     this.appBar, | ||||
|     this.floatingActionButton, | ||||
|     this.floatingActionButtonLocation, | ||||
|     this.title, | ||||
|     this.body, | ||||
|     this.autoImplyAppBar = false, | ||||
|     this.showAppBar = true, | ||||
|     this.showBottomNavigation = false, | ||||
|     this.showDrawer = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final isShowDrawer = showDrawer | ||||
|         ? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE) | ||||
|         : false; | ||||
|     final isShowBottomNavigation = (showBottomNavigation) | ||||
|         ? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE) | ||||
|         : false; | ||||
|  | ||||
|     final state = GoRouter.maybeOf(context); | ||||
|     final routeName = | ||||
|         state?.routerDelegate.currentConfiguration.last.route.name; | ||||
|  | ||||
|     final innerWidget = AppBackground( | ||||
|     final autoTitle = | ||||
|         state != null ? 'screen${routeName?.capitalize()}' : 'screen'; | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: showAppBar | ||||
|           ? AppBar( | ||||
|               title: Text(title ?? autoTitle.tr()), | ||||
|             ) | ||||
|           : null, | ||||
|       body: body, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class AppRootScaffold extends StatelessWidget { | ||||
|   final Widget body; | ||||
|   const AppRootScaffold({super.key, required this.body}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; | ||||
|  | ||||
|     final isCollapseDrawer = | ||||
|         ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE); | ||||
|     final isExpandDrawer = ResponsiveBreakpoints.of(context).largerThan(TABLET); | ||||
|  | ||||
|     final routeName = GoRouter.of(context) | ||||
|         .routerDelegate | ||||
|         .currentConfiguration | ||||
|         .last | ||||
|         .route | ||||
|         .name; | ||||
|     final isShowBottomNavigation = | ||||
|         NavigationProvider.kShowBottomNavScreen.contains(routeName) | ||||
|             ? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE) | ||||
|             : false; | ||||
|  | ||||
|     final innerWidget = isCollapseDrawer | ||||
|         ? body | ||||
|         : Row( | ||||
|             children: [ | ||||
|               Container( | ||||
|                 decoration: BoxDecoration( | ||||
|                   border: Border( | ||||
|                     right: BorderSide( | ||||
|                       color: Theme.of(context).dividerColor, | ||||
|                       width: 1 / devicePixelRatio, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|                 child: isExpandDrawer | ||||
|                     ? AppNavigationDrawer(elevation: 0) | ||||
|                     : AppRailNavigation(), | ||||
|               ), | ||||
|               Expanded(child: body), | ||||
|             ], | ||||
|           ); | ||||
|  | ||||
|     return AppBackground( | ||||
|       isRoot: true, | ||||
|       child: Scaffold( | ||||
|         appBar: appBar ?? | ||||
|             (autoImplyAppBar | ||||
|                 ? AppBar( | ||||
|                     title: title != null | ||||
|                         ? Text(title!) | ||||
|                         : state != null | ||||
|                             ? Text( | ||||
|                                 ('screen${state.routerDelegate.currentConfiguration.last.route.name?.capitalize()}') | ||||
|                                     .tr(), | ||||
|                               ) | ||||
|                             : null) | ||||
|                 : null), | ||||
|         body: body, | ||||
|         floatingActionButtonLocation: floatingActionButtonLocation, | ||||
|         floatingActionButton: floatingActionButton, | ||||
|         drawer: isShowDrawer ? AppNavigationDrawer() : null, | ||||
|         body: Column( | ||||
|           children: [ | ||||
|             ConnectionIndicator(), | ||||
|             Expanded(child: innerWidget), | ||||
|           ], | ||||
|         ), | ||||
|         drawer: !isExpandDrawer ? AppNavigationDrawer() : null, | ||||
|         bottomNavigationBar: | ||||
|             isShowBottomNavigation ? AppBottomNavigationBar() : null, | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     if (showDrawer) { | ||||
|       return Row( | ||||
|         children: [ | ||||
|           AppNavigationDrawer(), | ||||
|           VerticalDivider(width: 1, color: Theme.of(context).dividerColor), | ||||
|           Expanded(child: innerWidget), | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return innerWidget; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -5,8 +5,8 @@ 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/sn_attachment.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/post.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
| import 'package:surface/widgets/post/post_item.dart'; | ||||
| import 'package:surface/widgets/post/post_mini_editor.dart'; | ||||
| @@ -14,7 +14,12 @@ import 'package:very_good_infinite_list/very_good_infinite_list.dart'; | ||||
|  | ||||
| class PostCommentSliverList extends StatefulWidget { | ||||
|   final int parentPostId; | ||||
|   const PostCommentSliverList({super.key, required this.parentPostId}); | ||||
|   final double? maxWidth; | ||||
|   const PostCommentSliverList({ | ||||
|     super.key, | ||||
|     required this.parentPostId, | ||||
|     this.maxWidth, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<PostCommentSliverList> createState() => PostCommentSliverListState(); | ||||
| @@ -31,38 +36,13 @@ class PostCommentSliverListState extends State<PostCommentSliverList> { | ||||
|  | ||||
|     setState(() => _isBusy = true); | ||||
|  | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|     final resp = await sn.client.get( | ||||
|       '/cgi/co/posts/${widget.parentPostId}/replies', | ||||
|       queryParameters: { | ||||
|         'take': 10, | ||||
|         'offset': _posts.length, | ||||
|       }, | ||||
|     ); | ||||
|     final List<SnPost> out = | ||||
|         List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []); | ||||
|  | ||||
|     Set<String> rids = {}; | ||||
|     for (var i = 0; i < out.length; i++) { | ||||
|       rids.addAll(out[i].body['attachments']?.cast<String>() ?? []); | ||||
|     } | ||||
|     final pt = context.read<SnPostContentProvider>(); | ||||
|     final result = await pt.listPostReplies(widget.parentPostId); | ||||
|     final List<SnPost> out = result.$1; | ||||
|  | ||||
|     if (!mounted) return; | ||||
|     final attach = context.read<SnAttachmentProvider>(); | ||||
|     final attachments = await attach.getMultiple(rids.toList()); | ||||
|     for (var i = 0; i < out.length; i++) { | ||||
|       out[i] = out[i].copyWith( | ||||
|         preload: SnPostPreload( | ||||
|           attachments: attachments | ||||
|               .where( | ||||
|                 (ele) => out[i].body['attachments']?.contains(ele.rid) ?? false, | ||||
|               ) | ||||
|               .toList(), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     _postCount = resp.data['count']; | ||||
|     _postCount = result.$2; | ||||
|     _posts.addAll(out); | ||||
|  | ||||
|     if (mounted) setState(() => _isBusy = false); | ||||
| @@ -88,7 +68,13 @@ class PostCommentSliverListState extends State<PostCommentSliverList> { | ||||
|       onFetchData: _fetchPosts, | ||||
|       itemBuilder: (context, idx) { | ||||
|         return GestureDetector( | ||||
|           child: PostItem(data: _posts[idx]), | ||||
|           child: PostItem( | ||||
|             data: _posts[idx], | ||||
|             maxWidth: widget.maxWidth, | ||||
|             onChanged: (data) { | ||||
|               setState(() => _posts[idx] = data); | ||||
|             }, | ||||
|           ), | ||||
|           onTap: () { | ||||
|             GoRouter.of(context).pushNamed( | ||||
|               'postDetail', | ||||
| @@ -121,6 +107,7 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final ua = context.watch<UserProvider>(); | ||||
|     final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; | ||||
|  | ||||
|     return Column( | ||||
| @@ -139,25 +126,26 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> { | ||||
|         Expanded( | ||||
|           child: CustomScrollView( | ||||
|             slivers: [ | ||||
|               SliverToBoxAdapter( | ||||
|                 child: Container( | ||||
|                   height: 240, | ||||
|                   decoration: BoxDecoration( | ||||
|                     border: Border.symmetric( | ||||
|                       horizontal: BorderSide( | ||||
|                         color: Theme.of(context).dividerColor, | ||||
|                         width: 1 / devicePixelRatio, | ||||
|               if (ua.isAuthorized) | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: Container( | ||||
|                     height: 240, | ||||
|                     decoration: BoxDecoration( | ||||
|                       border: Border.symmetric( | ||||
|                         horizontal: BorderSide( | ||||
|                           color: Theme.of(context).dividerColor, | ||||
|                           width: 1 / devicePixelRatio, | ||||
|                         ), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                   child: PostMiniEditor( | ||||
|                     postReplyId: widget.postId, | ||||
|                     onPost: () { | ||||
|                       _childListKey.currentState!.refresh(); | ||||
|                     }, | ||||
|                     child: PostMiniEditor( | ||||
|                       postReplyId: widget.postId, | ||||
|                       onPost: () { | ||||
|                         _childListKey.currentState!.refresh(); | ||||
|                       }, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|               PostCommentSliverList( | ||||
|                 key: _childListKey, | ||||
|                 parentPostId: widget.postId, | ||||
|   | ||||
| @@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:relative_time/relative_time.dart'; | ||||
| import 'package:responsive_framework/responsive_framework.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
| @@ -19,12 +18,16 @@ class PostItem extends StatelessWidget { | ||||
|   final SnPost data; | ||||
|   final bool showReactions; | ||||
|   final bool showComments; | ||||
|   final bool showMenu; | ||||
|   final double? maxWidth; | ||||
|   final Function(SnPost data)? onChanged; | ||||
|   const PostItem({ | ||||
|     super.key, | ||||
|     required this.data, | ||||
|     this.showReactions = true, | ||||
|     this.showComments = true, | ||||
|     this.showMenu = true, | ||||
|     this.maxWidth, | ||||
|     this.onChanged, | ||||
|   }); | ||||
|  | ||||
| @@ -34,31 +37,53 @@ class PostItem extends StatelessWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final isListAttachments = | ||||
|         ResponsiveBreakpoints.of(context).largerThan(MOBILE) || | ||||
|             (data.preload?.attachments?.length ?? 0) > 1; | ||||
|  | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       crossAxisAlignment: CrossAxisAlignment.center, | ||||
|       children: [ | ||||
|         _PostContentHeader(data: data).padding(horizontal: 12, vertical: 8), | ||||
|         _PostContentBody(data: data.body).padding(horizontal: 16, bottom: 6), | ||||
|         if (data.repostTo != null) | ||||
|           _PostQuoteContent(child: data.repostTo!).padding( | ||||
|             horizontal: 12, | ||||
|         Container( | ||||
|           constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               _PostContentHeader(data: data, showMenu: showMenu) | ||||
|                   .padding(horizontal: 12, vertical: 8), | ||||
|               if (data.body['title'] != null || | ||||
|                   data.body['description'] != null) | ||||
|                 _PostHeadline(data: data).padding(horizontal: 16, bottom: 8), | ||||
|               _PostContentBody(data: data.body) | ||||
|                   .padding(horizontal: 16, bottom: 6), | ||||
|               if (data.repostTo != null) | ||||
|                 _PostQuoteContent(child: data.repostTo!).padding( | ||||
|                   horizontal: 12, | ||||
|                 ), | ||||
|               if (data.body['content_truncated'] == true) | ||||
|                 _PostTruncatedHint(data: data).padding( | ||||
|                   horizontal: 16, | ||||
|                   vertical: 4, | ||||
|                 ), | ||||
|             ], | ||||
|           ), | ||||
|         if (data.preload?.attachments?.isNotEmpty ?? true) | ||||
|         ), | ||||
|         if (data.preload?.attachments?.isNotEmpty ?? false) | ||||
|           AttachmentList( | ||||
|             data: data.preload!.attachments!, | ||||
|             bordered: true, | ||||
|             maxHeight: 520, | ||||
|           ).padding(horizontal: isListAttachments ? 12 : 0), | ||||
|         _PostBottomAction( | ||||
|           data: data, | ||||
|           showComments: showComments, | ||||
|           showReactions: showReactions, | ||||
|           onChanged: _onChanged, | ||||
|         ).padding(left: 12, right: 18), | ||||
|             maxHeight: 480, | ||||
|             listPadding: const EdgeInsets.symmetric(horizontal: 12), | ||||
|           ), | ||||
|         Container( | ||||
|           constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), | ||||
|           child: Column( | ||||
|             children: [ | ||||
|               _PostBottomAction( | ||||
|                 data: data, | ||||
|                 showComments: showComments, | ||||
|                 showReactions: showReactions, | ||||
|                 onChanged: _onChanged, | ||||
|               ).padding(left: 8, right: 14), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| @@ -84,65 +109,71 @@ class _PostBottomAction extends StatelessWidget { | ||||
|     return Row( | ||||
|       mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|       children: [ | ||||
|         Row( | ||||
|           children: [ | ||||
|             if (showReactions) | ||||
|               InkWell( | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
|                     Icon(Symbols.add_reaction, size: 20, color: iconColor), | ||||
|                     const Gap(8), | ||||
|                     if (data.totalDownvote > 0 || data.totalUpvote > 0) | ||||
|                       Text('postReactionPoints').plural( | ||||
|                         data.totalUpvote - data.totalDownvote, | ||||
|                       ) | ||||
|                     else | ||||
|                       Text('postReact').tr(), | ||||
|                   ], | ||||
|                 ).padding(horizontal: 8, vertical: 8), | ||||
|                 onTap: () { | ||||
|                   showModalBottomSheet( | ||||
|                     context: context, | ||||
|                     builder: (context) => PostReactionPopup( | ||||
|                       data: data, | ||||
|                       onChanged: (value, isPositive, delta) { | ||||
|                         onChanged(data.copyWith( | ||||
|                           totalUpvote: isPositive | ||||
|                               ? data.totalUpvote + delta | ||||
|                               : data.totalUpvote, | ||||
|                           totalDownvote: !isPositive | ||||
|                               ? data.totalDownvote + delta | ||||
|                               : data.totalDownvote, | ||||
|                           metric: data.metric.copyWith(reactionList: value), | ||||
|                         )); | ||||
|                       }, | ||||
|                     ), | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|             if (showComments) | ||||
|               InkWell( | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
|                     Icon(Symbols.comment, size: 20, color: iconColor), | ||||
|                     const Gap(8), | ||||
|                     Text('postComments').plural(data.metric.replyCount), | ||||
|                   ], | ||||
|                 ).padding(horizontal: 8, vertical: 8), | ||||
|                 onTap: () { | ||||
|                   showModalBottomSheet( | ||||
|                     context: context, | ||||
|                     useRootNavigator: true, | ||||
|                     builder: (context) => PostCommentListPopup( | ||||
|                       postId: data.id, | ||||
|                       commentCount: data.metric.replyCount, | ||||
|                     ), | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|           ].expand((ele) => [ele, const Gap(8)]).toList() | ||||
|             ..removeLast(), | ||||
|         ), | ||||
|         if (showReactions || showComments) | ||||
|           Row( | ||||
|             children: [ | ||||
|               if (showReactions) | ||||
|                 InkWell( | ||||
|                   child: Row( | ||||
|                     children: [ | ||||
|                       Icon(Symbols.add_reaction, size: 20, color: iconColor), | ||||
|                       const Gap(8), | ||||
|                       if (data.totalUpvote > 0 && | ||||
|                           data.totalUpvote >= data.totalDownvote) | ||||
|                         Text('postReactionUpvote').plural( | ||||
|                           data.totalUpvote, | ||||
|                         ) | ||||
|                       else if (data.totalDownvote > 0) | ||||
|                         Text('postReactionDownvote').plural( | ||||
|                           data.totalDownvote, | ||||
|                         ) | ||||
|                       else | ||||
|                         Text('postReact').tr(), | ||||
|                     ], | ||||
|                   ).padding(horizontal: 8, vertical: 8), | ||||
|                   onTap: () { | ||||
|                     showModalBottomSheet( | ||||
|                       context: context, | ||||
|                       builder: (context) => PostReactionPopup( | ||||
|                         data: data, | ||||
|                         onChanged: (value, attr, delta) { | ||||
|                           onChanged(data.copyWith( | ||||
|                             totalUpvote: attr == 1 | ||||
|                                 ? data.totalUpvote + delta | ||||
|                                 : data.totalUpvote, | ||||
|                             totalDownvote: attr == 2 | ||||
|                                 ? data.totalDownvote + delta | ||||
|                                 : data.totalDownvote, | ||||
|                             metric: data.metric.copyWith(reactionList: value), | ||||
|                           )); | ||||
|                         }, | ||||
|                       ), | ||||
|                     ); | ||||
|                   }, | ||||
|                 ), | ||||
|               if (showComments) | ||||
|                 InkWell( | ||||
|                   child: Row( | ||||
|                     children: [ | ||||
|                       Icon(Symbols.comment, size: 20, color: iconColor), | ||||
|                       const Gap(8), | ||||
|                       Text('postComments').plural(data.metric.replyCount), | ||||
|                     ], | ||||
|                   ).padding(horizontal: 8, vertical: 8), | ||||
|                   onTap: () { | ||||
|                     showModalBottomSheet( | ||||
|                       context: context, | ||||
|                       useRootNavigator: true, | ||||
|                       builder: (context) => PostCommentListPopup( | ||||
|                         postId: data.id, | ||||
|                         commentCount: data.metric.replyCount, | ||||
|                       ), | ||||
|                     ); | ||||
|                   }, | ||||
|                 ), | ||||
|             ].expand((ele) => [ele, const Gap(8)]).toList() | ||||
|               ..removeLast(), | ||||
|           ), | ||||
|         InkWell( | ||||
|           child: Icon( | ||||
|             Symbols.share, | ||||
| @@ -156,14 +187,38 @@ class _PostBottomAction extends StatelessWidget { | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _PostHeadline extends StatelessWidget { | ||||
|   final SnPost data; | ||||
|   const _PostHeadline({super.key, required this.data}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         if (data.body['title'] != null) | ||||
|           Text( | ||||
|             data.body['title'], | ||||
|             style: Theme.of(context).textTheme.titleMedium, | ||||
|           ), | ||||
|         if (data.body['description'] != null) | ||||
|           Text( | ||||
|             data.body['description'], | ||||
|             style: Theme.of(context).textTheme.bodyMedium, | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _PostContentHeader extends StatelessWidget { | ||||
|   final SnPost data; | ||||
|   final bool isCompact; | ||||
|   final bool showActions; | ||||
|   final bool showMenu; | ||||
|   const _PostContentHeader({ | ||||
|     required this.data, | ||||
|     this.isCompact = false, | ||||
|     this.showActions = true, | ||||
|     this.showMenu = true, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
| @@ -212,7 +267,7 @@ class _PostContentHeader extends StatelessWidget { | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         if (showActions) | ||||
|         if (showMenu) | ||||
|           PopupMenuButton( | ||||
|             icon: const Icon(Symbols.more_horiz), | ||||
|             style: const ButtonStyle( | ||||
| @@ -252,7 +307,7 @@ class _PostContentHeader extends StatelessWidget { | ||||
|                   children: [ | ||||
|                     const Icon(Symbols.reply), | ||||
|                     const Gap(16), | ||||
|                     Text('reply').tr(), | ||||
|                     Text('replyPost').tr(), | ||||
|                   ], | ||||
|                 ), | ||||
|                 onTap: () { | ||||
| @@ -324,7 +379,7 @@ class _PostQuoteContent extends StatelessWidget { | ||||
|       padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||
|       child: Column( | ||||
|         children: [ | ||||
|           _PostContentHeader(data: child, isCompact: true, showActions: false) | ||||
|           _PostContentHeader(data: child, isCompact: true, showMenu: false) | ||||
|               .padding(bottom: 4), | ||||
|           _PostContentBody(data: child.body), | ||||
|         ], | ||||
| @@ -332,3 +387,49 @@ class _PostQuoteContent extends StatelessWidget { | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _PostTruncatedHint extends StatelessWidget { | ||||
|   final SnPost data; | ||||
|   const _PostTruncatedHint({super.key, required this.data}); | ||||
|  | ||||
|   static const int kHumanReadSpeed = 238; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Row( | ||||
|       children: [ | ||||
|         if (data.body['content_length'] != null) | ||||
|           Row( | ||||
|             children: [ | ||||
|               const Icon(Symbols.timer, size: 20), | ||||
|               const Gap(4), | ||||
|               Text('postReadEstimate').tr(args: [ | ||||
|                 '${Duration( | ||||
|                   seconds: ((data.body['content_length'] as num).toDouble() / | ||||
|                           kHumanReadSpeed) | ||||
|                       .round(), | ||||
|                 ).inSeconds}s', | ||||
|               ]), | ||||
|             ], | ||||
|           ).padding(right: 12), | ||||
|         if (data.body['content_length'] != null) | ||||
|           Row( | ||||
|             children: [ | ||||
|               const Icon(Symbols.height, size: 20), | ||||
|               const Gap(4), | ||||
|               Text( | ||||
|                 'postTotalLength'.plural(data.body['content_length']), | ||||
|               ).padding(right: 12) | ||||
|             ], | ||||
|           ), | ||||
|         Row( | ||||
|           children: [ | ||||
|             const Icon(Symbols.unfold_more, size: 20), | ||||
|             const Gap(4), | ||||
|             Text('postReadMore').tr(), | ||||
|           ], | ||||
|         ) | ||||
|       ], | ||||
|     ).opacity(0.75); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -17,11 +17,23 @@ import 'package:surface/widgets/attachment/attachment_detail.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
|  | ||||
| class PostMediaPendingList extends StatelessWidget { | ||||
|   final PostWriteController controller; | ||||
|   const PostMediaPendingList({super.key, required this.controller}); | ||||
|   final List<PostWriteMedia> attachments; | ||||
|   final bool isBusy; | ||||
|   final Future<void> Function(int idx, PostWriteMedia updatedMedia)? onUpdate; | ||||
|   final Future<void> Function(int idx)? onRemove; | ||||
|   final void Function(bool state)? onUpdateBusy; | ||||
|  | ||||
|   void _cropImage(BuildContext context, int idx) async { | ||||
|     final media = controller.attachments[idx]; | ||||
|   const PostMediaPendingList({ | ||||
|     super.key, | ||||
|     required this.attachments, | ||||
|     required this.isBusy, | ||||
|     this.onUpdate, | ||||
|     this.onRemove, | ||||
|     this.onUpdateBusy, | ||||
|   }); | ||||
|  | ||||
|   Future<void> _cropImage(BuildContext context, int idx) async { | ||||
|     final media = attachments[idx]; | ||||
|     final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) | ||||
|         ? await showCupertinoImageCropper( | ||||
|             // ignore: use_build_context_synchronously | ||||
| @@ -37,144 +49,138 @@ class PostMediaPendingList extends StatelessWidget { | ||||
|           ); | ||||
|  | ||||
|     if (result == null) return; | ||||
|     if (!context.mounted) return; | ||||
|  | ||||
|     controller.setIsBusy(true); | ||||
|  | ||||
|     final rawBytes = | ||||
|         (await result.uiImage.toByteData(format: ImageByteFormat.png))! | ||||
|             .buffer | ||||
|             .asUint8List(); | ||||
|     controller.setAttachmentAt( | ||||
|       idx, | ||||
|       PostWriteMedia.fromBytes(rawBytes, media.name, media.type), | ||||
|     ); | ||||
|  | ||||
|     controller.setIsBusy(false); | ||||
|     if (onUpdate != null) { | ||||
|       final updatedMedia = PostWriteMedia.fromBytes( | ||||
|         rawBytes, | ||||
|         media.name, | ||||
|         media.type, | ||||
|       ); | ||||
|       await onUpdate!(idx, updatedMedia); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _deleteAttachment(BuildContext context, int idx) async { | ||||
|     final media = controller.attachments[idx]; | ||||
|   Future<void> _deleteAttachment(BuildContext context, int idx) async { | ||||
|     final media = attachments[idx]; | ||||
|     if (media.attachment == null) return; | ||||
|  | ||||
|     controller.setIsBusy(true); | ||||
|  | ||||
|     try { | ||||
|       onUpdateBusy?.call(true); | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       await sn.client.delete('/cgi/uc/attachments/${media.attachment!.id}'); | ||||
|       controller.removeAttachmentAt(idx); | ||||
|       onRemove!(idx); | ||||
|     } catch (err) { | ||||
|       if (!context.mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       controller.setIsBusy(false); | ||||
|       onUpdateBusy?.call(false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   ContextMenu _buildContextMenu( | ||||
|       BuildContext context, int idx, PostWriteMedia media) { | ||||
|     return ContextMenu( | ||||
|       entries: [ | ||||
|         if (media.type == PostWriteMediaType.image && media.attachment != null) | ||||
|           MenuItem( | ||||
|             label: 'preview'.tr(), | ||||
|             icon: Symbols.preview, | ||||
|             onSelected: () { | ||||
|               context.pushTransparentRoute( | ||||
|                 AttachmentZoomView(data: [media.attachment!]), | ||||
|                 rootNavigator: true, | ||||
|               ); | ||||
|             }, | ||||
|           ), | ||||
|         if (media.type == PostWriteMediaType.image && media.attachment == null) | ||||
|           MenuItem( | ||||
|             label: 'crop'.tr(), | ||||
|             icon: Symbols.crop, | ||||
|             onSelected: () => _cropImage(context, idx), | ||||
|           ), | ||||
|         if (media.attachment != null && onRemove != null) | ||||
|           MenuItem( | ||||
|             label: 'delete'.tr(), | ||||
|             icon: Symbols.delete, | ||||
|             onSelected: isBusy ? null : () => _deleteAttachment(context, idx), | ||||
|           ), | ||||
|         if (media.attachment == null && onRemove != null) | ||||
|           MenuItem( | ||||
|             label: 'delete'.tr(), | ||||
|             icon: Symbols.delete, | ||||
|             onSelected: () { | ||||
|               onRemove!(idx); | ||||
|             }, | ||||
|           ) | ||||
|         else if (onRemove != null) | ||||
|           MenuItem( | ||||
|             label: 'unlink'.tr(), | ||||
|             icon: Symbols.link_off, | ||||
|             onSelected: () { | ||||
|               onRemove!(idx); | ||||
|             }, | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; | ||||
|  | ||||
|     return ListenableBuilder( | ||||
|       listenable: controller, | ||||
|       builder: (context, _) { | ||||
|         return Container( | ||||
|           constraints: const BoxConstraints(maxHeight: 120), | ||||
|           child: ListView.separated( | ||||
|             scrollDirection: Axis.horizontal, | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 8), | ||||
|             separatorBuilder: (context, index) => const Gap(8), | ||||
|             itemCount: controller.attachments.length, | ||||
|             itemBuilder: (context, idx) { | ||||
|               final media = controller.attachments[idx]; | ||||
|               return ContextMenuRegion( | ||||
|                 contextMenu: ContextMenu( | ||||
|                   entries: [ | ||||
|                     if (media.type == PostWriteMediaType.image && | ||||
|                         media.attachment != null) | ||||
|                       MenuItem( | ||||
|                         label: 'preview'.tr(), | ||||
|                         icon: Symbols.preview, | ||||
|                         onSelected: () { | ||||
|                           context.pushTransparentRoute( | ||||
|                             AttachmentDetailPopup(data: media.attachment!), | ||||
|                             rootNavigator: true, | ||||
|                           ); | ||||
|                         }, | ||||
|                       ), | ||||
|                     if (media.type == PostWriteMediaType.image && | ||||
|                         media.attachment == null) | ||||
|                       MenuItem( | ||||
|                         label: 'crop'.tr(), | ||||
|                         icon: Symbols.crop, | ||||
|                         onSelected: () => _cropImage(context, idx), | ||||
|                       ), | ||||
|                     if (media.attachment != null) | ||||
|                       MenuItem( | ||||
|                         label: 'delete'.tr(), | ||||
|                         icon: Symbols.delete, | ||||
|                         onSelected: controller.isBusy | ||||
|                             ? null | ||||
|                             : () => _deleteAttachment(context, idx), | ||||
|                       ), | ||||
|                     if (media.attachment == null) | ||||
|                       MenuItem( | ||||
|                         label: 'delete'.tr(), | ||||
|                         icon: Symbols.delete, | ||||
|                         onSelected: () { | ||||
|                           controller.removeAttachmentAt(idx); | ||||
|                         }, | ||||
|                       ) | ||||
|                     else | ||||
|                       MenuItem( | ||||
|                         label: 'unlink'.tr(), | ||||
|                         icon: Symbols.link_off, | ||||
|                         onSelected: () { | ||||
|                           controller.removeAttachmentAt(idx); | ||||
|                         }, | ||||
|                       ), | ||||
|                   ], | ||||
|     return Container( | ||||
|       constraints: const BoxConstraints(maxHeight: 120), | ||||
|       child: ListView.separated( | ||||
|         scrollDirection: Axis.horizontal, | ||||
|         padding: const EdgeInsets.symmetric(horizontal: 8), | ||||
|         separatorBuilder: (context, index) => const Gap(8), | ||||
|         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, | ||||
|                 ), | ||||
|                 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(), | ||||
|                           ), | ||||
|                       }, | ||||
|                     ), | ||||
|                   ), | ||||
|                 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(), | ||||
|                       ), | ||||
|                   }, | ||||
|                 ), | ||||
|               ); | ||||
|             }, | ||||
|           ), | ||||
|         ); | ||||
|       }, | ||||
|               ), | ||||
|             ), | ||||
|           ); | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -11,7 +11,7 @@ import 'package:surface/widgets/dialog.dart'; | ||||
|  | ||||
| class PostReactionPopup extends StatefulWidget { | ||||
|   final SnPost data; | ||||
|   final Function(Map<String, int> value, bool isPositive, int delta)? onChanged; | ||||
|   final Function(Map<String, int> value, int attr, int delta)? onChanged; | ||||
|   const PostReactionPopup({super.key, required this.data, this.onChanged}); | ||||
|  | ||||
|   @override | ||||
| @@ -43,7 +43,7 @@ class _PostReactionPopupState extends State<PostReactionPopup> { | ||||
|         if (widget.onChanged != null) { | ||||
|           widget.onChanged!( | ||||
|             _reactions, | ||||
|             kTemplateReactions[symbol]!.attitude == 1, | ||||
|             kTemplateReactions[symbol]!.attitude, | ||||
|             1, | ||||
|           ); | ||||
|         } | ||||
| @@ -54,7 +54,7 @@ class _PostReactionPopupState extends State<PostReactionPopup> { | ||||
|         if (widget.onChanged != null) { | ||||
|           widget.onChanged!( | ||||
|             _reactions, | ||||
|             kTemplateReactions[symbol]!.attitude == 1, | ||||
|             kTemplateReactions[symbol]!.attitude, | ||||
|             -1, | ||||
|           ); | ||||
|         } | ||||
| @@ -89,11 +89,36 @@ class _PostReactionPopupState extends State<PostReactionPopup> { | ||||
|                   .textStyle(Theme.of(context).textTheme.titleLarge!), | ||||
|             ], | ||||
|           ).padding(horizontal: 20, top: 16, bottom: 12), | ||||
|           Container( | ||||
|             color: Theme.of(context).colorScheme.surfaceContainer, | ||||
|             child: Row( | ||||
|               children: [ | ||||
|                 const Icon(Symbols.thumb_up, size: 16), | ||||
|                 const Gap(8), | ||||
|                 Text('postReactionUpvote').plural(widget.data.totalUpvote), | ||||
|                 const Gap(24), | ||||
|                 const Icon(Symbols.thumb_down, size: 16), | ||||
|                 const Gap(8), | ||||
|                 Text('postReactionDownvote').plural(widget.data.totalDownvote), | ||||
|                 const Gap(24), | ||||
|                 Icon( | ||||
|                   widget.data.totalUpvote >= widget.data.totalDownvote | ||||
|                       ? Symbols.trending_up | ||||
|                       : Symbols.trending_down, | ||||
|                   size: 16, | ||||
|                 ), | ||||
|                 const Gap(8), | ||||
|                 Text('postReactionSocialPoint').plural( | ||||
|                   widget.data.totalUpvote - widget.data.totalDownvote, | ||||
|                 ), | ||||
|               ], | ||||
|             ).padding(vertical: 8, horizontal: 24), | ||||
|           ), | ||||
|           Expanded( | ||||
|             child: GridView.count( | ||||
|             child: GridView.extent( | ||||
|               crossAxisSpacing: 4, | ||||
|               mainAxisSpacing: 4, | ||||
|               crossAxisCount: 4, | ||||
|               maxCrossAxisExtent: 120, | ||||
|               children: kTemplateReactions.entries.map((e) { | ||||
|                 return InkWell( | ||||
|                   onTap: () { | ||||
|   | ||||
| @@ -7,16 +7,36 @@ | ||||
| #include "generated_plugin_registrant.h" | ||||
|  | ||||
| #include <file_selector_linux/file_selector_plugin.h> | ||||
| #include <flutter_secure_storage/flutter_secure_storage_plugin.h> | ||||
| #include <flutter_udid/flutter_udid_plugin.h> | ||||
| #include <flutter_webrtc/flutter_web_r_t_c_plugin.h> | ||||
| #include <media_kit_libs_linux/media_kit_libs_linux_plugin.h> | ||||
| #include <media_kit_video/media_kit_video_plugin.h> | ||||
| #include <pasteboard/pasteboard_plugin.h> | ||||
| #include <sentry_flutter/sentry_flutter_plugin.h> | ||||
| #include <url_launcher_linux/url_launcher_plugin.h> | ||||
|  | ||||
| void fl_register_plugins(FlPluginRegistry* registry) { | ||||
|   g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = | ||||
|       fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); | ||||
|   file_selector_plugin_register_with_registrar(file_selector_linux_registrar); | ||||
|   g_autoptr(FlPluginRegistrar) flutter_secure_storage_registrar = | ||||
|       fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStoragePlugin"); | ||||
|   flutter_secure_storage_plugin_register_with_registrar(flutter_secure_storage_registrar); | ||||
|   g_autoptr(FlPluginRegistrar) flutter_udid_registrar = | ||||
|       fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterUdidPlugin"); | ||||
|   flutter_udid_plugin_register_with_registrar(flutter_udid_registrar); | ||||
|   g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar = | ||||
|       fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin"); | ||||
|   flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar); | ||||
|   g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = | ||||
|       fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); | ||||
|   media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); | ||||
|   g_autoptr(FlPluginRegistrar) media_kit_video_registrar = | ||||
|       fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); | ||||
|   media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); | ||||
|   g_autoptr(FlPluginRegistrar) pasteboard_registrar = | ||||
|       fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin"); | ||||
|   pasteboard_plugin_register_with_registrar(pasteboard_registrar); | ||||
|   g_autoptr(FlPluginRegistrar) sentry_flutter_registrar = | ||||
|       fl_plugin_registry_get_registrar_for_plugin(registry, "SentryFlutterPlugin"); | ||||
|   sentry_flutter_plugin_register_with_registrar(sentry_flutter_registrar); | ||||
|   g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = | ||||
|       fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); | ||||
|   url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); | ||||
|   | ||||
| @@ -4,13 +4,19 @@ | ||||
|  | ||||
| list(APPEND FLUTTER_PLUGIN_LIST | ||||
|   file_selector_linux | ||||
|   flutter_secure_storage | ||||
|   flutter_udid | ||||
|   flutter_webrtc | ||||
|   media_kit_libs_linux | ||||
|   media_kit_video | ||||
|   pasteboard | ||||
|   sentry_flutter | ||||
|   url_launcher_linux | ||||
| ) | ||||
|  | ||||
| list(APPEND FLUTTER_FFI_PLUGIN_LIST | ||||
|   croppy | ||||
|   jni | ||||
|   media_kit_native_event_loop | ||||
| ) | ||||
|  | ||||
| set(PLUGIN_BUNDLED_LIBRARIES) | ||||
|   | ||||
| @@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) { | ||||
|   if (use_header_bar) { | ||||
|     GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); | ||||
|     gtk_widget_show(GTK_WIDGET(header_bar)); | ||||
|     gtk_header_bar_set_title(header_bar, "surface"); | ||||
|     gtk_header_bar_set_title(header_bar, "Surface"); | ||||
|     gtk_header_bar_set_show_close_button(header_bar, TRUE); | ||||
|     gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); | ||||
|   } else { | ||||
|     gtk_window_set_title(window, "surface"); | ||||
|     gtk_window_set_title(window, "Surface"); | ||||
|   } | ||||
|  | ||||
|   gtk_window_set_default_size(window, 1280, 720); | ||||
|   | ||||
| @@ -6,17 +6,45 @@ import FlutterMacOS | ||||
| import Foundation | ||||
|  | ||||
| import connectivity_plus | ||||
| import device_info_plus | ||||
| import file_selector_macos | ||||
| import firebase_analytics | ||||
| import firebase_core | ||||
| import firebase_messaging | ||||
| import flutter_udid | ||||
| import flutter_webrtc | ||||
| import livekit_client | ||||
| import media_kit_libs_macos_video | ||||
| import media_kit_video | ||||
| import package_info_plus | ||||
| import pasteboard | ||||
| import path_provider_foundation | ||||
| import screen_brightness_macos | ||||
| import sentry_flutter | ||||
| import shared_preferences_foundation | ||||
| import sqflite_darwin | ||||
| import url_launcher_macos | ||||
| import wakelock_plus | ||||
|  | ||||
| func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { | ||||
|   ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) | ||||
|   DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) | ||||
|   FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) | ||||
|   FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin")) | ||||
|   FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) | ||||
|   FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) | ||||
|   FlutterUdidPlugin.register(with: registry.registrar(forPlugin: "FlutterUdidPlugin")) | ||||
|   FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) | ||||
|   LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin")) | ||||
|   MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) | ||||
|   MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) | ||||
|   FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) | ||||
|   PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) | ||||
|   PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) | ||||
|   ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin")) | ||||
|   SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin")) | ||||
|   SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) | ||||
|   SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) | ||||
|   UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) | ||||
|   WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) | ||||
| } | ||||
|   | ||||
| @@ -21,6 +21,7 @@ | ||||
| /* End PBXAggregateTarget section */ | ||||
|  | ||||
| /* Begin PBXBuildFile section */ | ||||
| 		10866B66713B0EB784DE9AC6 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = B39E2ED81C91DEB7E23A8321 /* GoogleService-Info.plist */; }; | ||||
| 		331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; | ||||
| 		335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; | ||||
| 		33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; | ||||
| @@ -64,7 +65,7 @@ | ||||
| 		331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; }; | ||||
| 		333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; }; | ||||
| 		335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; }; | ||||
| 		33CC10ED2044A3C60003C045 /* surface.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "surface.app"; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||
| 		33CC10ED2044A3C60003C045 /* surface.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = surface.app; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||
| 		33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; | ||||
| 		33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; }; | ||||
| 		33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; }; | ||||
| @@ -78,6 +79,7 @@ | ||||
| 		33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; }; | ||||
| 		7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; }; | ||||
| 		9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; }; | ||||
| 		B39E2ED81C91DEB7E23A8321 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; }; | ||||
| /* End PBXFileReference section */ | ||||
|  | ||||
| /* Begin PBXFrameworksBuildPhase section */ | ||||
| @@ -125,6 +127,7 @@ | ||||
| 				331C80D6294CF71000263BE5 /* RunnerTests */, | ||||
| 				33CC10EE2044A3C60003C045 /* Products */, | ||||
| 				D73912EC22F37F3D000D13A0 /* Frameworks */, | ||||
| 				B39E2ED81C91DEB7E23A8321 /* GoogleService-Info.plist */, | ||||
| 			); | ||||
| 			sourceTree = "<group>"; | ||||
| 		}; | ||||
| @@ -209,6 +212,7 @@ | ||||
| 				33CC10EB2044A3C60003C045 /* Resources */, | ||||
| 				33CC110E2044A8840003C045 /* Bundle Framework */, | ||||
| 				3399D490228B24CF009A79C7 /* ShellScript */, | ||||
| 				EF14D4B7B68679D2F4942581 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */, | ||||
| 			); | ||||
| 			buildRules = ( | ||||
| 			); | ||||
| @@ -285,6 +289,7 @@ | ||||
| 			files = ( | ||||
| 				33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, | ||||
| 				33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, | ||||
| 				10866B66713B0EB784DE9AC6 /* GoogleService-Info.plist in Resources */, | ||||
| 			); | ||||
| 			runOnlyForDeploymentPostprocessing = 0; | ||||
| 		}; | ||||
| @@ -329,6 +334,24 @@ | ||||
| 			shellPath = /bin/sh; | ||||
| 			shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; | ||||
| 		}; | ||||
| 		EF14D4B7B68679D2F4942581 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = { | ||||
| 			isa = PBXShellScriptBuildPhase; | ||||
| 			buildActionMask = 2147483647; | ||||
| 			files = ( | ||||
| 			); | ||||
| 			inputFileListPaths = ( | ||||
| 			); | ||||
| 			inputPaths = ( | ||||
| 			); | ||||
| 			name = "FlutterFire: \"flutterfire upload-crashlytics-symbols\""; | ||||
| 			outputFileListPaths = ( | ||||
| 			); | ||||
| 			outputPaths = ( | ||||
| 			); | ||||
| 			runOnlyForDeploymentPostprocessing = 0; | ||||
| 			shellPath = /bin/sh; | ||||
| 			shellScript = "\n#!/bin/bash\nPATH=${PATH}:$FLUTTER_ROOT/bin:$HOME/.pub-cache/bin\nflutterfire upload-crashlytics-symbols --upload-symbols-script-path=$PODS_ROOT/FirebaseCrashlytics/upload-symbols --platform=macos --apple-project-path=${SRCROOT} --env-platform-name=${PLATFORM_NAME} --env-configuration=${CONFIGURATION} --env-project-dir=${PROJECT_DIR} --env-built-products-dir=${BUILT_PRODUCTS_DIR} --env-dwarf-dsym-folder-path=${DWARF_DSYM_FOLDER_PATH} --env-dwarf-dsym-file-name=${DWARF_DSYM_FILE_NAME} --env-infoplist-path=${INFOPLIST_PATH} --default-config=default\n"; | ||||
| 		}; | ||||
| /* End PBXShellScriptBuildPhase section */ | ||||
|  | ||||
| /* Begin PBXSourcesBuildPhase section */ | ||||
|   | ||||
| @@ -1,68 +1,68 @@ | ||||
| { | ||||
|     "info": { | ||||
|         "version": 1, | ||||
|         "author": "xcode" | ||||
|   "images": [ | ||||
|     { | ||||
|       "filename": "app_icon_16.png", | ||||
|       "idiom": "mac", | ||||
|       "scale": "1x", | ||||
|       "size": "16x16" | ||||
|     }, | ||||
|     "images": [ | ||||
|         { | ||||
|             "size": "16x16", | ||||
|             "idiom": "mac", | ||||
|             "filename": "app_icon_16.png", | ||||
|             "scale": "1x" | ||||
|         }, | ||||
|         { | ||||
|             "size": "16x16", | ||||
|             "idiom": "mac", | ||||
|             "filename": "app_icon_32.png", | ||||
|             "scale": "2x" | ||||
|         }, | ||||
|         { | ||||
|             "size": "32x32", | ||||
|             "idiom": "mac", | ||||
|             "filename": "app_icon_32.png", | ||||
|             "scale": "1x" | ||||
|         }, | ||||
|         { | ||||
|             "size": "32x32", | ||||
|             "idiom": "mac", | ||||
|             "filename": "app_icon_64.png", | ||||
|             "scale": "2x" | ||||
|         }, | ||||
|         { | ||||
|             "size": "128x128", | ||||
|             "idiom": "mac", | ||||
|             "filename": "app_icon_128.png", | ||||
|             "scale": "1x" | ||||
|         }, | ||||
|         { | ||||
|             "size": "128x128", | ||||
|             "idiom": "mac", | ||||
|             "filename": "app_icon_256.png", | ||||
|             "scale": "2x" | ||||
|         }, | ||||
|         { | ||||
|             "size": "256x256", | ||||
|             "idiom": "mac", | ||||
|             "filename": "app_icon_256.png", | ||||
|             "scale": "1x" | ||||
|         }, | ||||
|         { | ||||
|             "size": "256x256", | ||||
|             "idiom": "mac", | ||||
|             "filename": "app_icon_512.png", | ||||
|             "scale": "2x" | ||||
|         }, | ||||
|         { | ||||
|             "size": "512x512", | ||||
|             "idiom": "mac", | ||||
|             "filename": "app_icon_512.png", | ||||
|             "scale": "1x" | ||||
|         }, | ||||
|         { | ||||
|             "size": "512x512", | ||||
|             "idiom": "mac", | ||||
|             "filename": "app_icon_1024.png", | ||||
|             "scale": "2x" | ||||
|         } | ||||
|     ] | ||||
|     { | ||||
|       "filename": "app_icon_32.png", | ||||
|       "idiom": "mac", | ||||
|       "scale": "2x", | ||||
|       "size": "16x16" | ||||
|     }, | ||||
|     { | ||||
|       "filename": "app_icon_32.png", | ||||
|       "idiom": "mac", | ||||
|       "scale": "1x", | ||||
|       "size": "32x32" | ||||
|     }, | ||||
|     { | ||||
|       "filename": "app_icon_64.png", | ||||
|       "idiom": "mac", | ||||
|       "scale": "2x", | ||||
|       "size": "32x32" | ||||
|     }, | ||||
|     { | ||||
|       "filename": "app_icon_128.png", | ||||
|       "idiom": "mac", | ||||
|       "scale": "1x", | ||||
|       "size": "128x128" | ||||
|     }, | ||||
|     { | ||||
|       "filename": "app_icon_256.png", | ||||
|       "idiom": "mac", | ||||
|       "scale": "2x", | ||||
|       "size": "128x128" | ||||
|     }, | ||||
|     { | ||||
|       "filename": "app_icon_256.png", | ||||
|       "idiom": "mac", | ||||
|       "scale": "1x", | ||||
|       "size": "256x256" | ||||
|     }, | ||||
|     { | ||||
|       "filename": "app_icon_512.png", | ||||
|       "idiom": "mac", | ||||
|       "scale": "2x", | ||||
|       "size": "256x256" | ||||
|     }, | ||||
|     { | ||||
|       "filename": "app_icon_512.png", | ||||
|       "idiom": "mac", | ||||
|       "scale": "1x", | ||||
|       "size": "512x512" | ||||
|     }, | ||||
|     { | ||||
|       "filename": "app_icon_1024.png", | ||||
|       "idiom": "mac", | ||||
|       "scale": "2x", | ||||
|       "size": "512x512" | ||||
|     } | ||||
|   ], | ||||
|   "info": { | ||||
|     "author": "icons_launcher", | ||||
|     "version": 1 | ||||
|   } | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 132 KiB | 
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 7.6 KiB | 
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 315 B After Width: | Height: | Size: 1.6 KiB | 
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user