Compare commits
	
		
			27 Commits
		
	
	
		
			28335dd548
			...
			3.1.0+116
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8956723ac5 | |||
| ccc3ac415e | |||
| 8c47a59b80 | |||
| a6d869ebf6 | |||
| f3a8699389 | |||
| d345c00e84 | |||
| a706f127b6 | |||
| 680ece0b6a | |||
| b976c6ed37 | |||
| 6ae6b132de | |||
| 95aec7c95b | |||
| edd760fbcb | |||
| ba269dbbb8 | |||
| 1aa45dd9f1 | |||
| 92685d7410 | |||
| c8e351514d | |||
| f3900825e3 | |||
| 2cc6652f75 | |||
| 4d4409de2e | |||
| e1286c797f | |||
| bec037622f | |||
| a0d8c1a9b3 | |||
| 26135d2116 | |||
| 71b67fd22d | |||
| 855072dfea | |||
| b39e2e2d64 | |||
| 84b1d6a346 | 
| @@ -59,7 +59,6 @@ dependencies { | ||||
|     implementation("com.google.android.material:material:1.12.0") | ||||
|     implementation("com.github.bumptech.glide:glide:4.16.0") | ||||
|     implementation("com.squareup.okhttp3:okhttp:4.12.0") | ||||
|     implementation("com.google.firebase:firebase-messaging-ktx") | ||||
| } | ||||
|  | ||||
| flutter { | ||||
|   | ||||
| @@ -4,6 +4,7 @@ | ||||
|     <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.MODIFY_AUDIO_SETTINGS" /> | ||||
|     <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" /> | ||||
| @@ -89,6 +90,13 @@ | ||||
|             </intent-filter> | ||||
|         </activity> | ||||
|  | ||||
|         <!-- Livekit Screenshare --> | ||||
|         <service | ||||
|             android:name="de.julianassmann.flutter_background.IsolateHolderService" | ||||
|             android:enabled="true" | ||||
|             android:exported="false" | ||||
|             android:foregroundServiceType="mediaProjection" /> | ||||
|  | ||||
|         <!-- Sign in with Apple --> | ||||
|         <activity | ||||
|             android:name="com.aboutyou.dart_packages.sign_in_with_apple.SignInWithAppleCallback" | ||||
| @@ -109,14 +117,6 @@ | ||||
|             android:enabled="true" | ||||
|             android:exported="true" /> | ||||
|  | ||||
|         <service | ||||
|             android:name=".service.MessagingService" | ||||
|             android:exported="false"> | ||||
|             <intent-filter> | ||||
|                 <action android:name="com.google.firebase.MESSAGING_EVENT" /> | ||||
|             </intent-filter> | ||||
|         </service> | ||||
|  | ||||
|         <provider | ||||
|             android:name="androidx.core.content.FileProvider" | ||||
|             android:authorities="dev.solsynth.solian.provider" | ||||
| @@ -143,4 +143,4 @@ | ||||
|             <data android:mimeType="text/plain" /> | ||||
|         </intent> | ||||
|     </queries> | ||||
| </manifest> | ||||
| </manifest> | ||||
|   | ||||
| @@ -1,102 +0,0 @@ | ||||
| package dev.solsynth.solian.service | ||||
|  | ||||
| import android.app.PendingIntent | ||||
| import android.content.Intent | ||||
| import android.graphics.Bitmap | ||||
| import android.graphics.drawable.Drawable | ||||
| import android.os.Build | ||||
| import androidx.core.app.NotificationCompat | ||||
| import androidx.core.app.NotificationManagerCompat | ||||
| import androidx.core.app.RemoteInput | ||||
| import com.bumptech.glide.Glide | ||||
| import com.bumptech.glide.request.target.CustomTarget | ||||
| import com.bumptech.glide.request.transition.Transition | ||||
| import com.google.firebase.messaging.FirebaseMessagingService | ||||
| import com.google.firebase.messaging.RemoteMessage | ||||
| import dev.solsynth.solian.MainActivity | ||||
| import dev.solsynth.solian.receiver.ReplyReceiver | ||||
| import org.json.JSONObject | ||||
|  | ||||
| class MessagingService: FirebaseMessagingService() { | ||||
|     override fun onMessageReceived(remoteMessage: RemoteMessage) { | ||||
|         val type = remoteMessage.data["type"] | ||||
|         if (type == "messages.new") { | ||||
|             handleMessageNotification(remoteMessage) | ||||
|         } else { | ||||
|             // Handle other notification types | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun handleMessageNotification(remoteMessage: RemoteMessage) { | ||||
|         val data = remoteMessage.data | ||||
|         val metaString = data["meta"] ?: return | ||||
|         val meta = JSONObject(metaString) | ||||
|  | ||||
|         val pfp = meta.optString("pfp", null) | ||||
|         val roomId = meta.optString("room_id", null) | ||||
|         val messageId = meta.optString("message_id", null) | ||||
|  | ||||
|         val notificationId = System.currentTimeMillis().toInt() | ||||
|  | ||||
|         val replyLabel = "Reply" | ||||
|         val remoteInput = RemoteInput.Builder("key_text_reply") | ||||
|             .setLabel(replyLabel) | ||||
|             .build() | ||||
|  | ||||
|         val replyIntent = Intent(this, ReplyReceiver::class.java).apply { | ||||
|             putExtra("room_id", roomId) | ||||
|             putExtra("message_id", messageId) | ||||
|             putExtra("notification_id", notificationId) | ||||
|         } | ||||
|  | ||||
|         val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { | ||||
|             PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE | ||||
|         } else { | ||||
|             PendingIntent.FLAG_UPDATE_CURRENT | ||||
|         } | ||||
|  | ||||
|         val replyPendingIntent = PendingIntent.getBroadcast( | ||||
|             applicationContext, | ||||
|             notificationId, | ||||
|             replyIntent, | ||||
|             pendingIntentFlags | ||||
|         ) | ||||
|  | ||||
|         val action = NotificationCompat.Action.Builder( | ||||
|             android.R.drawable.ic_menu_send, | ||||
|             replyLabel, | ||||
|             replyPendingIntent | ||||
|         ) | ||||
|             .addRemoteInput(remoteInput) | ||||
|             .build() | ||||
|  | ||||
|         val intent = Intent(this, MainActivity::class.java) | ||||
|         intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) | ||||
|         intent.putExtra("room_id", roomId) | ||||
|         val pendingIntent = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags) | ||||
|  | ||||
|         val notificationBuilder = NotificationCompat.Builder(this, "messages") | ||||
|             .setSmallIcon(android.R.drawable.ic_dialog_info) | ||||
|             .setContentTitle(remoteMessage.notification?.title) | ||||
|             .setContentText(remoteMessage.notification?.body) | ||||
|             .setPriority(NotificationCompat.PRIORITY_HIGH) | ||||
|             .setContentIntent(pendingIntent) | ||||
|             .addAction(action) | ||||
|  | ||||
|         if (pfp != null) { | ||||
|             Glide.with(applicationContext) | ||||
|                 .asBitmap() | ||||
|                 .load(pfp) | ||||
|                 .into(object : CustomTarget<Bitmap>() { | ||||
|                     override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { | ||||
|                         notificationBuilder.setLargeIcon(resource) | ||||
|                         NotificationManagerCompat.from(applicationContext).notify(notificationId, notificationBuilder.build()) | ||||
|                     } | ||||
|  | ||||
|                     override fun onLoadCleared(placeholder: Drawable?) {} | ||||
|                 }) | ||||
|         } else { | ||||
|             NotificationManagerCompat.from(this).notify(notificationId, notificationBuilder.build()) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -146,9 +146,12 @@ | ||||
|   "edited": "Edited", | ||||
|   "addVideo": "Add video", | ||||
|   "addPhoto": "Add photo", | ||||
|   "addAudio": "Add audio", | ||||
|   "addFile": "Add file", | ||||
|   "recordAudio": "Record Audio", | ||||
|   "linkAttachment": "Link Attachment", | ||||
|   "fileIdCannotBeEmpty": "File ID cannot be empty", | ||||
|   "fileIdLinkHint": "Haven't upload to the Solar Network? Tap here to open Solar Network Drive to customize your uploads.", | ||||
|   "failedToFetchFile": "Failed to fetch file: {}", | ||||
|   "createDirectMessage": "Send new DM", | ||||
|   "gotoDirectMessage": "Go to DM", | ||||
| @@ -728,5 +731,35 @@ | ||||
|   "selectCamera": "Select Camera", | ||||
|   "switchedTo": "Switched to {}", | ||||
|   "connecting": "Connecting", | ||||
|   "repliesLoadMore": "Load more replies" | ||||
|   "reconnecting": "Reconnecting", | ||||
|   "disconnected": "Disconnected", | ||||
|   "connected": "Connected", | ||||
|   "repliesLoadMore": "Load more replies", | ||||
|   "attachmentsRecentUploads": "Recent Uploads", | ||||
|   "attachmentsManualInput": "Manual Input", | ||||
|   "crop": "Crop", | ||||
|   "rename": "Rename", | ||||
|   "markAsSensitive": "Mark as Sensitive", | ||||
|   "fileName": "File name", | ||||
|   "sensitiveCategories.language": "Language", | ||||
|   "sensitiveCategories.sexualContent": "Sexual Content", | ||||
|   "sensitiveCategories.violence": "Violence", | ||||
|   "sensitiveCategories.profanity": "Profanity", | ||||
|   "sensitiveCategories.hateSpeech": "Hate Speech", | ||||
|   "sensitiveCategories.racism": "Racism", | ||||
|   "sensitiveCategories.adultContent": "Adult Content", | ||||
|   "sensitiveCategories.drugAbuse": "Drug Abuse", | ||||
|   "sensitiveCategories.alcoholAbuse": "Alcohol Abuse", | ||||
|   "sensitiveCategories.gambling": "Gambling", | ||||
|   "sensitiveCategories.selfHarm": "Self-harm", | ||||
|   "sensitiveCategories.childAbuse": "Child Abuse", | ||||
|   "sensitiveCategories.other": "Other", | ||||
|   "poll": "Poll", | ||||
|   "pollsRecent": "Recent Polls", | ||||
|   "pollCreateNew": "Create New", | ||||
|   "pollCreateNewHint": "Create a new poll for your post. Pick a publisher and continue.", | ||||
|   "publisher": "Publisher", | ||||
|   "publisherHint": "Enter the publisher name", | ||||
|   "publisherCannotBeEmpty": "Publisher cannot be empty", | ||||
|   "operationFailed": "Operation failed: {}" | ||||
| } | ||||
|   | ||||
| @@ -10,6 +10,8 @@ | ||||
| 		1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; | ||||
| 		331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; | ||||
| 		3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; | ||||
| 		73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */; }; | ||||
| 		73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | ||||
| 		73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | ||||
| 		73CDD6812DEC00480059D95D /* SolianNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | ||||
| 		73D4264B2DEB815D006C0AAE /* NotifyDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D4264A2DEB815D006C0AAE /* NotifyDelegate.swift */; }; | ||||
| @@ -32,6 +34,13 @@ | ||||
| 			remoteGlobalIDString = 97C146ED1CF9000F007C117D; | ||||
| 			remoteInfo = Runner; | ||||
| 		}; | ||||
| 		73ACDFC12E3D0E6100B63535 /* PBXContainerItemProxy */ = { | ||||
| 			isa = PBXContainerItemProxy; | ||||
| 			containerPortal = 97C146E61CF9000F007C117D /* Project object */; | ||||
| 			proxyType = 1; | ||||
| 			remoteGlobalIDString = 73ACDFAA2E3D0E6100B63535; | ||||
| 			remoteInfo = SolianBroadcastExtension; | ||||
| 		}; | ||||
| 		73C305D62E0BE878009035B9 /* PBXContainerItemProxy */ = { | ||||
| 			isa = PBXContainerItemProxy; | ||||
| 			containerPortal = 97C146E61CF9000F007C117D /* Project object */; | ||||
| @@ -55,6 +64,7 @@ | ||||
| 			dstPath = ""; | ||||
| 			dstSubfolderSpec = 13; | ||||
| 			files = ( | ||||
| 				73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */, | ||||
| 				73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */, | ||||
| 				73CDD6812DEC00480059D95D /* SolianNotificationService.appex in Embed Foundation Extensions */, | ||||
| 			); | ||||
| @@ -91,6 +101,9 @@ | ||||
| 		3A1C47BD29CC6AC2587D4DBE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; | ||||
| 		3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; | ||||
| 		737E920B2DB6A9FF00BE9CDB /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; }; | ||||
| 		73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianBroadcastExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||
| 		73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReplayKit.framework; path = System/Library/Frameworks/ReplayKit.framework; sourceTree = SDKROOT; }; | ||||
| 		73ACDFB82E3D0E6100B63535 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; | ||||
| 		73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||
| 		73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||
| 		73D4264A2DEB815D006C0AAE /* NotifyDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotifyDelegate.swift; sourceTree = "<group>"; }; | ||||
| @@ -117,6 +130,13 @@ | ||||
| /* End PBXFileReference section */ | ||||
|  | ||||
| /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ | ||||
| 		73ACDFCA2E3D0E6100B63535 /* Exceptions for "SolianBroadcastExtension" folder in "SolianBroadcastExtension" target */ = { | ||||
| 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | ||||
| 			membershipExceptions = ( | ||||
| 				Info.plist, | ||||
| 			); | ||||
| 			target = 73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */; | ||||
| 		}; | ||||
| 		73C305DC2E0BE878009035B9 /* Exceptions for "SolianShareExtension" folder in "SolianShareExtension" target */ = { | ||||
| 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | ||||
| 			membershipExceptions = ( | ||||
| @@ -150,6 +170,14 @@ | ||||
| 			path = Services; | ||||
| 			sourceTree = "<group>"; | ||||
| 		}; | ||||
| 		73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */ = { | ||||
| 			isa = PBXFileSystemSynchronizedRootGroup; | ||||
| 			exceptions = ( | ||||
| 				73ACDFCA2E3D0E6100B63535 /* Exceptions for "SolianBroadcastExtension" folder in "SolianBroadcastExtension" target */, | ||||
| 			); | ||||
| 			path = SolianBroadcastExtension; | ||||
| 			sourceTree = "<group>"; | ||||
| 		}; | ||||
| 		73C305CF2E0BE878009035B9 /* SolianShareExtension */ = { | ||||
| 			isa = PBXFileSystemSynchronizedRootGroup; | ||||
| 			exceptions = ( | ||||
| @@ -177,6 +205,14 @@ | ||||
| 			); | ||||
| 			runOnlyForDeploymentPostprocessing = 0; | ||||
| 		}; | ||||
| 		73ACDFA82E3D0E6100B63535 /* Frameworks */ = { | ||||
| 			isa = PBXFrameworksBuildPhase; | ||||
| 			buildActionMask = 2147483647; | ||||
| 			files = ( | ||||
| 				73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */, | ||||
| 			); | ||||
| 			runOnlyForDeploymentPostprocessing = 0; | ||||
| 		}; | ||||
| 		73C305CB2E0BE878009035B9 /* Frameworks */ = { | ||||
| 			isa = PBXFrameworksBuildPhase; | ||||
| 			buildActionMask = 2147483647; | ||||
| @@ -220,6 +256,8 @@ | ||||
| 				AA0CA8A3E15DEE023BB27438 /* Pods_NotificationService.framework */, | ||||
| 				39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */, | ||||
| 				7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */, | ||||
| 				73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */, | ||||
| 				73ACDFB82E3D0E6100B63535 /* UIKit.framework */, | ||||
| 			); | ||||
| 			name = Frameworks; | ||||
| 			sourceTree = "<group>"; | ||||
| @@ -264,6 +302,7 @@ | ||||
| 				97C146F01CF9000F007C117D /* Runner */, | ||||
| 				73CDD67B2DEC00480059D95D /* SolianNotificationService */, | ||||
| 				73C305CF2E0BE878009035B9 /* SolianShareExtension */, | ||||
| 				73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */, | ||||
| 				97C146EF1CF9000F007C117D /* Products */, | ||||
| 				331C8082294A63A400263BE5 /* RunnerTests */, | ||||
| 				91E124CE95BCB4DCD890160D /* Pods */, | ||||
| @@ -279,6 +318,7 @@ | ||||
| 				331C8081294A63A400263BE5 /* RunnerTests.xctest */, | ||||
| 				73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */, | ||||
| 				73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */, | ||||
| 				73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */, | ||||
| 			); | ||||
| 			name = Products; | ||||
| 			sourceTree = "<group>"; | ||||
| @@ -323,6 +363,26 @@ | ||||
| 			productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; | ||||
| 			productType = "com.apple.product-type.bundle.unit-test"; | ||||
| 		}; | ||||
| 		73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */ = { | ||||
| 			isa = PBXNativeTarget; | ||||
| 			buildConfigurationList = 73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */; | ||||
| 			buildPhases = ( | ||||
| 				73ACDFA72E3D0E6100B63535 /* Sources */, | ||||
| 				73ACDFA82E3D0E6100B63535 /* Frameworks */, | ||||
| 				73ACDFA92E3D0E6100B63535 /* Resources */, | ||||
| 			); | ||||
| 			buildRules = ( | ||||
| 			); | ||||
| 			dependencies = ( | ||||
| 			); | ||||
| 			fileSystemSynchronizedGroups = ( | ||||
| 				73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */, | ||||
| 			); | ||||
| 			name = SolianBroadcastExtension; | ||||
| 			productName = SolianBroadcastExtension; | ||||
| 			productReference = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */; | ||||
| 			productType = "com.apple.product-type.app-extension"; | ||||
| 		}; | ||||
| 		73C305CD2E0BE878009035B9 /* SolianShareExtension */ = { | ||||
| 			isa = PBXNativeTarget; | ||||
| 			buildConfigurationList = 73C305DD2E0BE878009035B9 /* Build configuration list for PBXNativeTarget "SolianShareExtension" */; | ||||
| @@ -385,6 +445,7 @@ | ||||
| 			dependencies = ( | ||||
| 				73CDD6802DEC00480059D95D /* PBXTargetDependency */, | ||||
| 				73C305D72E0BE878009035B9 /* PBXTargetDependency */, | ||||
| 				73ACDFC22E3D0E6100B63535 /* PBXTargetDependency */, | ||||
| 			); | ||||
| 			fileSystemSynchronizedGroups = ( | ||||
| 				73268D272DEB012A0076E970 /* Services */, | ||||
| @@ -409,6 +470,9 @@ | ||||
| 						CreatedOnToolsVersion = 14.0; | ||||
| 						TestTargetID = 97C146ED1CF9000F007C117D; | ||||
| 					}; | ||||
| 					73ACDFAA2E3D0E6100B63535 = { | ||||
| 						CreatedOnToolsVersion = 16.4; | ||||
| 					}; | ||||
| 					73C305CD2E0BE878009035B9 = { | ||||
| 						CreatedOnToolsVersion = 16.4; | ||||
| 					}; | ||||
| @@ -438,6 +502,7 @@ | ||||
| 				331C8080294A63A400263BE5 /* RunnerTests */, | ||||
| 				73CDD6792DEC00480059D95D /* SolianNotificationService */, | ||||
| 				73C305CD2E0BE878009035B9 /* SolianShareExtension */, | ||||
| 				73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */, | ||||
| 			); | ||||
| 		}; | ||||
| /* End PBXProject section */ | ||||
| @@ -450,6 +515,13 @@ | ||||
| 			); | ||||
| 			runOnlyForDeploymentPostprocessing = 0; | ||||
| 		}; | ||||
| 		73ACDFA92E3D0E6100B63535 /* Resources */ = { | ||||
| 			isa = PBXResourcesBuildPhase; | ||||
| 			buildActionMask = 2147483647; | ||||
| 			files = ( | ||||
| 			); | ||||
| 			runOnlyForDeploymentPostprocessing = 0; | ||||
| 		}; | ||||
| 		73C305CC2E0BE878009035B9 /* Resources */ = { | ||||
| 			isa = PBXResourcesBuildPhase; | ||||
| 			buildActionMask = 2147483647; | ||||
| @@ -643,6 +715,13 @@ | ||||
| 			); | ||||
| 			runOnlyForDeploymentPostprocessing = 0; | ||||
| 		}; | ||||
| 		73ACDFA72E3D0E6100B63535 /* Sources */ = { | ||||
| 			isa = PBXSourcesBuildPhase; | ||||
| 			buildActionMask = 2147483647; | ||||
| 			files = ( | ||||
| 			); | ||||
| 			runOnlyForDeploymentPostprocessing = 0; | ||||
| 		}; | ||||
| 		73C305CA2E0BE878009035B9 /* Sources */ = { | ||||
| 			isa = PBXSourcesBuildPhase; | ||||
| 			buildActionMask = 2147483647; | ||||
| @@ -675,6 +754,11 @@ | ||||
| 			target = 97C146ED1CF9000F007C117D /* Runner */; | ||||
| 			targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; | ||||
| 		}; | ||||
| 		73ACDFC22E3D0E6100B63535 /* PBXTargetDependency */ = { | ||||
| 			isa = PBXTargetDependency; | ||||
| 			target = 73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */; | ||||
| 			targetProxy = 73ACDFC12E3D0E6100B63535 /* PBXContainerItemProxy */; | ||||
| 		}; | ||||
| 		73C305D72E0BE878009035B9 /* PBXTargetDependency */ = { | ||||
| 			isa = PBXTargetDependency; | ||||
| 			target = 73C305CD2E0BE878009035B9 /* SolianShareExtension */; | ||||
| @@ -773,7 +857,7 @@ | ||||
| 				INFOPLIST_FILE = Runner/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
| @@ -836,6 +920,123 @@ | ||||
| 			}; | ||||
| 			name = Profile; | ||||
| 		}; | ||||
| 		73ACDFC42E3D0E6100B63535 /* Debug */ = { | ||||
| 			isa = XCBuildConfiguration; | ||||
| 			buildSettings = { | ||||
| 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; | ||||
| 				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; | ||||
| 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; | ||||
| 				CLANG_ENABLE_OBJC_WEAK = YES; | ||||
| 				CLANG_WARN_DOCUMENTATION_COMMENTS = YES; | ||||
| 				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; | ||||
| 				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | ||||
| 				CODE_SIGN_ENTITLEMENTS = SolianBroadcastExtension/SolianBroadcastExtension.entitlements; | ||||
| 				CODE_SIGN_STYLE = Automatic; | ||||
| 				CURRENT_PROJECT_VERSION = 1; | ||||
| 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||
| 				ENABLE_USER_SCRIPT_SANDBOXING = YES; | ||||
| 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||
| 				GENERATE_INFOPLIST_FILE = YES; | ||||
| 				INFOPLIST_FILE = SolianBroadcastExtension/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianBroadcastExtension; | ||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
| 					"@executable_path/../../Frameworks", | ||||
| 				); | ||||
| 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES; | ||||
| 				MARKETING_VERSION = 1.0; | ||||
| 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; | ||||
| 				MTL_FAST_MATH = YES; | ||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension; | ||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||
| 				SKIP_INSTALL = YES; | ||||
| 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; | ||||
| 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||
| 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | ||||
| 				SWIFT_VERSION = 5.0; | ||||
| 				TARGETED_DEVICE_FAMILY = "1,2"; | ||||
| 			}; | ||||
| 			name = Debug; | ||||
| 		}; | ||||
| 		73ACDFC52E3D0E6100B63535 /* Release */ = { | ||||
| 			isa = XCBuildConfiguration; | ||||
| 			buildSettings = { | ||||
| 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; | ||||
| 				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; | ||||
| 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; | ||||
| 				CLANG_ENABLE_OBJC_WEAK = YES; | ||||
| 				CLANG_WARN_DOCUMENTATION_COMMENTS = YES; | ||||
| 				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; | ||||
| 				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | ||||
| 				CODE_SIGN_ENTITLEMENTS = SolianBroadcastExtension/SolianBroadcastExtension.entitlements; | ||||
| 				CODE_SIGN_STYLE = Automatic; | ||||
| 				CURRENT_PROJECT_VERSION = 1; | ||||
| 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||
| 				ENABLE_USER_SCRIPT_SANDBOXING = YES; | ||||
| 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||
| 				GENERATE_INFOPLIST_FILE = YES; | ||||
| 				INFOPLIST_FILE = SolianBroadcastExtension/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianBroadcastExtension; | ||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
| 					"@executable_path/../../Frameworks", | ||||
| 				); | ||||
| 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES; | ||||
| 				MARKETING_VERSION = 1.0; | ||||
| 				MTL_FAST_MATH = YES; | ||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension; | ||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||
| 				SKIP_INSTALL = YES; | ||||
| 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||
| 				SWIFT_VERSION = 5.0; | ||||
| 				TARGETED_DEVICE_FAMILY = "1,2"; | ||||
| 			}; | ||||
| 			name = Release; | ||||
| 		}; | ||||
| 		73ACDFC62E3D0E6100B63535 /* Profile */ = { | ||||
| 			isa = XCBuildConfiguration; | ||||
| 			buildSettings = { | ||||
| 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; | ||||
| 				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; | ||||
| 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; | ||||
| 				CLANG_ENABLE_OBJC_WEAK = YES; | ||||
| 				CLANG_WARN_DOCUMENTATION_COMMENTS = YES; | ||||
| 				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; | ||||
| 				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | ||||
| 				CODE_SIGN_ENTITLEMENTS = SolianBroadcastExtension/SolianBroadcastExtension.entitlements; | ||||
| 				CODE_SIGN_STYLE = Automatic; | ||||
| 				CURRENT_PROJECT_VERSION = 1; | ||||
| 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||
| 				ENABLE_USER_SCRIPT_SANDBOXING = YES; | ||||
| 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||
| 				GENERATE_INFOPLIST_FILE = YES; | ||||
| 				INFOPLIST_FILE = SolianBroadcastExtension/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianBroadcastExtension; | ||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
| 					"@executable_path/../../Frameworks", | ||||
| 				); | ||||
| 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES; | ||||
| 				MARKETING_VERSION = 1.0; | ||||
| 				MTL_FAST_MATH = YES; | ||||
| 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension; | ||||
| 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||
| 				SKIP_INSTALL = YES; | ||||
| 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||
| 				SWIFT_VERSION = 5.0; | ||||
| 				TARGETED_DEVICE_FAMILY = "1,2"; | ||||
| 			}; | ||||
| 			name = Profile; | ||||
| 		}; | ||||
| 		73C305D92E0BE878009035B9 /* Debug */ = { | ||||
| 			isa = XCBuildConfiguration; | ||||
| 			baseConfigurationReference = 17FAB080A9C53193ABD9C15B /* Pods-SolianShareExtension.debug.xcconfig */; | ||||
| @@ -1204,7 +1405,7 @@ | ||||
| 				INFOPLIST_FILE = Runner/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
| @@ -1232,7 +1433,7 @@ | ||||
| 				INFOPLIST_FILE = Runner/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
| @@ -1258,6 +1459,16 @@ | ||||
| 			defaultConfigurationIsVisible = 0; | ||||
| 			defaultConfigurationName = Release; | ||||
| 		}; | ||||
| 		73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */ = { | ||||
| 			isa = XCConfigurationList; | ||||
| 			buildConfigurations = ( | ||||
| 				73ACDFC42E3D0E6100B63535 /* Debug */, | ||||
| 				73ACDFC52E3D0E6100B63535 /* Release */, | ||||
| 				73ACDFC62E3D0E6100B63535 /* Profile */, | ||||
| 			); | ||||
| 			defaultConfigurationIsVisible = 0; | ||||
| 			defaultConfigurationName = Release; | ||||
| 		}; | ||||
| 		73C305DD2E0BE878009035B9 /* Build configuration list for PBXNativeTarget "SolianShareExtension" */ = { | ||||
| 			isa = XCConfigurationList; | ||||
| 			buildConfigurations = ( | ||||
|   | ||||
							
								
								
									
										37
									
								
								ios/SolianBroadcastExtension/Atomic.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								ios/SolianBroadcastExtension/Atomic.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| // | ||||
| //  Atomic.swift | ||||
| //  Broadcast Extension | ||||
| // | ||||
| //  Created by Maksym Shcheglov. | ||||
| //  https://www.onswiftwings.com/posts/atomic-property-wrapper/ | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| @propertyWrapper | ||||
| struct Atomic<Value> { | ||||
|  | ||||
|     private var value: Value | ||||
|     private let lock = NSLock() | ||||
|  | ||||
|     init(wrappedValue value: Value) { | ||||
|         self.value = value | ||||
|     } | ||||
|  | ||||
|     var wrappedValue: Value { | ||||
|         get { load() } | ||||
|         set { store(newValue: newValue) } | ||||
|     } | ||||
|  | ||||
|     func load() -> Value { | ||||
|         lock.lock() | ||||
|         defer { lock.unlock() } | ||||
|         return value | ||||
|     } | ||||
|  | ||||
|     mutating func store(newValue: Value) { | ||||
|         lock.lock() | ||||
|         defer { lock.unlock() } | ||||
|         value = newValue | ||||
|     } | ||||
| } | ||||
							
								
								
									
										29
									
								
								ios/SolianBroadcastExtension/DarwinNotification.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								ios/SolianBroadcastExtension/DarwinNotification.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| // | ||||
| //  DarwinNotificationCenter.swift | ||||
| //  Broadcast Extension | ||||
| // | ||||
| //  Created by Alex-Dan Bumbu on 23/03/2021. | ||||
| //  Copyright © 2021 8x8, Inc. All rights reserved. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| enum DarwinNotification: String { | ||||
|     case broadcastStarted = "iOS_BroadcastStarted" | ||||
|     case broadcastStopped = "iOS_BroadcastStopped" | ||||
| } | ||||
|  | ||||
| class DarwinNotificationCenter { | ||||
|      | ||||
|     static let shared = DarwinNotificationCenter() | ||||
|      | ||||
|     private let notificationCenter: CFNotificationCenter | ||||
|      | ||||
|     init() { | ||||
|         notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() | ||||
|     } | ||||
|      | ||||
|     func postNotification(_ name: DarwinNotification) { | ||||
|         CFNotificationCenterPostNotification(notificationCenter, CFNotificationName(rawValue: name.rawValue as CFString), nil, nil, true) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										15
									
								
								ios/SolianBroadcastExtension/Info.plist
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								ios/SolianBroadcastExtension/Info.plist
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| <?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>NSExtension</key> | ||||
| 	<dict> | ||||
| 		<key>NSExtensionPointIdentifier</key> | ||||
| 		<string>com.apple.broadcast-services-upload</string> | ||||
| 		<key>NSExtensionPrincipalClass</key> | ||||
| 		<string>$(PRODUCT_MODULE_NAME).SampleHandler</string> | ||||
| 		<key>RPBroadcastProcessMode</key> | ||||
| 		<string>RPBroadcastProcessModeSampleBuffer</string> | ||||
| 	</dict> | ||||
| </dict> | ||||
| </plist> | ||||
							
								
								
									
										103
									
								
								ios/SolianBroadcastExtension/SampleHandler.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								ios/SolianBroadcastExtension/SampleHandler.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | ||||
| // | ||||
| //  SampleHandler.swift | ||||
| //  Broadcast Extension | ||||
| // | ||||
| //  Created by Alex-Dan Bumbu on 04.06.2021. | ||||
| // | ||||
|  | ||||
| import ReplayKit | ||||
| import OSLog | ||||
|  | ||||
| let broadcastLogger = OSLog(subsystem: "dev.solsynth.solian", category: "Broadcast") | ||||
| private enum Constants { | ||||
|     // the App Group ID value that the app and the broadcast extension targets are setup with. It differs for each app. | ||||
|     static let appGroupIdentifier = "group.solsynth.solian" | ||||
| } | ||||
|  | ||||
| class SampleHandler: RPBroadcastSampleHandler { | ||||
|  | ||||
|     private var clientConnection: SocketConnection? | ||||
|     private var uploader: SampleUploader? | ||||
|  | ||||
|     private var frameCount: Int = 0 | ||||
|  | ||||
|     var socketFilePath: String { | ||||
|       let sharedContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.appGroupIdentifier) | ||||
|         return sharedContainer?.appendingPathComponent("rtc_SSFD").path ?? "" | ||||
|     } | ||||
|  | ||||
|     override init() { | ||||
|       super.init() | ||||
|         if let connection = SocketConnection(filePath: socketFilePath) { | ||||
|           clientConnection = connection | ||||
|           setupConnection() | ||||
|  | ||||
|           uploader = SampleUploader(connection: connection) | ||||
|         } | ||||
|         os_log(.debug, log: broadcastLogger, "%{public}s", socketFilePath) | ||||
|     } | ||||
|  | ||||
|     override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) { | ||||
|         // User has requested to start the broadcast. Setup info from the UI extension can be supplied but optional. | ||||
|         frameCount = 0 | ||||
|  | ||||
|         DarwinNotificationCenter.shared.postNotification(.broadcastStarted) | ||||
|         openConnection() | ||||
|     } | ||||
|  | ||||
|     override func broadcastPaused() { | ||||
|         // User has requested to pause the broadcast. Samples will stop being delivered. | ||||
|     } | ||||
|  | ||||
|     override func broadcastResumed() { | ||||
|         // User has requested to resume the broadcast. Samples delivery will resume. | ||||
|     } | ||||
|  | ||||
|     override func broadcastFinished() { | ||||
|         // User has requested to finish the broadcast. | ||||
|         DarwinNotificationCenter.shared.postNotification(.broadcastStopped) | ||||
|         clientConnection?.close() | ||||
|     } | ||||
|  | ||||
|     override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) { | ||||
|         switch sampleBufferType { | ||||
|         case RPSampleBufferType.video: | ||||
|             uploader?.send(sample: sampleBuffer) | ||||
|         default: | ||||
|             break | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| private extension SampleHandler { | ||||
|  | ||||
|     func setupConnection() { | ||||
|         clientConnection?.didClose = { [weak self] error in | ||||
|             os_log(.debug, log: broadcastLogger, "client connection did close \(String(describing: error))") | ||||
|  | ||||
|             if let error = error { | ||||
|                 self?.finishBroadcastWithError(error) | ||||
|             } else { | ||||
|                 // the displayed failure message is more user friendly when using NSError instead of Error | ||||
|                 let JMScreenSharingStopped = 10001 | ||||
|                 let customError = NSError(domain: RPRecordingErrorDomain, code: JMScreenSharingStopped, userInfo: [NSLocalizedDescriptionKey: "Screen sharing stopped"]) | ||||
|                 self?.finishBroadcastWithError(customError) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     func openConnection() { | ||||
|         let queue = DispatchQueue(label: "broadcast.connectTimer") | ||||
|         let timer = DispatchSource.makeTimerSource(queue: queue) | ||||
|         timer.schedule(deadline: .now(), repeating: .milliseconds(100), leeway: .milliseconds(500)) | ||||
|         timer.setEventHandler { [weak self] in | ||||
|             guard self?.clientConnection?.open() == true else { | ||||
|                 return | ||||
|             } | ||||
|  | ||||
|             timer.cancel() | ||||
|         } | ||||
|  | ||||
|         timer.resume() | ||||
|     } | ||||
| } | ||||
							
								
								
									
										147
									
								
								ios/SolianBroadcastExtension/SampleUploader.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								ios/SolianBroadcastExtension/SampleUploader.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,147 @@ | ||||
| // | ||||
| //  SampleUploader.swift | ||||
| //  Broadcast Extension | ||||
| // | ||||
| //  Created by Alex-Dan Bumbu on 22/03/2021. | ||||
| //  Copyright © 2021 8x8, Inc. All rights reserved. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import ReplayKit | ||||
| import OSLog | ||||
|  | ||||
| private enum Constants { | ||||
|     static let bufferMaxLength = 10240 | ||||
| } | ||||
|  | ||||
| class SampleUploader { | ||||
|      | ||||
|     private static var imageContext = CIContext(options: nil) | ||||
|      | ||||
|     @Atomic private var isReady = false | ||||
|     private var connection: SocketConnection | ||||
|    | ||||
|     private var dataToSend: Data? | ||||
|     private var byteIndex = 0 | ||||
|    | ||||
|     private let serialQueue: DispatchQueue | ||||
|      | ||||
|     init(connection: SocketConnection) { | ||||
|         self.connection = connection | ||||
|         self.serialQueue = DispatchQueue(label: "org.jitsi.meet.broadcast.sampleUploader") | ||||
|        | ||||
|         setupConnection() | ||||
|     } | ||||
|    | ||||
|     @discardableResult func send(sample buffer: CMSampleBuffer) -> Bool { | ||||
|         guard isReady else { | ||||
|             return false | ||||
|         } | ||||
|          | ||||
|         isReady = false | ||||
|  | ||||
|         dataToSend = prepare(sample: buffer) | ||||
|         byteIndex = 0 | ||||
|  | ||||
|         serialQueue.async { [weak self] in | ||||
|             self?.sendDataChunk() | ||||
|         } | ||||
|          | ||||
|         return true | ||||
|     } | ||||
| } | ||||
|  | ||||
| private extension SampleUploader { | ||||
|      | ||||
|     func setupConnection() { | ||||
|         connection.didOpen = { [weak self] in | ||||
|             self?.isReady = true | ||||
|         } | ||||
|         connection.streamHasSpaceAvailable = { [weak self] in | ||||
|             self?.serialQueue.async { | ||||
|                 if let success = self?.sendDataChunk() { | ||||
|                     self?.isReady = !success | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     @discardableResult func sendDataChunk() -> Bool { | ||||
|         guard let dataToSend = dataToSend else { | ||||
|             return false | ||||
|         } | ||||
|        | ||||
|         var bytesLeft = dataToSend.count - byteIndex | ||||
|         var length = bytesLeft > Constants.bufferMaxLength ? Constants.bufferMaxLength : bytesLeft | ||||
|  | ||||
|         length = dataToSend[byteIndex..<(byteIndex + length)].withUnsafeBytes { | ||||
|             guard let ptr = $0.bindMemory(to: UInt8.self).baseAddress else { | ||||
|                 return 0 | ||||
|             } | ||||
|  | ||||
|             return connection.writeToStream(buffer: ptr, maxLength: length) | ||||
|         } | ||||
|  | ||||
|         if length > 0 { | ||||
|             byteIndex += length | ||||
|             bytesLeft -= length | ||||
|  | ||||
|             if bytesLeft == 0 { | ||||
|                 self.dataToSend = nil | ||||
|                 byteIndex = 0 | ||||
|             } | ||||
|         } else { | ||||
|             os_log(.debug, log: broadcastLogger, "writeBufferToStream failure") | ||||
|         } | ||||
|        | ||||
|         return true | ||||
|     } | ||||
|      | ||||
|     func prepare(sample buffer: CMSampleBuffer) -> Data? { | ||||
|         guard let imageBuffer = CMSampleBufferGetImageBuffer(buffer) else { | ||||
|             os_log(.debug, log: broadcastLogger, "image buffer not available") | ||||
|             return nil | ||||
|         } | ||||
|          | ||||
|         CVPixelBufferLockBaseAddress(imageBuffer, .readOnly) | ||||
|          | ||||
|         let scaleFactor = 1.0 | ||||
|         let width = CVPixelBufferGetWidth(imageBuffer)/Int(scaleFactor) | ||||
|         let height = CVPixelBufferGetHeight(imageBuffer)/Int(scaleFactor) | ||||
|         let orientation = CMGetAttachment(buffer, key: RPVideoSampleOrientationKey as CFString, attachmentModeOut: nil)?.uintValue ?? 0 | ||||
|                                      | ||||
|         let scaleTransform = CGAffineTransform(scaleX: CGFloat(1.0/scaleFactor), y: CGFloat(1.0/scaleFactor)) | ||||
|         let bufferData = self.jpegData(from: imageBuffer, scale: scaleTransform) | ||||
|          | ||||
|         CVPixelBufferUnlockBaseAddress(imageBuffer, .readOnly) | ||||
|          | ||||
|         guard let messageData = bufferData else { | ||||
|             os_log(.debug, log: broadcastLogger, "corrupted image buffer") | ||||
|             return nil | ||||
|         } | ||||
|                | ||||
|         let httpResponse = CFHTTPMessageCreateResponse(nil, 200, nil, kCFHTTPVersion1_1).takeRetainedValue() | ||||
|         CFHTTPMessageSetHeaderFieldValue(httpResponse, "Content-Length" as CFString, String(messageData.count) as CFString) | ||||
|         CFHTTPMessageSetHeaderFieldValue(httpResponse, "Buffer-Width" as CFString, String(width) as CFString) | ||||
|         CFHTTPMessageSetHeaderFieldValue(httpResponse, "Buffer-Height" as CFString, String(height) as CFString) | ||||
|         CFHTTPMessageSetHeaderFieldValue(httpResponse, "Buffer-Orientation" as CFString, String(orientation) as CFString) | ||||
|          | ||||
|         CFHTTPMessageSetBody(httpResponse, messageData as CFData) | ||||
|          | ||||
|         let serializedMessage = CFHTTPMessageCopySerializedMessage(httpResponse)?.takeRetainedValue() as Data? | ||||
|        | ||||
|         return serializedMessage | ||||
|     } | ||||
|      | ||||
|     func jpegData(from buffer: CVPixelBuffer, scale scaleTransform: CGAffineTransform) -> Data? { | ||||
|         let image = CIImage(cvPixelBuffer: buffer).transformed(by: scaleTransform) | ||||
|          | ||||
|         guard let colorSpace = image.colorSpace else { | ||||
|             return nil | ||||
|         } | ||||
|        | ||||
|         let options: [CIImageRepresentationOption: Float] = [kCGImageDestinationLossyCompressionQuality as CIImageRepresentationOption: 1.0] | ||||
|  | ||||
|         return SampleUploader.imageContext.jpegRepresentation(of: image, colorSpace: colorSpace, options: options) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										199
									
								
								ios/SolianBroadcastExtension/SocketConnection.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								ios/SolianBroadcastExtension/SocketConnection.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,199 @@ | ||||
| // | ||||
| //  SocketConnection.swift | ||||
| //  Broadcast Extension | ||||
| // | ||||
| //  Created by Alex-Dan Bumbu on 22/03/2021. | ||||
| //  Copyright © 2021 Atlassian Inc. All rights reserved. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import OSLog | ||||
|  | ||||
| class SocketConnection: NSObject { | ||||
|     var didOpen: (() -> Void)? | ||||
|     var didClose: ((Error?) -> Void)? | ||||
|     var streamHasSpaceAvailable: (() -> Void)? | ||||
|  | ||||
|     private let filePath: String | ||||
|     private var socketHandle: Int32 = -1 | ||||
|     private var address: sockaddr_un? | ||||
|  | ||||
|     private var inputStream: InputStream? | ||||
|     private var outputStream: OutputStream? | ||||
|      | ||||
|     private var networkQueue: DispatchQueue? | ||||
|     private var shouldKeepRunning = false | ||||
|  | ||||
|     init?(filePath path: String) { | ||||
|         filePath = path | ||||
|         socketHandle = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) | ||||
|  | ||||
|         guard socketHandle != -1 else { | ||||
|             os_log(.debug, log: broadcastLogger, "failure: create socket") | ||||
|             return nil | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     func open() -> Bool { | ||||
|         os_log(.debug, log: broadcastLogger, "open socket connection") | ||||
|  | ||||
|         guard FileManager.default.fileExists(atPath: filePath) else { | ||||
|             os_log(.debug, log: broadcastLogger, "failure: socket file missing") | ||||
|             return false | ||||
|         } | ||||
|        | ||||
|         guard setupAddress() == true else { | ||||
|             return false | ||||
|         } | ||||
|          | ||||
|         guard connectSocket() == true else { | ||||
|             return false | ||||
|         } | ||||
|  | ||||
|         setupStreams() | ||||
|          | ||||
|         inputStream?.open() | ||||
|         outputStream?.open() | ||||
|  | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     func close() { | ||||
|         unscheduleStreams() | ||||
|  | ||||
|         inputStream?.delegate = nil | ||||
|         outputStream?.delegate = nil | ||||
|  | ||||
|         inputStream?.close() | ||||
|         outputStream?.close() | ||||
|          | ||||
|         inputStream = nil | ||||
|         outputStream = nil | ||||
|     } | ||||
|  | ||||
|     func writeToStream(buffer: UnsafePointer<UInt8>, maxLength length: Int) -> Int { | ||||
|         outputStream?.write(buffer, maxLength: length) ?? 0 | ||||
|     } | ||||
| } | ||||
|  | ||||
| extension SocketConnection: StreamDelegate { | ||||
|  | ||||
|     func stream(_ aStream: Stream, handle eventCode: Stream.Event) { | ||||
|         switch eventCode { | ||||
|         case .openCompleted: | ||||
|             os_log(.debug, log: broadcastLogger, "client stream open completed") | ||||
|             if aStream == outputStream { | ||||
|                 didOpen?() | ||||
|             } | ||||
|         case .hasBytesAvailable: | ||||
|             if aStream == inputStream { | ||||
|                 var buffer: UInt8 = 0 | ||||
|                 let numberOfBytesRead = inputStream?.read(&buffer, maxLength: 1) | ||||
|                 if numberOfBytesRead == 0 && aStream.streamStatus == .atEnd { | ||||
|                     os_log(.debug, log: broadcastLogger, "server socket closed") | ||||
|                     close() | ||||
|                     notifyDidClose(error: nil) | ||||
|                 } | ||||
|             } | ||||
|         case .hasSpaceAvailable: | ||||
|             if aStream == outputStream { | ||||
|                 streamHasSpaceAvailable?() | ||||
|             } | ||||
|         case .errorOccurred: | ||||
|             os_log(.debug, log: broadcastLogger, "client stream error occured: \(String(describing: aStream.streamError))") | ||||
|             close() | ||||
|             notifyDidClose(error: aStream.streamError) | ||||
|  | ||||
|         default: | ||||
|             break | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| private extension SocketConnection { | ||||
|    | ||||
|     func setupAddress() -> Bool { | ||||
|         var addr = sockaddr_un() | ||||
|         guard filePath.count < MemoryLayout.size(ofValue: addr.sun_path) else { | ||||
|             os_log(.debug, log: broadcastLogger, "failure: fd path is too long") | ||||
|             return false | ||||
|         } | ||||
|  | ||||
|         _ = withUnsafeMutablePointer(to: &addr.sun_path.0) { ptr in | ||||
|             filePath.withCString { | ||||
|                 strncpy(ptr, $0, filePath.count) | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         address = addr | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     func connectSocket() -> Bool { | ||||
|         guard var addr = address else { | ||||
|             return false | ||||
|         } | ||||
|          | ||||
|         let status = withUnsafePointer(to: &addr) { ptr in | ||||
|             ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { | ||||
|                 Darwin.connect(socketHandle, $0, socklen_t(MemoryLayout<sockaddr_un>.size)) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         guard status == noErr else { | ||||
|             os_log(.debug, log: broadcastLogger, "failure: \(status)") | ||||
|             return false | ||||
|         } | ||||
|          | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     func setupStreams() { | ||||
|         var readStream: Unmanaged<CFReadStream>? | ||||
|         var writeStream: Unmanaged<CFWriteStream>? | ||||
|  | ||||
|         CFStreamCreatePairWithSocket(kCFAllocatorDefault, socketHandle, &readStream, &writeStream) | ||||
|  | ||||
|         inputStream = readStream?.takeRetainedValue() | ||||
|         inputStream?.delegate = self | ||||
|         inputStream?.setProperty(kCFBooleanTrue, forKey: Stream.PropertyKey(kCFStreamPropertyShouldCloseNativeSocket as String)) | ||||
|  | ||||
|         outputStream = writeStream?.takeRetainedValue() | ||||
|         outputStream?.delegate = self | ||||
|         outputStream?.setProperty(kCFBooleanTrue, forKey: Stream.PropertyKey(kCFStreamPropertyShouldCloseNativeSocket as String)) | ||||
|  | ||||
|         scheduleStreams() | ||||
|     } | ||||
|    | ||||
|     func scheduleStreams() { | ||||
|         shouldKeepRunning = true | ||||
|          | ||||
|         networkQueue = DispatchQueue.global(qos: .userInitiated) | ||||
|         networkQueue?.async { [weak self] in | ||||
|             self?.inputStream?.schedule(in: .current, forMode: .common) | ||||
|             self?.outputStream?.schedule(in: .current, forMode: .common) | ||||
|             RunLoop.current.run() | ||||
|              | ||||
|             var isRunning = false | ||||
|                          | ||||
|             repeat { | ||||
|                 isRunning = self?.shouldKeepRunning ?? false && RunLoop.current.run(mode: .default, before: .distantFuture) | ||||
|             } while (isRunning) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func unscheduleStreams() { | ||||
|         networkQueue?.sync { [weak self] in | ||||
|             self?.inputStream?.remove(from: .current, forMode: .common) | ||||
|             self?.outputStream?.remove(from: .current, forMode: .common) | ||||
|         } | ||||
|          | ||||
|         shouldKeepRunning = false | ||||
|     } | ||||
|      | ||||
|     func notifyDidClose(error: Error?) { | ||||
|         if didClose != nil { | ||||
|             didClose?(error) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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>com.apple.security.application-groups</key> | ||||
| 	<array> | ||||
| 		<string>group.solsynth.solian</string> | ||||
| 	</array> | ||||
| </dict> | ||||
| </plist> | ||||
| @@ -11,8 +11,8 @@ sealed class SnEmbedLink with _$SnEmbedLink { | ||||
|     @JsonKey(name: 'Title') required String title, | ||||
|     @JsonKey(name: 'Description') required String? description, | ||||
|     @JsonKey(name: 'ImageUrl') required String? imageUrl, | ||||
|     @JsonKey(name: 'FaviconUrl') required String faviconUrl, | ||||
|     @JsonKey(name: 'SiteName') required String siteName, | ||||
|     @JsonKey(name: 'FaviconUrl') @Default("") String faviconUrl, | ||||
|     @JsonKey(name: 'SiteName') @Default("") String siteName, | ||||
|     @JsonKey(name: 'ContentType') required String? contentType, | ||||
|     @JsonKey(name: 'Author') required String? author, | ||||
|     @JsonKey(name: 'PublishedDate') required DateTime? publishedDate, | ||||
|   | ||||
| @@ -212,7 +212,7 @@ return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUr | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnEmbedLink implements SnEmbedLink { | ||||
|   const _SnEmbedLink({@JsonKey(name: 'Type') required this.type, @JsonKey(name: 'Url') required this.url, @JsonKey(name: 'Title') required this.title, @JsonKey(name: 'Description') required this.description, @JsonKey(name: 'ImageUrl') required this.imageUrl, @JsonKey(name: 'FaviconUrl') required this.faviconUrl, @JsonKey(name: 'SiteName') required this.siteName, @JsonKey(name: 'ContentType') required this.contentType, @JsonKey(name: 'Author') required this.author, @JsonKey(name: 'PublishedDate') required this.publishedDate}); | ||||
|   const _SnEmbedLink({@JsonKey(name: 'Type') required this.type, @JsonKey(name: 'Url') required this.url, @JsonKey(name: 'Title') required this.title, @JsonKey(name: 'Description') required this.description, @JsonKey(name: 'ImageUrl') required this.imageUrl, @JsonKey(name: 'FaviconUrl') this.faviconUrl = "", @JsonKey(name: 'SiteName') this.siteName = "", @JsonKey(name: 'ContentType') required this.contentType, @JsonKey(name: 'Author') required this.author, @JsonKey(name: 'PublishedDate') required this.publishedDate}); | ||||
|   factory _SnEmbedLink.fromJson(Map<String, dynamic> json) => _$SnEmbedLinkFromJson(json); | ||||
|  | ||||
| @override@JsonKey(name: 'Type') final  String type; | ||||
|   | ||||
| @@ -12,8 +12,8 @@ _SnEmbedLink _$SnEmbedLinkFromJson(Map<String, dynamic> json) => _SnEmbedLink( | ||||
|   title: json['Title'] as String, | ||||
|   description: json['Description'] as String?, | ||||
|   imageUrl: json['ImageUrl'] as String?, | ||||
|   faviconUrl: json['FaviconUrl'] as String, | ||||
|   siteName: json['SiteName'] as String, | ||||
|   faviconUrl: json['FaviconUrl'] as String? ?? "", | ||||
|   siteName: json['SiteName'] as String? ?? "", | ||||
|   contentType: json['ContentType'] as String?, | ||||
|   author: json['Author'] as String?, | ||||
|   publishedDate: | ||||
|   | ||||
| @@ -12,6 +12,7 @@ sealed class UniversalFile with _$UniversalFile { | ||||
|   const factory UniversalFile({ | ||||
|     required dynamic data, | ||||
|     required UniversalFileType type, | ||||
|     @Default(false) bool isLink, | ||||
|   }) = _UniversalFile; | ||||
|  | ||||
|   factory UniversalFile.fromJson(Map<String, dynamic> json) => | ||||
| @@ -41,6 +42,7 @@ sealed class SnCloudFile with _$SnCloudFile { | ||||
|     required String? description, | ||||
|     required Map<String, dynamic>? fileMeta, | ||||
|     required Map<String, dynamic>? userMeta, | ||||
|     @Default([]) List<int> sensitiveMarks, | ||||
|     required String? mimeType, | ||||
|     required String? hash, | ||||
|     required int size, | ||||
|   | ||||
| @@ -15,7 +15,7 @@ T _$identity<T>(T value) => value; | ||||
| /// @nodoc | ||||
| mixin _$UniversalFile { | ||||
|  | ||||
|  dynamic get data; UniversalFileType get type; | ||||
|  dynamic get data; UniversalFileType get type; bool get isLink; | ||||
| /// Create a copy of UniversalFile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -28,16 +28,16 @@ $UniversalFileCopyWith<UniversalFile> get copyWith => _$UniversalFileCopyWithImp | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type); | ||||
| int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'UniversalFile(data: $data, type: $type)'; | ||||
|   return 'UniversalFile(data: $data, type: $type, isLink: $isLink)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -48,7 +48,7 @@ abstract mixin class $UniversalFileCopyWith<$Res>  { | ||||
|   factory $UniversalFileCopyWith(UniversalFile value, $Res Function(UniversalFile) _then) = _$UniversalFileCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  dynamic data, UniversalFileType type | ||||
|  dynamic data, UniversalFileType type, bool isLink | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -65,11 +65,12 @@ class _$UniversalFileCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of UniversalFile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? type = null,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable | ||||
| as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| as UniversalFileType, | ||||
| as UniversalFileType,isLink: null == isLink ? _self.isLink : isLink // ignore: cast_nullable_to_non_nullable | ||||
| as bool, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| @@ -151,10 +152,10 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( dynamic data,  UniversalFileType type)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( dynamic data,  UniversalFileType type,  bool isLink)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _UniversalFile() when $default != null: | ||||
| return $default(_that.data,_that.type);case _: | ||||
| return $default(_that.data,_that.type,_that.isLink);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| @@ -172,10 +173,10 @@ return $default(_that.data,_that.type);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( dynamic data,  UniversalFileType type)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( dynamic data,  UniversalFileType type,  bool isLink)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _UniversalFile(): | ||||
| return $default(_that.data,_that.type);} | ||||
| return $default(_that.data,_that.type,_that.isLink);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| @@ -189,10 +190,10 @@ return $default(_that.data,_that.type);} | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( dynamic data,  UniversalFileType type)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( dynamic data,  UniversalFileType type,  bool isLink)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _UniversalFile() when $default != null: | ||||
| return $default(_that.data,_that.type);case _: | ||||
| return $default(_that.data,_that.type,_that.isLink);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| @@ -204,11 +205,12 @@ return $default(_that.data,_that.type);case _: | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _UniversalFile extends UniversalFile { | ||||
|   const _UniversalFile({required this.data, required this.type}): super._(); | ||||
|   const _UniversalFile({required this.data, required this.type, this.isLink = false}): super._(); | ||||
|   factory _UniversalFile.fromJson(Map<String, dynamic> json) => _$UniversalFileFromJson(json); | ||||
|  | ||||
| @override final  dynamic data; | ||||
| @override final  UniversalFileType type; | ||||
| @override@JsonKey() final  bool isLink; | ||||
|  | ||||
| /// Create a copy of UniversalFile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @@ -223,16 +225,16 @@ Map<String, dynamic> toJson() { | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type); | ||||
| int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'UniversalFile(data: $data, type: $type)'; | ||||
|   return 'UniversalFile(data: $data, type: $type, isLink: $isLink)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -243,7 +245,7 @@ abstract mixin class _$UniversalFileCopyWith<$Res> implements $UniversalFileCopy | ||||
|   factory _$UniversalFileCopyWith(_UniversalFile value, $Res Function(_UniversalFile) _then) = __$UniversalFileCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  dynamic data, UniversalFileType type | ||||
|  dynamic data, UniversalFileType type, bool isLink | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -260,11 +262,12 @@ class __$UniversalFileCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of UniversalFile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? type = null,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,}) { | ||||
|   return _then(_UniversalFile( | ||||
| data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable | ||||
| as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| as UniversalFileType, | ||||
| as UniversalFileType,isLink: null == isLink ? _self.isLink : isLink // ignore: cast_nullable_to_non_nullable | ||||
| as bool, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| @@ -275,7 +278,7 @@ as UniversalFileType, | ||||
| /// @nodoc | ||||
| mixin _$SnCloudFile { | ||||
|  | ||||
|  String get id; String get name; String? get description; Map<String, dynamic>? get fileMeta; Map<String, dynamic>? get userMeta; String? get mimeType; String? get hash; int get size; DateTime? get uploadedAt; String? get uploadedTo; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
|  String get id; String get name; String? get description; Map<String, dynamic>? get fileMeta; Map<String, dynamic>? get userMeta; List<int> get sensitiveMarks; String? get mimeType; String? get hash; int get size; DateTime? get uploadedAt; String? get uploadedTo; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
| /// Create a copy of SnCloudFile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -288,16 +291,16 @@ $SnCloudFileCopyWith<SnCloudFile> get copyWith => _$SnCloudFileCopyWithImpl<SnCl | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.fileMeta, fileMeta)&&const DeepCollectionEquality().equals(other.userMeta, userMeta)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.fileMeta, fileMeta)&&const DeepCollectionEquality().equals(other.userMeta, userMeta)&&const DeepCollectionEquality().equals(other.sensitiveMarks, sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(fileMeta),const DeepCollectionEquality().hash(userMeta),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); | ||||
| int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(fileMeta),const DeepCollectionEquality().hash(userMeta),const DeepCollectionEquality().hash(sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
|   return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -308,7 +311,7 @@ abstract mixin class $SnCloudFileCopyWith<$Res>  { | ||||
|   factory $SnCloudFileCopyWith(SnCloudFile value, $Res Function(SnCloudFile) _then) = _$SnCloudFileCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -325,14 +328,15 @@ class _$SnCloudFileCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnCloudFile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||
| as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | ||||
| as String?,fileMeta: freezed == fileMeta ? _self.fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,userMeta: freezed == userMeta ? _self.userMeta : userMeta // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,sensitiveMarks: null == sensitiveMarks ? _self.sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable | ||||
| as List<int>,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable | ||||
| as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable | ||||
| as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable | ||||
| as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable | ||||
| @@ -422,10 +426,10 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String name,  String? description,  Map<String, dynamic>? fileMeta,  Map<String, dynamic>? userMeta,  String? mimeType,  String? hash,  int size,  DateTime? uploadedAt,  String? uploadedTo,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id,  String name,  String? description,  Map<String, dynamic>? fileMeta,  Map<String, dynamic>? userMeta,  List<int> sensitiveMarks,  String? mimeType,  String? hash,  int size,  DateTime? uploadedAt,  String? uploadedTo,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnCloudFile() when $default != null: | ||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| @@ -443,10 +447,10 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String name,  String? description,  Map<String, dynamic>? fileMeta,  Map<String, dynamic>? userMeta,  String? mimeType,  String? hash,  int size,  DateTime? uploadedAt,  String? uploadedTo,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id,  String name,  String? description,  Map<String, dynamic>? fileMeta,  Map<String, dynamic>? userMeta,  List<int> sensitiveMarks,  String? mimeType,  String? hash,  int size,  DateTime? uploadedAt,  String? uploadedTo,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnCloudFile(): | ||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);} | ||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| @@ -460,10 +464,10 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String name,  String? description,  Map<String, dynamic>? fileMeta,  Map<String, dynamic>? userMeta,  String? mimeType,  String? hash,  int size,  DateTime? uploadedAt,  String? uploadedTo,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id,  String name,  String? description,  Map<String, dynamic>? fileMeta,  Map<String, dynamic>? userMeta,  List<int> sensitiveMarks,  String? mimeType,  String? hash,  int size,  DateTime? uploadedAt,  String? uploadedTo,  DateTime createdAt,  DateTime updatedAt,  DateTime? deletedAt)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _SnCloudFile() when $default != null: | ||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
| return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| @@ -475,7 +479,7 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnCloudFile implements SnCloudFile { | ||||
|   const _SnCloudFile({required this.id, required this.name, required this.description, required final  Map<String, dynamic>? fileMeta, required final  Map<String, dynamic>? userMeta, required this.mimeType, required this.hash, required this.size, required this.uploadedAt, required this.uploadedTo, required this.createdAt, required this.updatedAt, required this.deletedAt}): _fileMeta = fileMeta,_userMeta = userMeta; | ||||
|   const _SnCloudFile({required this.id, required this.name, required this.description, required final  Map<String, dynamic>? fileMeta, required final  Map<String, dynamic>? userMeta, final  List<int> sensitiveMarks = const [], required this.mimeType, required this.hash, required this.size, required this.uploadedAt, required this.uploadedTo, required this.createdAt, required this.updatedAt, required this.deletedAt}): _fileMeta = fileMeta,_userMeta = userMeta,_sensitiveMarks = sensitiveMarks; | ||||
|   factory _SnCloudFile.fromJson(Map<String, dynamic> json) => _$SnCloudFileFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @@ -499,6 +503,13 @@ class _SnCloudFile implements SnCloudFile { | ||||
|   return EqualUnmodifiableMapView(value); | ||||
| } | ||||
|  | ||||
|  final  List<int> _sensitiveMarks; | ||||
| @override@JsonKey() List<int> get sensitiveMarks { | ||||
|   if (_sensitiveMarks is EqualUnmodifiableListView) return _sensitiveMarks; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableListView(_sensitiveMarks); | ||||
| } | ||||
|  | ||||
| @override final  String? mimeType; | ||||
| @override final  String? hash; | ||||
| @override final  int size; | ||||
| @@ -521,16 +532,16 @@ Map<String, dynamic> toJson() { | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._fileMeta, _fileMeta)&&const DeepCollectionEquality().equals(other._userMeta, _userMeta)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._fileMeta, _fileMeta)&&const DeepCollectionEquality().equals(other._userMeta, _userMeta)&&const DeepCollectionEquality().equals(other._sensitiveMarks, _sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(_fileMeta),const DeepCollectionEquality().hash(_userMeta),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); | ||||
| int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(_fileMeta),const DeepCollectionEquality().hash(_userMeta),const DeepCollectionEquality().hash(_sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
|   return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -541,7 +552,7 @@ abstract mixin class _$SnCloudFileCopyWith<$Res> implements $SnCloudFileCopyWith | ||||
|   factory _$SnCloudFileCopyWith(_SnCloudFile value, $Res Function(_SnCloudFile) _then) = __$SnCloudFileCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
|  String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -558,14 +569,15 @@ class __$SnCloudFileCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnCloudFile | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_SnCloudFile( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||
| as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | ||||
| as String?,fileMeta: freezed == fileMeta ? _self._fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,userMeta: freezed == userMeta ? _self._userMeta : userMeta // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, dynamic>?,sensitiveMarks: null == sensitiveMarks ? _self._sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable | ||||
| as List<int>,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable | ||||
| as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable | ||||
| as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable | ||||
| as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable | ||||
|   | ||||
| @@ -10,12 +10,14 @@ _UniversalFile _$UniversalFileFromJson(Map<String, dynamic> json) => | ||||
|     _UniversalFile( | ||||
|       data: json['data'], | ||||
|       type: $enumDecode(_$UniversalFileTypeEnumMap, json['type']), | ||||
|       isLink: json['is_link'] as bool? ?? false, | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) => | ||||
|     <String, dynamic>{ | ||||
|       'data': instance.data, | ||||
|       'type': _$UniversalFileTypeEnumMap[instance.type]!, | ||||
|       'is_link': instance.isLink, | ||||
|     }; | ||||
|  | ||||
| const _$UniversalFileTypeEnumMap = { | ||||
| @@ -31,6 +33,11 @@ _SnCloudFile _$SnCloudFileFromJson(Map<String, dynamic> json) => _SnCloudFile( | ||||
|   description: json['description'] as String?, | ||||
|   fileMeta: json['file_meta'] as Map<String, dynamic>?, | ||||
|   userMeta: json['user_meta'] as Map<String, dynamic>?, | ||||
|   sensitiveMarks: | ||||
|       (json['sensitive_marks'] as List<dynamic>?) | ||||
|           ?.map((e) => (e as num).toInt()) | ||||
|           .toList() ?? | ||||
|       const [], | ||||
|   mimeType: json['mime_type'] as String?, | ||||
|   hash: json['hash'] as String?, | ||||
|   size: (json['size'] as num).toInt(), | ||||
| @@ -54,6 +61,7 @@ Map<String, dynamic> _$SnCloudFileToJson(_SnCloudFile instance) => | ||||
|       'description': instance.description, | ||||
|       'file_meta': instance.fileMeta, | ||||
|       'user_meta': instance.userMeta, | ||||
|       'sensitive_marks': instance.sensitiveMarks, | ||||
|       'mime_type': instance.mimeType, | ||||
|       'hash': instance.hash, | ||||
|       'size': instance.size, | ||||
|   | ||||
							
								
								
									
										92
									
								
								lib/models/poll.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								lib/models/poll.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/publisher.dart'; | ||||
|  | ||||
| part 'poll.freezed.dart'; | ||||
| part 'poll.g.dart'; | ||||
|  | ||||
| @freezed | ||||
| sealed class SnPollWithStats with _$SnPollWithStats { | ||||
|   const factory SnPollWithStats({ | ||||
|     required Map<String, dynamic>? userAnswer, | ||||
|     required Map<String, dynamic> stats, | ||||
|     required String id, | ||||
|     required List<SnPollQuestion> questions, | ||||
|     String? title, | ||||
|     String? description, | ||||
|     DateTime? endedAt, | ||||
|     required String publisherId, | ||||
|     required DateTime createdAt, | ||||
|     required DateTime updatedAt, | ||||
|     DateTime? deletedAt, | ||||
|   }) = _SnPollWithStats; | ||||
|  | ||||
|   factory SnPollWithStats.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnPollWithStatsFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| sealed class SnPoll with _$SnPoll { | ||||
|   const factory SnPoll({ | ||||
|     required String id, | ||||
|     required List<SnPollQuestion> questions, | ||||
|  | ||||
|     String? title, | ||||
|     String? description, | ||||
|  | ||||
|     DateTime? endedAt, | ||||
|  | ||||
|     required String publisherId, | ||||
|     SnPublisher? publisher, | ||||
|  | ||||
|     // ModelBase fields | ||||
|     required DateTime createdAt, | ||||
|     required DateTime updatedAt, | ||||
|     DateTime? deletedAt, | ||||
|   }) = _SnPoll; | ||||
|  | ||||
|   factory SnPoll.fromJson(Map<String, dynamic> json) => _$SnPollFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| sealed class SnPollQuestion with _$SnPollQuestion { | ||||
|   const factory SnPollQuestion({ | ||||
|     required String id, | ||||
|  | ||||
|     required SnPollQuestionType type, | ||||
|     List<SnPollOption>? options, | ||||
|  | ||||
|     required String title, | ||||
|     String? description, | ||||
|     required int order, | ||||
|     required bool isRequired, | ||||
|   }) = _SnPollQuestion; | ||||
|  | ||||
|   factory SnPollQuestion.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnPollQuestionFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| sealed class SnPollOption with _$SnPollOption { | ||||
|   const factory SnPollOption({ | ||||
|     required String id, | ||||
|     required String label, | ||||
|     String? description, | ||||
|     required int order, | ||||
|   }) = _SnPollOption; | ||||
|  | ||||
|   factory SnPollOption.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnPollOptionFromJson(json); | ||||
| } | ||||
|  | ||||
| enum SnPollQuestionType { | ||||
|   @JsonValue(0) | ||||
|   singleChoice, | ||||
|   @JsonValue(1) | ||||
|   multipleChoice, | ||||
|   @JsonValue(2) | ||||
|   yesNo, | ||||
|   @JsonValue(3) | ||||
|   rating, | ||||
|   @JsonValue(4) | ||||
|   freeText, | ||||
| } | ||||
							
								
								
									
										1186
									
								
								lib/models/poll.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1186
									
								
								lib/models/poll.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										133
									
								
								lib/models/poll.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								lib/models/poll.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'poll.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| _SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) => | ||||
|     _SnPollWithStats( | ||||
|       userAnswer: json['user_answer'] as Map<String, dynamic>?, | ||||
|       stats: json['stats'] as Map<String, dynamic>, | ||||
|       id: json['id'] as String, | ||||
|       questions: | ||||
|           (json['questions'] as List<dynamic>) | ||||
|               .map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>)) | ||||
|               .toList(), | ||||
|       title: json['title'] as String?, | ||||
|       description: json['description'] as String?, | ||||
|       endedAt: | ||||
|           json['ended_at'] == null | ||||
|               ? null | ||||
|               : DateTime.parse(json['ended_at'] as String), | ||||
|       publisherId: json['publisher_id'] as String, | ||||
|       createdAt: DateTime.parse(json['created_at'] as String), | ||||
|       updatedAt: DateTime.parse(json['updated_at'] as String), | ||||
|       deletedAt: | ||||
|           json['deleted_at'] == null | ||||
|               ? null | ||||
|               : DateTime.parse(json['deleted_at'] as String), | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$SnPollWithStatsToJson(_SnPollWithStats instance) => | ||||
|     <String, dynamic>{ | ||||
|       'user_answer': instance.userAnswer, | ||||
|       'stats': instance.stats, | ||||
|       'id': instance.id, | ||||
|       'questions': instance.questions.map((e) => e.toJson()).toList(), | ||||
|       'title': instance.title, | ||||
|       'description': instance.description, | ||||
|       'ended_at': instance.endedAt?.toIso8601String(), | ||||
|       'publisher_id': instance.publisherId, | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
|     }; | ||||
|  | ||||
| _SnPoll _$SnPollFromJson(Map<String, dynamic> json) => _SnPoll( | ||||
|   id: json['id'] as String, | ||||
|   questions: | ||||
|       (json['questions'] as List<dynamic>) | ||||
|           .map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList(), | ||||
|   title: json['title'] as String?, | ||||
|   description: json['description'] as String?, | ||||
|   endedAt: | ||||
|       json['ended_at'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['ended_at'] as String), | ||||
|   publisherId: json['publisher_id'] as String, | ||||
|   publisher: | ||||
|       json['publisher'] == null | ||||
|           ? null | ||||
|           : SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>), | ||||
|   createdAt: DateTime.parse(json['created_at'] as String), | ||||
|   updatedAt: DateTime.parse(json['updated_at'] as String), | ||||
|   deletedAt: | ||||
|       json['deleted_at'] == null | ||||
|           ? null | ||||
|           : DateTime.parse(json['deleted_at'] as String), | ||||
| ); | ||||
|  | ||||
| Map<String, dynamic> _$SnPollToJson(_SnPoll instance) => <String, dynamic>{ | ||||
|   'id': instance.id, | ||||
|   'questions': instance.questions.map((e) => e.toJson()).toList(), | ||||
|   'title': instance.title, | ||||
|   'description': instance.description, | ||||
|   'ended_at': instance.endedAt?.toIso8601String(), | ||||
|   'publisher_id': instance.publisherId, | ||||
|   'publisher': instance.publisher?.toJson(), | ||||
|   'created_at': instance.createdAt.toIso8601String(), | ||||
|   'updated_at': instance.updatedAt.toIso8601String(), | ||||
|   'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
| }; | ||||
|  | ||||
| _SnPollQuestion _$SnPollQuestionFromJson(Map<String, dynamic> json) => | ||||
|     _SnPollQuestion( | ||||
|       id: json['id'] as String, | ||||
|       type: $enumDecode(_$SnPollQuestionTypeEnumMap, json['type']), | ||||
|       options: | ||||
|           (json['options'] as List<dynamic>?) | ||||
|               ?.map((e) => SnPollOption.fromJson(e as Map<String, dynamic>)) | ||||
|               .toList(), | ||||
|       title: json['title'] as String, | ||||
|       description: json['description'] as String?, | ||||
|       order: (json['order'] as num).toInt(), | ||||
|       isRequired: json['is_required'] as bool, | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$SnPollQuestionToJson(_SnPollQuestion instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'type': _$SnPollQuestionTypeEnumMap[instance.type]!, | ||||
|       'options': instance.options?.map((e) => e.toJson()).toList(), | ||||
|       'title': instance.title, | ||||
|       'description': instance.description, | ||||
|       'order': instance.order, | ||||
|       'is_required': instance.isRequired, | ||||
|     }; | ||||
|  | ||||
| const _$SnPollQuestionTypeEnumMap = { | ||||
|   SnPollQuestionType.singleChoice: 0, | ||||
|   SnPollQuestionType.multipleChoice: 1, | ||||
|   SnPollQuestionType.yesNo: 2, | ||||
|   SnPollQuestionType.rating: 3, | ||||
|   SnPollQuestionType.freeText: 4, | ||||
| }; | ||||
|  | ||||
| _SnPollOption _$SnPollOptionFromJson(Map<String, dynamic> json) => | ||||
|     _SnPollOption( | ||||
|       id: json['id'] as String, | ||||
|       label: json['label'] as String, | ||||
|       description: json['description'] as String?, | ||||
|       order: (json['order'] as num).toInt(), | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$SnPollOptionToJson(_SnPollOption instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'label': instance.label, | ||||
|       'description': instance.description, | ||||
|       'order': instance.order, | ||||
|     }; | ||||
| @@ -1,4 +1,8 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:developer'; | ||||
| import 'dart:io'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter_webrtc/flutter_webrtc.dart'; | ||||
| import 'package:island/widgets/chat/call_button.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| @@ -24,6 +28,7 @@ sealed class CallState with _$CallState { | ||||
|     required bool isMicrophoneEnabled, | ||||
|     required bool isCameraEnabled, | ||||
|     required bool isScreenSharing, | ||||
|     required bool isSpeakerphone, | ||||
|     @Default(Duration(seconds: 0)) Duration duration, | ||||
|     String? error, | ||||
|   }) = _CallState; | ||||
| @@ -61,6 +66,8 @@ class CallNotifier extends _$CallNotifier { | ||||
|       List.unmodifiable(_participants); | ||||
|   LocalParticipant? get localParticipant => _localParticipant; | ||||
|  | ||||
|   Map<String, double> participantsVolumes = {}; | ||||
|  | ||||
|   Timer? _durationTimer; | ||||
|  | ||||
|   Room? get room => _room; | ||||
| @@ -73,6 +80,7 @@ class CallNotifier extends _$CallNotifier { | ||||
|       isMicrophoneEnabled: true, | ||||
|       isCameraEnabled: false, | ||||
|       isScreenSharing: false, | ||||
|       isSpeakerphone: true, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -203,7 +211,13 @@ class CallNotifier extends _$CallNotifier { | ||||
|  | ||||
|   Future<void> joinRoom(String roomId) async { | ||||
|     if (_roomId == roomId && _room != null) { | ||||
|       log('[Call] Call skipped. Already has data'); | ||||
|       return; | ||||
|     } else if (_room != null) { | ||||
|       if (!_room!.isDisposed && | ||||
|           _room!.connectionState != ConnectionState.disconnected) { | ||||
|         throw Exception('Call already connected'); | ||||
|       } | ||||
|     } | ||||
|     _roomId = roomId; | ||||
|     if (_room != null) { | ||||
| @@ -257,6 +271,10 @@ class CallNotifier extends _$CallNotifier { | ||||
|         _initRoomListeners(); | ||||
|         _updateLiveParticipants(participants); | ||||
|  | ||||
|         if (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) { | ||||
|           Hardware.instance.setSpeakerphoneOn(true); | ||||
|         } | ||||
|  | ||||
|         // Listen for connection updates | ||||
|         _room!.addListener(() { | ||||
|           state = state.copyWith( | ||||
| @@ -311,6 +329,12 @@ class CallNotifier extends _$CallNotifier { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> toggleSpeakerphone() async { | ||||
|     state = state.copyWith(isSpeakerphone: !state.isSpeakerphone); | ||||
|     await Hardware.instance.setSpeakerphoneOn(state.isSpeakerphone); | ||||
|     state = state.copyWith(); | ||||
|   } | ||||
|  | ||||
|   Future<void> disconnect() async { | ||||
|     if (_room != null) { | ||||
|       await _room!.disconnect(); | ||||
| @@ -323,6 +347,26 @@ class CallNotifier extends _$CallNotifier { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void setParticipantVolume(CallParticipantLive live, double volume) { | ||||
|     if (participantsVolumes[live.remoteParticipant.sid] == null) { | ||||
|       participantsVolumes[live.remoteParticipant.sid] = 1; | ||||
|     } | ||||
|     Helper.setVolume( | ||||
|       volume, | ||||
|       live | ||||
|           .remoteParticipant | ||||
|           .audioTrackPublications | ||||
|           .first | ||||
|           .track! | ||||
|           .mediaStreamTrack, | ||||
|     ); | ||||
|     participantsVolumes[live.remoteParticipant.sid] = volume; | ||||
|   } | ||||
|  | ||||
|   double getParticipantVolume(CallParticipantLive live) { | ||||
|     return participantsVolumes[live.remoteParticipant.sid] ?? 1; | ||||
|   } | ||||
|  | ||||
|   void dispose() { | ||||
|     state = state.copyWith( | ||||
|       error: null, | ||||
| @@ -335,5 +379,7 @@ class CallNotifier extends _$CallNotifier { | ||||
|     _room?.removeListener(_onRoomChange); | ||||
|     _room?.dispose(); | ||||
|     _durationTimer?.cancel(); | ||||
|     _roomId = null; | ||||
|     participantsVolumes = {}; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -12,9 +12,9 @@ part of 'call.dart'; | ||||
| // dart format off | ||||
| T _$identity<T>(T value) => value; | ||||
| /// @nodoc | ||||
| mixin _$CallState { | ||||
| mixin _$CallState implements DiagnosticableTreeMixin { | ||||
|  | ||||
|  bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; Duration get duration; String? get error; | ||||
|  bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; bool get isSpeakerphone; Duration get duration; String? get error; | ||||
| /// Create a copy of CallState | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -22,19 +22,25 @@ mixin _$CallState { | ||||
| $CallStateCopyWith<CallState> get copyWith => _$CallStateCopyWithImpl<CallState>(this as CallState, _$identity); | ||||
|  | ||||
|  | ||||
| @override | ||||
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||
|   properties | ||||
|     ..add(DiagnosticsProperty('type', 'CallState')) | ||||
|     ..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('error', error)); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error)); | ||||
| } | ||||
|  | ||||
|  | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,duration,error); | ||||
| int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,error); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, duration: $duration, error: $error)'; | ||||
| String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||
|   return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, error: $error)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -45,7 +51,7 @@ abstract mixin class $CallStateCopyWith<$Res>  { | ||||
|   factory $CallStateCopyWith(CallState value, $Res Function(CallState) _then) = _$CallStateCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, Duration duration, String? error | ||||
|  bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -62,12 +68,13 @@ class _$CallStateCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of CallState | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? duration = null,Object? error = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? error = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable | ||||
| as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable | ||||
| as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable | ||||
| as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable | ||||
| as bool,isSpeakerphone: null == isSpeakerphone ? _self.isSpeakerphone : isSpeakerphone // ignore: cast_nullable_to_non_nullable | ||||
| as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable | ||||
| as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable | ||||
| as String?, | ||||
| @@ -152,10 +159,10 @@ return $default(_that);case _: | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isConnected,  bool isMicrophoneEnabled,  bool isCameraEnabled,  bool isScreenSharing,  Duration duration,  String? error)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isConnected,  bool isMicrophoneEnabled,  bool isCameraEnabled,  bool isScreenSharing,  bool isSpeakerphone,  Duration duration,  String? error)?  $default,{required TResult orElse(),}) {final _that = this; | ||||
| switch (_that) { | ||||
| case _CallState() when $default != null: | ||||
| return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.duration,_that.error);case _: | ||||
| return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);case _: | ||||
|   return orElse(); | ||||
|  | ||||
| } | ||||
| @@ -173,10 +180,10 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isConnected,  bool isMicrophoneEnabled,  bool isCameraEnabled,  bool isScreenSharing,  Duration duration,  String? error)  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isConnected,  bool isMicrophoneEnabled,  bool isCameraEnabled,  bool isScreenSharing,  bool isSpeakerphone,  Duration duration,  String? error)  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _CallState(): | ||||
| return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.duration,_that.error);} | ||||
| return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);} | ||||
| } | ||||
| /// A variant of `when` that fallback to returning `null` | ||||
| /// | ||||
| @@ -190,10 +197,10 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable | ||||
| /// } | ||||
| /// ``` | ||||
|  | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isConnected,  bool isMicrophoneEnabled,  bool isCameraEnabled,  bool isScreenSharing,  Duration duration,  String? error)?  $default,) {final _that = this; | ||||
| @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isConnected,  bool isMicrophoneEnabled,  bool isCameraEnabled,  bool isScreenSharing,  bool isSpeakerphone,  Duration duration,  String? error)?  $default,) {final _that = this; | ||||
| switch (_that) { | ||||
| case _CallState() when $default != null: | ||||
| return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.duration,_that.error);case _: | ||||
| return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);case _: | ||||
|   return null; | ||||
|  | ||||
| } | ||||
| @@ -204,14 +211,15 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable | ||||
| /// @nodoc | ||||
|  | ||||
|  | ||||
| class _CallState implements CallState { | ||||
|   const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, this.duration = const Duration(seconds: 0), this.error}); | ||||
| class _CallState with DiagnosticableTreeMixin implements CallState { | ||||
|   const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, required this.isSpeakerphone, this.duration = const Duration(seconds: 0), this.error}); | ||||
|    | ||||
|  | ||||
| @override final  bool isConnected; | ||||
| @override final  bool isMicrophoneEnabled; | ||||
| @override final  bool isCameraEnabled; | ||||
| @override final  bool isScreenSharing; | ||||
| @override final  bool isSpeakerphone; | ||||
| @override@JsonKey() final  Duration duration; | ||||
| @override final  String? error; | ||||
|  | ||||
| @@ -222,19 +230,25 @@ class _CallState implements CallState { | ||||
| _$CallStateCopyWith<_CallState> get copyWith => __$CallStateCopyWithImpl<_CallState>(this, _$identity); | ||||
|  | ||||
|  | ||||
| @override | ||||
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||
|   properties | ||||
|     ..add(DiagnosticsProperty('type', 'CallState')) | ||||
|     ..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('error', error)); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error)); | ||||
| } | ||||
|  | ||||
|  | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,duration,error); | ||||
| int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,error); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, duration: $duration, error: $error)'; | ||||
| String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||
|   return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, error: $error)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -245,7 +259,7 @@ abstract mixin class _$CallStateCopyWith<$Res> implements $CallStateCopyWith<$Re | ||||
|   factory _$CallStateCopyWith(_CallState value, $Res Function(_CallState) _then) = __$CallStateCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, Duration duration, String? error | ||||
|  bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -262,12 +276,13 @@ class __$CallStateCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of CallState | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? duration = null,Object? error = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? error = freezed,}) { | ||||
|   return _then(_CallState( | ||||
| isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable | ||||
| as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable | ||||
| as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable | ||||
| as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable | ||||
| as bool,isSpeakerphone: null == isSpeakerphone ? _self.isSpeakerphone : isSpeakerphone // ignore: cast_nullable_to_non_nullable | ||||
| as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable | ||||
| as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable | ||||
| as String?, | ||||
| @@ -278,7 +293,7 @@ as String?, | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$CallParticipantLive { | ||||
| mixin _$CallParticipantLive implements DiagnosticableTreeMixin { | ||||
|  | ||||
|  CallParticipant get participant; Participant get remoteParticipant; | ||||
| /// Create a copy of CallParticipantLive | ||||
| @@ -288,6 +303,12 @@ mixin _$CallParticipantLive { | ||||
| $CallParticipantLiveCopyWith<CallParticipantLive> get copyWith => _$CallParticipantLiveCopyWithImpl<CallParticipantLive>(this as CallParticipantLive, _$identity); | ||||
|  | ||||
|  | ||||
| @override | ||||
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||
|   properties | ||||
|     ..add(DiagnosticsProperty('type', 'CallParticipantLive')) | ||||
|     ..add(DiagnosticsProperty('participant', participant))..add(DiagnosticsProperty('remoteParticipant', remoteParticipant)); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
| @@ -299,7 +320,7 @@ bool operator ==(Object other) { | ||||
| int get hashCode => Object.hash(runtimeType,participant,remoteParticipant); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
| String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||
|   return 'CallParticipantLive(participant: $participant, remoteParticipant: $remoteParticipant)'; | ||||
| } | ||||
|  | ||||
| @@ -475,7 +496,7 @@ return $default(_that.participant,_that.remoteParticipant);case _: | ||||
| /// @nodoc | ||||
|  | ||||
|  | ||||
| class _CallParticipantLive extends CallParticipantLive { | ||||
| class _CallParticipantLive extends CallParticipantLive with DiagnosticableTreeMixin { | ||||
|   const _CallParticipantLive({required this.participant, required this.remoteParticipant}): super._(); | ||||
|    | ||||
|  | ||||
| @@ -489,6 +510,12 @@ class _CallParticipantLive extends CallParticipantLive { | ||||
| _$CallParticipantLiveCopyWith<_CallParticipantLive> get copyWith => __$CallParticipantLiveCopyWithImpl<_CallParticipantLive>(this, _$identity); | ||||
|  | ||||
|  | ||||
| @override | ||||
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||
|   properties | ||||
|     ..add(DiagnosticsProperty('type', 'CallParticipantLive')) | ||||
|     ..add(DiagnosticsProperty('participant', participant))..add(DiagnosticsProperty('remoteParticipant', remoteParticipant)); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
| @@ -500,7 +527,7 @@ bool operator ==(Object other) { | ||||
| int get hashCode => Object.hash(runtimeType,participant,remoteParticipant); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
| String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||
|   return 'CallParticipantLive(participant: $participant, remoteParticipant: $remoteParticipant)'; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -6,7 +6,7 @@ part of 'call.dart'; | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$callNotifierHash() => r'e4312feadb5b34f186b5349a7ee8b671b842dafc'; | ||||
| String _$callNotifierHash() => r'18fb807f067eecd3ea42631c1426c3e5f1fb4280'; | ||||
|  | ||||
| /// See also [CallNotifier]. | ||||
| @ProviderFor(CallNotifier) | ||||
|   | ||||
| @@ -28,9 +28,11 @@ import 'package:island/screens/creators/hub.dart'; | ||||
| import 'package:island/screens/creators/posts/post_manage_list.dart'; | ||||
| import 'package:island/screens/creators/stickers/stickers.dart'; | ||||
| import 'package:island/screens/creators/stickers/pack_detail.dart'; | ||||
| import 'package:island/screens/creators/poll/poll_list.dart'; | ||||
| import 'package:island/screens/creators/publishers.dart'; | ||||
| import 'package:island/screens/creators/webfeed/webfeed_list.dart'; | ||||
| import 'package:island/screens/creators/webfeed/webfeed_edit.dart'; | ||||
| import 'package:island/screens/poll/poll_editor.dart'; | ||||
| import 'package:island/screens/posts/compose.dart'; | ||||
| import 'package:island/screens/posts/post_detail.dart'; | ||||
| import 'package:island/screens/posts/pub_profile.dart'; | ||||
| @@ -144,6 +146,37 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|                   return CreatorPostListScreen(pubName: name); | ||||
|                 }, | ||||
|               ), | ||||
|               // Poll list route | ||||
|               GoRoute( | ||||
|                 name: 'creatorPolls', | ||||
|                 path: '/creators/:name/polls', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   return CreatorPollListScreen(pubName: name); | ||||
|                 }, | ||||
|               ), | ||||
|               // Poll routes | ||||
|               GoRoute( | ||||
|                 name: 'creatorPollNew', | ||||
|                 path: '/creators/:name/polls/new', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   // initialPollId left null for create; initialPublisher prefilled | ||||
|                   return PollEditorScreen(initialPublisher: name); | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 name: 'creatorPollEdit', | ||||
|                 path: '/creators/:name/polls/:id/edit', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   final id = state.pathParameters['id']!; | ||||
|                   return PollEditorScreen( | ||||
|                     initialPollId: id, | ||||
|                     initialPublisher: name, | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 name: 'creatorStickers', | ||||
|                 path: '/creators/:name/stickers', | ||||
| @@ -433,14 +466,6 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|                     path: '/account/relationships', | ||||
|                     builder: (context, state) => const RelationshipScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     name: 'accountProfile', | ||||
|                     path: '/account/:name', | ||||
|                     builder: (context, state) { | ||||
|                       final name = state.pathParameters['name']!; | ||||
|                       return AccountProfileScreen(name: name); | ||||
|                     }, | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     name: 'profileUpdate', | ||||
|                     path: '/account/me/update', | ||||
| @@ -463,6 +488,15 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|  | ||||
|               GoRoute( | ||||
|                 name: 'accountProfile', | ||||
|                 path: '/account/:name', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   return AccountProfileScreen(name: name); | ||||
|                 }, | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ], | ||||
|   | ||||
| @@ -93,7 +93,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> { | ||||
|     final theme = Theme.of(context); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       isNoBackground: false, | ||||
|       appBar: AppBar(title: Text('about'.tr()), elevation: 0), | ||||
|       body: | ||||
|           _isLoading | ||||
|   | ||||
| @@ -64,7 +64,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: isWide, | ||||
|       isNoBackground: isWide, | ||||
|       appBar: AppBar(backgroundColor: Colors.transparent, toolbarHeight: 0), | ||||
|       body: SingleChildScrollView( | ||||
|         padding: getTabbedPadding(context), | ||||
|   | ||||
| @@ -46,7 +46,7 @@ class EventCalanderScreen extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       isNoBackground: false, | ||||
|       appBar: AppBar( | ||||
|         leading: const PageBackButton(), | ||||
|         title: Text('eventCalander').tr(), | ||||
|   | ||||
| @@ -280,7 +280,7 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.watch(apiClientProvider); | ||||
|         await client.post('/subscriptions/${membership.identifier}/cancel'); | ||||
|         await client.post('/id/subscriptions/${membership.identifier}/cancel'); | ||||
|         ref.invalidate(accountStellarSubscriptionProvider); | ||||
|         ref.read(userInfoProvider.notifier).fetchUser(); | ||||
|         if (context.mounted) { | ||||
| @@ -603,7 +603,7 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|     try { | ||||
|       showLoadingModal(context); | ||||
|       final resp = await client.post( | ||||
|         '/subscriptions', | ||||
|         '/id/subscriptions', | ||||
|         data: { | ||||
|           'identifier': tierId, | ||||
|           'payment_method': 'solian.wallet', | ||||
| @@ -615,7 +615,7 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|       final subscription = SnWalletSubscription.fromJson(resp.data); | ||||
|       if (subscription.status == 1) return; | ||||
|       final orderResp = await client.post( | ||||
|         '/subscriptions/${subscription.identifier}/order', | ||||
|         '/id/subscriptions/${subscription.identifier}/order', | ||||
|       ); | ||||
|       final order = SnWalletOrder.fromJson(orderResp.data); | ||||
|  | ||||
| @@ -633,7 +633,7 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|  | ||||
|       if (paidOrder != null) { | ||||
|         await client.post( | ||||
|           '/subscriptions/order/handle', | ||||
|           '/id/subscriptions/order/handle', | ||||
|           data: {'order_id': paidOrder.id}, | ||||
|         ); | ||||
|  | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import 'package:island/pods/event_calendar.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/services/color.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/services/timezone/native.dart'; | ||||
| import 'package:island/widgets/account/account_name.dart'; | ||||
| @@ -22,10 +23,12 @@ import 'package:island/widgets/account/status.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:island/widgets/safety/abuse_report_helper.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:palette_generator/palette_generator.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:share_plus/share_plus.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| part 'profile.g.dart'; | ||||
| @@ -248,294 +251,402 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|  | ||||
|     final user = ref.watch(userInfoProvider); | ||||
|  | ||||
|     Widget accountBasicInfo(SnAccount data) => Padding( | ||||
|       padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), | ||||
|       child: Row( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           ProfilePictureWidget(file: data.profile.picture, radius: 32), | ||||
|           const Gap(20), | ||||
|           Expanded( | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|               children: [ | ||||
|                 Row( | ||||
|                   children: [ | ||||
|                     AccountName(account: data, style: TextStyle(fontSize: 20)), | ||||
|                     const Gap(6), | ||||
|                     Flexible( | ||||
|                       child: Text( | ||||
|                         '@${data.name}', | ||||
|                         maxLines: 1, | ||||
|                         overflow: TextOverflow.ellipsis, | ||||
|                       ).fontSize(14).opacity(0.85), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|                 AccountStatusWidget(uname: name, padding: EdgeInsets.zero), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|           IconButton( | ||||
|             onPressed: () { | ||||
|               SharePlus.instance.share( | ||||
|                 ShareParams( | ||||
|                   uri: Uri.parse('https://id.solian.app/@${data.name}'), | ||||
|                 ), | ||||
|               ); | ||||
|             }, | ||||
|             icon: const Icon(Symbols.share), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     Widget accountProfileBio(SnAccount data) => Card( | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           Text('bio').tr().bold().fontSize(15).padding(bottom: 8), | ||||
|           if (data.profile.bio.isEmpty) | ||||
|             Text('descriptionNone').tr().italic() | ||||
|           else | ||||
|             MarkdownTextContent( | ||||
|               content: data.profile.bio, | ||||
|               linesMargin: EdgeInsets.zero, | ||||
|             ), | ||||
|         ], | ||||
|       ).padding(horizontal: 24, vertical: 20), | ||||
|     ); | ||||
|  | ||||
|     Widget accountProfileDetail(SnAccount data) => Card( | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         spacing: 24, | ||||
|         children: [ | ||||
|           if (buildSubcolumn(data).isNotEmpty) | ||||
|             Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               spacing: 2, | ||||
|               children: buildSubcolumn(data), | ||||
|             ), | ||||
|           if (data.profile.timeZone.isNotEmpty) | ||||
|             Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Text('timeZone').tr().bold(), | ||||
|                 Row( | ||||
|                   crossAxisAlignment: CrossAxisAlignment.baseline, | ||||
|                   textBaseline: TextBaseline.alphabetic, | ||||
|                   spacing: 6, | ||||
|                   children: [ | ||||
|                     Text(data.profile.timeZone), | ||||
|                     Text( | ||||
|                       getTzInfo( | ||||
|                         data.profile.timeZone, | ||||
|                       ).$2.formatCustomGlobal('HH:mm'), | ||||
|                     ), | ||||
|                     Text( | ||||
|                       getTzInfo(data.profile.timeZone).$1.formatOffsetLocal(), | ||||
|                     ).fontSize(11), | ||||
|                     Text( | ||||
|                       'UTC${getTzInfo(data.profile.timeZone).$1.formatOffset()}', | ||||
|                     ).fontSize(11).opacity(0.75), | ||||
|                   ], | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|         ], | ||||
|       ).padding(horizontal: 24, vertical: 16), | ||||
|     ); | ||||
|  | ||||
|     Widget accountAction(SnAccount data) => Card( | ||||
|       child: Column( | ||||
|         children: [ | ||||
|           Row( | ||||
|             spacing: 8, | ||||
|             children: [ | ||||
|               if (accountRelationship.value == null || | ||||
|                   accountRelationship.value!.status > -100) | ||||
|                 Expanded( | ||||
|                   child: FilledButton.icon( | ||||
|                     style: ButtonStyle( | ||||
|                       backgroundColor: WidgetStatePropertyAll( | ||||
|                         accountRelationship.value == null | ||||
|                             ? null | ||||
|                             : Theme.of(context).colorScheme.secondary, | ||||
|                       ), | ||||
|                       foregroundColor: WidgetStatePropertyAll( | ||||
|                         accountRelationship.value == null | ||||
|                             ? null | ||||
|                             : Theme.of(context).colorScheme.onSecondary, | ||||
|                       ), | ||||
|                     ), | ||||
|                     onPressed: relationshipAction, | ||||
|                     label: | ||||
|                         Text( | ||||
|                           accountRelationship.value == null | ||||
|                               ? 'addFriendShort' | ||||
|                               : 'added', | ||||
|                         ).tr(), | ||||
|                     icon: | ||||
|                         accountRelationship.value == null | ||||
|                             ? const Icon(Symbols.person_add) | ||||
|                             : const Icon(Symbols.person_check), | ||||
|                   ), | ||||
|                 ), | ||||
|               if (accountRelationship.value == null || | ||||
|                   accountRelationship.value!.status <= -100) | ||||
|                 Expanded( | ||||
|                   child: FilledButton.icon( | ||||
|                     style: ButtonStyle( | ||||
|                       backgroundColor: WidgetStatePropertyAll( | ||||
|                         accountRelationship.value == null | ||||
|                             ? null | ||||
|                             : Theme.of(context).colorScheme.secondary, | ||||
|                       ), | ||||
|                       foregroundColor: WidgetStatePropertyAll( | ||||
|                         accountRelationship.value == null | ||||
|                             ? null | ||||
|                             : Theme.of(context).colorScheme.onSecondary, | ||||
|                       ), | ||||
|                     ), | ||||
|                     onPressed: blockAction, | ||||
|                     label: | ||||
|                         Text( | ||||
|                           accountRelationship.value == null | ||||
|                               ? 'blockUser' | ||||
|                               : 'unblockUser', | ||||
|                         ).tr(), | ||||
|                     icon: | ||||
|                         accountRelationship.value == null | ||||
|                             ? const Icon(Symbols.block) | ||||
|                             : const Icon(Symbols.person_cancel), | ||||
|                   ), | ||||
|                 ), | ||||
|             ], | ||||
|           ), | ||||
|           Row( | ||||
|             spacing: 8, | ||||
|             children: [ | ||||
|               Expanded( | ||||
|                 child: FilledButton.icon( | ||||
|                   onPressed: directMessageAction, | ||||
|                   icon: const Icon(Symbols.message), | ||||
|                   label: | ||||
|                       Text( | ||||
|                         accountChat.value == null | ||||
|                             ? 'createDirectMessage' | ||||
|                             : 'gotoDirectMessage', | ||||
|                         maxLines: 1, | ||||
|                       ).tr(), | ||||
|                 ), | ||||
|               ), | ||||
|               IconButton.filled( | ||||
|                 onPressed: () { | ||||
|                   showAbuseReportSheet( | ||||
|                     context, | ||||
|                     resourceIdentifier: 'account/${data.id}', | ||||
|                   ); | ||||
|                 }, | ||||
|                 icon: Icon( | ||||
|                   Symbols.flag, | ||||
|                   color: Theme.of(context).colorScheme.onError, | ||||
|                 ), | ||||
|                 style: ButtonStyle( | ||||
|                   backgroundColor: WidgetStatePropertyAll( | ||||
|                     Theme.of(context).colorScheme.error, | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ], | ||||
|       ).padding(horizontal: 16, vertical: 8), | ||||
|     ); | ||||
|  | ||||
|     return account.when( | ||||
|       data: | ||||
|           (data) => AppScaffold( | ||||
|             body: CustomScrollView( | ||||
|               slivers: [ | ||||
|                 SliverAppBar( | ||||
|                   foregroundColor: appbarColor.value, | ||||
|                   expandedHeight: 180, | ||||
|                   pinned: true, | ||||
|                   leading: PageBackButton( | ||||
|                     color: appbarColor.value, | ||||
|                     shadows: [appbarShadow], | ||||
|                   ), | ||||
|                   flexibleSpace: Stack( | ||||
|                     children: [ | ||||
|                       Positioned.fill( | ||||
|                         child: | ||||
|                             data.profile.background?.id != null | ||||
|                                 ? CloudImageWidget( | ||||
|                                   file: data.profile.background, | ||||
|                                 ) | ||||
|                                 : Container( | ||||
|                                   color: | ||||
|                                       Theme.of( | ||||
|                                         context, | ||||
|                                       ).appBarTheme.backgroundColor, | ||||
|                                 ), | ||||
|             isNoBackground: false, | ||||
|             appBar: | ||||
|                 isWideScreen(context) | ||||
|                     ? AppBar( | ||||
|                       foregroundColor: appbarColor.value, | ||||
|                       leading: PageBackButton( | ||||
|                         color: appbarColor.value, | ||||
|                         shadows: [appbarShadow], | ||||
|                       ), | ||||
|                       FlexibleSpaceBar( | ||||
|                         title: Text( | ||||
|                           data.nick, | ||||
|                           style: TextStyle( | ||||
|                             color: | ||||
|                                 appbarColor.value ?? | ||||
|                                 Theme.of(context).appBarTheme.foregroundColor, | ||||
|                             shadows: [appbarShadow], | ||||
|                       flexibleSpace: Stack( | ||||
|                         children: [ | ||||
|                           Positioned.fill( | ||||
|                             child: | ||||
|                                 data.profile.background?.id != null | ||||
|                                     ? CloudImageWidget( | ||||
|                                       file: data.profile.background, | ||||
|                                     ) | ||||
|                                     : Container( | ||||
|                                       color: | ||||
|                                           Theme.of( | ||||
|                                             context, | ||||
|                                           ).appBarTheme.backgroundColor, | ||||
|                                     ), | ||||
|                           ), | ||||
|                           FlexibleSpaceBar( | ||||
|                             title: Text( | ||||
|                               data.nick, | ||||
|                               style: TextStyle( | ||||
|                                 color: | ||||
|                                     appbarColor.value ?? | ||||
|                                     Theme.of( | ||||
|                                       context, | ||||
|                                     ).appBarTheme.foregroundColor, | ||||
|                                 shadows: [appbarShadow], | ||||
|                               ), | ||||
|                             ), | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ) | ||||
|                     : null, | ||||
|             body: | ||||
|                 isWideScreen(context) | ||||
|                     ? Row( | ||||
|                       children: [ | ||||
|                         Flexible( | ||||
|                           child: CustomScrollView( | ||||
|                             slivers: [ | ||||
|                               SliverToBoxAdapter(child: accountBasicInfo(data)), | ||||
|                               if (data.badges.isNotEmpty) | ||||
|                                 SliverToBoxAdapter( | ||||
|                                   child: BadgeList( | ||||
|                                     badges: data.badges, | ||||
|                                   ).padding(horizontal: 24, bottom: 24), | ||||
|                                 ), | ||||
|                               SliverToBoxAdapter( | ||||
|                                 child: Column( | ||||
|                                   spacing: 12, | ||||
|                                   children: [ | ||||
|                                     LevelingProgressCard( | ||||
|                                       level: data.profile.level, | ||||
|                                       experience: data.profile.experience, | ||||
|                                       progress: data.profile.levelingProgress, | ||||
|                                     ), | ||||
|                                     if (data.profile.verification != null) | ||||
|                                       Card( | ||||
|                                         child: VerificationStatusCard( | ||||
|                                           mark: data.profile.verification!, | ||||
|                                         ), | ||||
|                                       ), | ||||
|                                   ], | ||||
|                                 ).padding(horizontal: 4, top: 8), | ||||
|                               ), | ||||
|                               SliverToBoxAdapter( | ||||
|                                 child: accountProfileBio(data).padding(top: 4), | ||||
|                               ), | ||||
|                               SliverToBoxAdapter( | ||||
|                                 child: accountProfileDetail(data), | ||||
|                               ), | ||||
|                             ], | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: Padding( | ||||
|                     padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), | ||||
|                     child: Row( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         ProfilePictureWidget( | ||||
|                           file: data.profile.picture, | ||||
|                           radius: 32, | ||||
|                         ), | ||||
|                         const Gap(20), | ||||
|                         Expanded( | ||||
|                           child: Column( | ||||
|                             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                             children: [ | ||||
|                               Row( | ||||
|                                 children: [ | ||||
|                                   AccountName( | ||||
|                                     account: data, | ||||
|                                     style: TextStyle(fontSize: 20), | ||||
|                         Flexible( | ||||
|                           child: CustomScrollView( | ||||
|                             slivers: [ | ||||
|                               SliverGap(24), | ||||
|                               if (user.value != null) | ||||
|                                 SliverToBoxAdapter(child: accountAction(data)), | ||||
|                               SliverToBoxAdapter( | ||||
|                                 child: Card( | ||||
|                                   child: FortuneGraphWidget( | ||||
|                                     events: accountEvents, | ||||
|                                     eventCalanderUser: data.name, | ||||
|                                     margin: EdgeInsets.zero, | ||||
|                                   ), | ||||
|                                   const Gap(6), | ||||
|                                   Text( | ||||
|                                     '@${data.name}', | ||||
|                                   ).fontSize(14).opacity(0.85), | ||||
|                                 ], | ||||
|                               ), | ||||
|                               AccountStatusWidget( | ||||
|                                 uname: name, | ||||
|                                 padding: EdgeInsets.zero, | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ], | ||||
|                           ), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|                 if (data.badges.isNotEmpty) | ||||
|                   SliverToBoxAdapter( | ||||
|                     child: BadgeList( | ||||
|                       badges: data.badges, | ||||
|                     ).padding(horizontal: 24, bottom: 24), | ||||
|                   ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: Column( | ||||
|                     spacing: 12, | ||||
|                     children: [ | ||||
|                       LevelingProgressCard( | ||||
|                         level: data.profile.level, | ||||
|                         experience: data.profile.experience, | ||||
|                         progress: data.profile.levelingProgress, | ||||
|                       ), | ||||
|                       if (data.profile.verification != null) | ||||
|                         VerificationStatusCard( | ||||
|                           mark: data.profile.verification!, | ||||
|                         ), | ||||
|                     ], | ||||
|                   ).padding(horizontal: 20), | ||||
|                 ), | ||||
|  | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: const Divider(height: 1).padding(vertical: 24), | ||||
|                 ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                     spacing: 24, | ||||
|                     children: [ | ||||
|                       if (buildSubcolumn(data).isNotEmpty) | ||||
|                         Column( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                           spacing: 2, | ||||
|                           children: buildSubcolumn(data), | ||||
|                         ), | ||||
|                       Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Text('bio').tr().bold(), | ||||
|                           Text( | ||||
|                             data.profile.bio.isEmpty | ||||
|                                 ? 'descriptionNone'.tr() | ||||
|                                 : data.profile.bio, | ||||
|                     ).padding(horizontal: 24) | ||||
|                     : CustomScrollView( | ||||
|                       slivers: [ | ||||
|                         SliverAppBar( | ||||
|                           foregroundColor: appbarColor.value, | ||||
|                           expandedHeight: 180, | ||||
|                           pinned: true, | ||||
|                           leading: PageBackButton( | ||||
|                             color: appbarColor.value, | ||||
|                             shadows: [appbarShadow], | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                       if (data.profile.timeZone.isNotEmpty) | ||||
|                         Column( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                           children: [ | ||||
|                             Text('timeZone').tr().bold(), | ||||
|                             Row( | ||||
|                               crossAxisAlignment: CrossAxisAlignment.baseline, | ||||
|                               textBaseline: TextBaseline.alphabetic, | ||||
|                               spacing: 6, | ||||
|                               children: [ | ||||
|                                 Text(data.profile.timeZone), | ||||
|                                 Text( | ||||
|                                   getTzInfo( | ||||
|                                     data.profile.timeZone, | ||||
|                                   ).$2.formatCustomGlobal('HH:mm'), | ||||
|                                 ), | ||||
|                                 Text( | ||||
|                                   getTzInfo( | ||||
|                                     data.profile.timeZone, | ||||
|                                   ).$1.formatOffsetLocal(), | ||||
|                                 ).fontSize(11), | ||||
|                                 Text( | ||||
|                                   'UTC${getTzInfo(data.profile.timeZone).$1.formatOffset()}', | ||||
|                                 ).fontSize(11).opacity(0.75), | ||||
|                               ], | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                     ], | ||||
|                   ).padding(horizontal: 24), | ||||
|                 ), | ||||
|  | ||||
|                 if (user.value != null) | ||||
|                   SliverToBoxAdapter( | ||||
|                     child: const Divider( | ||||
|                       height: 1, | ||||
|                     ).padding(top: 24, bottom: 12), | ||||
|                   ), | ||||
|                 if (user.value != null) | ||||
|                   SliverToBoxAdapter( | ||||
|                     child: Row( | ||||
|                       spacing: 8, | ||||
|                       children: [ | ||||
|                         if (accountRelationship.value == null || | ||||
|                             accountRelationship.value!.status > -100) | ||||
|                           Expanded( | ||||
|                             child: FilledButton.icon( | ||||
|                               style: ButtonStyle( | ||||
|                                 backgroundColor: WidgetStatePropertyAll( | ||||
|                                   accountRelationship.value == null | ||||
|                                       ? null | ||||
|                                       : Theme.of(context).colorScheme.secondary, | ||||
|                                 ), | ||||
|                                 foregroundColor: WidgetStatePropertyAll( | ||||
|                                   accountRelationship.value == null | ||||
|                                       ? null | ||||
|                                       : Theme.of( | ||||
|                                         context, | ||||
|                                       ).colorScheme.onSecondary, | ||||
|                           flexibleSpace: Stack( | ||||
|                             children: [ | ||||
|                               Positioned.fill( | ||||
|                                 child: | ||||
|                                     data.profile.background?.id != null | ||||
|                                         ? CloudImageWidget( | ||||
|                                           file: data.profile.background, | ||||
|                                         ) | ||||
|                                         : Container( | ||||
|                                           color: | ||||
|                                               Theme.of( | ||||
|                                                 context, | ||||
|                                               ).appBarTheme.backgroundColor, | ||||
|                                         ), | ||||
|                               ), | ||||
|                               FlexibleSpaceBar( | ||||
|                                 title: Text( | ||||
|                                   data.nick, | ||||
|                                   style: TextStyle( | ||||
|                                     color: | ||||
|                                         appbarColor.value ?? | ||||
|                                         Theme.of( | ||||
|                                           context, | ||||
|                                         ).appBarTheme.foregroundColor, | ||||
|                                     shadows: [appbarShadow], | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ), | ||||
|                               onPressed: relationshipAction, | ||||
|                               label: | ||||
|                                   Text( | ||||
|                                     accountRelationship.value == null | ||||
|                                         ? 'addFriendShort' | ||||
|                                         : 'added', | ||||
|                                   ).tr(), | ||||
|                               icon: | ||||
|                                   accountRelationship.value == null | ||||
|                                       ? const Icon(Symbols.person_add) | ||||
|                                       : const Icon(Symbols.person_check), | ||||
|                             ), | ||||
|                             ], | ||||
|                           ), | ||||
|                         if (accountRelationship.value == null || | ||||
|                             accountRelationship.value!.status <= -100) | ||||
|                           Expanded( | ||||
|                             child: FilledButton.icon( | ||||
|                               style: ButtonStyle( | ||||
|                                 backgroundColor: WidgetStatePropertyAll( | ||||
|                                   accountRelationship.value == null | ||||
|                                       ? null | ||||
|                                       : Theme.of(context).colorScheme.secondary, | ||||
|                                 ), | ||||
|                                 foregroundColor: WidgetStatePropertyAll( | ||||
|                                   accountRelationship.value == null | ||||
|                                       ? null | ||||
|                                       : Theme.of( | ||||
|                                         context, | ||||
|                                       ).colorScheme.onSecondary, | ||||
|                                 ), | ||||
|                               ), | ||||
|                               onPressed: blockAction, | ||||
|                               label: | ||||
|                                   Text( | ||||
|                                     accountRelationship.value == null | ||||
|                                         ? 'blockUser' | ||||
|                                         : 'unblockUser', | ||||
|                                   ).tr(), | ||||
|                               icon: | ||||
|                                   accountRelationship.value == null | ||||
|                                       ? const Icon(Symbols.block) | ||||
|                                       : const Icon(Symbols.person_cancel), | ||||
|                             ), | ||||
|                         ), | ||||
|                         SliverToBoxAdapter(child: accountBasicInfo(data)), | ||||
|                         if (data.badges.isNotEmpty) | ||||
|                           SliverToBoxAdapter( | ||||
|                             child: BadgeList( | ||||
|                               badges: data.badges, | ||||
|                             ).padding(horizontal: 24, bottom: 24), | ||||
|                           ), | ||||
|                         SliverToBoxAdapter( | ||||
|                           child: Column( | ||||
|                             children: [ | ||||
|                               LevelingProgressCard( | ||||
|                                 level: data.profile.level, | ||||
|                                 experience: data.profile.experience, | ||||
|                                 progress: data.profile.levelingProgress, | ||||
|                               ).padding(top: 8, horizontal: 8, bottom: 4), | ||||
|                               if (data.profile.verification != null) | ||||
|                                 Card( | ||||
|                                   child: VerificationStatusCard( | ||||
|                                     mark: data.profile.verification!, | ||||
|                                   ), | ||||
|                                 ).padding(horizontal: 4), | ||||
|                             ], | ||||
|                           ), | ||||
|                         ), | ||||
|                         SliverToBoxAdapter( | ||||
|                           child: accountProfileBio(data).padding(horizontal: 4), | ||||
|                         ), | ||||
|                         SliverToBoxAdapter( | ||||
|                           child: accountProfileDetail( | ||||
|                             data, | ||||
|                           ).padding(horizontal: 4), | ||||
|                         ), | ||||
|                         if (user.value != null) | ||||
|                           SliverToBoxAdapter( | ||||
|                             child: accountAction(data).padding(horizontal: 4), | ||||
|                           ), | ||||
|                         SliverToBoxAdapter( | ||||
|                           child: Card( | ||||
|                             child: FortuneGraphWidget( | ||||
|                               events: accountEvents, | ||||
|                               eventCalanderUser: data.name, | ||||
|                             ), | ||||
|                           ).padding(horizontal: 4), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ).padding(horizontal: 16), | ||||
|                   ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: Row( | ||||
|                     spacing: 8, | ||||
|                     children: [ | ||||
|                       Expanded( | ||||
|                         child: FilledButton.icon( | ||||
|                           onPressed: directMessageAction, | ||||
|                           icon: const Icon(Symbols.message), | ||||
|                           label: | ||||
|                               Text( | ||||
|                                 accountChat.value == null | ||||
|                                     ? 'createDirectMessage' | ||||
|                                     : 'gotoDirectMessage', | ||||
|                                 maxLines: 1, | ||||
|                               ).tr(), | ||||
|                         ), | ||||
|                       ), | ||||
|                       IconButton.filled( | ||||
|                         onPressed: () { | ||||
|                           showAbuseReportSheet( | ||||
|                             context, | ||||
|                             resourceIdentifier: 'account/${data.id}', | ||||
|                           ); | ||||
|                         }, | ||||
|                         icon: Icon( | ||||
|                           Symbols.flag, | ||||
|                           color: Theme.of(context).colorScheme.onError, | ||||
|                         ), | ||||
|                         style: ButtonStyle( | ||||
|                           backgroundColor: WidgetStatePropertyAll( | ||||
|                             Theme.of(context).colorScheme.error, | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ).padding(horizontal: 16, top: 4), | ||||
|                 ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: const Divider(height: 1).padding(top: 12), | ||||
|                 ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: Column( | ||||
|                     children: [ | ||||
|                       FortuneGraphWidget( | ||||
|                         events: accountEvents, | ||||
|                         eventCalanderUser: data.name, | ||||
|                       ), | ||||
|                     ], | ||||
|                   ).padding(all: 8), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|                     ), | ||||
|           ), | ||||
|       error: | ||||
|           (error, stackTrace) => AppScaffold( | ||||
|   | ||||
| @@ -73,7 +73,7 @@ class CreateAccountScreen extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       isNoBackground: false, | ||||
|       appBar: AppBar( | ||||
|         leading: const PageBackButton(), | ||||
|         title: Text('createAccount').tr(), | ||||
|   | ||||
| @@ -55,7 +55,7 @@ class LoginScreen extends HookConsumerWidget { | ||||
|     final factorPicked = useState<SnAuthFactor?>(null); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       isNoBackground: false, | ||||
|       appBar: AppBar( | ||||
|         leading: const PageBackButton(), | ||||
|         title: Text('login').tr(), | ||||
|   | ||||
| @@ -80,7 +80,7 @@ class _OidcScreenState extends ConsumerState<OidcScreen> { | ||||
|                             : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', | ||||
|                   ), | ||||
|                   initialUrlRequest: URLRequest( | ||||
|                     url: WebUri('$serverUrl/auth/login/${widget.provider}'), | ||||
|                     url: WebUri('$serverUrl/id/auth/login/${widget.provider}'), | ||||
|                     headers: { | ||||
|                       if (token?.token.isNotEmpty ?? false) | ||||
|                         'Authorization': 'AtField ${token!.token}', | ||||
| @@ -120,7 +120,7 @@ class _OidcScreenState extends ConsumerState<OidcScreen> { | ||||
|                       final queryParams = url.queryParameters; | ||||
|  | ||||
|                       // Check if we're on the token page | ||||
|                       if (path.endsWith('/id/auth/callback')) { | ||||
|                       if (path.endsWith('/auth/callback')) { | ||||
|                         // Extract token from URL | ||||
|                         final challenge = queryParams['challenge']; | ||||
|                         // Return the token and close the webview | ||||
| @@ -205,7 +205,7 @@ class _OidcScreenState extends ConsumerState<OidcScreen> { | ||||
|                       onPressed: () { | ||||
|                         if (currentUrl != null) { | ||||
|                           Clipboard.setData(ClipboardData(text: currentUrl!)); | ||||
|                           showSnackBar('copyToClipboard'); | ||||
|                           showSnackBar('copyToClipboard'.tr()); | ||||
|                         } | ||||
|                       }, | ||||
|                     ), | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import 'dart:developer'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/material.dart' hide ConnectionState; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| @@ -8,6 +10,7 @@ import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/chat/call_button.dart'; | ||||
| import 'package:island/widgets/chat/call_overlay.dart'; | ||||
| import 'package:island/widgets/chat/call_participant_tile.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| @@ -23,7 +26,19 @@ class CallScreen extends HookConsumerWidget { | ||||
|     final callNotifier = ref.watch(callNotifierProvider.notifier); | ||||
|  | ||||
|     useEffect(() { | ||||
|       callNotifier.joinRoom(roomId); | ||||
|       log('[Call] Joining the call...'); | ||||
|       callNotifier.joinRoom(roomId).catchError((_) { | ||||
|         showConfirmAlert( | ||||
|           'Seems there already has a call connected, do you want override it?', | ||||
|           'Call already connected', | ||||
|         ).then((value) { | ||||
|           if (value != true) return; | ||||
|           log('[Call] Joining the call... with overrides'); | ||||
|           callNotifier.disconnect(); | ||||
|           callNotifier.dispose(); | ||||
|           callNotifier.joinRoom(roomId); | ||||
|         }); | ||||
|       }); | ||||
|       return null; | ||||
|     }, []); | ||||
|  | ||||
| @@ -31,12 +46,16 @@ class CallScreen extends HookConsumerWidget { | ||||
|       (p) => | ||||
|           !(p.hasVideo && | ||||
|               p.remoteParticipant.trackPublications.values.any( | ||||
|                 (pub) => pub.track != null && pub.kind == TrackType.VIDEO, | ||||
|                 (pub) => | ||||
|                     pub.track != null && | ||||
|                     pub.kind == TrackType.VIDEO && | ||||
|                     !pub.muted && | ||||
|                     !pub.isDisposed, | ||||
|               )), | ||||
|     ); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       isNoBackground: false, | ||||
|       appBar: AppBar( | ||||
|         leading: PageBackButton(), | ||||
|         title: Column( | ||||
| @@ -49,7 +68,12 @@ class CallScreen extends HookConsumerWidget { | ||||
|             Text( | ||||
|               callState.isConnected | ||||
|                   ? formatDuration(callState.duration) | ||||
|                   : 'connecting', | ||||
|                   : (switch (callNotifier.room?.connectionState) { | ||||
|                     ConnectionState.connected => 'connected', | ||||
|                     ConnectionState.connecting => 'connecting', | ||||
|                     ConnectionState.reconnecting => 'reconnecting', | ||||
|                     _ => 'disconnected', | ||||
|                   }).tr(), | ||||
|               style: const TextStyle(fontSize: 14), | ||||
|             ), | ||||
|           ], | ||||
|   | ||||
| @@ -1070,6 +1070,10 @@ class _ChatInput extends HookConsumerWidget { | ||||
|                     item: attachments[idx], | ||||
|                     onRequestUpload: () => onUploadAttachment(idx), | ||||
|                     onDelete: () => onDeleteAttachment(idx), | ||||
|                     onUpdate: (value) { | ||||
|                       attachments[idx] = value; | ||||
|                       onAttachmentsChanged(attachments); | ||||
|                     }, | ||||
|                     onMove: (delta) => onMoveAttachment(idx, delta), | ||||
|                   ); | ||||
|                 }, | ||||
|   | ||||
| @@ -421,10 +421,10 @@ class _ChatRoomActionMenu extends HookConsumerWidget { | ||||
|                   showConfirmAlert( | ||||
|                     'deleteChatRoomHint'.tr(), | ||||
|                     'deleteChatRoom'.tr(), | ||||
|                   ).then((confirm) { | ||||
|                   ).then((confirm) async { | ||||
|                     if (confirm) { | ||||
|                       final client = ref.watch(apiClientProvider); | ||||
|                       client.delete('/sphere/chat/$id'); | ||||
|                       await client.delete('/sphere/chat/$id'); | ||||
|                       ref.invalidate(chatroomsJoinedProvider); | ||||
|                       if (context.mounted) { | ||||
|                         context.pop(); | ||||
| @@ -454,10 +454,10 @@ class _ChatRoomActionMenu extends HookConsumerWidget { | ||||
|                   showConfirmAlert( | ||||
|                     'leaveChatRoomHint'.tr(), | ||||
|                     'leaveChatRoom'.tr(), | ||||
|                   ).then((confirm) { | ||||
|                   ).then((confirm) async { | ||||
|                     if (confirm) { | ||||
|                       final client = ref.watch(apiClientProvider); | ||||
|                       client.delete('/sphere/chat/$id/members/me'); | ||||
|                       await client.delete('/sphere/chat/$id/members/me'); | ||||
|                       ref.invalidate(chatroomsJoinedProvider); | ||||
|                       if (context.mounted) { | ||||
|                         context.pop(); | ||||
|   | ||||
| @@ -380,6 +380,23 @@ class CreatorHubScreen extends HookConsumerWidget { | ||||
|                               ); | ||||
|                             }, | ||||
|                           ), | ||||
|                           ListTile( | ||||
|                             minTileHeight: 48, | ||||
|                             title: const Text('Polls'), | ||||
|                             trailing: const Icon(Symbols.chevron_right), | ||||
|                             leading: const Icon(Symbols.poll), | ||||
|                             contentPadding: const EdgeInsets.symmetric( | ||||
|                               horizontal: 24, | ||||
|                             ), | ||||
|                             onTap: () { | ||||
|                               context.pushNamed( | ||||
|                                 'creatorPolls', | ||||
|                                 pathParameters: { | ||||
|                                   'name': currentPublisher.value!.name, | ||||
|                                 }, | ||||
|                               ); | ||||
|                             }, | ||||
|                           ), | ||||
|                           ListTile( | ||||
|                             minTileHeight: 48, | ||||
|                             title: Text('publisherMembers').tr(), | ||||
|   | ||||
							
								
								
									
										175
									
								
								lib/screens/creators/poll/poll_list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								lib/screens/creators/poll/poll_list.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,175 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/poll.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||
|  | ||||
| part 'poll_list.g.dart'; | ||||
|  | ||||
| @riverpod | ||||
| class PollListNotifier extends _$PollListNotifier | ||||
|     with CursorPagingNotifierMixin<SnPoll> { | ||||
|   static const int _pageSize = 20; | ||||
|  | ||||
|   @override | ||||
|   Future<CursorPagingData<SnPoll>> build(String? pubName) { | ||||
|     // immediately load first page | ||||
|     return fetch(cursor: null); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<CursorPagingData<SnPoll>> fetch({required String? cursor}) async { | ||||
|     final client = ref.read(apiClientProvider); | ||||
|     final offset = cursor == null ? 0 : int.parse(cursor); | ||||
|  | ||||
|     // read the current family argument passed to provider | ||||
|     final currentPub = pubName; | ||||
|     final queryParams = { | ||||
|       'offset': offset, | ||||
|       'take': _pageSize, | ||||
|       if (currentPub != null) 'pub': currentPub, | ||||
|     }; | ||||
|  | ||||
|     final response = await client.get( | ||||
|       '/sphere/polls/me', | ||||
|       queryParameters: queryParams, | ||||
|     ); | ||||
|     final total = int.parse(response.headers.value('X-Total') ?? '0'); | ||||
|     final List<dynamic> data = response.data; | ||||
|     final items = data.map((json) => SnPoll.fromJson(json)).toList(); | ||||
|  | ||||
|     final hasMore = offset + items.length < total; | ||||
|     final nextCursor = hasMore ? (offset + items.length).toString() : null; | ||||
|  | ||||
|     return CursorPagingData( | ||||
|       items: items, | ||||
|       hasMore: hasMore, | ||||
|       nextCursor: nextCursor, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class CreatorPollListScreen extends HookConsumerWidget { | ||||
|   const CreatorPollListScreen({super.key, required this.pubName}); | ||||
|  | ||||
|   final String pubName; | ||||
|  | ||||
|   Future<void> _createPoll(BuildContext context) async { | ||||
|     final result = await GoRouter.of( | ||||
|       context, | ||||
|     ).pushNamed('creatorPollNew', pathParameters: {'name': pubName}); | ||||
|     if (result is SnPoll && context.mounted) { | ||||
|       Navigator.of(context).maybePop(result); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     return Scaffold( | ||||
|       appBar: AppBar(title: const Text('Polls')), | ||||
|       floatingActionButton: FloatingActionButton( | ||||
|         onPressed: () => _createPoll(context), | ||||
|         child: const Icon(Icons.add), | ||||
|       ), | ||||
|       body: RefreshIndicator( | ||||
|         onRefresh: () => ref.refresh(pollListNotifierProvider(pubName).future), | ||||
|         child: CustomScrollView( | ||||
|           slivers: [ | ||||
|             PagingHelperSliverView( | ||||
|               provider: pollListNotifierProvider(pubName), | ||||
|               futureRefreshable: pollListNotifierProvider(pubName).future, | ||||
|               notifierRefreshable: pollListNotifierProvider(pubName).notifier, | ||||
|               contentBuilder: | ||||
|                   (data, widgetCount, endItemView) => SliverList.builder( | ||||
|                     itemCount: widgetCount, | ||||
|                     itemBuilder: (context, index) { | ||||
|                       if (index == widgetCount - 1) { | ||||
|                         return endItemView; | ||||
|                       } | ||||
|                       final poll = data.items[index]; | ||||
|                       return _CreatorPollItem(poll: poll, pubName: pubName); | ||||
|                     }, | ||||
|                   ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _CreatorPollItem extends StatelessWidget { | ||||
|   final String pubName; | ||||
|   const _CreatorPollItem({required this.poll, required this.pubName}); | ||||
|  | ||||
|   final SnPoll poll; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final theme = Theme.of(context); | ||||
|     final ended = poll.endedAt; | ||||
|     final endedText = | ||||
|         ended == null | ||||
|             ? 'No end' | ||||
|             : MaterialLocalizations.of(context).formatFullDate(ended); | ||||
|  | ||||
|     return Card( | ||||
|       margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), | ||||
|       clipBehavior: Clip.antiAlias, | ||||
|       child: ListTile( | ||||
|         title: Text(poll.title ?? 'Untitled poll'), | ||||
|         subtitle: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             if (poll.description != null && poll.description!.isNotEmpty) | ||||
|               Padding( | ||||
|                 padding: const EdgeInsets.only(top: 4), | ||||
|                 child: Text( | ||||
|                   poll.description!, | ||||
|                   maxLines: 2, | ||||
|                   overflow: TextOverflow.ellipsis, | ||||
|                 ), | ||||
|               ), | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.only(top: 4), | ||||
|               child: Text( | ||||
|                 'Questions: ${poll.questions.length} · Ends: $endedText', | ||||
|                 style: theme.textTheme.bodySmall, | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|         trailing: PopupMenuButton<String>( | ||||
|           itemBuilder: | ||||
|               (context) => [ | ||||
|                 PopupMenuItem( | ||||
|                   child: Row( | ||||
|                     children: [ | ||||
|                       const Icon(Symbols.edit), | ||||
|                       const Gap(16), | ||||
|                       Text('Edit'), | ||||
|                     ], | ||||
|                   ), | ||||
|                   onTap: () { | ||||
|                     GoRouter.of(context).pushNamed( | ||||
|                       'creatorPollEdit', | ||||
|                       pathParameters: {'name': pubName, 'id': poll.id}, | ||||
|                     ); | ||||
|                   }, | ||||
|                 ), | ||||
|               ], | ||||
|         ), | ||||
|         onTap: () { | ||||
|           // Open editor for edit | ||||
|           // Navigator push by path to keep consistency with rest of app: | ||||
|           // Note: pub name string may be required in route; when absent, route may need query or pick later. | ||||
|           // For safety, just do nothing if no publisher in list item. | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										179
									
								
								lib/screens/creators/poll/poll_list.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								lib/screens/creators/poll/poll_list.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'poll_list.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$pollListNotifierHash() => r'd3da24ff6bbb8f35b06d57fc41625dc0312508e4'; | ||||
|  | ||||
| /// Copied from Dart SDK | ||||
| class _SystemHash { | ||||
|   _SystemHash._(); | ||||
|  | ||||
|   static int combine(int hash, int value) { | ||||
|     // ignore: parameter_assignments | ||||
|     hash = 0x1fffffff & (hash + value); | ||||
|     // ignore: parameter_assignments | ||||
|     hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); | ||||
|     return hash ^ (hash >> 6); | ||||
|   } | ||||
|  | ||||
|   static int finish(int hash) { | ||||
|     // ignore: parameter_assignments | ||||
|     hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); | ||||
|     // ignore: parameter_assignments | ||||
|     hash = hash ^ (hash >> 11); | ||||
|     return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| abstract class _$PollListNotifier | ||||
|     extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnPoll>> { | ||||
|   late final String? pubName; | ||||
|  | ||||
|   FutureOr<CursorPagingData<SnPoll>> build(String? pubName); | ||||
| } | ||||
|  | ||||
| /// See also [PollListNotifier]. | ||||
| @ProviderFor(PollListNotifier) | ||||
| const pollListNotifierProvider = PollListNotifierFamily(); | ||||
|  | ||||
| /// See also [PollListNotifier]. | ||||
| class PollListNotifierFamily | ||||
|     extends Family<AsyncValue<CursorPagingData<SnPoll>>> { | ||||
|   /// See also [PollListNotifier]. | ||||
|   const PollListNotifierFamily(); | ||||
|  | ||||
|   /// See also [PollListNotifier]. | ||||
|   PollListNotifierProvider call(String? pubName) { | ||||
|     return PollListNotifierProvider(pubName); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   PollListNotifierProvider getProviderOverride( | ||||
|     covariant PollListNotifierProvider provider, | ||||
|   ) { | ||||
|     return call(provider.pubName); | ||||
|   } | ||||
|  | ||||
|   static const Iterable<ProviderOrFamily>? _dependencies = null; | ||||
|  | ||||
|   @override | ||||
|   Iterable<ProviderOrFamily>? get dependencies => _dependencies; | ||||
|  | ||||
|   static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null; | ||||
|  | ||||
|   @override | ||||
|   Iterable<ProviderOrFamily>? get allTransitiveDependencies => | ||||
|       _allTransitiveDependencies; | ||||
|  | ||||
|   @override | ||||
|   String? get name => r'pollListNotifierProvider'; | ||||
| } | ||||
|  | ||||
| /// See also [PollListNotifier]. | ||||
| class PollListNotifierProvider | ||||
|     extends | ||||
|         AutoDisposeAsyncNotifierProviderImpl< | ||||
|           PollListNotifier, | ||||
|           CursorPagingData<SnPoll> | ||||
|         > { | ||||
|   /// See also [PollListNotifier]. | ||||
|   PollListNotifierProvider(String? pubName) | ||||
|     : this._internal( | ||||
|         () => PollListNotifier()..pubName = pubName, | ||||
|         from: pollListNotifierProvider, | ||||
|         name: r'pollListNotifierProvider', | ||||
|         debugGetCreateSourceHash: | ||||
|             const bool.fromEnvironment('dart.vm.product') | ||||
|                 ? null | ||||
|                 : _$pollListNotifierHash, | ||||
|         dependencies: PollListNotifierFamily._dependencies, | ||||
|         allTransitiveDependencies: | ||||
|             PollListNotifierFamily._allTransitiveDependencies, | ||||
|         pubName: pubName, | ||||
|       ); | ||||
|  | ||||
|   PollListNotifierProvider._internal( | ||||
|     super._createNotifier, { | ||||
|     required super.name, | ||||
|     required super.dependencies, | ||||
|     required super.allTransitiveDependencies, | ||||
|     required super.debugGetCreateSourceHash, | ||||
|     required super.from, | ||||
|     required this.pubName, | ||||
|   }) : super.internal(); | ||||
|  | ||||
|   final String? pubName; | ||||
|  | ||||
|   @override | ||||
|   FutureOr<CursorPagingData<SnPoll>> runNotifierBuild( | ||||
|     covariant PollListNotifier notifier, | ||||
|   ) { | ||||
|     return notifier.build(pubName); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Override overrideWith(PollListNotifier Function() create) { | ||||
|     return ProviderOverride( | ||||
|       origin: this, | ||||
|       override: PollListNotifierProvider._internal( | ||||
|         () => create()..pubName = pubName, | ||||
|         from: from, | ||||
|         name: null, | ||||
|         dependencies: null, | ||||
|         allTransitiveDependencies: null, | ||||
|         debugGetCreateSourceHash: null, | ||||
|         pubName: pubName, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AutoDisposeAsyncNotifierProviderElement< | ||||
|     PollListNotifier, | ||||
|     CursorPagingData<SnPoll> | ||||
|   > | ||||
|   createElement() { | ||||
|     return _PollListNotifierProviderElement(this); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return other is PollListNotifierProvider && other.pubName == pubName; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||
|     hash = _SystemHash.combine(hash, pubName.hashCode); | ||||
|  | ||||
|     return _SystemHash.finish(hash); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| mixin PollListNotifierRef | ||||
|     on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnPoll>> { | ||||
|   /// The parameter `pubName` of this provider. | ||||
|   String? get pubName; | ||||
| } | ||||
|  | ||||
| class _PollListNotifierProviderElement | ||||
|     extends | ||||
|         AutoDisposeAsyncNotifierProviderElement< | ||||
|           PollListNotifier, | ||||
|           CursorPagingData<SnPoll> | ||||
|         > | ||||
|     with PollListNotifierRef { | ||||
|   _PollListNotifierProviderElement(super.provider); | ||||
|  | ||||
|   @override | ||||
|   String? get pubName => (origin as PollListNotifierProvider).pubName; | ||||
| } | ||||
|  | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package | ||||
| @@ -111,7 +111,7 @@ class DeveloperHubScreen extends HookConsumerWidget { | ||||
|     ); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       isNoBackground: false, | ||||
|       appBar: AppBar( | ||||
|         leading: !isWide ? const PageBackButton() : null, | ||||
|         title: Text('developerHub').tr(), | ||||
|   | ||||
| @@ -17,7 +17,7 @@ class DiscoveryRealmsScreen extends HookConsumerWidget { | ||||
|     final currentQuery = useState<String?>(null); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       isNoBackground: false, | ||||
|       appBar: AppBar(title: Text('discoverRealms'.tr())), | ||||
|       body: Stack( | ||||
|         children: [ | ||||
|   | ||||
| @@ -87,7 +87,7 @@ class ExploreScreen extends HookConsumerWidget { | ||||
|     final user = ref.watch(userInfoProvider); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       isNoBackground: false, | ||||
|       appBar: AppBar( | ||||
|         toolbarHeight: 0, | ||||
|         bottom: PreferredSize( | ||||
| @@ -344,6 +344,10 @@ class _DiscoveryActivityItem extends StatelessWidget { | ||||
|                 flexWeights: | ||||
|                     isWideScreen(context) ? <int>[3, 2, 1] : <int>[4, 1], | ||||
|                 consumeMaxWeight: false, | ||||
|                 enableSplash: false, | ||||
|                 shape: RoundedRectangleBorder( | ||||
|                   borderRadius: BorderRadius.all(Radius.circular(8)), | ||||
|                 ), | ||||
|                 children: [ | ||||
|                   for (final item in items) | ||||
|                     switch (type) { | ||||
| @@ -364,7 +368,7 @@ class _DiscoveryActivityItem extends StatelessWidget { | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           ).padding(bottom: 8), | ||||
|           ).padding(bottom: 8, horizontal: 8), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   | ||||
							
								
								
									
										1094
									
								
								lib/screens/poll/poll_editor.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1094
									
								
								lib/screens/poll/poll_editor.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -53,13 +53,13 @@ class PostEditScreen extends HookConsumerWidget { | ||||
|       data: (post) => PostComposeScreen(originalPost: post), | ||||
|       loading: | ||||
|           () => AppScaffold( | ||||
|             noBackground: false, | ||||
|             isNoBackground: false, | ||||
|             appBar: AppBar(leading: const PageBackButton()), | ||||
|             body: const Center(child: CircularProgressIndicator()), | ||||
|           ), | ||||
|       error: | ||||
|           (e, _) => AppScaffold( | ||||
|             noBackground: false, | ||||
|             isNoBackground: false, | ||||
|             appBar: AppBar(leading: const PageBackButton()), | ||||
|             body: Text('Error: $e', textAlign: TextAlign.center), | ||||
|           ), | ||||
| @@ -238,6 +238,8 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|             onRequestUpload: | ||||
|                 () => ComposeLogic.uploadAttachment(ref, state, idx), | ||||
|             onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx), | ||||
|             onUpdate: | ||||
|                 (value) => ComposeLogic.updateAttachment(state, value, idx), | ||||
|             onMove: (delta) { | ||||
|               state.attachments.value = ComposeLogic.moveAttachment( | ||||
|                 state.attachments.value, | ||||
| @@ -265,6 +267,9 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                       () => ComposeLogic.uploadAttachment(ref, state, idx), | ||||
|                   onDelete: | ||||
|                       () => ComposeLogic.deleteAttachment(ref, state, idx), | ||||
|                   onUpdate: | ||||
|                       (value) => | ||||
|                           ComposeLogic.updateAttachment(state, value, idx), | ||||
|                   onMove: (delta) { | ||||
|                     state.attachments.value = ComposeLogic.moveAttachment( | ||||
|                       state.attachments.value, | ||||
| @@ -287,7 +292,7 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|         } | ||||
|       }, | ||||
|       child: AppScaffold( | ||||
|         noBackground: false, | ||||
|         isNoBackground: false, | ||||
|         appBar: AppBar( | ||||
|           leading: const PageBackButton(), | ||||
|           actions: [ | ||||
|   | ||||
| @@ -308,6 +308,13 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|                                           state, | ||||
|                                           idx, | ||||
|                                         ), | ||||
|                                     onUpdate: | ||||
|                                         (value) => | ||||
|                                             ComposeLogic.updateAttachment( | ||||
|                                               state, | ||||
|                                               value, | ||||
|                                               idx, | ||||
|                                             ), | ||||
|                                     onDelete: | ||||
|                                         () => ComposeLogic.deleteAttachment( | ||||
|                                           ref, | ||||
| @@ -352,7 +359,7 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|         } | ||||
|       }, | ||||
|       child: AppScaffold( | ||||
|         noBackground: false, | ||||
|         isNoBackground: false, | ||||
|         appBar: AppBar( | ||||
|           leading: const PageBackButton(), | ||||
|           title: ValueListenableBuilder<TextEditingValue>( | ||||
|   | ||||
| @@ -54,7 +54,7 @@ class PostDetailScreen extends HookConsumerWidget { | ||||
|     final user = ref.watch(userInfoProvider); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       isNoBackground: false, | ||||
|       appBar: AppBar(title: const Text('Post')), | ||||
|       body: postState.when( | ||||
|         data: (post) { | ||||
|   | ||||
| @@ -110,7 +110,7 @@ class _PostSearchScreenState extends ConsumerState<PostSearchScreen> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       isNoBackground: false, | ||||
|       appBar: AppBar( | ||||
|         title: TextField( | ||||
|           controller: _searchController, | ||||
|   | ||||
| @@ -18,6 +18,7 @@ import 'package:island/widgets/account/status.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:island/widgets/post/post_list.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:palette_generator/palette_generator.dart'; | ||||
| @@ -233,25 +234,36 @@ class PublisherProfileScreen extends HookConsumerWidget { | ||||
|       ], | ||||
|     ).padding(horizontal: 24, top: 24); | ||||
|  | ||||
|     Widget publisherVerificationWidget(SnPublisher data) => Card( | ||||
|       margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), | ||||
|       child: Column( | ||||
|         children: [ | ||||
|           if (badges.value?.isNotEmpty ?? false) | ||||
|             BadgeList(badges: badges.value!).padding(top: 16), | ||||
|           if (data.verification != null) | ||||
|             VerificationStatusCard(mark: data.verification!), | ||||
|         ], | ||||
|       ), | ||||
|     ).padding(top: 16); | ||||
|     Widget publisherBadgesWidget(SnPublisher data) => | ||||
|         (badges.value?.isNotEmpty ?? false) | ||||
|             ? Card( | ||||
|               child: BadgeList( | ||||
|                 badges: badges.value!, | ||||
|               ).padding(horizontal: 26, vertical: 20), | ||||
|             ).padding(horizontal: 4) | ||||
|             : const SizedBox.shrink(); | ||||
|  | ||||
|     Widget publisherDetailWidget(SnPublisher data) => Card( | ||||
|     Widget publisherVerificationWidget(SnPublisher data) => | ||||
|         (data.verification != null) | ||||
|             ? Card( | ||||
|               margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), | ||||
|               child: VerificationStatusCard(mark: data.verification!), | ||||
|             ) | ||||
|             : const SizedBox.shrink(); | ||||
|  | ||||
|     Widget publisherBioWidget(SnPublisher data) => Card( | ||||
|       margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           Text('bio').tr().bold().padding(bottom: 2), | ||||
|           Text(data.bio.isEmpty ? 'descriptionNone'.tr() : data.bio), | ||||
|           Text('bio').tr().bold().fontSize(15).padding(bottom: 8), | ||||
|           if (data.bio.isEmpty) | ||||
|             Text('descriptionNone').tr().italic() | ||||
|           else | ||||
|             MarkdownTextContent( | ||||
|               content: data.bio, | ||||
|               linesMargin: EdgeInsets.zero, | ||||
|             ), | ||||
|         ], | ||||
|       ).padding(horizontal: 20, vertical: 16), | ||||
|     ); | ||||
| @@ -259,7 +271,7 @@ class PublisherProfileScreen extends HookConsumerWidget { | ||||
|     return publisher.when( | ||||
|       data: | ||||
|           (data) => AppScaffold( | ||||
|             noBackground: false, | ||||
|             isNoBackground: false, | ||||
|             appBar: | ||||
|                 isWideScreen(context) | ||||
|                     ? AppBar( | ||||
| @@ -325,8 +337,9 @@ class PublisherProfileScreen extends HookConsumerWidget { | ||||
|                                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                                 children: [ | ||||
|                                   publisherBasisWidget(data), | ||||
|                                   publisherBadgesWidget(data), | ||||
|                                   publisherVerificationWidget(data), | ||||
|                                   publisherDetailWidget(data), | ||||
|                                   publisherBioWidget(data), | ||||
|                                 ], | ||||
|                               ), | ||||
|                             ), | ||||
| @@ -377,11 +390,14 @@ class PublisherProfileScreen extends HookConsumerWidget { | ||||
|                             ], | ||||
|                           ), | ||||
|                         ), | ||||
|                         SliverToBoxAdapter(child: publisherBasisWidget(data)), | ||||
|                         SliverToBoxAdapter( | ||||
|                           child: publisherBasisWidget(data).padding(bottom: 8), | ||||
|                         ), | ||||
|                         SliverToBoxAdapter(child: publisherBadgesWidget(data)), | ||||
|                         SliverToBoxAdapter( | ||||
|                           child: publisherVerificationWidget(data), | ||||
|                         ), | ||||
|                         SliverToBoxAdapter(child: publisherDetailWidget(data)), | ||||
|                         SliverToBoxAdapter(child: publisherBioWidget(data)), | ||||
|                         SliverPostList(pubName: name), | ||||
|                         SliverGap(MediaQuery.of(context).padding.bottom + 16), | ||||
|                       ], | ||||
| @@ -389,11 +405,13 @@ class PublisherProfileScreen extends HookConsumerWidget { | ||||
|           ), | ||||
|       error: | ||||
|           (error, stackTrace) => AppScaffold( | ||||
|             isNoBackground: false, | ||||
|             appBar: AppBar(leading: const PageBackButton()), | ||||
|             body: Center(child: Text(error.toString())), | ||||
|           ), | ||||
|       loading: | ||||
|           () => AppScaffold( | ||||
|             isNoBackground: false, | ||||
|             appBar: AppBar(leading: const PageBackButton()), | ||||
|             body: Center(child: CircularProgressIndicator()), | ||||
|           ), | ||||
|   | ||||
| @@ -79,7 +79,7 @@ class RealmDetailScreen extends HookConsumerWidget { | ||||
|     ); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       isNoBackground: false, | ||||
|       body: realmState.when( | ||||
|         loading: () => const Center(child: CircularProgressIndicator()), | ||||
|         error: (error, _) => Center(child: Text('Error: $error')), | ||||
| @@ -321,10 +321,10 @@ class _RealmActionMenu extends HookConsumerWidget { | ||||
|                               showConfirmAlert( | ||||
|                                 'leaveRealmHint'.tr(), | ||||
|                                 'leaveRealm'.tr(), | ||||
|                               ).then((confirm) { | ||||
|                               ).then((confirm) async { | ||||
|                                 if (confirm) { | ||||
|                                   final client = ref.watch(apiClientProvider); | ||||
|                                   client.delete( | ||||
|                                   await client.delete( | ||||
|                                     '/sphere/realms/$realmSlug/members/me', | ||||
|                                   ); | ||||
|                                   ref.invalidate(realmsJoinedProvider); | ||||
| @@ -361,10 +361,12 @@ class _RealmActionMenu extends HookConsumerWidget { | ||||
|                       showConfirmAlert( | ||||
|                         'leaveRealmHint'.tr(), | ||||
|                         'leaveRealm'.tr(), | ||||
|                       ).then((confirm) { | ||||
|                       ).then((confirm) async { | ||||
|                         if (confirm) { | ||||
|                           final client = ref.watch(apiClientProvider); | ||||
|                           client.delete('/sphere/realms/$realmSlug/members/me'); | ||||
|                           await client.delete( | ||||
|                             '/sphere/realms/$realmSlug/members/me', | ||||
|                           ); | ||||
|                           ref.invalidate(realmsJoinedProvider); | ||||
|                           if (context.mounted) { | ||||
|                             context.pop(true); | ||||
|   | ||||
| @@ -41,7 +41,7 @@ class RealmListScreen extends HookConsumerWidget { | ||||
|     final realmInvites = ref.watch(realmInvitesProvider); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       isNoBackground: false, | ||||
|       appBar: AppBar( | ||||
|         title: const Text('realms').tr(), | ||||
|         actions: [ | ||||
| @@ -279,7 +279,7 @@ class EditRealmScreen extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       isNoBackground: false, | ||||
|       appBar: AppBar( | ||||
|         title: Text(slug == null ? 'createRealm'.tr() : 'editRealm'.tr()), | ||||
|         leading: const PageBackButton(), | ||||
|   | ||||
| @@ -552,7 +552,7 @@ class SettingsScreen extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       isNoBackground: false, | ||||
|       appBar: AppBar( | ||||
|         title: Text('settings').tr(), | ||||
|         actions: | ||||
|   | ||||
| @@ -15,6 +15,7 @@ Future<XFile?> cropImage( | ||||
|   BuildContext context, { | ||||
|   required XFile image, | ||||
|   List<CropAspectRatio?>? allowedAspectRatios, | ||||
|   bool replacePath = false, | ||||
| }) async { | ||||
|   final result = await showMaterialImageCropper( | ||||
|     context, | ||||
| @@ -34,7 +35,7 @@ Future<XFile?> cropImage( | ||||
|   croppedFile.dispose(); | ||||
|   return XFile.fromData( | ||||
|     croppedBytes.buffer.asUint8List(), | ||||
|     path: image.path, | ||||
|     path: !replacePath ? image.path : null, | ||||
|     mimeType: image.mimeType, | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -20,6 +20,33 @@ extension DurationFormatter on Duration { | ||||
|     return '${isNegative ? '-' : ''}$hours:$minutes:$seconds'; | ||||
|   } | ||||
|  | ||||
|   String formatShortDuration() { | ||||
|     final isNegative = inMicroseconds < 0; | ||||
|     final positiveDuration = isNegative ? -this : this; | ||||
|  | ||||
|     final hours = positiveDuration.inHours; | ||||
|     final minutes = (positiveDuration.inMinutes % 60).toString().padLeft( | ||||
|       2, | ||||
|       '0', | ||||
|     ); | ||||
|     final seconds = (positiveDuration.inSeconds % 60).toString().padLeft( | ||||
|       2, | ||||
|       '0', | ||||
|     ); | ||||
|     final milliseconds = (positiveDuration.inMilliseconds % 1000) | ||||
|         .toString() | ||||
|         .padLeft(3, '0'); | ||||
|  | ||||
|     String result; | ||||
|     if (hours > 0) { | ||||
|       result = | ||||
|           '${isNegative ? '-' : ''}${hours.toString().padLeft(2, '0')}:$minutes:$seconds.$milliseconds'; | ||||
|     } else { | ||||
|       result = '${isNegative ? '-' : ''}$minutes:$seconds.$milliseconds'; | ||||
|     } | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   String formatOffset() { | ||||
|     final isNegative = inMicroseconds < 0; | ||||
|     final positiveDuration = isNegative ? -this : this; | ||||
|   | ||||
| @@ -32,12 +32,12 @@ class BadgeItem extends StatelessWidget { | ||||
|       child: Container( | ||||
|         padding: const EdgeInsets.all(4), | ||||
|         decoration: BoxDecoration( | ||||
|           color: (template?.color ?? Colors.blue).withOpacity(0.1), | ||||
|           color: (template?.color ?? Colors.blue).withOpacity(0.2), | ||||
|           shape: BoxShape.circle, | ||||
|         ), | ||||
|         child: Icon( | ||||
|           template?.icon ?? Icons.stars, | ||||
|           color: template?.color ?? Colors.orange, | ||||
|           color: template?.color ?? Colors.blue, | ||||
|           size: 20, | ||||
|         ), | ||||
|       ), | ||||
|   | ||||
| @@ -32,7 +32,7 @@ class RestorePurchaseSheet extends HookConsumerWidget { | ||||
|       try { | ||||
|         final client = ref.read(apiClientProvider); | ||||
|         await client.post( | ||||
|           '/subscriptions/order/restore/${selectedProvider.value!}', | ||||
|           '/id/subscriptions/order/restore/${selectedProvider.value!}', | ||||
|           data: {'order_id': orderIdController.text.trim()}, | ||||
|         ); | ||||
|  | ||||
|   | ||||
| @@ -86,6 +86,7 @@ class AccountStatusCreationWidget extends HookConsumerWidget { | ||||
|       onTap: () { | ||||
|         showModalBottomSheet( | ||||
|           context: context, | ||||
|           isScrollControlled: true, | ||||
|           useRootNavigator: true, | ||||
|           builder: | ||||
|               (context) => AccountStatusCreationSheet( | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/widgets/account/status.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
|  | ||||
| class AccountStatusCreationSheet extends HookConsumerWidget { | ||||
| @@ -71,178 +72,145 @@ class AccountStatusCreationSheet extends HookConsumerWidget { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return Container( | ||||
|       constraints: BoxConstraints( | ||||
|         maxHeight: MediaQuery.of(context).size.height * 0.8, | ||||
|       ), | ||||
|       child: Column( | ||||
|         children: [ | ||||
|           Padding( | ||||
|             padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12), | ||||
|             child: Row( | ||||
|               children: [ | ||||
|                 Text( | ||||
|                   initialStatus == null | ||||
|                       ? 'statusCreate'.tr() | ||||
|                       : 'statusUpdate'.tr(), | ||||
|                   style: Theme.of(context).textTheme.headlineSmall?.copyWith( | ||||
|                     fontWeight: FontWeight.w600, | ||||
|                     letterSpacing: -0.5, | ||||
|                   ), | ||||
|     return SheetScaffold( | ||||
|       heightFactor: 0.6, | ||||
|       titleText: | ||||
|           initialStatus == null ? 'statusCreate'.tr() : 'statusUpdate'.tr(), | ||||
|       actions: [ | ||||
|         TextButton.icon( | ||||
|           onPressed: | ||||
|               submitting.value | ||||
|                   ? null | ||||
|                   : () { | ||||
|                     submitStatus(); | ||||
|                   }, | ||||
|           icon: const Icon(Symbols.upload), | ||||
|           label: Text(initialStatus == null ? 'create' : 'update').tr(), | ||||
|           style: ButtonStyle( | ||||
|             visualDensity: VisualDensity( | ||||
|               horizontal: VisualDensity.minimumDensity, | ||||
|             ), | ||||
|             foregroundColor: WidgetStatePropertyAll( | ||||
|               Theme.of(context).colorScheme.onSurface, | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|         if (initialStatus != null) | ||||
|           IconButton( | ||||
|             icon: const Icon(Symbols.delete), | ||||
|             onPressed: submitting.value ? null : () => clearStatus(), | ||||
|             style: IconButton.styleFrom(minimumSize: const Size(36, 36)), | ||||
|           ), | ||||
|       ], | ||||
|       child: SingleChildScrollView( | ||||
|         padding: const EdgeInsets.symmetric(horizontal: 20), | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|           children: [ | ||||
|             const Gap(24), | ||||
|             TextField( | ||||
|               controller: labelController, | ||||
|               decoration: InputDecoration( | ||||
|                 labelText: 'statusLabel'.tr(), | ||||
|                 border: const OutlineInputBorder( | ||||
|                   borderRadius: BorderRadius.all(Radius.circular(12)), | ||||
|                 ), | ||||
|                 const Spacer(), | ||||
|                 TextButton.icon( | ||||
|                   onPressed: | ||||
|                       submitting.value | ||||
|                           ? null | ||||
|                           : () { | ||||
|                             submitStatus(); | ||||
|                           }, | ||||
|                   icon: const Icon(Symbols.upload), | ||||
|                   label: Text(initialStatus == null ? 'create' : 'update').tr(), | ||||
|                   style: ButtonStyle( | ||||
|                     visualDensity: VisualDensity( | ||||
|                       horizontal: VisualDensity.minimumDensity, | ||||
|                     ), | ||||
|                     foregroundColor: WidgetStatePropertyAll( | ||||
|                       Theme.of(context).colorScheme.onSurface, | ||||
|                     ), | ||||
|                   ), | ||||
|               ), | ||||
|               onTapOutside: | ||||
|                   (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|             ), | ||||
|             const SizedBox(height: 24), | ||||
|             Text( | ||||
|               'statusAttitude'.tr(), | ||||
|               style: Theme.of(context).textTheme.titleMedium, | ||||
|             ), | ||||
|             const SizedBox(height: 8), | ||||
|             SegmentedButton( | ||||
|               segments: [ | ||||
|                 ButtonSegment( | ||||
|                   value: 0, | ||||
|                   icon: const Icon(Symbols.sentiment_satisfied), | ||||
|                   label: Text('attitudePositive'.tr()), | ||||
|                 ), | ||||
|                 if (initialStatus != null) | ||||
|                   IconButton( | ||||
|                     icon: const Icon(Symbols.delete), | ||||
|                     onPressed: submitting.value ? null : () => clearStatus(), | ||||
|                     style: IconButton.styleFrom( | ||||
|                       minimumSize: const Size(36, 36), | ||||
|                     ), | ||||
|                   ), | ||||
|                 IconButton( | ||||
|                   icon: const Icon(Symbols.close), | ||||
|                   onPressed: () => Navigator.pop(context), | ||||
|                   style: IconButton.styleFrom(minimumSize: const Size(36, 36)), | ||||
|                 ButtonSegment( | ||||
|                   value: 1, | ||||
|                   icon: const Icon(Symbols.sentiment_stressed), | ||||
|                   label: Text('attitudeNeutral'.tr()), | ||||
|                 ), | ||||
|                 ButtonSegment( | ||||
|                   value: 2, | ||||
|                   icon: const Icon(Symbols.sentiment_sad), | ||||
|                   label: Text('attitudeNegative'.tr()), | ||||
|                 ), | ||||
|               ], | ||||
|               selected: {attitude.value}, | ||||
|               onSelectionChanged: (Set<int> newSelection) { | ||||
|                 attitude.value = newSelection.first; | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|           const Divider(height: 1), | ||||
|           Expanded( | ||||
|             child: SingleChildScrollView( | ||||
|               padding: const EdgeInsets.symmetric(horizontal: 20), | ||||
|               child: Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 children: [ | ||||
|                   const Gap(24), | ||||
|                   TextField( | ||||
|                     controller: labelController, | ||||
|                     decoration: InputDecoration( | ||||
|                       labelText: 'statusLabel'.tr(), | ||||
|                       border: const OutlineInputBorder( | ||||
|                         borderRadius: BorderRadius.all(Radius.circular(12)), | ||||
|                       ), | ||||
|                     ), | ||||
|                     onTapOutside: | ||||
|                         (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                   ), | ||||
|                   const SizedBox(height: 24), | ||||
|                   Text( | ||||
|                     'statusAttitude'.tr(), | ||||
|                     style: Theme.of(context).textTheme.titleMedium, | ||||
|                   ), | ||||
|                   const SizedBox(height: 8), | ||||
|                   SegmentedButton( | ||||
|                     segments: [ | ||||
|                       ButtonSegment( | ||||
|                         value: 0, | ||||
|                         icon: const Icon(Symbols.sentiment_satisfied), | ||||
|                         label: Text('attitudePositive'.tr()), | ||||
|                       ), | ||||
|                       ButtonSegment( | ||||
|                         value: 1, | ||||
|                         icon: const Icon(Symbols.sentiment_stressed), | ||||
|                         label: Text('attitudeNeutral'.tr()), | ||||
|                       ), | ||||
|                       ButtonSegment( | ||||
|                         value: 2, | ||||
|                         icon: const Icon(Symbols.sentiment_sad), | ||||
|                         label: Text('attitudeNegative'.tr()), | ||||
|                       ), | ||||
|                     ], | ||||
|                     selected: {attitude.value}, | ||||
|                     onSelectionChanged: (Set<int> newSelection) { | ||||
|                       attitude.value = newSelection.first; | ||||
|                     }, | ||||
|                   ), | ||||
|                   const Gap(12), | ||||
|                   SwitchListTile( | ||||
|                     title: Text('statusInvisible'.tr()), | ||||
|                     subtitle: Text('statusInvisibleDescription'.tr()), | ||||
|                     value: isInvisible.value, | ||||
|                     contentPadding: EdgeInsets.symmetric(horizontal: 8), | ||||
|                     onChanged: (bool value) { | ||||
|                       isInvisible.value = value; | ||||
|                     }, | ||||
|                   ), | ||||
|                   SwitchListTile( | ||||
|                     title: Text('statusNotDisturb'.tr()), | ||||
|                     subtitle: Text('statusNotDisturbDescription'.tr()), | ||||
|                     value: isNotDisturb.value, | ||||
|                     contentPadding: EdgeInsets.symmetric(horizontal: 8), | ||||
|                     onChanged: (bool value) { | ||||
|                       isNotDisturb.value = value; | ||||
|                     }, | ||||
|                   ), | ||||
|                   const SizedBox(height: 24), | ||||
|                   Text( | ||||
|                     'statusClearTime'.tr(), | ||||
|                     style: Theme.of(context).textTheme.titleMedium, | ||||
|                   ), | ||||
|                   const SizedBox(height: 8), | ||||
|                   ListTile( | ||||
|                     title: Text( | ||||
|                       clearedAt.value == null | ||||
|                           ? 'statusNoAutoClear'.tr() | ||||
|                           : DateFormat.yMMMd().add_jm().format( | ||||
|                             clearedAt.value!, | ||||
|                           ), | ||||
|                     ), | ||||
|                     trailing: const Icon(Symbols.schedule), | ||||
|                     shape: RoundedRectangleBorder( | ||||
|                       borderRadius: BorderRadius.circular(8), | ||||
|                       side: BorderSide( | ||||
|                         color: Theme.of(context).colorScheme.outline, | ||||
|                       ), | ||||
|                     ), | ||||
|                     onTap: () async { | ||||
|                       final now = DateTime.now(); | ||||
|                       final date = await showDatePicker( | ||||
|                         context: context, | ||||
|                         initialDate: now, | ||||
|                         firstDate: now, | ||||
|                         lastDate: now.add(const Duration(days: 365)), | ||||
|                       ); | ||||
|                       if (date == null) return; | ||||
|                       if (!context.mounted) return; | ||||
|                       final time = await showTimePicker( | ||||
|                         context: context, | ||||
|                         initialTime: TimeOfDay.now(), | ||||
|                       ); | ||||
|                       if (time == null) return; | ||||
|                       clearedAt.value = DateTime( | ||||
|                         date.year, | ||||
|                         date.month, | ||||
|                         date.day, | ||||
|                         time.hour, | ||||
|                         time.minute, | ||||
|                       ); | ||||
|                     }, | ||||
|                   ), | ||||
|                   Gap(MediaQuery.of(context).padding.bottom + 24), | ||||
|                 ], | ||||
|             const Gap(12), | ||||
|             SwitchListTile( | ||||
|               title: Text('statusInvisible'.tr()), | ||||
|               subtitle: Text('statusInvisibleDescription'.tr()), | ||||
|               value: isInvisible.value, | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 8), | ||||
|               onChanged: (bool value) { | ||||
|                 isInvisible.value = value; | ||||
|               }, | ||||
|             ), | ||||
|             SwitchListTile( | ||||
|               title: Text('statusNotDisturb'.tr()), | ||||
|               subtitle: Text('statusNotDisturbDescription'.tr()), | ||||
|               value: isNotDisturb.value, | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 8), | ||||
|               onChanged: (bool value) { | ||||
|                 isNotDisturb.value = value; | ||||
|               }, | ||||
|             ), | ||||
|             const SizedBox(height: 24), | ||||
|             Text( | ||||
|               'statusClearTime'.tr(), | ||||
|               style: Theme.of(context).textTheme.titleMedium, | ||||
|             ), | ||||
|             const SizedBox(height: 8), | ||||
|             ListTile( | ||||
|               title: Text( | ||||
|                 clearedAt.value == null | ||||
|                     ? 'statusNoAutoClear'.tr() | ||||
|                     : DateFormat.yMMMd().add_jm().format(clearedAt.value!), | ||||
|               ), | ||||
|               trailing: const Icon(Symbols.schedule), | ||||
|               shape: RoundedRectangleBorder( | ||||
|                 borderRadius: BorderRadius.circular(8), | ||||
|                 side: BorderSide(color: Theme.of(context).colorScheme.outline), | ||||
|               ), | ||||
|               onTap: () async { | ||||
|                 final now = DateTime.now(); | ||||
|                 final date = await showDatePicker( | ||||
|                   context: context, | ||||
|                   initialDate: now, | ||||
|                   firstDate: now, | ||||
|                   lastDate: now.add(const Duration(days: 365)), | ||||
|                 ); | ||||
|                 if (date == null) return; | ||||
|                 if (!context.mounted) return; | ||||
|                 final time = await showTimePicker( | ||||
|                   context: context, | ||||
|                   initialTime: TimeOfDay.now(), | ||||
|                 ); | ||||
|                 if (time == null) return; | ||||
|                 clearedAt.value = DateTime( | ||||
|                   date.year, | ||||
|                   date.month, | ||||
|                   date.day, | ||||
|                   time.hour, | ||||
|                   time.minute, | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|             Gap(MediaQuery.of(context).padding.bottom + 24), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -165,7 +165,7 @@ class AppScaffold extends StatelessWidget { | ||||
|   final AppBar? appBar; | ||||
|   final DrawerCallback? onDrawerChanged; | ||||
|   final DrawerCallback? onEndDrawerChanged; | ||||
|   final bool? noBackground; | ||||
|   final bool? isNoBackground; | ||||
|   final bool? extendBody; | ||||
|  | ||||
|   const AppScaffold({ | ||||
| @@ -181,7 +181,7 @@ class AppScaffold extends StatelessWidget { | ||||
|     this.endDrawer, | ||||
|     this.onDrawerChanged, | ||||
|     this.onEndDrawerChanged, | ||||
|     this.noBackground, | ||||
|     this.isNoBackground, | ||||
|     this.extendBody, | ||||
|   }); | ||||
|  | ||||
| @@ -190,7 +190,7 @@ class AppScaffold extends StatelessWidget { | ||||
|     final appBarHeight = appBar?.preferredSize.height ?? 0; | ||||
|     final safeTop = MediaQuery.of(context).padding.top; | ||||
|  | ||||
|     final noBackground = this.noBackground ?? isWideScreen(context); | ||||
|     final noBackground = isNoBackground ?? isWideScreen(context); | ||||
|  | ||||
|     final content = Column( | ||||
|       children: [ | ||||
|   | ||||
| @@ -22,8 +22,10 @@ class CallControlsBar extends HookConsumerWidget { | ||||
|  | ||||
|     return Container( | ||||
|       padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), | ||||
|       child: Row( | ||||
|         mainAxisAlignment: MainAxisAlignment.center, | ||||
|       child: Wrap( | ||||
|         alignment: WrapAlignment.center, | ||||
|         runSpacing: 16, | ||||
|         spacing: 16, | ||||
|         children: [ | ||||
|           _buildCircularButtonWithDropdown( | ||||
|             context: context, | ||||
| @@ -35,7 +37,6 @@ class CallControlsBar extends HookConsumerWidget { | ||||
|             hasDropdown: true, | ||||
|             deviceType: 'videoinput', | ||||
|           ), | ||||
|           const Gap(16), | ||||
|           _buildCircularButton( | ||||
|             icon: | ||||
|                 callState.isScreenSharing | ||||
| @@ -44,7 +45,6 @@ class CallControlsBar extends HookConsumerWidget { | ||||
|             onPressed: () => callNotifier.toggleScreenShare(), | ||||
|             backgroundColor: const Color(0xFF424242), | ||||
|           ), | ||||
|           const Gap(16), | ||||
|           _buildCircularButtonWithDropdown( | ||||
|             context: context, | ||||
|             ref: ref, | ||||
| @@ -54,7 +54,14 @@ class CallControlsBar extends HookConsumerWidget { | ||||
|             hasDropdown: true, | ||||
|             deviceType: 'audioinput', | ||||
|           ), | ||||
|           const Gap(16), | ||||
|           _buildCircularButton( | ||||
|             icon: | ||||
|                 callState.isSpeakerphone | ||||
|                     ? Symbols.mobile_speaker | ||||
|                     : Symbols.ear_sound, | ||||
|             onPressed: () => callNotifier.toggleSpeakerphone(), | ||||
|             backgroundColor: const Color(0xFF424242), | ||||
|           ), | ||||
|           _buildCircularButton( | ||||
|             icon: Icons.call_end, | ||||
|             onPressed: | ||||
| @@ -63,41 +70,44 @@ class CallControlsBar extends HookConsumerWidget { | ||||
|                   isScrollControlled: true, | ||||
|                   useRootNavigator: true, | ||||
|                   builder: | ||||
|                       (context) => ClipRRect( | ||||
|                         borderRadius: BorderRadius.only( | ||||
|                           topLeft: Radius.circular(8), | ||||
|                           topRight: Radius.circular(8), | ||||
|                         ), | ||||
|                         child: Column( | ||||
|                           mainAxisSize: MainAxisSize.min, | ||||
|                           children: [ | ||||
|                             ListTile( | ||||
|                               leading: const Icon(Symbols.logout, fill: 1), | ||||
|                               title: Text('callLeave').tr(), | ||||
|                               onTap: () { | ||||
|                                 callNotifier.disconnect(); | ||||
|                                 Navigator.of(context).pop(); | ||||
|                               }, | ||||
|                             ), | ||||
|                             ListTile( | ||||
|                               leading: const Icon(Symbols.call_end, fill: 1), | ||||
|                               iconColor: Colors.red, | ||||
|                               title: Text('callEnd').tr(), | ||||
|                               onTap: () async { | ||||
|                                 callNotifier.disconnect(); | ||||
|                                 final apiClient = ref.watch(apiClientProvider); | ||||
|                       (innerContext) => Column( | ||||
|                         mainAxisSize: MainAxisSize.min, | ||||
|                         children: [ | ||||
|                           ListTile( | ||||
|                             leading: const Icon(Symbols.logout, fill: 1), | ||||
|                             title: Text('callLeave').tr(), | ||||
|                             onTap: () { | ||||
|                               callNotifier.disconnect(); | ||||
|                               Navigator.of(context).pop(); | ||||
|                               Navigator.of(innerContext).pop(); | ||||
|                             }, | ||||
|                           ), | ||||
|                           ListTile( | ||||
|                             leading: const Icon(Symbols.call_end, fill: 1), | ||||
|                             iconColor: Colors.red, | ||||
|                             title: Text('callEnd').tr(), | ||||
|                             onTap: () async { | ||||
|                               callNotifier.disconnect(); | ||||
|                               final apiClient = ref.watch(apiClientProvider); | ||||
|                               try { | ||||
|                                 showLoadingModal(context); | ||||
|                                 await apiClient.delete( | ||||
|                                   '/sphere/chat/realtime/${callNotifier.roomId}', | ||||
|                                 ); | ||||
|                                 callNotifier.dispose(); | ||||
|                                 if (context.mounted) { | ||||
|                                   Navigator.of(context).pop(); | ||||
|                                   Navigator.of(innerContext).pop(); | ||||
|                                 } | ||||
|                               }, | ||||
|                             ), | ||||
|                             Gap(MediaQuery.of(context).padding.bottom), | ||||
|                           ], | ||||
|                         ), | ||||
|                               } catch (err) { | ||||
|                                 showErrorAlert(err); | ||||
|                               } finally { | ||||
|                                 if (context.mounted) hideLoadingModal(context); | ||||
|                               } | ||||
|                             }, | ||||
|                           ), | ||||
|                           Gap(MediaQuery.of(context).padding.bottom), | ||||
|                         ], | ||||
|                       ), | ||||
|                 ), | ||||
|             backgroundColor: const Color(0xFFE53E3E), | ||||
| @@ -256,24 +266,14 @@ class CallControlsBar extends HookConsumerWidget { | ||||
|       } | ||||
|  | ||||
|       if (context.mounted) { | ||||
|         ScaffoldMessenger.of(context).showSnackBar( | ||||
|           SnackBar( | ||||
|             content: Text( | ||||
|               '${'switchedTo'.tr()} ${device.label.isNotEmpty ? device.label : 'selectedDevice'.tr()}', | ||||
|             ), | ||||
|             backgroundColor: Colors.green, | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       if (context.mounted) { | ||||
|         ScaffoldMessenger.of(context).showSnackBar( | ||||
|           SnackBar( | ||||
|             content: Text('${'failedToSwitchDevice'.tr()}: $e'), | ||||
|             backgroundColor: Colors.red, | ||||
|         showSnackBar( | ||||
|           'switchedTo'.tr( | ||||
|             args: [device.label.isNotEmpty ? device.label : 'device'], | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       showErrorAlert(err); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:flutter_popup_card/flutter_popup_card.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| @@ -18,6 +19,10 @@ class CallParticipantCard extends HookConsumerWidget { | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final width = | ||||
|         math.min(MediaQuery.of(context).size.width - 80, 360).toDouble(); | ||||
|     final callNotifier = ref.watch(callNotifierProvider.notifier); | ||||
|  | ||||
|     final volumeSliderValue = useState(callNotifier.getParticipantVolume(live)); | ||||
|  | ||||
|     return PopupCard( | ||||
|       elevation: 8, | ||||
|       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), | ||||
| @@ -28,7 +33,35 @@ class CallParticipantCard extends HookConsumerWidget { | ||||
|           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|           children: [ | ||||
|             Column( | ||||
|               spacing: 4, | ||||
|               children: [ | ||||
|                 Row( | ||||
|                   children: [ | ||||
|                     const Icon(Symbols.sound_detection_loud_sound, size: 16), | ||||
|                     const Gap(8), | ||||
|                     Expanded( | ||||
|                       child: Slider( | ||||
|                         max: 2, | ||||
|                         value: volumeSliderValue.value, | ||||
|                         onChanged: (value) { | ||||
|                           volumeSliderValue.value = value; | ||||
|                         }, | ||||
|                         onChangeEnd: (value) { | ||||
|                           callNotifier.setParticipantVolume(live, value); | ||||
|                         }, | ||||
|                         year2023: true, | ||||
|                         padding: EdgeInsets.zero, | ||||
|                       ), | ||||
|                     ), | ||||
|                     const Gap(16), | ||||
|                     SizedBox( | ||||
|                       width: 40, | ||||
|                       child: Text( | ||||
|                         '${(volumeSliderValue.value * 100).toStringAsFixed(0)}%', | ||||
|                       ), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|                 Row( | ||||
|                   children: [ | ||||
|                     const Icon(Symbols.wifi, size: 16), | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import 'dart:convert'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:cross_file/cross_file.dart'; | ||||
| @@ -5,18 +6,89 @@ import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/services/file.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:super_context_menu/super_context_menu.dart'; | ||||
|  | ||||
| class AttachmentPreview extends StatelessWidget { | ||||
| import 'sensitive.dart'; | ||||
|  | ||||
| class SensitiveMarksSelector extends StatefulWidget { | ||||
|   final List<int> initial; | ||||
|   final ValueChanged<List<int>>? onChanged; | ||||
|  | ||||
|   const SensitiveMarksSelector({ | ||||
|     super.key, | ||||
|     required this.initial, | ||||
|     this.onChanged, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<SensitiveMarksSelector> createState() => SensitiveMarksSelectorState(); | ||||
| } | ||||
|  | ||||
| class SensitiveMarksSelectorState extends State<SensitiveMarksSelector> { | ||||
|   late List<int> _selected; | ||||
|  | ||||
|   List<int> get current => _selected; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _selected = [...widget.initial]; | ||||
|   } | ||||
|  | ||||
|   void _toggle(int value) { | ||||
|     setState(() { | ||||
|       if (_selected.contains(value)) { | ||||
|         _selected.remove(value); | ||||
|       } else { | ||||
|         _selected.add(value); | ||||
|       } | ||||
|     }); | ||||
|     widget.onChanged?.call([..._selected]); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     // Build a list of all categories in fixed order as int list indices | ||||
|     final categories = kSensitiveCategoriesOrdered; | ||||
|  | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|       children: [ | ||||
|         Wrap( | ||||
|           spacing: 8, | ||||
|           children: [ | ||||
|             for (var i = 0; i < categories.length; i++) | ||||
|               FilterChip( | ||||
|                 label: Text(categories[i].i18nKey.tr()), | ||||
|                 avatar: Text(categories[i].symbol), | ||||
|                 selected: _selected.contains(i), | ||||
|                 onSelected: (_) => _toggle(i), | ||||
|               ), | ||||
|           ], | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class AttachmentPreview extends HookConsumerWidget { | ||||
|   final UniversalFile item; | ||||
|   final double? progress; | ||||
|   final Function(int)? onMove; | ||||
|   final Function? onDelete; | ||||
|   final Function? onInsert; | ||||
|   final Function(UniversalFile)? onUpdate; | ||||
|   final Function? onRequestUpload; | ||||
|  | ||||
|   const AttachmentPreview({ | ||||
|     super.key, | ||||
|     required this.item, | ||||
| @@ -24,11 +96,170 @@ class AttachmentPreview extends StatelessWidget { | ||||
|     this.onRequestUpload, | ||||
|     this.onMove, | ||||
|     this.onDelete, | ||||
|     this.onUpdate, | ||||
|     this.onInsert, | ||||
|   }); | ||||
|  | ||||
|   // GlobalKey for selector | ||||
|   static final GlobalKey<SensitiveMarksSelectorState> _sensitiveSelectorKey = | ||||
|       GlobalKey<SensitiveMarksSelectorState>(); | ||||
|  | ||||
|   Future<void> _showRenameDialog(BuildContext context, WidgetRef ref) async { | ||||
|     final nameController = TextEditingController(text: item.data.name); | ||||
|     String? errorMessage; | ||||
|  | ||||
|     await showModalBottomSheet( | ||||
|       context: context, | ||||
|       isScrollControlled: true, | ||||
|       builder: | ||||
|           (context) => SheetScaffold( | ||||
|             heightFactor: 0.6, | ||||
|             titleText: 'rename'.tr(), | ||||
|             child: Column( | ||||
|               mainAxisSize: MainAxisSize.min, | ||||
|               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|               children: [ | ||||
|                 Padding( | ||||
|                   padding: const EdgeInsets.symmetric( | ||||
|                     horizontal: 24, | ||||
|                     vertical: 24, | ||||
|                   ), | ||||
|                   child: TextField( | ||||
|                     controller: nameController, | ||||
|                     decoration: InputDecoration( | ||||
|                       labelText: 'fileName'.tr(), | ||||
|                       border: const OutlineInputBorder(), | ||||
|                       errorText: errorMessage, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|                 Row( | ||||
|                   mainAxisAlignment: MainAxisAlignment.end, | ||||
|                   children: [ | ||||
|                     TextButton( | ||||
|                       onPressed: () => Navigator.pop(context), | ||||
|                       child: Text('cancel'.tr()), | ||||
|                     ), | ||||
|                     const Gap(8), | ||||
|                     TextButton( | ||||
|                       onPressed: () async { | ||||
|                         final newName = nameController.text.trim(); | ||||
|                         if (newName.isEmpty) { | ||||
|                           errorMessage = 'fieldCannotBeEmpty'.tr(); | ||||
|                           return; | ||||
|                         } | ||||
|  | ||||
|                         try { | ||||
|                           showLoadingModal(context); | ||||
|                           final apiClient = ref.watch(apiClientProvider); | ||||
|                           await apiClient.patch( | ||||
|                             '/drive/files/${item.data.id}/name', | ||||
|                             data: jsonEncode(newName), | ||||
|                           ); | ||||
|                           final newData = item.data; | ||||
|                           newData.name = newName; | ||||
|                           final updatedFile = item.copyWith(data: newData); | ||||
|                           onUpdate?.call(item.copyWith(data: updatedFile)); | ||||
|                           if (context.mounted) Navigator.pop(context); | ||||
|                         } catch (err) { | ||||
|                           showErrorAlert(err); | ||||
|                         } finally { | ||||
|                           if (context.mounted) hideLoadingModal(context); | ||||
|                         } | ||||
|                       }, | ||||
|                       child: Text('rename'.tr()), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ).padding(horizontal: 16, vertical: 8), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Future<void> _showSensitiveDialog(BuildContext context, WidgetRef ref) async { | ||||
|     await showModalBottomSheet( | ||||
|       context: context, | ||||
|       isScrollControlled: true, | ||||
|       builder: | ||||
|           (context) => SheetScaffold( | ||||
|             heightFactor: 0.6, | ||||
|             titleText: 'markAsSensitive'.tr(), | ||||
|             child: Column( | ||||
|               mainAxisSize: MainAxisSize.min, | ||||
|               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|               children: [ | ||||
|                 Padding( | ||||
|                   padding: const EdgeInsets.symmetric( | ||||
|                     horizontal: 24, | ||||
|                     vertical: 24, | ||||
|                   ), | ||||
|                   child: Column( | ||||
|                     children: [ | ||||
|                       // Sensitive categories checklist | ||||
|                       SensitiveMarksSelector( | ||||
|                         key: _sensitiveSelectorKey, | ||||
|                         initial: | ||||
|                             (item.data.sensitiveMarks ?? []) | ||||
|                                 .map((e) => e as int) | ||||
|                                 .cast<int>() | ||||
|                                 .toList(), | ||||
|                         onChanged: (marks) { | ||||
|                           // Update local data immediately (optimistic) | ||||
|                           final newData = item.data; | ||||
|                           newData.sensitiveMarks = marks; | ||||
|                           final updatedFile = item.copyWith(data: newData); | ||||
|                           onUpdate?.call(item.copyWith(data: updatedFile)); | ||||
|                         }, | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|                 Row( | ||||
|                   mainAxisAlignment: MainAxisAlignment.end, | ||||
|                   children: [ | ||||
|                     TextButton( | ||||
|                       onPressed: () => Navigator.pop(context), | ||||
|                       child: Text('cancel'.tr()), | ||||
|                     ), | ||||
|                     const Gap(8), | ||||
|                     TextButton( | ||||
|                       onPressed: () async { | ||||
|                         try { | ||||
|                           showLoadingModal(context); | ||||
|                           final apiClient = ref.watch(apiClientProvider); | ||||
|                           // Use the current selections from stateful selector via GlobalKey | ||||
|                           final selectorState = | ||||
|                               _sensitiveSelectorKey.currentState; | ||||
|                           final marks = selectorState?.current ?? <int>[]; | ||||
|                           await apiClient.put( | ||||
|                             '/drive/files/${item.data.id}/marks', | ||||
|                             data: jsonEncode({'sensitive_marks': marks}), | ||||
|                           ); | ||||
|                           final newData = item.data as SnCloudFile; | ||||
|                           final updatedFile = item.copyWith( | ||||
|                             data: newData.copyWith(sensitiveMarks: marks), | ||||
|                           ); | ||||
|                           onUpdate?.call(updatedFile); | ||||
|                           if (context.mounted) Navigator.pop(context); | ||||
|                         } catch (err) { | ||||
|                           showErrorAlert(err); | ||||
|                         } finally { | ||||
|                           if (context.mounted) hideLoadingModal(context); | ||||
|                         } | ||||
|                       }, | ||||
|                       child: Text('confirm'.tr()), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ).padding(horizontal: 16, vertical: 8), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     var ratio = | ||||
|         item.isOnCloud | ||||
|             ? (item.data.fileMeta?['ratio'] is num | ||||
| @@ -37,217 +268,265 @@ class AttachmentPreview extends StatelessWidget { | ||||
|             : 1.0; | ||||
|     if (ratio == 0) ratio = 1.0; | ||||
|  | ||||
|     return AspectRatio( | ||||
|       aspectRatio: ratio, | ||||
|       child: ClipRRect( | ||||
|         borderRadius: BorderRadius.circular(8), | ||||
|         child: Stack( | ||||
|           fit: StackFit.expand, | ||||
|     final contentWidget = ClipRRect( | ||||
|       borderRadius: BorderRadius.circular(8), | ||||
|       child: Container( | ||||
|         color: Theme.of(context).colorScheme.surfaceContainer, | ||||
|         child: Column( | ||||
|           children: [ | ||||
|             Container( | ||||
|               color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|               child: Builder( | ||||
|                 builder: (context) { | ||||
|                   if (item.isOnCloud) { | ||||
|                     return CloudFileWidget(item: item.data); | ||||
|                   } else if (item.data is XFile) { | ||||
|                     if (item.type == UniversalFileType.image) { | ||||
|                       final file = item.data as XFile; | ||||
|                       if (file.path.isEmpty) { | ||||
|                         return FutureBuilder<Uint8List>( | ||||
|                           future: file.readAsBytes(), | ||||
|                           builder: (context, snapshot) { | ||||
|                             if (snapshot.hasData) { | ||||
|                               return Image.memory(snapshot.data!); | ||||
|                             } | ||||
|                             return const Center( | ||||
|                               child: CircularProgressIndicator(), | ||||
|             Row( | ||||
|               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|               children: [ | ||||
|                 ClipRRect( | ||||
|                   borderRadius: BorderRadius.circular(8), | ||||
|                   child: Container( | ||||
|                     color: Colors.black.withOpacity(0.5), | ||||
|                     child: Material( | ||||
|                       color: Colors.transparent, | ||||
|                       child: Row( | ||||
|                         mainAxisSize: MainAxisSize.min, | ||||
|                         children: [ | ||||
|                           if (onDelete != null) | ||||
|                             InkWell( | ||||
|                               borderRadius: BorderRadius.circular(8), | ||||
|                               child: Icon( | ||||
|                                 item.isLink ? Symbols.link_off : Symbols.delete, | ||||
|                                 size: 14, | ||||
|                                 color: Colors.white, | ||||
|                               ).padding(horizontal: 8, vertical: 6), | ||||
|                               onTap: () { | ||||
|                                 onDelete?.call(); | ||||
|                               }, | ||||
|                             ), | ||||
|                           if (onDelete != null && onMove != null) | ||||
|                             SizedBox( | ||||
|                               height: 26, | ||||
|                               child: const VerticalDivider( | ||||
|                                 width: 0.3, | ||||
|                                 color: Colors.white, | ||||
|                                 thickness: 0.3, | ||||
|                               ), | ||||
|                             ).padding(horizontal: 2), | ||||
|                           if (onMove != null) | ||||
|                             InkWell( | ||||
|                               borderRadius: BorderRadius.circular(8), | ||||
|                               child: const Icon( | ||||
|                                 Symbols.keyboard_arrow_up, | ||||
|                                 size: 14, | ||||
|                                 color: Colors.white, | ||||
|                               ).padding(horizontal: 8, vertical: 6), | ||||
|                               onTap: () { | ||||
|                                 onMove?.call(-1); | ||||
|                               }, | ||||
|                             ), | ||||
|                           if (onMove != null) | ||||
|                             InkWell( | ||||
|                               borderRadius: BorderRadius.circular(8), | ||||
|                               child: const Icon( | ||||
|                                 Symbols.keyboard_arrow_down, | ||||
|                                 size: 14, | ||||
|                                 color: Colors.white, | ||||
|                               ).padding(horizontal: 8, vertical: 6), | ||||
|                               onTap: () { | ||||
|                                 onMove?.call(1); | ||||
|                               }, | ||||
|                             ), | ||||
|                           if (onInsert != null) | ||||
|                             InkWell( | ||||
|                               borderRadius: BorderRadius.circular(8), | ||||
|                               child: const Icon( | ||||
|                                 Symbols.add, | ||||
|                                 size: 14, | ||||
|                                 color: Colors.white, | ||||
|                               ).padding(horizontal: 8, vertical: 6), | ||||
|                               onTap: () { | ||||
|                                 onInsert?.call(); | ||||
|                               }, | ||||
|                             ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|                 if (onRequestUpload != null) | ||||
|                   InkWell( | ||||
|                     borderRadius: BorderRadius.circular(8), | ||||
|                     onTap: () => onRequestUpload?.call(), | ||||
|                     child: ClipRRect( | ||||
|                       borderRadius: BorderRadius.circular(8), | ||||
|                       child: Container( | ||||
|                         color: Colors.black.withOpacity(0.5), | ||||
|                         padding: EdgeInsets.symmetric( | ||||
|                           horizontal: 8, | ||||
|                           vertical: 4, | ||||
|                         ), | ||||
|                         child: | ||||
|                             (item.isOnCloud) | ||||
|                                 ? Row( | ||||
|                                   mainAxisSize: MainAxisSize.min, | ||||
|                                   children: [ | ||||
|                                     Icon( | ||||
|                                       Symbols.cloud, | ||||
|                                       size: 16, | ||||
|                                       color: Colors.white, | ||||
|                                     ), | ||||
|                                     const Gap(8), | ||||
|                                     Text( | ||||
|                                       'On-cloud', | ||||
|                                       style: TextStyle(color: Colors.white), | ||||
|                                     ), | ||||
|                                   ], | ||||
|                                 ) | ||||
|                                 : Row( | ||||
|                                   mainAxisSize: MainAxisSize.min, | ||||
|                                   children: [ | ||||
|                                     Icon( | ||||
|                                       Symbols.cloud_off, | ||||
|                                       size: 16, | ||||
|                                       color: Colors.white, | ||||
|                                     ), | ||||
|                                     const Gap(8), | ||||
|                                     Text( | ||||
|                                       'On-device', | ||||
|                                       style: TextStyle(color: Colors.white), | ||||
|                                     ), | ||||
|                                   ], | ||||
|                                 ), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|               ], | ||||
|             ).padding(horizontal: 12, vertical: 8), | ||||
|             AspectRatio( | ||||
|               aspectRatio: ratio, | ||||
|               child: Stack( | ||||
|                 fit: StackFit.expand, | ||||
|                 children: [ | ||||
|                   Builder( | ||||
|                     key: ValueKey(item.hashCode), | ||||
|                     builder: (context) { | ||||
|                       if (item.isOnCloud) { | ||||
|                         return CloudFileWidget(item: item.data); | ||||
|                       } else if (item.data is XFile) { | ||||
|                         final file = item.data as XFile; | ||||
|                         if (file.path.isEmpty) { | ||||
|                           return FutureBuilder<Uint8List>( | ||||
|                             future: file.readAsBytes(), | ||||
|                             builder: (context, snapshot) { | ||||
|                               if (snapshot.hasData) { | ||||
|                                 return Image.memory(snapshot.data!); | ||||
|                               } | ||||
|                               return const Center( | ||||
|                                 child: CircularProgressIndicator(), | ||||
|                               ); | ||||
|                             }, | ||||
|                           ); | ||||
|                         } | ||||
|  | ||||
|                         switch (item.type) { | ||||
|                           case UniversalFileType.image: | ||||
|                             return kIsWeb | ||||
|                                 ? Image.network(file.path) | ||||
|                                 : Image.file(File(file.path)); | ||||
|                           default: | ||||
|                             return Column( | ||||
|                               children: [ | ||||
|                                 const Icon(Symbols.document_scanner), | ||||
|                                 Text(file.name), | ||||
|                               ], | ||||
|                             ); | ||||
|                           }, | ||||
|                         ); | ||||
|                         } | ||||
|                       } else if (item is List<int> || item is Uint8List) { | ||||
|                         switch (item.type) { | ||||
|                           case UniversalFileType.image: | ||||
|                             return Image.memory(item.data); | ||||
|                           default: | ||||
|                             return Column( | ||||
|                               children: [const Icon(Symbols.document_scanner)], | ||||
|                             ); | ||||
|                         } | ||||
|                       } | ||||
|                       return kIsWeb | ||||
|                           ? Image.network(file.path) | ||||
|                           : Image.file(File(file.path)); | ||||
|                     } else { | ||||
|                       return Center( | ||||
|                         child: Text( | ||||
|                           'Preview is not supported for ${item.type}', | ||||
|                           textAlign: TextAlign.center, | ||||
|                       return Placeholder(); | ||||
|                     }, | ||||
|                   ), | ||||
|                   if (progress != null) | ||||
|                     Positioned.fill( | ||||
|                       child: Container( | ||||
|                         color: Colors.black.withOpacity(0.3), | ||||
|                         padding: EdgeInsets.symmetric( | ||||
|                           horizontal: 40, | ||||
|                           vertical: 16, | ||||
|                         ), | ||||
|                       ); | ||||
|                     } | ||||
|                   } else if (item is List<int> || item is Uint8List) { | ||||
|                     if (item.type == UniversalFileType.image) { | ||||
|                       return Image.memory(item.data); | ||||
|                     } else { | ||||
|                       return Center( | ||||
|                         child: Text( | ||||
|                           'Preview is not supported for ${item.type}', | ||||
|                           textAlign: TextAlign.center, | ||||
|                         ), | ||||
|                       ); | ||||
|                     } | ||||
|                   } | ||||
|                   return Placeholder(); | ||||
|                 }, | ||||
|               ), | ||||
|             ), | ||||
|             if (progress != null) | ||||
|               Positioned.fill( | ||||
|                 child: Container( | ||||
|                   color: Colors.black.withOpacity(0.3), | ||||
|                   padding: EdgeInsets.symmetric(horizontal: 40, vertical: 16), | ||||
|                   child: Column( | ||||
|                     mainAxisAlignment: MainAxisAlignment.center, | ||||
|                     crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                     children: [ | ||||
|                       if (progress != null) | ||||
|                         Text( | ||||
|                           '${progress!.toStringAsFixed(2)}%', | ||||
|                           style: TextStyle(color: Colors.white), | ||||
|                         ) | ||||
|                       else | ||||
|                         Text( | ||||
|                           'uploading'.tr(), | ||||
|                           style: TextStyle(color: Colors.white), | ||||
|                         ), | ||||
|                       Gap(6), | ||||
|                       Center( | ||||
|                         child: LinearProgressIndicator( | ||||
|                           value: progress != null ? progress! / 100.0 : null, | ||||
|                         child: Column( | ||||
|                           mainAxisAlignment: MainAxisAlignment.center, | ||||
|                           crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                           children: [ | ||||
|                             if (progress != null) | ||||
|                               Text( | ||||
|                                 '${progress!.toStringAsFixed(2)}%', | ||||
|                                 style: TextStyle(color: Colors.white), | ||||
|                               ) | ||||
|                             else | ||||
|                               Text( | ||||
|                                 'uploading'.tr(), | ||||
|                                 style: TextStyle(color: Colors.white), | ||||
|                               ), | ||||
|                             Gap(6), | ||||
|                             Center( | ||||
|                               child: LinearProgressIndicator( | ||||
|                                 value: | ||||
|                                     progress != null ? progress! / 100.0 : null, | ||||
|                               ), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             Positioned( | ||||
|               left: 8, | ||||
|               top: 8, | ||||
|               child: ClipRRect( | ||||
|                 borderRadius: BorderRadius.circular(8), | ||||
|                 child: Container( | ||||
|                   color: Colors.black.withOpacity(0.5), | ||||
|                   child: Material( | ||||
|                     color: Colors.transparent, | ||||
|                     child: Row( | ||||
|                       mainAxisSize: MainAxisSize.min, | ||||
|                       children: [ | ||||
|                         if (onDelete != null) | ||||
|                           InkWell( | ||||
|                             borderRadius: BorderRadius.circular(8), | ||||
|                             child: const Icon( | ||||
|                               Symbols.delete, | ||||
|                               size: 14, | ||||
|                               color: Colors.white, | ||||
|                             ).padding(horizontal: 8, vertical: 6), | ||||
|                             onTap: () { | ||||
|                               onDelete?.call(); | ||||
|                             }, | ||||
|                           ), | ||||
|                         if (onDelete != null && onMove != null) | ||||
|                           SizedBox( | ||||
|                             height: 26, | ||||
|                             child: const VerticalDivider( | ||||
|                               width: 0.3, | ||||
|                               color: Colors.white, | ||||
|                               thickness: 0.3, | ||||
|                             ), | ||||
|                           ).padding(horizontal: 2), | ||||
|                         if (onMove != null) | ||||
|                           InkWell( | ||||
|                             borderRadius: BorderRadius.circular(8), | ||||
|                             child: const Icon( | ||||
|                               Symbols.keyboard_arrow_up, | ||||
|                               size: 14, | ||||
|                               color: Colors.white, | ||||
|                             ).padding(horizontal: 8, vertical: 6), | ||||
|                             onTap: () { | ||||
|                               onMove?.call(-1); | ||||
|                             }, | ||||
|                           ), | ||||
|                         if (onMove != null) | ||||
|                           InkWell( | ||||
|                             borderRadius: BorderRadius.circular(8), | ||||
|                             child: const Icon( | ||||
|                               Symbols.keyboard_arrow_down, | ||||
|                               size: 14, | ||||
|                               color: Colors.white, | ||||
|                             ).padding(horizontal: 8, vertical: 6), | ||||
|                             onTap: () { | ||||
|                               onMove?.call(1); | ||||
|                             }, | ||||
|                           ), | ||||
|                         if (onInsert != null) | ||||
|                           InkWell( | ||||
|                             borderRadius: BorderRadius.circular(8), | ||||
|                             child: const Icon( | ||||
|                               Symbols.add, | ||||
|                               size: 14, | ||||
|                               color: Colors.white, | ||||
|                             ).padding(horizontal: 8, vertical: 6), | ||||
|                             onTap: () { | ||||
|                               onInsert?.call(); | ||||
|                             }, | ||||
|                           ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|             if (onRequestUpload != null) | ||||
|               Positioned( | ||||
|                 top: 8, | ||||
|                 right: 8, | ||||
|                 child: InkWell( | ||||
|                   borderRadius: BorderRadius.circular(8), | ||||
|                   onTap: () => onRequestUpload?.call(), | ||||
|                   child: ClipRRect( | ||||
|                     borderRadius: BorderRadius.circular(8), | ||||
|                     child: Container( | ||||
|                       color: Colors.black.withOpacity(0.5), | ||||
|                       padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), | ||||
|                       child: | ||||
|                           (item.isOnCloud) | ||||
|                               ? Row( | ||||
|                                 mainAxisSize: MainAxisSize.min, | ||||
|                                 children: [ | ||||
|                                   Icon( | ||||
|                                     Symbols.cloud, | ||||
|                                     size: 16, | ||||
|                                     color: Colors.white, | ||||
|                                   ), | ||||
|                                   const Gap(8), | ||||
|                                   Text( | ||||
|                                     'On-cloud', | ||||
|                                     style: TextStyle(color: Colors.white), | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ) | ||||
|                               : Row( | ||||
|                                 mainAxisSize: MainAxisSize.min, | ||||
|                                 children: [ | ||||
|                                   Icon( | ||||
|                                     Symbols.cloud_off, | ||||
|                                     size: 16, | ||||
|                                     color: Colors.white, | ||||
|                                   ), | ||||
|                                   const Gap(8), | ||||
|                                   Text( | ||||
|                                     'On-device', | ||||
|                                     style: TextStyle(color: Colors.white), | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     return ContextMenuWidget( | ||||
|       menuProvider: | ||||
|           (MenuRequest request) => Menu( | ||||
|             children: [ | ||||
|               if (item.isOnDevice && item.type == UniversalFileType.image) | ||||
|                 MenuAction( | ||||
|                   title: 'crop'.tr(), | ||||
|                   image: MenuImage.icon(Symbols.crop), | ||||
|                   callback: () async { | ||||
|                     final result = await cropImage( | ||||
|                       context, | ||||
|                       image: item.data, | ||||
|                       replacePath: true, | ||||
|                     ); | ||||
|                     if (result == null) return; | ||||
|                     onUpdate?.call(item.copyWith(data: result)); | ||||
|                   }, | ||||
|                 ), | ||||
|               if (item.isOnCloud) | ||||
|                 MenuAction( | ||||
|                   title: 'rename'.tr(), | ||||
|                   image: MenuImage.icon(Symbols.edit), | ||||
|                   callback: () async { | ||||
|                     await _showRenameDialog(context, ref); | ||||
|                   }, | ||||
|                 ), | ||||
|               if (item.isOnCloud) | ||||
|                 MenuAction( | ||||
|                   title: 'markAsSensitive'.tr(), | ||||
|                   image: MenuImage.icon(Symbols.no_adult_content), | ||||
|                   callback: () async { | ||||
|                     await _showSensitiveDialog(context, ref); | ||||
|                   }, | ||||
|                 ), | ||||
|             ], | ||||
|           ), | ||||
|       child: contentWidget, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										168
									
								
								lib/widgets/content/audio.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								lib/widgets/content/audio.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,168 @@ | ||||
| import 'dart:developer'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_cache_manager/flutter_cache_manager.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:media_kit/media_kit.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class UniversalAudio extends ConsumerStatefulWidget { | ||||
|   final String uri; | ||||
|   final String filename; | ||||
|   final bool autoplay; | ||||
|   const UniversalAudio({ | ||||
|     super.key, | ||||
|     required this.uri, | ||||
|     required this.filename, | ||||
|     this.autoplay = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   ConsumerState<UniversalAudio> createState() => _UniversalAudioState(); | ||||
| } | ||||
|  | ||||
| class _UniversalAudioState extends ConsumerState<UniversalAudio> { | ||||
|   Player? _player; | ||||
|  | ||||
|   Duration _duration = Duration(seconds: 1); | ||||
|   Duration _duartionBuffered = Duration(seconds: 1); | ||||
|   Duration _position = Duration(seconds: 0); | ||||
|  | ||||
|   bool _sliderWorking = false; | ||||
|   Duration _sliderPosition = Duration(seconds: 0); | ||||
|  | ||||
|   void _openAudio() async { | ||||
|     final url = widget.uri; | ||||
|     MediaKit.ensureInitialized(); | ||||
|  | ||||
|     _player = Player(); | ||||
|     _player!.stream.position.listen((value) { | ||||
|       _position = value; | ||||
|       if (!_sliderWorking) _sliderPosition = _position; | ||||
|       setState(() {}); | ||||
|     }); | ||||
|     _player!.stream.buffer.listen((value) { | ||||
|       _duartionBuffered = value; | ||||
|       setState(() {}); | ||||
|     }); | ||||
|     _player!.stream.duration.listen((value) { | ||||
|       _duration = value; | ||||
|       setState(() {}); | ||||
|     }); | ||||
|  | ||||
|     String? uri; | ||||
|     final inCacheInfo = await DefaultCacheManager().getFileFromCache(url); | ||||
|     if (inCacheInfo == null) { | ||||
|       log('[MediaPlayer] Miss cache: $url'); | ||||
|       final token = ref.watch(tokenProvider)?.token; | ||||
|       DefaultCacheManager().downloadFile( | ||||
|         url, | ||||
|         authHeaders: {'Authorization': 'AtField $token'}, | ||||
|       ); | ||||
|       uri = url; | ||||
|     } else { | ||||
|       uri = inCacheInfo.file.path; | ||||
|       log('[MediaPlayer] Hit cache: $url'); | ||||
|     } | ||||
|  | ||||
|     _player!.open(Media(uri), play: widget.autoplay); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _openAudio(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     super.dispose(); | ||||
|     _player?.dispose(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (_player == null) { | ||||
|       return Center(child: CircularProgressIndicator()); | ||||
|     } | ||||
|  | ||||
|     return Card( | ||||
|       color: Theme.of(context).colorScheme.surfaceContainerLowest, | ||||
|       child: Row( | ||||
|         children: [ | ||||
|           IconButton.filled( | ||||
|             onPressed: () { | ||||
|               _player!.playOrPause().then((_) { | ||||
|                 if (mounted) setState(() {}); | ||||
|               }); | ||||
|             }, | ||||
|             icon: | ||||
|                 _player!.state.playing | ||||
|                     ? const Icon(Symbols.pause, fill: 1, color: Colors.white) | ||||
|                     : const Icon( | ||||
|                       Symbols.play_arrow, | ||||
|                       fill: 1, | ||||
|                       color: Colors.white, | ||||
|                     ), | ||||
|           ), | ||||
|           const Gap(20), | ||||
|           Expanded( | ||||
|             child: Column( | ||||
|               mainAxisSize: MainAxisSize.min, | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 AnimatedSwitcher( | ||||
|                   duration: const Duration(milliseconds: 300), | ||||
|                   child: | ||||
|                       (_player!.state.playing || _sliderWorking) | ||||
|                           ? SizedBox( | ||||
|                             width: double.infinity, | ||||
|                             key: const ValueKey('playing'), | ||||
|                             child: Text( | ||||
|                               '${_position.formatShortDuration()} / ${_duration.formatShortDuration()}', | ||||
|                             ), | ||||
|                           ) | ||||
|                           : SizedBox( | ||||
|                             width: double.infinity, | ||||
|                             key: const ValueKey('filename'), | ||||
|                             child: Text( | ||||
|                               widget.filename.isEmpty | ||||
|                                   ? 'Audio' | ||||
|                                   : widget.filename, | ||||
|                               maxLines: 1, | ||||
|                               overflow: TextOverflow.ellipsis, | ||||
|                             ), | ||||
|                           ), | ||||
|                 ), | ||||
|                 Slider( | ||||
|                   value: _sliderPosition.inMilliseconds.toDouble(), | ||||
|                   secondaryTrackValue: | ||||
|                       _duartionBuffered.inMilliseconds.toDouble(), | ||||
|                   max: _duration.inMilliseconds.toDouble(), | ||||
|                   onChangeStart: (_) { | ||||
|                     _sliderWorking = true; | ||||
|                   }, | ||||
|                   onChanged: (value) { | ||||
|                     _sliderPosition = Duration(milliseconds: value.toInt()); | ||||
|                     setState(() {}); | ||||
|                   }, | ||||
|                   onChangeEnd: (value) { | ||||
|                     _sliderPosition = Duration(milliseconds: value.toInt()); | ||||
|                     _sliderWorking = false; | ||||
|                     _player!.seek(_sliderPosition); | ||||
|                   }, | ||||
|                   year2023: true, | ||||
|                   padding: EdgeInsets.zero, | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ).padding(horizontal: 24, vertical: 16), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -2,6 +2,7 @@ import 'dart:math' as math; | ||||
| import 'dart:ui'; | ||||
|  | ||||
| import 'package:dismissible_page/dismissible_page.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_blurhash/flutter_blurhash.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| @@ -13,6 +14,7 @@ import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/sensitive.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:path/path.dart' show extension; | ||||
| @@ -63,6 +65,27 @@ class CloudFileList extends HookConsumerWidget { | ||||
|     if (files.isEmpty) return const SizedBox.shrink(); | ||||
|     if (files.length == 1) { | ||||
|       final isImage = files.first.mimeType?.startsWith('image') ?? false; | ||||
|       final isAudio = files.first.mimeType?.startsWith('audio') ?? false; | ||||
|       final widgetItem = ClipRRect( | ||||
|         borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|         child: _CloudFileListEntry( | ||||
|           file: files.first, | ||||
|           heroTag: heroTags.first, | ||||
|           isImage: isImage, | ||||
|           disableZoomIn: disableZoomIn, | ||||
|           onTap: () { | ||||
|             if (!isImage) { | ||||
|               return; | ||||
|             } | ||||
|             if (!disableZoomIn) { | ||||
|               context.pushTransparentRoute( | ||||
|                 CloudFileZoomIn(item: files.first, heroTag: heroTags.first), | ||||
|                 rootNavigator: true, | ||||
|               ); | ||||
|             } | ||||
|           }, | ||||
|         ), | ||||
|       ); | ||||
|       return Container( | ||||
|         padding: padding, | ||||
|         constraints: BoxConstraints( | ||||
| @@ -70,29 +93,14 @@ class CloudFileList extends HookConsumerWidget { | ||||
|           minWidth: minWidth ?? 0, | ||||
|           maxWidth: files.length == 1 ? maxWidth : double.infinity, | ||||
|         ), | ||||
|         child: AspectRatio( | ||||
|           aspectRatio: calculateAspectRatio(), | ||||
|           child: ClipRRect( | ||||
|             borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|             child: _CloudFileListEntry( | ||||
|               file: files.first, | ||||
|               heroTag: heroTags.first, | ||||
|               isImage: isImage, | ||||
|               disableZoomIn: disableZoomIn, | ||||
|               onTap: () { | ||||
|                 if (!isImage) { | ||||
|                   return; | ||||
|                 } | ||||
|                 if (!disableZoomIn) { | ||||
|                   context.pushTransparentRoute( | ||||
|                     CloudFileZoomIn(item: files.first, heroTag: heroTags.first), | ||||
|                     rootNavigator: true, | ||||
|                   ); | ||||
|                 } | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|         height: isAudio ? 120 : null, | ||||
|         child: | ||||
|             isAudio | ||||
|                 ? widgetItem | ||||
|                 : AspectRatio( | ||||
|                   aspectRatio: calculateAspectRatio(), | ||||
|                   child: widgetItem, | ||||
|                 ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
| @@ -106,51 +114,57 @@ class CloudFileList extends HookConsumerWidget { | ||||
|         constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth), | ||||
|         child: AspectRatio( | ||||
|           aspectRatio: calculateAspectRatio(), | ||||
|           child: CarouselView( | ||||
|             padding: padding, | ||||
|             itemSnapping: true, | ||||
|             itemExtent: math.min( | ||||
|               MediaQuery.of(context).size.width * 0.85, | ||||
|               maxWidth * 0.85, | ||||
|             ), | ||||
|             shape: RoundedRectangleBorder( | ||||
|               borderRadius: const BorderRadius.all(Radius.circular(16)), | ||||
|             ), | ||||
|             children: [ | ||||
|               for (var i = 0; i < files.length; i++) | ||||
|                 Stack( | ||||
|                   children: [ | ||||
|                     _CloudFileListEntry( | ||||
|                       file: files[i], | ||||
|                       heroTag: heroTags[i], | ||||
|                       isImage: files[i].mimeType?.startsWith('image') ?? false, | ||||
|                       disableZoomIn: disableZoomIn, | ||||
|                     ), | ||||
|                     Positioned( | ||||
|                       bottom: 12, | ||||
|                       left: 16, | ||||
|                       child: Text('${i + 1}/${files.length}') | ||||
|                           .textColor(Colors.white) | ||||
|                           .textShadow( | ||||
|                             color: Colors.black54, | ||||
|                             offset: Offset(1, 1), | ||||
|                             blurRadius: 3, | ||||
|                           ), | ||||
|                     ), | ||||
|                   ], | ||||
|           child: Padding( | ||||
|             padding: padding ?? EdgeInsets.zero, | ||||
|             child: CarouselView( | ||||
|               itemSnapping: true, | ||||
|               itemExtent: math.min( | ||||
|                 math.min( | ||||
|                   MediaQuery.of(context).size.width * 0.75, | ||||
|                   maxWidth * 0.75, | ||||
|                 ), | ||||
|             ], | ||||
|             onTap: (i) { | ||||
|               if (!(files[i].mimeType?.startsWith('image') ?? false)) { | ||||
|                 return; | ||||
|               } | ||||
|               if (!disableZoomIn) { | ||||
|                 context.pushTransparentRoute( | ||||
|                   CloudFileZoomIn(item: files[i], heroTag: heroTags[i]), | ||||
|                   rootNavigator: true, | ||||
|                 ); | ||||
|               } | ||||
|             }, | ||||
|                 640, | ||||
|               ), | ||||
|               shape: RoundedRectangleBorder( | ||||
|                 borderRadius: const BorderRadius.all(Radius.circular(16)), | ||||
|               ), | ||||
|               children: [ | ||||
|                 for (var i = 0; i < files.length; i++) | ||||
|                   Stack( | ||||
|                     children: [ | ||||
|                       _CloudFileListEntry( | ||||
|                         file: files[i], | ||||
|                         heroTag: heroTags[i], | ||||
|                         isImage: | ||||
|                             files[i].mimeType?.startsWith('image') ?? false, | ||||
|                         disableZoomIn: disableZoomIn, | ||||
|                       ), | ||||
|                       Positioned( | ||||
|                         bottom: 12, | ||||
|                         left: 16, | ||||
|                         child: Text('${i + 1}/${files.length}') | ||||
|                             .textColor(Colors.white) | ||||
|                             .textShadow( | ||||
|                               color: Colors.black54, | ||||
|                               offset: Offset(1, 1), | ||||
|                               blurRadius: 3, | ||||
|                             ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|               ], | ||||
|               onTap: (i) { | ||||
|                 if (!(files[i].mimeType?.startsWith('image') ?? false)) { | ||||
|                   return; | ||||
|                 } | ||||
|                 if (!disableZoomIn) { | ||||
|                   context.pushTransparentRoute( | ||||
|                     CloudFileZoomIn(item: files[i], heroTag: heroTags[i]), | ||||
|                     rootNavigator: true, | ||||
|                   ); | ||||
|                 } | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
| @@ -434,9 +448,8 @@ class CloudFileZoomIn extends HookConsumerWidget { | ||||
|                         showOriginal.value = !showOriginal.value; | ||||
|                       }, | ||||
|                       icon: Icon( | ||||
|                         showOriginal.value ? Symbols.raw_on : Symbols.raw_off, | ||||
|                         showOriginal.value ? Symbols.hd : Symbols.sd, | ||||
|                         color: Colors.white, | ||||
|                         size: 24, | ||||
|                         shadows: [ | ||||
|                           Shadow( | ||||
|                             color: Colors.black54, | ||||
| @@ -547,7 +560,7 @@ class CloudFileZoomIn extends HookConsumerWidget { | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _CloudFileListEntry extends StatelessWidget { | ||||
| class _CloudFileListEntry extends HookConsumerWidget { | ||||
|   final SnCloudFile file; | ||||
|   final String heroTag; | ||||
|   final bool isImage; | ||||
| @@ -563,8 +576,10 @@ class _CloudFileListEntry extends StatelessWidget { | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final content = Stack( | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final showMature = useState(false); | ||||
|  | ||||
|     var content = Stack( | ||||
|       fit: StackFit.expand, | ||||
|       children: [ | ||||
|         if (isImage) | ||||
| @@ -589,10 +604,133 @@ class _CloudFileListEntry extends StatelessWidget { | ||||
|       ], | ||||
|     ); | ||||
|  | ||||
|     if (file.sensitiveMarks.isNotEmpty) { | ||||
|       // Show a blurred overlay only when not revealed yet, with a smooth transition | ||||
|       content = Stack( | ||||
|         children: [ | ||||
|           content, | ||||
|           // Toggle blur overlay with animation | ||||
|           Positioned.fill( | ||||
|             child: AnimatedSwitcher( | ||||
|               duration: const Duration(milliseconds: 250), | ||||
|               switchInCurve: Curves.easeOut, | ||||
|               switchOutCurve: Curves.easeIn, | ||||
|               layoutBuilder: | ||||
|                   (currentChild, previousChildren) => Stack( | ||||
|                     fit: StackFit.expand, | ||||
|                     children: [ | ||||
|                       ...previousChildren, | ||||
|                       if (currentChild != null) currentChild, | ||||
|                     ], | ||||
|                   ), | ||||
|               child: | ||||
|                   showMature.value | ||||
|                       ? const SizedBox.shrink(key: ValueKey('revealed')) | ||||
|                       : ColoredBox( | ||||
|                         key: const ValueKey('blurred'), | ||||
|                         color: Colors.transparent, | ||||
|                         child: BackdropFilter( | ||||
|                           filter: ImageFilter.blur(sigmaX: 64, sigmaY: 64), | ||||
|                           child: Stack( | ||||
|                             fit: StackFit.expand, | ||||
|                             children: [ | ||||
|                               const ColoredBox(color: Colors.transparent), | ||||
|                               Center( | ||||
|                                 child: Container( | ||||
|                                   margin: const EdgeInsets.all(12), | ||||
|                                   padding: const EdgeInsets.symmetric( | ||||
|                                     horizontal: 12, | ||||
|                                     vertical: 8, | ||||
|                                   ), | ||||
|                                   decoration: BoxDecoration( | ||||
|                                     color: Colors.black54, | ||||
|                                     borderRadius: BorderRadius.circular(12), | ||||
|                                   ), | ||||
|                                   child: ConstrainedBox( | ||||
|                                     constraints: const BoxConstraints( | ||||
|                                       maxWidth: 280, | ||||
|                                     ), | ||||
|                                     child: Column( | ||||
|                                       mainAxisSize: MainAxisSize.min, | ||||
|                                       children: [ | ||||
|                                         const Icon( | ||||
|                                           Icons.warning, | ||||
|                                           color: Colors.white, | ||||
|                                           fill: 1, | ||||
|                                           size: 24, | ||||
|                                         ), | ||||
|                                         const Gap(4), | ||||
|                                         Text( | ||||
|                                           file.sensitiveMarks | ||||
|                                               .map( | ||||
|                                                 (e) => | ||||
|                                                     SensitiveCategory | ||||
|                                                         .values[e] | ||||
|                                                         .i18nKey | ||||
|                                                         .tr(), | ||||
|                                               ) | ||||
|                                               .join(' · '), | ||||
|                                           style: const TextStyle( | ||||
|                                             color: Colors.white, | ||||
|                                             fontWeight: FontWeight.w600, | ||||
|                                           ), | ||||
|                                           textAlign: TextAlign.center, | ||||
|                                         ), | ||||
|                                         Text( | ||||
|                                           'Sensitive Content', | ||||
|                                           style: TextStyle( | ||||
|                                             color: Colors.white, | ||||
|                                             fontSize: 13, | ||||
|                                           ), | ||||
|                                         ), | ||||
|                                         const Gap(4), | ||||
|                                         Text( | ||||
|                                           'Tap to Reveal', | ||||
|                                           style: TextStyle( | ||||
|                                             color: Colors.white, | ||||
|                                             fontSize: 11, | ||||
|                                           ), | ||||
|                                         ), | ||||
|                                       ], | ||||
|                                     ), | ||||
|                                   ).padding(horizontal: 24, vertical: 16), | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ], | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|             ), | ||||
|           ), | ||||
|           // When revealed (no blur), show a small control at top-left to re-enable blur | ||||
|           if (showMature.value) | ||||
|             Positioned( | ||||
|               top: 3, | ||||
|               left: 4, | ||||
|               child: IconButton( | ||||
|                 iconSize: 16, | ||||
|                 constraints: const BoxConstraints(), | ||||
|                 icon: const Icon(Icons.visibility_off, color: Colors.white), | ||||
|                 tooltip: 'Blur content', | ||||
|                 onPressed: () { | ||||
|                   showMature.value = false; | ||||
|                 }, | ||||
|               ), | ||||
|             ), | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     if (onTap != null) { | ||||
|       return InkWell( | ||||
|         borderRadius: const BorderRadius.all(Radius.circular(16)), | ||||
|         onTap: onTap, | ||||
|         onTap: () { | ||||
|           if (!showMature.value) { | ||||
|             showMature.value = true; | ||||
|           } else { | ||||
|             onTap?.call(); | ||||
|           } | ||||
|         }, | ||||
|         child: content, | ||||
|       ); | ||||
|     } | ||||
|   | ||||
| @@ -1,15 +1,21 @@ | ||||
| import 'dart:math' as math; | ||||
| import 'dart:ui'; | ||||
|  | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/widgets/content/audio.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| import 'image.dart'; | ||||
| import 'video.dart'; | ||||
|  | ||||
| class CloudFileWidget extends ConsumerWidget { | ||||
| class CloudFileWidget extends HookConsumerWidget { | ||||
|   final SnCloudFile item; | ||||
|   final BoxFit fit; | ||||
|   final String? heroTag; | ||||
| @@ -32,7 +38,7 @@ class CloudFileWidget extends ConsumerWidget { | ||||
|             ? item.fileMeta!['ratio'].toDouble() | ||||
|             : 1.0; | ||||
|     if (ratio == 0) ratio = 1.0; | ||||
|     final content = switch (item.mimeType?.split('/').firstOrNull) { | ||||
|     var content = switch (item.mimeType?.split('/').firstOrNull) { | ||||
|       "image" => AspectRatio( | ||||
|         aspectRatio: ratio, | ||||
|         child: UniversalImage( | ||||
| @@ -45,19 +51,140 @@ class CloudFileWidget extends ConsumerWidget { | ||||
|       ), | ||||
|       "video" => AspectRatio( | ||||
|         aspectRatio: ratio, | ||||
|         child: UniversalVideo(uri: uri, aspectRatio: ratio), | ||||
|         child: CloudVideoWidget(item: item), | ||||
|       ), | ||||
|       "audio" => Center( | ||||
|         child: ConstrainedBox( | ||||
|           constraints: BoxConstraints( | ||||
|             maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8), | ||||
|           ), | ||||
|           child: UniversalAudio(uri: uri, filename: item.name), | ||||
|         ), | ||||
|       ), | ||||
|       _ => Text('Unable render for ${item.mimeType}'), | ||||
|     }; | ||||
|  | ||||
|     if (heroTag != null) { | ||||
|       return Hero(tag: heroTag!, child: content); | ||||
|       content = Hero(tag: heroTag!, child: content); | ||||
|     } | ||||
|  | ||||
|     return content; | ||||
|   } | ||||
| } | ||||
|  | ||||
| class CloudVideoWidget extends HookConsumerWidget { | ||||
|   final SnCloudFile item; | ||||
|   const CloudVideoWidget({super.key, required this.item}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final open = useState(false); | ||||
|  | ||||
|     final serverUrl = ref.watch(serverUrlProvider); | ||||
|     final uri = '$serverUrl/drive/files/${item.id}'; | ||||
|  | ||||
|     var ratio = | ||||
|         item.fileMeta?['ratio'] is num | ||||
|             ? item.fileMeta!['ratio'].toDouble() | ||||
|             : 1.0; | ||||
|     if (ratio == 0) ratio = 1.0; | ||||
|  | ||||
|     if (open.value) { | ||||
|       return UniversalVideo(uri: uri, aspectRatio: ratio, autoplay: true); | ||||
|     } | ||||
|  | ||||
|     return GestureDetector( | ||||
|       child: Stack( | ||||
|         children: [ | ||||
|           UniversalImage(uri: '$uri?thumbnail=true'), | ||||
|           Positioned.fill( | ||||
|             child: Center( | ||||
|               child: const Icon( | ||||
|                 Symbols.play_arrow, | ||||
|                 fill: 1, | ||||
|                 size: 32, | ||||
|                 shadows: [ | ||||
|                   BoxShadow( | ||||
|                     color: Colors.black54, | ||||
|                     offset: Offset(1, 1), | ||||
|                     spreadRadius: 8, | ||||
|                     blurRadius: 8, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|           Positioned( | ||||
|             bottom: 0, | ||||
|             left: 0, | ||||
|             right: 0, | ||||
|             child: Column( | ||||
|               mainAxisAlignment: MainAxisAlignment.end, | ||||
|               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|               children: [ | ||||
|                 Row( | ||||
|                   spacing: 8, | ||||
|                   children: [ | ||||
|                     if (item.fileMeta?['duration'] != null) | ||||
|                       Text( | ||||
|                         Duration( | ||||
|                           milliseconds: | ||||
|                               ((item.fileMeta?['duration'] as num) * 1000) | ||||
|                                   .toInt(), | ||||
|                         ).formatDuration(), | ||||
|                         style: TextStyle( | ||||
|                           shadows: [ | ||||
|                             BoxShadow( | ||||
|                               color: Colors.black54, | ||||
|                               offset: Offset(1, 1), | ||||
|                               spreadRadius: 8, | ||||
|                               blurRadius: 8, | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ), | ||||
|                     if (item.fileMeta?['bit_rate'] != null) | ||||
|                       Text( | ||||
|                         '${int.parse(item.fileMeta?['bit_rate'] as String) ~/ 1000} Kbps', | ||||
|                         style: TextStyle( | ||||
|                           shadows: [ | ||||
|                             BoxShadow( | ||||
|                               color: Colors.black54, | ||||
|                               offset: Offset(1, 1), | ||||
|                               spreadRadius: 8, | ||||
|                               blurRadius: 8, | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ), | ||||
|                   ], | ||||
|                 ), | ||||
|                 Text( | ||||
|                   item.name, | ||||
|                   style: TextStyle( | ||||
|                     fontWeight: FontWeight.bold, | ||||
|                     shadows: [ | ||||
|                       BoxShadow( | ||||
|                         color: Colors.black54, | ||||
|                         offset: Offset(1, 1), | ||||
|                         spreadRadius: 8, | ||||
|                         blurRadius: 8, | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ).padding(horizontal: 16, bottom: 12), | ||||
|         ], | ||||
|       ), | ||||
|       onTap: () { | ||||
|         open.value = true; | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class CloudImageWidget extends ConsumerWidget { | ||||
|   final String? fileId; | ||||
|   final SnCloudFile? file; | ||||
|   | ||||
| @@ -81,7 +81,10 @@ class MarkdownTextContent extends HookConsumerWidget { | ||||
|               if (url != null) { | ||||
|                 if (url.scheme == 'solian') { | ||||
|                   if (url.host == 'account') { | ||||
|                     context.pushNamed('accountProfile', pathParameters: {'name': url.pathSegments[0]}); | ||||
|                     context.pushNamed( | ||||
|                       'accountProfile', | ||||
|                       pathParameters: {'name': url.pathSegments[0]}, | ||||
|                     ); | ||||
|                   } | ||||
|                   return; | ||||
|                 } | ||||
| @@ -153,7 +156,7 @@ class MarkdownTextContent extends HookConsumerWidget { | ||||
|                         ), | ||||
|                         child: UniversalImage( | ||||
|                           uri: | ||||
|                               '$baseUrl/stickers/lookup/${uri.pathSegments[0]}/open', | ||||
|                               '$baseUrl/sphere/stickers/lookup/${uri.pathSegments[0]}/open', | ||||
|                           width: size, | ||||
|                           height: size, | ||||
|                           fit: BoxFit.cover, | ||||
|   | ||||
							
								
								
									
										71
									
								
								lib/widgets/content/sensitive.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								lib/widgets/content/sensitive.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| // Copyright (c) Solsynth | ||||
| // Sensitive content categories for content warnings, in fixed order. | ||||
|  | ||||
| enum SensitiveCategory { | ||||
|   language, | ||||
|   sexualContent, | ||||
|   violence, | ||||
|   profanity, | ||||
|   hateSpeech, | ||||
|   racism, | ||||
|   adultContent, | ||||
|   drugAbuse, | ||||
|   alcoholAbuse, | ||||
|   gambling, | ||||
|   selfHarm, | ||||
|   childAbuse, | ||||
|   other, | ||||
| } | ||||
|  | ||||
| extension SensitiveCategoryI18n on SensitiveCategory { | ||||
|   /// i18n key to look up localized label | ||||
|   String get i18nKey => switch (this) { | ||||
|     SensitiveCategory.language => 'sensitiveCategories.language', | ||||
|     SensitiveCategory.sexualContent => 'sensitiveCategories.sexualContent', | ||||
|     SensitiveCategory.violence => 'sensitiveCategories.violence', | ||||
|     SensitiveCategory.profanity => 'sensitiveCategories.profanity', | ||||
|     SensitiveCategory.hateSpeech => 'sensitiveCategories.hateSpeech', | ||||
|     SensitiveCategory.racism => 'sensitiveCategories.racism', | ||||
|     SensitiveCategory.adultContent => 'sensitiveCategories.adultContent', | ||||
|     SensitiveCategory.drugAbuse => 'sensitiveCategories.drugAbuse', | ||||
|     SensitiveCategory.alcoholAbuse => 'sensitiveCategories.alcoholAbuse', | ||||
|     SensitiveCategory.gambling => 'sensitiveCategories.gambling', | ||||
|     SensitiveCategory.selfHarm => 'sensitiveCategories.selfHarm', | ||||
|     SensitiveCategory.childAbuse => 'sensitiveCategories.childAbuse', | ||||
|     SensitiveCategory.other => 'sensitiveCategories.other', | ||||
|   }; | ||||
|  | ||||
|   /// Optional symbol you can use alongside the label in UI | ||||
|   String get symbol => switch (this) { | ||||
|     SensitiveCategory.language => '🌐', | ||||
|     SensitiveCategory.sexualContent => '🔞', | ||||
|     SensitiveCategory.violence => '⚠️', | ||||
|     SensitiveCategory.profanity => '🗯️', | ||||
|     SensitiveCategory.hateSpeech => '🚫', | ||||
|     SensitiveCategory.racism => '✋', | ||||
|     SensitiveCategory.adultContent => '🍑', | ||||
|     SensitiveCategory.drugAbuse => '💊', | ||||
|     SensitiveCategory.alcoholAbuse => '🍺', | ||||
|     SensitiveCategory.gambling => '🎲', | ||||
|     SensitiveCategory.selfHarm => '🆘', | ||||
|     SensitiveCategory.childAbuse => '🛑', | ||||
|     SensitiveCategory.other => '❗', | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /// Ordered list for UI consumption, matching enum declaration order. | ||||
| const List<SensitiveCategory> kSensitiveCategoriesOrdered = [ | ||||
|   SensitiveCategory.language, | ||||
|   SensitiveCategory.sexualContent, | ||||
|   SensitiveCategory.violence, | ||||
|   SensitiveCategory.profanity, | ||||
|   SensitiveCategory.hateSpeech, | ||||
|   SensitiveCategory.racism, | ||||
|   SensitiveCategory.adultContent, | ||||
|   SensitiveCategory.drugAbuse, | ||||
|   SensitiveCategory.alcoholAbuse, | ||||
|   SensitiveCategory.gambling, | ||||
|   SensitiveCategory.selfHarm, | ||||
|   SensitiveCategory.childAbuse, | ||||
|   SensitiveCategory.other, | ||||
| ]; | ||||
| @@ -33,6 +33,7 @@ class SheetScaffold extends StatelessWidget { | ||||
|         ); | ||||
|  | ||||
|     return Container( | ||||
|       padding: MediaQuery.of(context).viewInsets, | ||||
|       constraints: BoxConstraints( | ||||
|         maxHeight: height ?? MediaQuery.of(context).size.height * heightFactor, | ||||
|       ), | ||||
|   | ||||
| @@ -11,10 +11,12 @@ import 'package:media_kit_video/media_kit_video.dart'; | ||||
| class UniversalVideo extends ConsumerStatefulWidget { | ||||
|   final String uri; | ||||
|   final double aspectRatio; | ||||
|   final bool autoplay; | ||||
|   const UniversalVideo({ | ||||
|     super.key, | ||||
|     required this.uri, | ||||
|     this.aspectRatio = 16 / 9, | ||||
|     this.autoplay = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
| @@ -47,7 +49,7 @@ class _UniversalVideoState extends ConsumerState<UniversalVideo> { | ||||
|       log('[MediaPlayer] Hit cache: $url'); | ||||
|     } | ||||
|  | ||||
|     _player!.open(Media(uri), play: false); | ||||
|     _player!.open(Media(uri), play: widget.autoplay); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   | ||||
| @@ -4,10 +4,12 @@ import 'package:flutter/material.dart'; | ||||
| class UniversalVideo extends StatelessWidget { | ||||
|   final String uri; | ||||
|   final double aspectRatio; | ||||
|   final bool autoplay; | ||||
|   const UniversalVideo({ | ||||
|     super.key, | ||||
|     required this.uri, | ||||
|     required this.aspectRatio, | ||||
|     this.autoplay = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   | ||||
| @@ -1,84 +0,0 @@ | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
|  | ||||
| typedef ContextMenuBuilder = | ||||
|     Widget Function(BuildContext context, Offset offset); | ||||
|  | ||||
| class ContextMenuRegion extends HookWidget { | ||||
|   final Offset? mobileAnchor; | ||||
|   final Widget child; | ||||
|   final ContextMenuBuilder contextMenuBuilder; | ||||
|   const ContextMenuRegion({ | ||||
|     super.key, | ||||
|     required this.child, | ||||
|     required this.contextMenuBuilder, | ||||
|     this.mobileAnchor, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final contextMenuController = useMemoized(() => ContextMenuController()); | ||||
|     final mobileOffset = useState<Offset?>(null); | ||||
|  | ||||
|     bool canBeTouchScreen = switch (defaultTargetPlatform) { | ||||
|       TargetPlatform.android || TargetPlatform.iOS => true, | ||||
|       _ => false, | ||||
|     }; | ||||
|  | ||||
|     void showMenu(Offset position) { | ||||
|       contextMenuController.show( | ||||
|         context: context, | ||||
|         contextMenuBuilder: (BuildContext context) { | ||||
|           return contextMenuBuilder(context, position); | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     void hideMenu() { | ||||
|       contextMenuController.remove(); | ||||
|     } | ||||
|  | ||||
|     void onSecondaryTapUp(TapUpDetails details) { | ||||
|       showMenu(details.globalPosition); | ||||
|     } | ||||
|  | ||||
|     void onTap() { | ||||
|       if (!contextMenuController.isShown) { | ||||
|         return; | ||||
|       } | ||||
|       hideMenu(); | ||||
|     } | ||||
|  | ||||
|     void onLongPressStart(LongPressStartDetails details) { | ||||
|       mobileOffset.value = details.globalPosition; | ||||
|     } | ||||
|  | ||||
|     void onLongPress() { | ||||
|       assert(mobileOffset.value != null); | ||||
|       showMenu(mobileAnchor ?? mobileOffset.value!); | ||||
|       mobileOffset.value = null; | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       return () { | ||||
|         hideMenu(); | ||||
|       }; | ||||
|     }, []); | ||||
|  | ||||
|     return TapRegion( | ||||
|       behavior: HitTestBehavior.opaque, | ||||
|       child: GestureDetector( | ||||
|         behavior: HitTestBehavior.opaque, | ||||
|         onSecondaryTapUp: onSecondaryTapUp, | ||||
|         onTap: onTap, | ||||
|         onLongPress: canBeTouchScreen ? onLongPress : null, | ||||
|         onLongPressStart: canBeTouchScreen ? onLongPressStart : null, | ||||
|         child: child, | ||||
|       ), | ||||
|       onTapOutside: (_) { | ||||
|         hideMenu(); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										728
									
								
								lib/widgets/poll/poll_submit.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										728
									
								
								lib/widgets/poll/poll_submit.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,728 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:island/models/poll.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
|  | ||||
| /// A poll answering widget that shows one question at a time and collects answers. | ||||
| /// | ||||
| /// Usage: | ||||
| /// PollSubmit( | ||||
| ///   poll: poll, | ||||
| ///   onSubmit: (answers) { | ||||
| ///     // answers is Map<String, dynamic>: questionId -> answer | ||||
| ///     // answer types by question: | ||||
| ///     // - singleChoice: String optionId | ||||
| ///     // - multipleChoice: List<String> optionIds | ||||
| ///     // - yesNo: bool | ||||
| ///     // - rating: int (1..5) | ||||
| ///     // - freeText: String | ||||
| ///   }, | ||||
| /// ) | ||||
| class PollSubmit extends ConsumerStatefulWidget { | ||||
|   const PollSubmit({ | ||||
|     super.key, | ||||
|     required this.poll, | ||||
|     required this.onSubmit, | ||||
|     required this.stats, | ||||
|     this.initialAnswers, | ||||
|     this.onCancel, | ||||
|     this.showProgress = true, | ||||
|   }); | ||||
|  | ||||
|   final SnPollWithStats poll; | ||||
|  | ||||
|   /// Callback when user submits all answers. Map questionId -> answer. | ||||
|   final void Function(Map<String, dynamic> answers) onSubmit; | ||||
|  | ||||
|   /// Optional initial answers, keyed by questionId. | ||||
|   final Map<String, dynamic>? initialAnswers; | ||||
|   final Map<String, dynamic>? stats; | ||||
|  | ||||
|   /// Optional cancel callback. | ||||
|   final VoidCallback? onCancel; | ||||
|  | ||||
|   /// Whether to show a progress indicator (e.g., "2 / N"). | ||||
|   final bool showProgress; | ||||
|  | ||||
|   @override | ||||
|   ConsumerState<PollSubmit> createState() => _PollSubmitState(); | ||||
| } | ||||
|  | ||||
| class _PollSubmitState extends ConsumerState<PollSubmit> { | ||||
|   late final List<SnPollQuestion> _questions; | ||||
|   int _index = 0; | ||||
|   bool _submitting = false; | ||||
|  | ||||
|   /// Collected answers, keyed by questionId | ||||
|   late Map<String, dynamic> _answers; | ||||
|  | ||||
|   /// Local controller for free text input | ||||
|   final TextEditingController _textController = TextEditingController(); | ||||
|  | ||||
|   /// Local state holders for inputs to avoid rebuilding whole list | ||||
|   String? _singleChoiceSelected; // optionId | ||||
|   final Set<String> _multiChoiceSelected = {}; | ||||
|   bool? _yesNoSelected; | ||||
|   int? _ratingSelected; // 1..5 | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     // Ensure questions are ordered by `order` | ||||
|     _questions = [...widget.poll.questions] | ||||
|       ..sort((a, b) => a.order.compareTo(b.order)); | ||||
|     _answers = Map<String, dynamic>.from(widget.initialAnswers ?? {}); | ||||
|     _loadCurrentIntoLocalState(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void didUpdateWidget(covariant PollSubmit oldWidget) { | ||||
|     super.didUpdateWidget(oldWidget); | ||||
|     if (oldWidget.poll.id != widget.poll.id) { | ||||
|       _index = 0; | ||||
|       _answers = Map<String, dynamic>.from(widget.initialAnswers ?? {}); | ||||
|       _questions | ||||
|         ..clear() | ||||
|         ..addAll( | ||||
|           [...widget.poll.questions] | ||||
|             ..sort((a, b) => a.order.compareTo(b.order)), | ||||
|         ); | ||||
|       _loadCurrentIntoLocalState(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _textController.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   SnPollQuestion get _current => _questions[_index]; | ||||
|  | ||||
|   void _loadCurrentIntoLocalState() { | ||||
|     final q = _current; | ||||
|     final saved = _answers[q.id]; | ||||
|  | ||||
|     _singleChoiceSelected = null; | ||||
|     _multiChoiceSelected.clear(); | ||||
|     _yesNoSelected = null; | ||||
|     _ratingSelected = null; | ||||
|     _textController.text = ''; | ||||
|  | ||||
|     switch (q.type) { | ||||
|       case SnPollQuestionType.singleChoice: | ||||
|         if (saved is String) _singleChoiceSelected = saved; | ||||
|         break; | ||||
|       case SnPollQuestionType.multipleChoice: | ||||
|         if (saved is List) { | ||||
|           _multiChoiceSelected.addAll(saved.whereType<String>()); | ||||
|         } | ||||
|         break; | ||||
|       case SnPollQuestionType.yesNo: | ||||
|         if (saved is bool) _yesNoSelected = saved; | ||||
|         break; | ||||
|       case SnPollQuestionType.rating: | ||||
|         if (saved is int) _ratingSelected = saved; | ||||
|         break; | ||||
|       case SnPollQuestionType.freeText: | ||||
|         if (saved is String) _textController.text = saved; | ||||
|         break; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   bool _isCurrentAnswered() { | ||||
|     final q = _current; | ||||
|     if (!q.isRequired) return true; | ||||
|  | ||||
|     switch (q.type) { | ||||
|       case SnPollQuestionType.singleChoice: | ||||
|         return _singleChoiceSelected != null; | ||||
|       case SnPollQuestionType.multipleChoice: | ||||
|         return _multiChoiceSelected.isNotEmpty; | ||||
|       case SnPollQuestionType.yesNo: | ||||
|         return _yesNoSelected != null; | ||||
|       case SnPollQuestionType.rating: | ||||
|         return (_ratingSelected ?? 0) > 0; | ||||
|       case SnPollQuestionType.freeText: | ||||
|         return _textController.text.trim().isNotEmpty; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _persistCurrentAnswer() { | ||||
|     final q = _current; | ||||
|     switch (q.type) { | ||||
|       case SnPollQuestionType.singleChoice: | ||||
|         if (_singleChoiceSelected == null) { | ||||
|           _answers.remove(q.id); | ||||
|         } else { | ||||
|           _answers[q.id] = _singleChoiceSelected!; | ||||
|         } | ||||
|         break; | ||||
|       case SnPollQuestionType.multipleChoice: | ||||
|         if (_multiChoiceSelected.isEmpty) { | ||||
|           _answers.remove(q.id); | ||||
|         } else { | ||||
|           _answers[q.id] = _multiChoiceSelected.toList(growable: false); | ||||
|         } | ||||
|         break; | ||||
|       case SnPollQuestionType.yesNo: | ||||
|         if (_yesNoSelected == null) { | ||||
|           _answers.remove(q.id); | ||||
|         } else { | ||||
|           _answers[q.id] = _yesNoSelected!; | ||||
|         } | ||||
|         break; | ||||
|       case SnPollQuestionType.rating: | ||||
|         if (_ratingSelected == null) { | ||||
|           _answers.remove(q.id); | ||||
|         } else { | ||||
|           _answers[q.id] = _ratingSelected!; | ||||
|         } | ||||
|         break; | ||||
|       case SnPollQuestionType.freeText: | ||||
|         final text = _textController.text.trim(); | ||||
|         if (text.isEmpty) { | ||||
|           _answers.remove(q.id); | ||||
|         } else { | ||||
|           _answers[q.id] = text; | ||||
|         } | ||||
|         break; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _submitToServer() async { | ||||
|     // Persist current question before final submit | ||||
|     _persistCurrentAnswer(); | ||||
|  | ||||
|     setState(() { | ||||
|       _submitting = true; | ||||
|     }); | ||||
|  | ||||
|     try { | ||||
|       final dio = ref.read(apiClientProvider); | ||||
|  | ||||
|       await dio.post( | ||||
|         '/sphere/polls/${widget.poll.id}/answer', | ||||
|         data: {'answer': _answers}, | ||||
|       ); | ||||
|  | ||||
|       // Only call onSubmit after server accepts | ||||
|       widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers)); | ||||
|     } catch (e) { | ||||
|       if (mounted) { | ||||
|         ScaffoldMessenger.of( | ||||
|           context, | ||||
|         ).showSnackBar(SnackBar(content: Text('Failed to submit poll: $e'))); | ||||
|       } | ||||
|     } finally { | ||||
|       if (mounted) { | ||||
|         setState(() { | ||||
|           _submitting = false; | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _next() { | ||||
|     if (_submitting) return; | ||||
|     _persistCurrentAnswer(); | ||||
|     if (_index < _questions.length - 1) { | ||||
|       setState(() { | ||||
|         _index++; | ||||
|         _loadCurrentIntoLocalState(); | ||||
|       }); | ||||
|     } else { | ||||
|       // Final submit to API | ||||
|       _submitToServer(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void _back() { | ||||
|     if (_submitting) return; | ||||
|     _persistCurrentAnswer(); | ||||
|     if (_index > 0) { | ||||
|       setState(() { | ||||
|         _index--; | ||||
|         _loadCurrentIntoLocalState(); | ||||
|       }); | ||||
|     } else { | ||||
|       // at the first question; allow cancel if provided | ||||
|       widget.onCancel?.call(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Widget _buildHeader(BuildContext context) { | ||||
|     final q = _current; | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         if (widget.poll.title != null || widget.poll.description != null) | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.only(bottom: 12), | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 if (widget.poll.title != null) | ||||
|                   Text( | ||||
|                     widget.poll.title!, | ||||
|                     style: Theme.of(context).textTheme.titleLarge, | ||||
|                   ), | ||||
|                 if (widget.poll.description != null) | ||||
|                   Padding( | ||||
|                     padding: const EdgeInsets.only(top: 4), | ||||
|                     child: Text( | ||||
|                       widget.poll.description!, | ||||
|                       style: Theme.of(context).textTheme.bodyMedium?.copyWith( | ||||
|                         color: Theme.of( | ||||
|                           context, | ||||
|                         ).textTheme.bodyMedium?.color?.withOpacity(0.7), | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         if (widget.showProgress) | ||||
|           Text( | ||||
|             '${_index + 1} / ${_questions.length}', | ||||
|             style: Theme.of(context).textTheme.labelMedium, | ||||
|           ), | ||||
|         Row( | ||||
|           children: [ | ||||
|             Expanded( | ||||
|               child: Text( | ||||
|                 q.title, | ||||
|                 style: Theme.of(context).textTheme.titleMedium, | ||||
|               ), | ||||
|             ), | ||||
|             if (q.isRequired) | ||||
|               Padding( | ||||
|                 padding: const EdgeInsets.only(left: 8), | ||||
|                 child: Text( | ||||
|                   '*', | ||||
|                   style: Theme.of(context).textTheme.titleMedium?.copyWith( | ||||
|                     color: Theme.of(context).colorScheme.error, | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|           ], | ||||
|         ), | ||||
|         if (q.description != null) | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.only(top: 4), | ||||
|             child: Text( | ||||
|               q.description!, | ||||
|               style: Theme.of(context).textTheme.bodySmall?.copyWith( | ||||
|                 color: Theme.of( | ||||
|                   context, | ||||
|                 ).textTheme.bodySmall?.color?.withOpacity(0.7), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildStats(BuildContext context, SnPollQuestion q) { | ||||
|     if (widget.stats == null) return const SizedBox.shrink(); | ||||
|     final raw = widget.stats![q.id]; | ||||
|     if (raw == null) return const SizedBox.shrink(); | ||||
|  | ||||
|     Widget? body; | ||||
|  | ||||
|     switch (q.type) { | ||||
|       case SnPollQuestionType.rating: | ||||
|         // rating: avg score (double or int) | ||||
|         final avg = (raw['rating'] as num?)?.toDouble(); | ||||
|         if (avg == null) break; | ||||
|         final theme = Theme.of(context); | ||||
|         body = Row( | ||||
|           mainAxisAlignment: MainAxisAlignment.start, | ||||
|           children: [ | ||||
|             Icon(Icons.star, color: Colors.amber.shade600, size: 18), | ||||
|             const SizedBox(width: 6), | ||||
|             Text( | ||||
|               avg.toStringAsFixed(1), | ||||
|               style: theme.textTheme.labelMedium?.copyWith( | ||||
|                 color: theme.colorScheme.onSurfaceVariant, | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ); | ||||
|         break; | ||||
|  | ||||
|       case SnPollQuestionType.yesNo: | ||||
|         // yes/no: map {true: count, false: count} | ||||
|         if (raw is Map) { | ||||
|           final int yes = | ||||
|               (raw[true] is int) | ||||
|                   ? raw[true] as int | ||||
|                   : int.tryParse('${raw[true]}') ?? 0; | ||||
|           final int no = | ||||
|               (raw[false] is int) | ||||
|                   ? raw[false] as int | ||||
|                   : int.tryParse('${raw[false]}') ?? 0; | ||||
|           final total = (yes + no).clamp(0, 1 << 31); | ||||
|           final yesPct = total == 0 ? 0.0 : yes / total; | ||||
|           final noPct = total == 0 ? 0.0 : no / total; | ||||
|           final theme = Theme.of(context); | ||||
|           body = Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               _BarStatRow( | ||||
|                 label: 'Yes', | ||||
|                 count: yes, | ||||
|                 fraction: yesPct, | ||||
|                 color: Colors.green.shade600, | ||||
|               ), | ||||
|               const SizedBox(height: 6), | ||||
|               _BarStatRow( | ||||
|                 label: 'No', | ||||
|                 count: no, | ||||
|                 fraction: noPct, | ||||
|                 color: Colors.red.shade600, | ||||
|               ), | ||||
|               const SizedBox(height: 4), | ||||
|               Text( | ||||
|                 'Total: $total', | ||||
|                 style: theme.textTheme.labelSmall?.copyWith( | ||||
|                   color: theme.colorScheme.onSurfaceVariant, | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ); | ||||
|         } | ||||
|         break; | ||||
|  | ||||
|       case SnPollQuestionType.singleChoice: | ||||
|       case SnPollQuestionType.multipleChoice: | ||||
|         // map optionId -> count | ||||
|         if (raw is Map) { | ||||
|           final options = [...?q.options] | ||||
|             ..sort((a, b) => a.order.compareTo(b.order)); | ||||
|           final List<_OptionCount> items = []; | ||||
|           int total = 0; | ||||
|           for (final opt in options) { | ||||
|             final dynamic v = raw[opt.id]; | ||||
|             final int count = v is int ? v : int.tryParse('$v') ?? 0; | ||||
|             total += count; | ||||
|             items.add(_OptionCount(id: opt.id, label: opt.label, count: count)); | ||||
|           } | ||||
|           if (items.isNotEmpty) { | ||||
|             items.sort( | ||||
|               (a, b) => b.count.compareTo(a.count), | ||||
|             ); // show highest first | ||||
|           } | ||||
|           body = Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               for (final it in items) | ||||
|                 Padding( | ||||
|                   padding: const EdgeInsets.only(bottom: 6), | ||||
|                   child: _BarStatRow( | ||||
|                     label: it.label, | ||||
|                     count: it.count, | ||||
|                     fraction: total == 0 ? 0 : it.count / total, | ||||
|                   ), | ||||
|                 ), | ||||
|               if (items.isNotEmpty) | ||||
|                 Text( | ||||
|                   'Total: $total', | ||||
|                   style: Theme.of(context).textTheme.labelSmall?.copyWith( | ||||
|                     color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                   ), | ||||
|                 ), | ||||
|             ], | ||||
|           ); | ||||
|         } | ||||
|         break; | ||||
|  | ||||
|       case SnPollQuestionType.freeText: | ||||
|         // No stats | ||||
|         break; | ||||
|     } | ||||
|  | ||||
|     if (body == null) return const SizedBox.shrink(); | ||||
|  | ||||
|     return Padding( | ||||
|       padding: const EdgeInsets.only(top: 8), | ||||
|       child: DecoratedBox( | ||||
|         decoration: BoxDecoration( | ||||
|           color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.35), | ||||
|           borderRadius: BorderRadius.circular(8), | ||||
|         ), | ||||
|         child: Padding( | ||||
|           padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               Text( | ||||
|                 'Stats', | ||||
|                 style: Theme.of(context).textTheme.labelLarge?.copyWith( | ||||
|                   color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                 ), | ||||
|               ), | ||||
|               const SizedBox(height: 8), | ||||
|               body, | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildBody(BuildContext context) { | ||||
|     final q = _current; | ||||
|     switch (q.type) { | ||||
|       case SnPollQuestionType.singleChoice: | ||||
|         return _buildSingleChoice(context, q); | ||||
|       case SnPollQuestionType.multipleChoice: | ||||
|         return _buildMultipleChoice(context, q); | ||||
|       case SnPollQuestionType.yesNo: | ||||
|         return _buildYesNo(context, q); | ||||
|       case SnPollQuestionType.rating: | ||||
|         return _buildRating(context, q); | ||||
|       case SnPollQuestionType.freeText: | ||||
|         return _buildFreeText(context, q); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Widget _buildSingleChoice(BuildContext context, SnPollQuestion q) { | ||||
|     final options = [...?q.options]..sort((a, b) => a.order.compareTo(b.order)); | ||||
|     return Column( | ||||
|       children: [ | ||||
|         for (final opt in options) | ||||
|           RadioListTile<String>( | ||||
|             value: opt.id, | ||||
|             groupValue: _singleChoiceSelected, | ||||
|             onChanged: (val) => setState(() => _singleChoiceSelected = val), | ||||
|             title: Text(opt.label), | ||||
|             subtitle: opt.description != null ? Text(opt.description!) : null, | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildMultipleChoice(BuildContext context, SnPollQuestion q) { | ||||
|     final options = [...?q.options]..sort((a, b) => a.order.compareTo(b.order)); | ||||
|     return Column( | ||||
|       children: [ | ||||
|         for (final opt in options) | ||||
|           CheckboxListTile( | ||||
|             value: _multiChoiceSelected.contains(opt.id), | ||||
|             onChanged: (val) { | ||||
|               setState(() { | ||||
|                 if (val == true) { | ||||
|                   _multiChoiceSelected.add(opt.id); | ||||
|                 } else { | ||||
|                   _multiChoiceSelected.remove(opt.id); | ||||
|                 } | ||||
|               }); | ||||
|             }, | ||||
|             title: Text(opt.label), | ||||
|             subtitle: opt.description != null ? Text(opt.description!) : null, | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildYesNo(BuildContext context, SnPollQuestion q) { | ||||
|     return Row( | ||||
|       children: [ | ||||
|         Expanded( | ||||
|           child: SegmentedButton<bool>( | ||||
|             segments: const [ | ||||
|               ButtonSegment(value: true, label: Text('Yes')), | ||||
|               ButtonSegment(value: false, label: Text('No')), | ||||
|             ], | ||||
|             selected: _yesNoSelected == null ? {} : {_yesNoSelected!}, | ||||
|             onSelectionChanged: (sel) { | ||||
|               setState(() { | ||||
|                 _yesNoSelected = sel.isEmpty ? null : sel.first; | ||||
|               }); | ||||
|             }, | ||||
|             multiSelectionEnabled: false, | ||||
|           ), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildRating(BuildContext context, SnPollQuestion q) { | ||||
|     const max = 5; | ||||
|     return Row( | ||||
|       mainAxisAlignment: MainAxisAlignment.center, | ||||
|       children: List.generate(max, (i) { | ||||
|         final value = i + 1; | ||||
|         final selected = (_ratingSelected ?? 0) >= value; | ||||
|         return IconButton( | ||||
|           icon: Icon( | ||||
|             selected ? Icons.star : Icons.star_border, | ||||
|             color: selected ? Colors.amber : null, | ||||
|           ), | ||||
|           onPressed: () { | ||||
|             setState(() { | ||||
|               _ratingSelected = value; | ||||
|             }); | ||||
|           }, | ||||
|         ); | ||||
|       }), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildFreeText(BuildContext context, SnPollQuestion q) { | ||||
|     return TextField( | ||||
|       controller: _textController, | ||||
|       maxLines: 6, | ||||
|       decoration: const InputDecoration(border: OutlineInputBorder()), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildNavBar(BuildContext context) { | ||||
|     final isLast = _index == _questions.length - 1; | ||||
|     final canProceed = _isCurrentAnswered() && !_submitting; | ||||
|  | ||||
|     return Row( | ||||
|       children: [ | ||||
|         OutlinedButton.icon( | ||||
|           icon: const Icon(Icons.arrow_back), | ||||
|           label: Text(_index == 0 ? 'Cancel' : 'Back'), | ||||
|           onPressed: _submitting ? null : _back, | ||||
|         ), | ||||
|         const Spacer(), | ||||
|         FilledButton.icon( | ||||
|           icon: | ||||
|               _submitting | ||||
|                   ? const SizedBox( | ||||
|                     width: 16, | ||||
|                     height: 16, | ||||
|                     child: CircularProgressIndicator(strokeWidth: 2), | ||||
|                   ) | ||||
|                   : Icon(isLast ? Icons.check : Icons.arrow_forward), | ||||
|           label: Text(isLast ? 'Submit' : 'Next'), | ||||
|           onPressed: canProceed ? _next : null, | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (_questions.isEmpty) { | ||||
|       return const SizedBox.shrink(); | ||||
|     } | ||||
|  | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|       children: [ | ||||
|         _buildHeader(context), | ||||
|         const SizedBox(height: 12), | ||||
|         _AnimatedStep( | ||||
|           key: ValueKey(_current.id), | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|             children: [_buildBody(context), _buildStats(context, _current)], | ||||
|           ), | ||||
|         ), | ||||
|         const SizedBox(height: 16), | ||||
|         _buildNavBar(context), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _OptionCount { | ||||
|   final String id; | ||||
|   final String label; | ||||
|   final int count; | ||||
|   const _OptionCount({ | ||||
|     required this.id, | ||||
|     required this.label, | ||||
|     required this.count, | ||||
|   }); | ||||
| } | ||||
|  | ||||
| class _BarStatRow extends StatelessWidget { | ||||
|   const _BarStatRow({ | ||||
|     required this.label, | ||||
|     required this.count, | ||||
|     required this.fraction, | ||||
|     this.color, | ||||
|   }); | ||||
|  | ||||
|   final String label; | ||||
|   final int count; | ||||
|   final double fraction; | ||||
|   final Color? color; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final barColor = color ?? Theme.of(context).colorScheme.primary; | ||||
|     final bgColor = Theme.of( | ||||
|       context, | ||||
|     ).colorScheme.surfaceVariant.withOpacity(0.6); | ||||
|     final fg = | ||||
|         (fraction.isNaN || fraction.isInfinite) | ||||
|             ? 0.0 | ||||
|             : fraction.clamp(0.0, 1.0); | ||||
|  | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         Text('$label · $count', style: Theme.of(context).textTheme.labelMedium), | ||||
|         const SizedBox(height: 4), | ||||
|         LayoutBuilder( | ||||
|           builder: (context, constraints) { | ||||
|             final width = constraints.maxWidth; | ||||
|             final filled = width * fg; | ||||
|             return Stack( | ||||
|               children: [ | ||||
|                 Container( | ||||
|                   height: 8, | ||||
|                   width: width, | ||||
|                   decoration: BoxDecoration( | ||||
|                     color: bgColor, | ||||
|                     borderRadius: BorderRadius.circular(999), | ||||
|                   ), | ||||
|                 ), | ||||
|                 Container( | ||||
|                   height: 8, | ||||
|                   width: filled, | ||||
|                   decoration: BoxDecoration( | ||||
|                     color: barColor, | ||||
|                     borderRadius: BorderRadius.circular(999), | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ); | ||||
|           }, | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// Simple fade/slide transition between questions. | ||||
| class _AnimatedStep extends StatelessWidget { | ||||
|   const _AnimatedStep({super.key, required this.child}); | ||||
|   final Widget child; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AnimatedSwitcher( | ||||
|       duration: const Duration(milliseconds: 250), | ||||
|       transitionBuilder: (child, anim) { | ||||
|         final offset = Tween<Offset>( | ||||
|           begin: const Offset(0.1, 0), | ||||
|           end: Offset.zero, | ||||
|         ).animate(anim); | ||||
|         final fade = CurvedAnimation(parent: anim, curve: Curves.easeInOut); | ||||
|         return FadeTransition( | ||||
|           opacity: fade, | ||||
|           child: SlideTransition(position: offset, child: child), | ||||
|         ); | ||||
|       }, | ||||
|       child: child, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										204
									
								
								lib/widgets/post/compose_link_attachments.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								lib/widgets/post/compose_link_attachments.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,204 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| part 'compose_link_attachments.g.dart'; | ||||
|  | ||||
| @riverpod | ||||
| class CloudFileListNotifier extends _$CloudFileListNotifier | ||||
|     with CursorPagingNotifierMixin<SnCloudFile> { | ||||
|   @override | ||||
|   Future<CursorPagingData<SnCloudFile>> build() => fetch(cursor: null); | ||||
|  | ||||
|   @override | ||||
|   Future<CursorPagingData<SnCloudFile>> fetch({required String? cursor}) async { | ||||
|     final client = ref.read(apiClientProvider); | ||||
|     final offset = cursor == null ? 0 : int.parse(cursor); | ||||
|     final take = 20; | ||||
|  | ||||
|     final queryParameters = {'offset': offset, 'take': take}; | ||||
|  | ||||
|     final response = await client.get( | ||||
|       '/drive/files/me', | ||||
|       queryParameters: queryParameters, | ||||
|     ); | ||||
|  | ||||
|     final List<SnCloudFile> items = | ||||
|         (response.data as List) | ||||
|             .map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>)) | ||||
|             .toList(); | ||||
|     final total = int.parse(response.headers.value('X-Total') ?? '0'); | ||||
|  | ||||
|     final hasMore = offset + items.length < total; | ||||
|     final nextCursor = hasMore ? (offset + items.length).toString() : null; | ||||
|  | ||||
|     return CursorPagingData( | ||||
|       items: items, | ||||
|       hasMore: hasMore, | ||||
|       nextCursor: nextCursor, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class ComposeLinkAttachment extends HookConsumerWidget { | ||||
|   const ComposeLinkAttachment({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final idController = useTextEditingController(); | ||||
|     final errorMessage = useState<String?>(null); | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       heightFactor: 0.6, | ||||
|       titleText: 'linkAttachment'.tr(), | ||||
|       child: DefaultTabController( | ||||
|         length: 2, | ||||
|         child: Column( | ||||
|           mainAxisSize: MainAxisSize.min, | ||||
|           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|           children: [ | ||||
|             TabBar( | ||||
|               tabs: [ | ||||
|                 Tab(text: 'attachmentsRecentUploads'.tr()), | ||||
|                 Tab(text: 'attachmentsManualInput'.tr()), | ||||
|               ], | ||||
|             ), | ||||
|             Expanded( | ||||
|               child: TabBarView( | ||||
|                 children: [ | ||||
|                   PagingHelperView( | ||||
|                     provider: cloudFileListNotifierProvider, | ||||
|                     futureRefreshable: cloudFileListNotifierProvider.future, | ||||
|                     notifierRefreshable: cloudFileListNotifierProvider.notifier, | ||||
|                     contentBuilder: | ||||
|                         (data, widgetCount, endItemView) => ListView.builder( | ||||
|                           padding: EdgeInsets.only(top: 8), | ||||
|                           itemCount: widgetCount, | ||||
|                           itemBuilder: (context, index) { | ||||
|                             if (index == widgetCount - 1) { | ||||
|                               return endItemView; | ||||
|                             } | ||||
|  | ||||
|                             final item = data.items[index]; | ||||
|                             final itemType = | ||||
|                                 item.mimeType?.split('/').firstOrNull; | ||||
|                             return ListTile( | ||||
|                               leading: ClipRRect( | ||||
|                                 borderRadius: const BorderRadius.all( | ||||
|                                   Radius.circular(8), | ||||
|                                 ), | ||||
|                                 child: SizedBox( | ||||
|                                   height: 48, | ||||
|                                   width: 48, | ||||
|                                   child: switch (itemType) { | ||||
|                                     'image' => CloudImageWidget(file: item), | ||||
|                                     'audio' => | ||||
|                                       const Icon( | ||||
|                                         Symbols.audio_file, | ||||
|                                         fill: 1, | ||||
|                                       ).center(), | ||||
|                                     'video' => | ||||
|                                       const Icon( | ||||
|                                         Symbols.video_file, | ||||
|                                         fill: 1, | ||||
|                                       ).center(), | ||||
|                                     _ => | ||||
|                                       const Icon( | ||||
|                                         Symbols.body_system, | ||||
|                                         fill: 1, | ||||
|                                       ).center(), | ||||
|                                   }, | ||||
|                                 ), | ||||
|                               ), | ||||
|                               title: | ||||
|                                   item.name.isEmpty | ||||
|                                       ? Text('untitled').tr().italic() | ||||
|                                       : Text(item.name), | ||||
|                               onTap: () { | ||||
|                                 Navigator.pop(context, item); | ||||
|                               }, | ||||
|                             ); | ||||
|                           }, | ||||
|                         ), | ||||
|                   ), | ||||
|                   SingleChildScrollView( | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         TextField( | ||||
|                           controller: idController, | ||||
|                           decoration: InputDecoration( | ||||
|                             labelText: 'fileId'.tr(), | ||||
|                             helperText: 'fileIdHint'.tr(), | ||||
|                             helperMaxLines: 3, | ||||
|                             errorText: errorMessage.value, | ||||
|                             border: OutlineInputBorder(), | ||||
|                           ), | ||||
|                           onTapOutside: | ||||
|                               (_) => | ||||
|                                   FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                         ), | ||||
|                         const Gap(16), | ||||
|                         InkWell( | ||||
|                           child: Text( | ||||
|                             'fileIdLinkHint', | ||||
|                           ).tr().fontSize(13).opacity(0.85), | ||||
|                           onTap: () { | ||||
|                             launchUrlString('https://fs.solian.app'); | ||||
|                           }, | ||||
|                         ).padding(horizontal: 14), | ||||
|                         const Gap(16), | ||||
|                         Align( | ||||
|                           alignment: Alignment.centerRight, | ||||
|                           child: TextButton.icon( | ||||
|                             icon: const Icon(Symbols.add), | ||||
|                             label: Text('add'.tr()), | ||||
|                             onPressed: () async { | ||||
|                               final fileId = idController.text.trim(); | ||||
|                               if (fileId.isEmpty) { | ||||
|                                 errorMessage.value = 'fileIdCannotBeEmpty'.tr(); | ||||
|                                 return; | ||||
|                               } | ||||
|  | ||||
|                               try { | ||||
|                                 final client = ref.read(apiClientProvider); | ||||
|                                 final response = await client.get( | ||||
|                                   '/drive/files/$fileId/info', | ||||
|                                 ); | ||||
|                                 final SnCloudFile cloudFile = | ||||
|                                     SnCloudFile.fromJson(response.data); | ||||
|  | ||||
|                                 if (context.mounted) { | ||||
|                                   Navigator.of(context).pop(cloudFile); | ||||
|                                 } | ||||
|                               } catch (e) { | ||||
|                                 errorMessage.value = 'failedToFetchFile'.tr( | ||||
|                                   args: [e.toString()], | ||||
|                                 ); | ||||
|                               } | ||||
|                             }, | ||||
|                           ), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ).padding(horizontal: 24, vertical: 24), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										31
									
								
								lib/widgets/post/compose_link_attachments.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								lib/widgets/post/compose_link_attachments.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'compose_link_attachments.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$cloudFileListNotifierHash() => | ||||
|     r'e2c8a076a9e635c7b43a87d00f78775427ba6334'; | ||||
|  | ||||
| /// See also [CloudFileListNotifier]. | ||||
| @ProviderFor(CloudFileListNotifier) | ||||
| final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider< | ||||
|   CloudFileListNotifier, | ||||
|   CursorPagingData<SnCloudFile> | ||||
| >.internal( | ||||
|   CloudFileListNotifier.new, | ||||
|   name: r'cloudFileListNotifierProvider', | ||||
|   debugGetCreateSourceHash: | ||||
|       const bool.fromEnvironment('dart.vm.product') | ||||
|           ? null | ||||
|           : _$cloudFileListNotifierHash, | ||||
|   dependencies: null, | ||||
|   allTransitiveDependencies: null, | ||||
| ); | ||||
|  | ||||
| typedef _$CloudFileListNotifier = | ||||
|     AutoDisposeAsyncNotifier<CursorPagingData<SnCloudFile>>; | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package | ||||
							
								
								
									
										201
									
								
								lib/widgets/post/compose_poll.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								lib/widgets/post/compose_poll.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,201 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/poll.dart'; | ||||
| import 'package:island/models/publisher.dart'; | ||||
| import 'package:island/screens/creators/poll/poll_list.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:island/widgets/post/publishers_modal.dart'; | ||||
|  | ||||
| /// Bottom sheet for selecting or creating a poll. Returns SnPoll via Navigator.pop. | ||||
| class ComposePollSheet extends HookConsumerWidget { | ||||
|   /// Optional publisher name to filter polls and prefill creation. | ||||
|   final String? pubName; | ||||
|  | ||||
|   const ComposePollSheet({super.key, this.pubName}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final selectedPublisher = useState<String?>(pubName); | ||||
|     final isPushing = useState(false); | ||||
|     final errorText = useState<String?>(null); | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       heightFactor: 0.6, | ||||
|       titleText: 'poll'.tr(), | ||||
|       child: DefaultTabController( | ||||
|         length: 2, | ||||
|         child: Column( | ||||
|           mainAxisSize: MainAxisSize.min, | ||||
|           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|           children: [ | ||||
|             TabBar( | ||||
|               tabs: [ | ||||
|                 Tab(text: 'pollsRecent'.tr()), | ||||
|                 Tab(text: 'pollCreateNew'.tr()), | ||||
|               ], | ||||
|             ), | ||||
|             Expanded( | ||||
|               child: TabBarView( | ||||
|                 children: [ | ||||
|                   // Link/Select existing poll list | ||||
|                   PagingHelperView( | ||||
|                     provider: pollListNotifierProvider(pubName), | ||||
|                     futureRefreshable: pollListNotifierProvider(pubName).future, | ||||
|                     notifierRefreshable: | ||||
|                         pollListNotifierProvider(pubName).notifier, | ||||
|                     contentBuilder: | ||||
|                         (data, widgetCount, endItemView) => ListView.builder( | ||||
|                           padding: EdgeInsets.zero, | ||||
|                           itemCount: widgetCount, | ||||
|                           itemBuilder: (context, index) { | ||||
|                             if (index == widgetCount - 1) { | ||||
|                               return endItemView; | ||||
|                             } | ||||
|  | ||||
|                             final poll = data.items[index]; | ||||
|  | ||||
|                             return ListTile( | ||||
|                               leading: const Icon(Symbols.how_to_vote, fill: 1), | ||||
|                               title: Text(poll.title ?? 'untitled'.tr()), | ||||
|                               subtitle: _buildPollSubtitle(poll), | ||||
|                               onTap: () { | ||||
|                                 Navigator.of(context).pop(poll); | ||||
|                               }, | ||||
|                             ); | ||||
|                           }, | ||||
|                         ), | ||||
|                   ), | ||||
|  | ||||
|                   // Create new poll and return it | ||||
|                   SingleChildScrollView( | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         Text( | ||||
|                           'pollCreateNewHint', | ||||
|                         ).tr().fontSize(13).opacity(0.85).padding(bottom: 8), | ||||
|                         ListTile( | ||||
|                           title: Text( | ||||
|                             selectedPublisher.value == null | ||||
|                                 ? 'publisher'.tr() | ||||
|                                 : '@${selectedPublisher.value}', | ||||
|                           ), | ||||
|                           subtitle: Text( | ||||
|                             selectedPublisher.value == null | ||||
|                                 ? 'publisherHint'.tr() | ||||
|                                 : 'selected'.tr(), | ||||
|                           ), | ||||
|                           leading: const Icon(Symbols.account_circle), | ||||
|                           trailing: const Icon(Symbols.chevron_right), | ||||
|                           onTap: () async { | ||||
|                             final picked = | ||||
|                                 await showModalBottomSheet<SnPublisher>( | ||||
|                                   context: context, | ||||
|                                   isScrollControlled: true, | ||||
|                                   builder: (context) => const PublisherModal(), | ||||
|                                 ); | ||||
|                             if (picked != null) { | ||||
|                               try { | ||||
|                                 final name = picked.name; | ||||
|                                 if (name.isNotEmpty) { | ||||
|                                   selectedPublisher.value = name; | ||||
|                                   errorText.value = null; | ||||
|                                 } | ||||
|                               } catch (_) { | ||||
|                                 // ignore | ||||
|                               } | ||||
|                             } | ||||
|                           }, | ||||
|                         ), | ||||
|                         if (errorText.value != null) | ||||
|                           Padding( | ||||
|                             padding: const EdgeInsets.only( | ||||
|                               left: 16, | ||||
|                               right: 16, | ||||
|                               top: 4, | ||||
|                             ), | ||||
|                             child: Text( | ||||
|                               errorText.value!, | ||||
|                               style: TextStyle(color: Colors.red[700]), | ||||
|                             ), | ||||
|                           ), | ||||
|                         const Gap(16), | ||||
|                         Align( | ||||
|                           alignment: Alignment.centerRight, | ||||
|                           child: FilledButton.icon( | ||||
|                             icon: | ||||
|                                 isPushing.value | ||||
|                                     ? const SizedBox( | ||||
|                                       width: 18, | ||||
|                                       height: 18, | ||||
|                                       child: CircularProgressIndicator( | ||||
|                                         strokeWidth: 2, | ||||
|                                         color: Colors.white, | ||||
|                                       ), | ||||
|                                     ) | ||||
|                                     : const Icon(Symbols.add_circle), | ||||
|                             label: Text('create'.tr()), | ||||
|                             onPressed: | ||||
|                                 isPushing.value | ||||
|                                     ? null | ||||
|                                     : () async { | ||||
|                                       final pub = selectedPublisher.value ?? ''; | ||||
|                                       if (pub.isEmpty) { | ||||
|                                         errorText.value = | ||||
|                                             'publisherCannotBeEmpty'.tr(); | ||||
|                                         return; | ||||
|                                       } | ||||
|                                       errorText.value = null; | ||||
|  | ||||
|                                       isPushing.value = true; | ||||
|                                       // Push to creatorPollNew route and await result | ||||
|                                       final result = await GoRouter.of( | ||||
|                                         context, | ||||
|                                       ).push<SnPoll>( | ||||
|                                         '/creators/$pub/polls/new', | ||||
|                                       ); | ||||
|  | ||||
|                                       if (result == null) { | ||||
|                                         isPushing.value = false; | ||||
|                                         return; | ||||
|                                       } | ||||
|  | ||||
|                                       if (!context.mounted) return; | ||||
|  | ||||
|                                       // Return created poll to caller of this bottom sheet | ||||
|                                       Navigator.of(context).pop(result); | ||||
|                                     }, | ||||
|                           ), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ).padding(horizontal: 24, vertical: 24), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget? _buildPollSubtitle(SnPoll poll) { | ||||
|     try { | ||||
|       final SnPoll dyn = poll; | ||||
|       final List<SnPollQuestion>? options = dyn.questions; | ||||
|       if (options == null || options.isEmpty) return null; | ||||
|       final preview = options.take(3).map((e) => e.title).join(' · '); | ||||
|       if (preview.trim().isEmpty) return null; | ||||
|       return Text(preview); | ||||
|     } catch (_) { | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										147
									
								
								lib/widgets/post/compose_recorder.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								lib/widgets/post/compose_recorder.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,147 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:developer'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:file_picker/file_picker.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| import 'package:record/record.dart' hide Amplitude; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:uuid/uuid.dart'; | ||||
| import 'package:waveform_flutter/waveform_flutter.dart'; | ||||
|  | ||||
| class ComposeRecorder extends HookConsumerWidget { | ||||
|   const ComposeRecorder({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final recording = useState(false); | ||||
|     final recordingStartAt = useState<DateTime?>(null); | ||||
|     final recordingDuration = useState<Duration>(Duration(seconds: 0)); | ||||
|  | ||||
|     StreamSubscription? originalAmplitude; | ||||
|     StreamController<Amplitude> amplitudeStream = StreamController(); | ||||
|     var record = AudioRecorder(); | ||||
|  | ||||
|     final resultPath = useState<String?>(null); | ||||
|  | ||||
|     Future<void> startRecord() async { | ||||
|       recording.value = true; | ||||
|  | ||||
|       // Check and request permission if needed | ||||
|       final tempPath = !kIsWeb ? (await getTemporaryDirectory()).path : 'temp'; | ||||
|       final uuid = const Uuid().v4().substring(0, 8); | ||||
|       if (!await record.hasPermission()) return; | ||||
|  | ||||
|       const recordConfig = RecordConfig( | ||||
|         encoder: AudioEncoder.pcm16bits, | ||||
|         autoGain: true, | ||||
|         echoCancel: true, | ||||
|         noiseSuppress: true, | ||||
|       ); | ||||
|       resultPath.value = '$tempPath/solar-network-record-$uuid.m4a'; | ||||
|       await record.start(recordConfig, path: resultPath.value!); | ||||
|  | ||||
|       recordingStartAt.value = DateTime.now(); | ||||
|       originalAmplitude = record | ||||
|           .onAmplitudeChanged(const Duration(milliseconds: 100)) | ||||
|           .listen((value) async { | ||||
|             amplitudeStream.add( | ||||
|               Amplitude(current: value.current, max: value.max), | ||||
|             ); | ||||
|             recordingDuration.value = DateTime.now().difference( | ||||
|               recordingStartAt.value!, | ||||
|             ); | ||||
|           }); | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       return () { | ||||
|         // Called when widget is unmounted | ||||
|         log('[Recorder] Clean up!'); | ||||
|         originalAmplitude?.cancel(); | ||||
|         amplitudeStream.close(); | ||||
|         record.dispose(); | ||||
|       }; | ||||
|     }, []); | ||||
|  | ||||
|     Future<void> stopRecord() async { | ||||
|       recording.value = false; | ||||
|       await record.pause(); | ||||
|       final newResult = await record.stop(); | ||||
|       await record.cancel(); | ||||
|       if (newResult != null) resultPath.value = newResult; | ||||
|  | ||||
|       if (context.mounted) Navigator.of(context).pop(resultPath.value); | ||||
|     } | ||||
|  | ||||
|     Future<void> addExistingAudio() async { | ||||
|       var result = await FilePicker.platform.pickFiles( | ||||
|         type: FileType.custom, | ||||
|         allowedExtensions: ['mp3', 'm4a', 'wav', 'aac', 'flac', 'ogg', 'opus'], | ||||
|         onFileLoading: (status) { | ||||
|           if (!context.mounted) return; | ||||
|           if (status == FilePickerStatus.picking) { | ||||
|             showLoadingModal(context); | ||||
|           } else { | ||||
|             hideLoadingModal(context); | ||||
|           } | ||||
|         }, | ||||
|       ); | ||||
|       if (result == null || result.count == 0) return; | ||||
|       if (context.mounted) Navigator.of(context).pop(result.files.first.path); | ||||
|     } | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: "recordAudio".tr(), | ||||
|       actions: [ | ||||
|         IconButton( | ||||
|           onPressed: addExistingAudio, | ||||
|           icon: const Icon(Symbols.upload), | ||||
|         ), | ||||
|       ], | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.center, | ||||
|         children: [ | ||||
|           const Gap(32), | ||||
|           Text( | ||||
|             recordingDuration.value.formatShortDuration(), | ||||
|           ).fontSize(20).bold().padding(bottom: 8), | ||||
|           SizedBox( | ||||
|             height: 120, | ||||
|             child: Center( | ||||
|               child: ConstrainedBox( | ||||
|                 constraints: const BoxConstraints(maxWidth: 480), | ||||
|                 child: Card( | ||||
|                   color: Theme.of(context).colorScheme.surfaceContainer, | ||||
|                   child: AnimatedWaveList(stream: amplitudeStream.stream), | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ).padding(horizontal: 24), | ||||
|           const Gap(12), | ||||
|           IconButton.filled( | ||||
|             onPressed: recording.value ? stopRecord : startRecord, | ||||
|             iconSize: 32, | ||||
|             icon: | ||||
|                 recording.value | ||||
|                     ? const Icon(Symbols.stop, fill: 1, color: Colors.white) | ||||
|                     : const Icon( | ||||
|                       Symbols.play_arrow, | ||||
|                       fill: 1, | ||||
|                       color: Colors.white, | ||||
|                     ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -3,7 +3,6 @@ import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:image_picker/image_picker.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| @@ -14,15 +13,14 @@ import 'package:island/pods/network.dart'; | ||||
| import 'package:island/services/file.dart'; | ||||
| import 'package:island/services/compose_storage_db.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:island/widgets/post/compose_link_attachments.dart'; | ||||
| import 'package:island/widgets/post/compose_poll.dart'; | ||||
| import 'package:island/widgets/post/compose_recorder.dart'; | ||||
| import 'package:pasteboard/pasteboard.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:textfield_tags/textfield_tags.dart'; | ||||
| import 'dart:async'; | ||||
| import 'dart:developer'; | ||||
|  | ||||
| import 'package:textfield_tags/textfield_tags.dart'; | ||||
|  | ||||
| class ComposeState { | ||||
|   final TextEditingController titleController; | ||||
|   final TextEditingController descriptionController; | ||||
| @@ -36,6 +34,8 @@ class ComposeState { | ||||
|   StringTagController categoriesController; | ||||
|   final String draftId; | ||||
|   int postType; | ||||
|   // Linked poll id for this compose session (nullable) | ||||
|   final ValueNotifier<String?> pollId; | ||||
|   Timer? _autoSaveTimer; | ||||
|  | ||||
|   ComposeState({ | ||||
| @@ -51,7 +51,8 @@ class ComposeState { | ||||
|     required this.categoriesController, | ||||
|     required this.draftId, | ||||
|     this.postType = 0, | ||||
|   }); | ||||
|     String? pollId, | ||||
|   }) : pollId = ValueNotifier<String?>(pollId); | ||||
|  | ||||
|   void startAutoSave(WidgetRef ref) { | ||||
|     _autoSaveTimer?.cancel(); | ||||
| @@ -114,6 +115,8 @@ class ComposeLogic { | ||||
|       categoriesController: categoriesController, | ||||
|       draftId: id, | ||||
|       postType: postType, | ||||
|       // initialize without poll by default | ||||
|       pollId: null, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -141,6 +144,7 @@ class ComposeLogic { | ||||
|       categoriesController: categoriesController, | ||||
|       draftId: draft.id, | ||||
|       postType: postType, | ||||
|       pollId: null, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -399,93 +403,64 @@ class ComposeLogic { | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   static Future<void> recordAudioMedia( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, | ||||
|     BuildContext context, | ||||
|   ) async { | ||||
|     final audioPath = await showModalBottomSheet<String?>( | ||||
|       context: context, | ||||
|       builder: (context) => ComposeRecorder(), | ||||
|     ); | ||||
|     if (audioPath == null) return; | ||||
|  | ||||
|     state.attachments.value = [ | ||||
|       ...state.attachments.value, | ||||
|       UniversalFile( | ||||
|         data: XFile(audioPath, mimeType: 'audio/m4a'), | ||||
|         type: UniversalFileType.audio, | ||||
|       ), | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   static Future<void> linkAttachment( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, | ||||
|     BuildContext context, | ||||
|   ) async { | ||||
|     final TextEditingController idController = TextEditingController(); | ||||
|     String? errorMessage; | ||||
|  | ||||
|     await showModalBottomSheet( | ||||
|     final cloudFile = await showModalBottomSheet<SnCloudFile?>( | ||||
|       context: context, | ||||
|       builder: (BuildContext dialogContext) { | ||||
|         return StatefulBuilder( | ||||
|           builder: (context, setState) { | ||||
|             return SheetScaffold( | ||||
|               titleText: 'linkAttachment'.tr(), | ||||
|               child: Column( | ||||
|                 mainAxisSize: MainAxisSize.min, | ||||
|                 crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                 children: [ | ||||
|                   TextField( | ||||
|                     controller: idController, | ||||
|                     decoration: InputDecoration( | ||||
|                       labelText: 'fileId'.tr(), | ||||
|                       helperText: 'fileIdHint'.tr(), | ||||
|                       helperMaxLines: 3, | ||||
|                       errorText: errorMessage, | ||||
|                       border: OutlineInputBorder(), | ||||
|                     ), | ||||
|                   ), | ||||
|                   const Gap(16), | ||||
|                   Align( | ||||
|                     alignment: Alignment.centerRight, | ||||
|                     child: TextButton.icon( | ||||
|                       icon: const Icon(Symbols.add), | ||||
|                       label: Text('add'.tr()), | ||||
|                       onPressed: () async { | ||||
|                         final fileId = idController.text.trim(); | ||||
|                         if (fileId.isEmpty) { | ||||
|                           setState(() { | ||||
|                             errorMessage = 'fileIdCannotBeEmpty'.tr(); | ||||
|                           }); | ||||
|                           return; | ||||
|                         } | ||||
|  | ||||
|                         try { | ||||
|                           final client = ref.read(apiClientProvider); | ||||
|                           final response = await client.get( | ||||
|                             '/drive/files/$fileId/info', | ||||
|                           ); | ||||
|                           final SnCloudFile cloudFile = SnCloudFile.fromJson( | ||||
|                             response.data, | ||||
|                           ); | ||||
|  | ||||
|                           state.attachments.value = [ | ||||
|                             ...state.attachments.value, | ||||
|                             UniversalFile( | ||||
|                               data: cloudFile, | ||||
|                               type: switch (cloudFile.mimeType | ||||
|                                   ?.split('/') | ||||
|                                   .firstOrNull) { | ||||
|                                 'image' => UniversalFileType.image, | ||||
|                                 'video' => UniversalFileType.video, | ||||
|                                 'audio' => UniversalFileType.audio, | ||||
|                                 _ => UniversalFileType.file, | ||||
|                               }, | ||||
|                             ), | ||||
|                           ]; | ||||
|                           if (context.mounted) { | ||||
|                             Navigator.of(dialogContext).pop(); | ||||
|                           } | ||||
|                         } catch (e) { | ||||
|                           setState(() { | ||||
|                             errorMessage = 'failedToFetchFile'.tr( | ||||
|                               args: [e.toString()], | ||||
|                             ); | ||||
|                           }); | ||||
|                         } | ||||
|                       }, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ).padding(horizontal: 24, vertical: 24), | ||||
|             ); | ||||
|           }, | ||||
|         ); | ||||
|       }, | ||||
|       useRootNavigator: true, | ||||
|       isScrollControlled: true, | ||||
|       builder: (context) => ComposeLinkAttachment(), | ||||
|     ); | ||||
|     if (cloudFile == null) return; | ||||
|  | ||||
|     state.attachments.value = [ | ||||
|       ...state.attachments.value, | ||||
|       UniversalFile( | ||||
|         data: cloudFile, | ||||
|         type: switch (cloudFile.mimeType?.split('/').firstOrNull) { | ||||
|           'image' => UniversalFileType.image, | ||||
|           'video' => UniversalFileType.video, | ||||
|           'audio' => UniversalFileType.audio, | ||||
|           _ => UniversalFileType.file, | ||||
|         }, | ||||
|         isLink: true, | ||||
|       ), | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   static void updateAttachment( | ||||
|     ComposeState state, | ||||
|     UniversalFile value, | ||||
|     int index, | ||||
|   ) { | ||||
|     state.attachments.value = | ||||
|         state.attachments.value.mapIndexed((idx, ele) { | ||||
|           if (idx == index) return value; | ||||
|           return ele; | ||||
|         }).toList(); | ||||
|   } | ||||
|  | ||||
|   static Future<void> uploadAttachment( | ||||
| @@ -561,7 +536,7 @@ class ComposeLogic { | ||||
|     int index, | ||||
|   ) async { | ||||
|     final attachment = state.attachments.value[index]; | ||||
|     if (attachment.isOnCloud) { | ||||
|     if (attachment.isOnCloud && !attachment.isLink) { | ||||
|       final client = ref.watch(apiClientProvider); | ||||
|       await client.delete('/drive/files/${attachment.data.id}'); | ||||
|     } | ||||
| @@ -587,6 +562,27 @@ class ComposeLogic { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   static Future<void> pickPoll( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, | ||||
|     BuildContext context, | ||||
|   ) async { | ||||
|     if (state.pollId.value != null) { | ||||
|       state.pollId.value = null; | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     final poll = await showModalBottomSheet( | ||||
|       context: context, | ||||
|       useRootNavigator: true, | ||||
|       isScrollControlled: true, | ||||
|       builder: (context) => const ComposePollSheet(), | ||||
|     ); | ||||
|  | ||||
|     if (poll == null) return; | ||||
|     state.pollId.value = poll.id; | ||||
|   } | ||||
|  | ||||
|   static Future<void> performAction( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, | ||||
| @@ -645,16 +641,15 @@ class ComposeLogic { | ||||
|         if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id, | ||||
|         'tags': state.tagsController.getTags, | ||||
|         'categories': state.categoriesController.getTags, | ||||
|         if (state.pollId.value != null) 'poll_id': state.pollId.value, | ||||
|       }; | ||||
|  | ||||
|       // Send request | ||||
|       await client.request( | ||||
|         endpoint, | ||||
|         queryParameters: {'pub': state.currentPublisher.value?.name}, | ||||
|         data: payload, | ||||
|         options: Options( | ||||
|           headers: {'X-Pub': state.currentPublisher.value?.name}, | ||||
|           method: isNewPost ? 'POST' : 'PATCH', | ||||
|         ), | ||||
|         options: Options(method: isNewPost ? 'POST' : 'PATCH'), | ||||
|       ); | ||||
|  | ||||
|       // Delete draft after successful submission | ||||
| @@ -737,5 +732,6 @@ class ComposeLogic { | ||||
|     state.currentPublisher.dispose(); | ||||
|     state.tagsController.dispose(); | ||||
|     state.categoriesController.dispose(); | ||||
|     state.pollId.dispose(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -24,6 +24,10 @@ class ComposeToolbar extends HookConsumerWidget { | ||||
|       ComposeLogic.pickVideoMedia(ref, state); | ||||
|     } | ||||
|  | ||||
|     void addAudio() { | ||||
|       ComposeLogic.recordAudioMedia(ref, state, context); | ||||
|     } | ||||
|  | ||||
|     void linkAttachment() { | ||||
|       ComposeLogic.linkAttachment(ref, state, context); | ||||
|     } | ||||
| @@ -32,6 +36,10 @@ class ComposeToolbar extends HookConsumerWidget { | ||||
|       ComposeLogic.saveDraft(ref, state); | ||||
|     } | ||||
|  | ||||
|     void pickPoll() { | ||||
|       ComposeLogic.pickPoll(ref, state, context); | ||||
|     } | ||||
|  | ||||
|     void showDraftManager() { | ||||
|       showModalBottomSheet( | ||||
|         context: context, | ||||
| @@ -72,12 +80,37 @@ class ComposeToolbar extends HookConsumerWidget { | ||||
|                 icon: const Icon(Symbols.videocam), | ||||
|                 color: colorScheme.primary, | ||||
|               ), | ||||
|               IconButton( | ||||
|                 onPressed: addAudio, | ||||
|                 tooltip: 'addAudio'.tr(), | ||||
|                 icon: const Icon(Symbols.mic), | ||||
|                 color: colorScheme.primary, | ||||
|               ), | ||||
|               IconButton( | ||||
|                 onPressed: linkAttachment, | ||||
|                 icon: const Icon(Symbols.attach_file), | ||||
|                 tooltip: 'linkAttachment'.tr(), | ||||
|                 color: colorScheme.primary, | ||||
|               ), | ||||
|               // Poll button with visual state when a poll is linked | ||||
|               ListenableBuilder( | ||||
|                 listenable: state.pollId, | ||||
|                 builder: (context, _) { | ||||
|                   return IconButton( | ||||
|                     onPressed: pickPoll, | ||||
|                     icon: const Icon(Symbols.how_to_vote), | ||||
|                     tooltip: 'poll'.tr(), | ||||
|                     color: colorScheme.primary, | ||||
|                     style: ButtonStyle( | ||||
|                       backgroundColor: WidgetStatePropertyAll( | ||||
|                         state.pollId.value != null | ||||
|                             ? colorScheme.primary.withOpacity(0.15) | ||||
|                             : null, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|               const Spacer(), | ||||
|               if (originalPost == null && state.isEmpty) | ||||
|                 IconButton( | ||||
|   | ||||
| @@ -8,8 +8,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/embed.dart'; | ||||
| import 'package:island/models/poll.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/translate.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| @@ -22,6 +22,7 @@ import 'package:island/widgets/content/cloud_file_collection.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/embed/link.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:island/widgets/poll/poll_submit.dart'; | ||||
| import 'package:island/widgets/post/post_replies_sheet.dart'; | ||||
| import 'package:island/widgets/safety/abuse_report_helper.dart'; | ||||
| import 'package:island/widgets/share/share_sheet.dart'; | ||||
| @@ -179,7 +180,7 @@ class PostActionableItem extends HookConsumerWidget { | ||||
|               callback: () { | ||||
|                 showShareSheetLink( | ||||
|                   context: context, | ||||
|                   link: '${ref.read(serverUrlProvider)}/posts/${item.id}', | ||||
|                   link: 'https://solian.app/posts/${item.id}', | ||||
|                   title: 'sharePost'.tr(), | ||||
|                   toSystem: true, | ||||
|                 ); | ||||
| @@ -410,7 +411,9 @@ class PostItem extends HookConsumerWidget { | ||||
|         if (!isFullPost && item.type == 1) | ||||
|           Container( | ||||
|             decoration: BoxDecoration( | ||||
|               border: Border.all(color: Theme.of(context).dividerColor), | ||||
|               border: Border.all( | ||||
|                 color: Theme.of(context).dividerColor.withOpacity(0.5), | ||||
|               ), | ||||
|               borderRadius: const BorderRadius.all(Radius.circular(16)), | ||||
|             ), | ||||
|             padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), | ||||
| @@ -458,6 +461,24 @@ class PostItem extends HookConsumerWidget { | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|               children: [ | ||||
|                 if ((item.title?.isNotEmpty ?? false) || | ||||
|                     (item.description?.isNotEmpty ?? false)) | ||||
|                   Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       if (item.title?.isNotEmpty ?? false) | ||||
|                         Text( | ||||
|                           item.title!, | ||||
|                           style: Theme.of(context).textTheme.titleMedium! | ||||
|                               .copyWith(fontWeight: FontWeight.bold), | ||||
|                         ), | ||||
|                       if (item.description?.isNotEmpty ?? false) | ||||
|                         Text( | ||||
|                           item.description!, | ||||
|                           style: Theme.of(context).textTheme.bodyMedium, | ||||
|                         ), | ||||
|                     ], | ||||
|                   ).padding(bottom: 4), | ||||
|                 MarkdownTextContent( | ||||
|                   content: | ||||
|                       item.isTruncated ? '${item.content!}...' : item.content!, | ||||
| @@ -523,23 +544,36 @@ class PostItem extends HookConsumerWidget { | ||||
|             ), | ||||
|           ), | ||||
|         if (item.meta?['embeds'] != null) | ||||
|           ...((item.meta!['embeds'] as List<dynamic>) | ||||
|               .where((embed) => embed['Type'] == 'link') | ||||
|               .map( | ||||
|                 (embedData) => EmbedLinkWidget( | ||||
|                   link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>), | ||||
|                   maxWidth: math.min( | ||||
|                     MediaQuery.of(context).size.width, | ||||
|                     kWideScreenWidth, | ||||
|                   ), | ||||
|                   margin: EdgeInsets.only( | ||||
|                     top: 4, | ||||
|                     bottom: 4, | ||||
|                     left: renderingPadding.horizontal, | ||||
|                     right: renderingPadding.horizontal, | ||||
|                   ), | ||||
|           ...((item.meta!['embeds'] as List<dynamic>).map( | ||||
|             (embedData) => switch (embedData['type']) { | ||||
|               'link' => EmbedLinkWidget( | ||||
|                 link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>), | ||||
|                 maxWidth: math.min( | ||||
|                   MediaQuery.of(context).size.width, | ||||
|                   kWideScreenWidth, | ||||
|                 ), | ||||
|               )), | ||||
|                 margin: EdgeInsets.only( | ||||
|                   top: 4, | ||||
|                   bottom: 4, | ||||
|                   left: renderingPadding.horizontal, | ||||
|                   right: renderingPadding.horizontal, | ||||
|                 ), | ||||
|               ), | ||||
|               'poll' => Card( | ||||
|                 margin: EdgeInsets.symmetric( | ||||
|                   horizontal: renderingPadding.horizontal, | ||||
|                   vertical: 8, | ||||
|                 ), | ||||
|                 child: PollSubmit( | ||||
|                   initialAnswers: embedData['poll']?['user_answer']?['answer'], | ||||
|                   stats: embedData['poll']?['stats'], | ||||
|                   poll: SnPollWithStats.fromJson(embedData['poll']), | ||||
|                   onSubmit: (_) {}, | ||||
|                 ).padding(horizontal: 16, vertical: 12), | ||||
|               ), | ||||
|               _ => Text('Unable show embed: ${embedData['type']}'), | ||||
|             }, | ||||
|           )), | ||||
|         if (isShowReference) | ||||
|           _buildReferencePost(context, item, renderingPadding), | ||||
|         if (item.repliesCount > 0 && isEmbedReply) | ||||
| @@ -578,7 +612,7 @@ Widget _buildReferencePost( | ||||
|       color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5), | ||||
|       borderRadius: BorderRadius.circular(12), | ||||
|       border: Border.all( | ||||
|         color: Theme.of(context).colorScheme.outline.withOpacity(0.3), | ||||
|         color: Theme.of(context).dividerColor.withOpacity(0.5), | ||||
|       ), | ||||
|     ), | ||||
|     child: Column( | ||||
| @@ -846,22 +880,22 @@ class PostReplyPreview extends HookConsumerWidget { | ||||
|             : featuredReply!.when( | ||||
|               data: | ||||
|                   (value) => Row( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                     spacing: 8, | ||||
|                     children: [ | ||||
|                       ProfilePictureWidget( | ||||
|                         file: value!.publisher.picture, | ||||
|                         file: value?.publisher.picture, | ||||
|                         radius: 12, | ||||
|                       ).padding(top: 4), | ||||
|                       if (value.content?.isNotEmpty ?? false) | ||||
|                       if (value?.content?.isNotEmpty ?? false) | ||||
|                         Expanded( | ||||
|                           child: MarkdownTextContent(content: value.content!), | ||||
|                           child: MarkdownTextContent(content: value!.content!), | ||||
|                         ) | ||||
|                       else | ||||
|                         Expanded( | ||||
|                           child: Text( | ||||
|                             'postHasAttachments', | ||||
|                           ).plural(value.attachments.length), | ||||
|                           ).plural(value?.attachments.length ?? 0), | ||||
|                         ), | ||||
|                     ], | ||||
|                   ), | ||||
| @@ -894,7 +928,9 @@ class PostReplyPreview extends HookConsumerWidget { | ||||
|               padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), | ||||
|               decoration: BoxDecoration( | ||||
|                 color: Theme.of(context).colorScheme.surfaceContainerLow, | ||||
|                 border: Border.all(color: Theme.of(context).dividerColor), | ||||
|                 border: Border.all( | ||||
|                   color: Theme.of(context).dividerColor.withOpacity(0.5), | ||||
|                 ), | ||||
|                 borderRadius: BorderRadius.all(Radius.circular(8)), | ||||
|               ), | ||||
|               child: Column( | ||||
|   | ||||
| @@ -27,9 +27,13 @@ class PublisherCard extends ConsumerWidget { | ||||
|  | ||||
|     Widget card = Card( | ||||
|       clipBehavior: Clip.antiAlias, | ||||
|       margin: EdgeInsets.zero, | ||||
|       child: InkWell( | ||||
|         onTap: () { | ||||
|           context.pushNamed('publisherProfile', pathParameters: {'name': publisher.name}); | ||||
|           context.pushNamed( | ||||
|             'publisherProfile', | ||||
|             pathParameters: {'name': publisher.name}, | ||||
|           ); | ||||
|         }, | ||||
|         child: AspectRatio( | ||||
|           aspectRatio: 16 / 7, | ||||
| @@ -79,7 +83,7 @@ class PublisherCard extends ConsumerWidget { | ||||
|                           color: Colors.white, | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                         ), | ||||
|                         maxLines: 2, | ||||
|                         maxLines: 1, | ||||
|                         overflow: TextOverflow.ellipsis, | ||||
|                       ), | ||||
|                     ], | ||||
|   | ||||
| @@ -29,9 +29,13 @@ class RealmCard extends ConsumerWidget { | ||||
|  | ||||
|     Widget card = Card( | ||||
|       clipBehavior: Clip.antiAlias, | ||||
|       margin: EdgeInsets.zero, | ||||
|       child: InkWell( | ||||
|         onTap: () { | ||||
|           context.pushNamed('realmDetail', pathParameters: {'slug': realm.slug}); | ||||
|           context.pushNamed( | ||||
|             'realmDetail', | ||||
|             pathParameters: {'slug': realm.slug}, | ||||
|           ); | ||||
|         }, | ||||
|         child: AspectRatio( | ||||
|           aspectRatio: 16 / 7, | ||||
| @@ -82,7 +86,7 @@ class RealmCard extends ConsumerWidget { | ||||
|                           color: Colors.white, | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                         ), | ||||
|                         maxLines: 2, | ||||
|                         maxLines: 1, | ||||
|                         overflow: TextOverflow.ellipsis, | ||||
|                       ), | ||||
|                     ], | ||||
|   | ||||
| @@ -284,7 +284,7 @@ class _ShareSheetState extends ConsumerState<ShareSheet> { | ||||
|  | ||||
|       // Send message to chat room | ||||
|       await apiClient.post( | ||||
|         '/chat/${chatRoom.id}/messages', | ||||
|         '/sphere/chat/${chatRoom.id}/messages', | ||||
|         data: {'content': content, 'attachments_id': attachmentIds, 'meta': {}}, | ||||
|       ); | ||||
|  | ||||
| @@ -328,12 +328,7 @@ class _ShareSheetState extends ConsumerState<ShareSheet> { | ||||
|       } | ||||
|     } catch (e) { | ||||
|       if (mounted) { | ||||
|         ScaffoldMessenger.of(context).showSnackBar( | ||||
|           SnackBar( | ||||
|             content: Text('Failed to share to chat: $e'), | ||||
|             backgroundColor: Theme.of(context).colorScheme.error, | ||||
|           ), | ||||
|         ); | ||||
|         showSnackBar('Failed to share to chat: $e'); | ||||
|       } | ||||
|     } finally { | ||||
|       if (mounted) { | ||||
| @@ -405,151 +400,137 @@ class _ShareSheetState extends ConsumerState<ShareSheet> { | ||||
|         children: [ | ||||
|           // Share options with keyboard avoidance | ||||
|           Expanded( | ||||
|             child: AnimatedPadding( | ||||
|               duration: const Duration(milliseconds: 300), | ||||
|               padding: EdgeInsets.only( | ||||
|                 bottom: MediaQuery.of(context).viewInsets.bottom, | ||||
|               ), | ||||
|               child: SingleChildScrollView( | ||||
|                 child: Column( | ||||
|                   crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                   children: [ | ||||
|                     // Content preview | ||||
|                     Container( | ||||
|                       margin: const EdgeInsets.all(16), | ||||
|                       padding: const EdgeInsets.all(16), | ||||
|                       decoration: BoxDecoration( | ||||
|                         color: | ||||
|                             Theme.of( | ||||
|                               context, | ||||
|                             ).colorScheme.surfaceContainerHighest, | ||||
|                         borderRadius: BorderRadius.circular(12), | ||||
|                       ), | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Text( | ||||
|                             'contentToShare'.tr(), | ||||
|                             style: Theme.of( | ||||
|                               context, | ||||
|                             ).textTheme.labelMedium?.copyWith( | ||||
|                               color: | ||||
|                                   Theme.of( | ||||
|                                     context, | ||||
|                                   ).colorScheme.onSurfaceVariant, | ||||
|                             ), | ||||
|                           ), | ||||
|                           const SizedBox(height: 8), | ||||
|                           _ContentPreview(content: widget.content), | ||||
|                         ], | ||||
|                       ), | ||||
|             child: SingleChildScrollView( | ||||
|               child: Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 children: [ | ||||
|                   // Content preview | ||||
|                   Container( | ||||
|                     margin: const EdgeInsets.all(16), | ||||
|                     padding: const EdgeInsets.all(16), | ||||
|                     decoration: BoxDecoration( | ||||
|                       color: | ||||
|                           Theme.of(context).colorScheme.surfaceContainerHighest, | ||||
|                       borderRadius: BorderRadius.circular(12), | ||||
|                     ), | ||||
|                     // Quick actions row (horizontally scrollable) | ||||
|                     Padding( | ||||
|                       padding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Text( | ||||
|                             'quickActions'.tr(), | ||||
|                             style: Theme.of( | ||||
|                               context, | ||||
|                             ).textTheme.titleSmall?.copyWith( | ||||
|                               color: | ||||
|                                   Theme.of( | ||||
|                                     context, | ||||
|                                   ).colorScheme.onSurfaceVariant, | ||||
|                             ), | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         Text( | ||||
|                           'contentToShare'.tr(), | ||||
|                           style: Theme.of( | ||||
|                             context, | ||||
|                           ).textTheme.labelMedium?.copyWith( | ||||
|                             color: | ||||
|                                 Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                           ), | ||||
|                           const SizedBox(height: 12), | ||||
|                           SizedBox( | ||||
|                             height: 80, | ||||
|                             child: ListView( | ||||
|                               scrollDirection: Axis.horizontal, | ||||
|                               children: [ | ||||
|                                 _CompactShareOption( | ||||
|                                   icon: Symbols.post_add, | ||||
|                                   title: 'post'.tr(), | ||||
|                                   onTap: _isLoading ? null : _shareToPost, | ||||
|                                 ), | ||||
|                         ), | ||||
|                         const SizedBox(height: 8), | ||||
|                         _ContentPreview(content: widget.content), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                   // Quick actions row (horizontally scrollable) | ||||
|                   Padding( | ||||
|                     padding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         Text( | ||||
|                           'quickActions'.tr(), | ||||
|                           style: Theme.of( | ||||
|                             context, | ||||
|                           ).textTheme.titleSmall?.copyWith( | ||||
|                             color: | ||||
|                                 Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                           ), | ||||
|                         ), | ||||
|                         const SizedBox(height: 12), | ||||
|                         SizedBox( | ||||
|                           height: 80, | ||||
|                           child: ListView( | ||||
|                             scrollDirection: Axis.horizontal, | ||||
|                             children: [ | ||||
|                               _CompactShareOption( | ||||
|                                 icon: Symbols.post_add, | ||||
|                                 title: 'post'.tr(), | ||||
|                                 onTap: _isLoading ? null : _shareToPost, | ||||
|                               ), | ||||
|                               const SizedBox(width: 12), | ||||
|                               _CompactShareOption( | ||||
|                                 icon: Symbols.content_copy, | ||||
|                                 title: 'copy'.tr(), | ||||
|                                 onTap: _isLoading ? null : _copyToClipboard, | ||||
|                               ), | ||||
|                               if (widget.toSystem) ...<Widget>[ | ||||
|                                 const SizedBox(width: 12), | ||||
|                                 _CompactShareOption( | ||||
|                                   icon: Symbols.content_copy, | ||||
|                                   title: 'copy'.tr(), | ||||
|                                   onTap: _isLoading ? null : _copyToClipboard, | ||||
|                                   icon: Symbols.share, | ||||
|                                   title: 'share'.tr(), | ||||
|                                   onTap: _isLoading ? null : _shareToSystem, | ||||
|                                 ), | ||||
|                                 if (widget.toSystem) ...<Widget>[ | ||||
|                                   const SizedBox(width: 12), | ||||
|                                   _CompactShareOption( | ||||
|                                     icon: Symbols.share, | ||||
|                                     title: 'share'.tr(), | ||||
|                                     onTap: _isLoading ? null : _shareToSystem, | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ], | ||||
|                             ), | ||||
|                             ], | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|  | ||||
|                     const SizedBox(height: 24), | ||||
|                   const SizedBox(height: 24), | ||||
|  | ||||
|                     // Chat section | ||||
|                     Padding( | ||||
|                       padding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Text( | ||||
|                             'sendToChat'.tr(), | ||||
|                             style: Theme.of( | ||||
|                               context, | ||||
|                             ).textTheme.titleSmall?.copyWith( | ||||
|                               color: | ||||
|                                   Theme.of( | ||||
|                                     context, | ||||
|                                   ).colorScheme.onSurfaceVariant, | ||||
|                             ), | ||||
|                   // Chat section | ||||
|                   Padding( | ||||
|                     padding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         Text( | ||||
|                           'sendToChat'.tr(), | ||||
|                           style: Theme.of( | ||||
|                             context, | ||||
|                           ).textTheme.titleSmall?.copyWith( | ||||
|                             color: | ||||
|                                 Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                           ), | ||||
|                           const SizedBox(height: 12), | ||||
|                         ), | ||||
|                         const SizedBox(height: 12), | ||||
|  | ||||
|                           // Additional message input | ||||
|                           Container( | ||||
|                             margin: const EdgeInsets.only(bottom: 16), | ||||
|                             child: TextField( | ||||
|                               controller: _messageController, | ||||
|                               decoration: InputDecoration( | ||||
|                                 hintText: 'addAdditionalMessage'.tr(), | ||||
|                                 border: OutlineInputBorder( | ||||
|                                   borderRadius: BorderRadius.circular(12), | ||||
|                                 ), | ||||
|                                 contentPadding: const EdgeInsets.symmetric( | ||||
|                                   horizontal: 16, | ||||
|                                   vertical: 12, | ||||
|                                 ), | ||||
|                         // Additional message input | ||||
|                         Container( | ||||
|                           margin: const EdgeInsets.only(bottom: 16), | ||||
|                           child: TextField( | ||||
|                             controller: _messageController, | ||||
|                             decoration: InputDecoration( | ||||
|                               hintText: 'addAdditionalMessage'.tr(), | ||||
|                               border: OutlineInputBorder( | ||||
|                                 borderRadius: BorderRadius.circular(12), | ||||
|                               ), | ||||
|                               contentPadding: const EdgeInsets.symmetric( | ||||
|                                 horizontal: 16, | ||||
|                                 vertical: 12, | ||||
|                               ), | ||||
|                               onTapOutside: | ||||
|                                   (_) => | ||||
|                                       FocusManager.instance.primaryFocus | ||||
|                                           ?.unfocus(), | ||||
|                               maxLines: 3, | ||||
|                               minLines: 1, | ||||
|                               enabled: !_isLoading, | ||||
|                             ), | ||||
|                             onTapOutside: | ||||
|                                 (_) => | ||||
|                                     FocusManager.instance.primaryFocus | ||||
|                                         ?.unfocus(), | ||||
|                             maxLines: 3, | ||||
|                             minLines: 1, | ||||
|                             enabled: !_isLoading, | ||||
|                           ), | ||||
|                         ), | ||||
|  | ||||
|                           _ChatRoomsList( | ||||
|                             onChatSelected: | ||||
|                                 _isLoading ? null : _shareToSpecificChat, | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                         _ChatRoomsList( | ||||
|                           onChatSelected: | ||||
|                               _isLoading ? null : _shareToSpecificChat, | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|  | ||||
|                     const SizedBox(height: 16), | ||||
|                   ], | ||||
|                 ), | ||||
|                   const SizedBox(height: 16), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|   | ||||
| @@ -28,6 +28,7 @@ class WebArticleCard extends StatelessWidget { | ||||
|     return ConstrainedBox( | ||||
|       constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), | ||||
|       child: Card( | ||||
|         margin: EdgeInsets.zero, | ||||
|         clipBehavior: Clip.antiAlias, | ||||
|         child: InkWell( | ||||
|           onTap: () => _onTap(context), | ||||
| @@ -92,7 +93,7 @@ class WebArticleCard extends StatelessWidget { | ||||
|                             fontWeight: FontWeight.bold, | ||||
|                             height: 1.3, | ||||
|                           ), | ||||
|                           maxLines: showDetails ? 3 : 2, | ||||
|                           maxLines: showDetails ? 3 : 1, | ||||
|                           overflow: TextOverflow.ellipsis, | ||||
|                         ), | ||||
|                         if (showDetails && | ||||
| @@ -124,6 +125,8 @@ class WebArticleCard extends StatelessWidget { | ||||
|                             fontSize: 9, | ||||
|                             color: Colors.white70, | ||||
|                           ), | ||||
|                           maxLines: 1, | ||||
|                           overflow: TextOverflow.ellipsis, | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|   | ||||
							
								
								
									
										16
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -573,10 +573,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: file_picker | ||||
|       sha256: ef9908739bdd9c476353d6adff72e88fd00c625f5b959ae23f7567bd5137db0a | ||||
|       sha256: "13ba4e627ef24503a465d1d61b32596ce10eb6b8903678d362a528f9939b4aa8" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "10.2.0" | ||||
|     version: "10.2.1" | ||||
|   file_selector_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1097,10 +1097,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: google_fonts | ||||
|       sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 | ||||
|       sha256: df9763500dadba0155373e9cb44e202ce21bd9ed5de6bdbd05c5854e86839cb8 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.2.1" | ||||
|     version: "6.3.0" | ||||
|   graphs: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2572,6 +2572,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.1.2" | ||||
|   waveform_flutter: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: waveform_flutter | ||||
|       sha256: "08c9e98d4cf119428d8b3c083ed42c11c468623eaffdf30420ae38e36662922a" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.2.0" | ||||
|   web: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|   | ||||
| @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev | ||||
| # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||
| # In Windows, build-name is used as the major, minor, and patch parts | ||||
| # of the product and file versions while build-number is used as the build suffix. | ||||
| version: 3.1.0+116 | ||||
| version: 3.1.0+117 | ||||
|  | ||||
| environment: | ||||
|   sdk: ^3.7.2 | ||||
| @@ -53,7 +53,7 @@ dependencies: | ||||
|   flutter_highlight: ^0.7.0 | ||||
|   uuid: ^4.5.1 | ||||
|   url_launcher: ^6.3.2 | ||||
|   google_fonts: ^6.2.1 | ||||
|   google_fonts: ^6.3.0 | ||||
|   gap: ^3.0.1 | ||||
|   cached_network_image: ^3.4.1 | ||||
|   web: ^1.1.1 | ||||
| @@ -73,7 +73,7 @@ dependencies: | ||||
|     git: https://github.com/LittleSheep2Code/tus_client.git | ||||
|   cross_file: ^0.3.4+2 | ||||
|   image_picker: ^1.1.2 | ||||
|   file_picker: ^10.2.0 | ||||
|   file_picker: ^10.2.1 | ||||
|   riverpod_annotation: ^2.6.1 | ||||
|   image_picker_platform_interface: ^2.10.1 | ||||
|   image_picker_android: ^0.8.12+24 | ||||
| @@ -132,6 +132,7 @@ dependencies: | ||||
|   html2md: ^1.3.2 | ||||
|   flutter_typeahead: ^5.2.0 | ||||
|   flutter_langdetect: ^0.0.2 | ||||
|   waveform_flutter: ^1.2.0 | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user