Compare commits
	
		
			24 Commits
		
	
	
		
			8bc8556f06
			...
			3.0.0+108
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 9e8f6d57df | |||
| 79227a12e2 | |||
| a23dcfe702 | |||
| 243ecb3f71 | |||
| b8dec9f798 | |||
| 536375729f | |||
| 5939a1dc5b | |||
| 9d115a5712 | |||
| f511612a53 | |||
| 180fbcc558 | |||
| 047cb9dc0d | |||
| 786f851a97 | |||
| 4deff5a920 | |||
| 0361f031db | |||
| e90b35f19f | |||
| f2829b2012 | |||
| 825e6b5b6d | |||
| 2a3276973c | |||
| f4e10afa8f | |||
| 60c5e584be | |||
| 2b237eaad9 | |||
|  | 891a0b999c | ||
| 01da729365 | |||
| cef313b356 | 
| @@ -51,14 +51,15 @@ android { | ||||
|     buildTypes { | ||||
|         release { | ||||
|             signingConfig = signingConfigs.getByName("release") | ||||
|             minifyEnabled = true | ||||
|             shrinkResources = true | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| 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 { | ||||
|   | ||||
| @@ -46,12 +46,37 @@ | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.SEND" /> | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <data android:mimeType="*/*" /> | ||||
|                 <data android:mimeType="image/*" /> | ||||
|             </intent-filter> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.SEND_MULTIPLE" /> | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <data android:mimeType="*/*" /> | ||||
|                 <data android:mimeType="image/*" /> | ||||
|             </intent-filter> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.SEND" /> | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <data android:mimeType="video/*" /> | ||||
|             </intent-filter> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.SEND_MULTIPLE" /> | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <data android:mimeType="video/*" /> | ||||
|             </intent-filter> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.SEND" /> | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <data android:mimeType="text/*" /> | ||||
|             </intent-filter> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.SEND" /> | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <data android:mimeType="application/*" /> | ||||
|             </intent-filter> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.SEND_MULTIPLE" /> | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <data android:mimeType="application/*" /> | ||||
|             </intent-filter> | ||||
|         </activity> | ||||
|  | ||||
| @@ -70,6 +95,19 @@ | ||||
|             </intent-filter> | ||||
|         </activity> | ||||
|  | ||||
|         <receiver | ||||
|             android:name=".receiver.ReplyReceiver" | ||||
|             android:enabled="true" | ||||
|             android:exported="false" /> | ||||
|  | ||||
|         <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" | ||||
|   | ||||
| @@ -0,0 +1,48 @@ | ||||
| package dev.solsynth.solian.network | ||||
|  | ||||
| import android.content.Context | ||||
| import okhttp3.* | ||||
| import okhttp3.MediaType.Companion.toMediaType | ||||
| import okhttp3.RequestBody.Companion.toRequestBody | ||||
| import org.json.JSONObject | ||||
| import java.io.IOException | ||||
|  | ||||
| class ApiClient(private val context: Context) { | ||||
|     private val client = OkHttpClient() | ||||
|  | ||||
|     fun sendMessage(roomId: String, content: String, repliedMessageId: String, callback: () -> Unit) { | ||||
|         val prefs = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) | ||||
|         val token = prefs.getString("flutter.token", null) | ||||
|         val serverUrl = prefs.getString("flutter.serverUrl", null) | ||||
|  | ||||
|         if (token == null || serverUrl == null) { | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         val url = "$serverUrl/chat/$roomId/messages" | ||||
|  | ||||
|         val json = JSONObject() | ||||
|         json.put("content", content) | ||||
|         json.put("replied_message_id", repliedMessageId) | ||||
|  | ||||
|         val requestBody = json.toString().toRequestBody("application/json; charset=utf-8".toMediaType()) | ||||
|  | ||||
|         val request = Request.Builder() | ||||
|             .url(url) | ||||
|             .post(requestBody) | ||||
|             .addHeader("Authorization", "AtField $token") | ||||
|             .build() | ||||
|  | ||||
|         client.newCall(request).enqueue(object : Callback { | ||||
|             override fun onFailure(call: Call, e: IOException) { | ||||
|                 // Handle failure | ||||
|                 callback() | ||||
|             } | ||||
|  | ||||
|             override fun onResponse(call: Call, response: Response) { | ||||
|                 // Handle success | ||||
|                 callback() | ||||
|             } | ||||
|         }) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,27 @@ | ||||
| package dev.solsynth.solian.receiver | ||||
|  | ||||
| import android.app.NotificationManager | ||||
| import android.content.BroadcastReceiver | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import androidx.core.app.RemoteInput | ||||
| import dev.solsynth.solian.network.ApiClient | ||||
|  | ||||
| class ReplyReceiver : BroadcastReceiver() { | ||||
|     override fun onReceive(context: Context, intent: Intent) { | ||||
|         val remoteInput = RemoteInput.getResultsFromIntent(intent) | ||||
|         if (remoteInput != null) { | ||||
|             val replyText = remoteInput.getCharSequence("key_text_reply").toString() | ||||
|             val roomId = intent.getStringExtra("room_id") | ||||
|             val messageId = intent.getStringExtra("message_id") | ||||
|             val notificationId = intent.getIntExtra("notification_id", 0) | ||||
|  | ||||
|             if (roomId != null && messageId != null) { | ||||
|                 ApiClient(context).sendMessage(roomId, replyText, messageId) { | ||||
|                     val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager | ||||
|                     notificationManager.cancel(notificationId) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,101 @@ | ||||
| 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) | ||||
|         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()) | ||||
|         } | ||||
|     } | ||||
| } | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -48,6 +48,28 @@ | ||||
|   "deletePublisherHint": "Are you sure to delete this publisher? This will also deleted all the post and collections under this publisher.", | ||||
|   "somethingWentWrong": "Something went wrong...", | ||||
|   "deletePost": "Delete Post", | ||||
|   "safetyReport": "Report", | ||||
|   "safetyReportTitle": "Safety Report", | ||||
|   "safetyReportDescription": "Help us keep the community safe by reporting inappropriate content or behavior.", | ||||
|   "safetyReportType": "Report Type", | ||||
|   "safetyReportReason": "Additional Details", | ||||
|   "safetyReportReasonHint": "Please provide more details about the issue...", | ||||
|   "safetyReportSubmit": "Submit Report", | ||||
|   "safetyReportSubmitting": "Submitting...", | ||||
|   "safetyReportSuccess": "Report submitted successfully. Thank you for helping keep our community safe.", | ||||
|   "safetyReportError": "Failed to submit report. Please try again.", | ||||
|   "safetyReportReasonRequired": "Please provide details about the issue", | ||||
|   "safetyReportTypeSpam": "Spam or Misleading", | ||||
|   "safetyReportTypeHarassment": "Harassment or Abuse", | ||||
|   "safetyReportTypeHateSpeech": "Hate Speech", | ||||
|   "safetyReportTypeViolence": "Violence or Threats", | ||||
|   "safetyReportTypeAdultContent": "Adult Content", | ||||
|   "safetyReportTypeIntellectualProperty": "Intellectual Property Violation", | ||||
|   "safetyReportTypeOther": "Other", | ||||
|   "safetyReportTypeInappropriate": "Inappropriate Content", | ||||
|   "safetyReportTypeCopyright": "Copyright Violation", | ||||
|   "safetyReportSuccessTitle": "Report Submitted", | ||||
|   "safetyReportErrorTitle": "Error", | ||||
|   "deletePostHint": "Are you sure to delete this post?", | ||||
|   "copyLink": "Copy Link", | ||||
|   "postCreateAccountTitle": "Thanks for joining!", | ||||
| @@ -67,30 +89,14 @@ | ||||
|   "authFactorInAppNotifyDescription": "A one-time code sent via in-app notification.", | ||||
|   "authFactorPin": "Pin Code", | ||||
|   "authFactorPinDescription": "It consists of 6 digits. It cannot be used to log in. When performing some dangerous operations, the system will ask you to enter this PIN for confirmation.", | ||||
|   "realms": "Realms", | ||||
|   "createRealm": "Create a Realm", | ||||
|   "createRealmHint": "Meet friends with same interests, build communities, and more.", | ||||
|   "editRealm": "Edit Realm", | ||||
|   "deleteRealm": "Delete Realm", | ||||
|   "deleteRealmHint": "Are you sure to delete this realm? This will also deleted all the channels, publishers, and posts under this realm.", | ||||
|   "explore": "Explore", | ||||
|   "exploreFilterSubscriptions": "Subscriptions", | ||||
|   "exploreFilterFriends": "Friends", | ||||
|   "discover": "Discover", | ||||
|   "account": "Account", | ||||
|   "name": "Name", | ||||
|   "slug": "Slug", | ||||
|   "slugHint": "The slug will be used in the URL to access this resource, it should be unique and URL safe.", | ||||
|   "createChatRoom": "Create a Room", | ||||
|   "editChatRoom": "Edit Room", | ||||
|   "deleteChatRoom": "Delete Room", | ||||
|   "deleteChatRoomHint": "Are you sure to delete this room? This action cannot be undone.", | ||||
|   "chat": "Chat", | ||||
|   "chatTabAll": "All", | ||||
|   "chatTabDirect": "Direct Messages", | ||||
|   "chatTabGroup": "Group Chats", | ||||
|   "chatMessageHint": "Message in {}", | ||||
|   "chatDirectMessageHint": "Message to {}", | ||||
|   "directMessage": "Direct Message", | ||||
|   "loading": "Loading...", | ||||
|   "descriptionNone": "No description yet.", | ||||
|   "invites": "Invites", | ||||
| @@ -225,7 +231,6 @@ | ||||
|   "uploadingProgress": "Uploading {} of {}", | ||||
|   "uploadAll": "Upload All", | ||||
|   "stickerCopyPlaceholder": "Copy Placeholder", | ||||
|   "realmSelection": "Select a Realm", | ||||
|   "individual": "Individual", | ||||
|   "firstPostBadgeName": "First Post", | ||||
|   "firstPostBadgeDescription": "Created your first post on Solar Network", | ||||
| @@ -281,10 +286,6 @@ | ||||
|   "levelingProgressExperience": "{} EXP", | ||||
|   "levelingProgressLevel": "Level {}", | ||||
|   "fileUploadingProgress": "Uploading file #{}: {}%", | ||||
|   "removeChatMember": "Remove Chat Room Member", | ||||
|   "removeChatMemberHint": "Are you sure to remove this member from the room?", | ||||
|   "removeRealmMember": "Remove Realm Member", | ||||
|   "removeRealmMemberHint": "Are you sure to remove this member from the realm?", | ||||
|   "memberRole": "Member Role", | ||||
|   "memberRoleHint": "Greater number has higher permission.", | ||||
|   "memberRoleEdit": "Edit role for @{}", | ||||
| @@ -292,10 +293,6 @@ | ||||
|   "openLinkConfirmDescription": "You're going to leave the Solar Network and open the link ({}) in your browser. It is not related to Solar Network. Beware of phishing and scams.", | ||||
|   "brokenLink": "Unable open link {}... It might be broken or missing uri parts...", | ||||
|   "copyToClipboard": "Copy to clipboard", | ||||
|   "leaveChatRoom": "Leave Chat Room", | ||||
|   "leaveChatRoomHint": "Are you sure to leave this chat room?", | ||||
|   "leaveRealm": "Leave Realm", | ||||
|   "leaveRealmHint": "Are you sure to leave this realm?", | ||||
|   "walletNotFound": "Wallet not found", | ||||
|   "walletCreateHint": "You don't have a wallet yet. Create one to start using the Solar Network eWallet.", | ||||
|   "walletCreate": "Create a Wallet", | ||||
| @@ -307,12 +304,6 @@ | ||||
|   "settingsBackgroundImageClear": "Clear Background Image", | ||||
|   "settingsBackgroundGenerateColor": "Generate color scheme from Bacground Image", | ||||
|   "messageNone": "No content to display", | ||||
|   "unreadMessages": { | ||||
|     "one": "{} unread message", | ||||
|     "other": "{} unread messages" | ||||
|   }, | ||||
|   "chatBreakNone": "None", | ||||
|   "settingsRealmCompactView": "Compact Realm View", | ||||
|   "settingsMixedFeed": "Mixed Feed", | ||||
|   "settingsAutoTranslate": "Auto Translate", | ||||
|   "settingsHideBottomNav": "Hide Bottom Navigation", | ||||
| @@ -355,7 +346,6 @@ | ||||
|   "postVisibilityUnlisted": "Unlisted", | ||||
|   "postVisibilityPrivate": "Private", | ||||
|   "postTruncated": "Content truncated, tap to view full post", | ||||
|   "copyMessage": "Copy Message", | ||||
|   "authFactor": "Authentication Factor", | ||||
|   "authFactorDelete": "Delete the Factor", | ||||
|   "authFactorDeleteHint": "Are you sure you want to delete this authentication factor? This action cannot be undone.", | ||||
| @@ -388,10 +378,6 @@ | ||||
|   "authDeviceLabelHint": "Enter a name for this device", | ||||
|   "authDeviceSwipeEditHint": "Swipe left to edit label", | ||||
|   "authDeviceSwipeLogoutHint": "Swipe right to logout device", | ||||
|   "typingHint": { | ||||
|     "one": "{} is typing...", | ||||
|     "other": "{} are typing..." | ||||
|   }, | ||||
|   "settingsAppearance": "Appearance", | ||||
|   "settingsServer": "Server", | ||||
|   "settingsBehavior": "Behavior", | ||||
| @@ -453,21 +439,6 @@ | ||||
|   "contactMethodSetPrimary": "Set as Primary", | ||||
|   "contactMethodSetPrimaryHint": "Set this contact method as your primary contact method for account recovery and notifications", | ||||
|   "contactMethodDeleteHint": "Are you sure to delete this contact method? This action cannot be undone.", | ||||
|   "chatNotifyLevel": "Notify Level", | ||||
|   "chatNotifyLevelDescription": "Decide how many notifications you will receive.", | ||||
|   "chatNotifyLevelAll": "All", | ||||
|   "chatNotifyLevelMention": "Mentions", | ||||
|   "chatNotifyLevelNone": "None", | ||||
|   "chatNotifyLevelUpdated": "The notify level has been updated to {}.", | ||||
|   "chatBreak": "Take a Break", | ||||
|   "chatBreakDescription": "Set a time, before that time, your notification level will be metions only, to take a break of the current topic they're talking about.", | ||||
|   "chatBreakClear": "Clear the break time", | ||||
|   "chatBreakHour": "{} break", | ||||
|   "chatBreakDay": "{} day break", | ||||
|   "chatBreakSet": "Break set for {}", | ||||
|   "chatBreakCleared": "Chat break has been cleared.", | ||||
|   "chatBreakCustom": "Custom duration", | ||||
|   "chatBreakEnterMinutes": "Enter minutes", | ||||
|   "firstName": "First Name", | ||||
|   "middleName": "Middle Name", | ||||
|   "lastName": "Last Name", | ||||
| @@ -549,22 +520,49 @@ | ||||
|   "quickActions": "Quick Actions", | ||||
|   "post": "Post", | ||||
|   "copy": "Copy", | ||||
|   "sendToChat": "Send to Chat", | ||||
|   "failedToShareToPost": "Failed to share to post: {}", | ||||
|   "shareToChatComingSoon": "Share to chat functionality coming soon", | ||||
|   "failedToShareToChat": "Failed to share to chat: {}", | ||||
|   "shareToSpecificChatComingSoon": "Share to {} coming soon", | ||||
|   "directChat": "Direct Chat", | ||||
|   "systemShareComingSoon": "System share functionality coming soon", | ||||
|   "failedToShareToSystem": "Failed to share to system: {}", | ||||
|   "failedToCopy": "Failed to copy: {}", | ||||
|   "noChatRoomsAvailable": "No chat rooms available", | ||||
|   "failedToLoadChats": "Failed to load chats", | ||||
|   "contentToShare": "Content to share:", | ||||
|   "unknownChat": "Unknown Chat", | ||||
|   "addAdditionalMessage": "Add additional message...", | ||||
|   "uploadingFiles": "Uploading files...", | ||||
|   "sharedSuccessfully": "Shared successfully!", | ||||
|   "navigateToChat": "Navigate to Chat", | ||||
|   "wouldYouLikeToNavigateToChat": "Would you like to navigate to the chat?" | ||||
|   "shareSuccess": "Shared successfully!", | ||||
|   "wouldYouLikeToGoToChat": "Would you like to go to the chat?", | ||||
|   "no": "No", | ||||
|   "yes": "Yes", | ||||
|   "abuseReport": "Report", | ||||
|   "abuseReportTitle": "Report Content", | ||||
|   "abuseReportDescription": "Help us keep the community safe by reporting inappropriate content or behavior.", | ||||
|   "abuseReportType": "Report Type", | ||||
|   "abuseReportReason": "Additional Details", | ||||
|   "abuseReportReasonHint": "Please provide more details about the issue...", | ||||
|   "abuseReportSubmit": "Submit Report", | ||||
|   "abuseReportSuccess": "Report submitted successfully. Thank you for helping keep our community safe.", | ||||
|   "abuseReportError": "Failed to submit report. Please try again.", | ||||
|   "abuseReportReasonRequired": "Please provide details about the issue", | ||||
|   "abuseReportSuccessTitle": "Report Submitted", | ||||
|   "abuseReportErrorTitle": "Error", | ||||
|   "abuseReportTypeSpam": "Spam or Misleading", | ||||
|   "abuseReportTypeHarassment": "Harassment or Abuse", | ||||
|   "abuseReportTypeInappropriate": "Inappropriate Content", | ||||
|   "abuseReportTypeViolence": "Violence or Threats", | ||||
|   "abuseReportTypeCopyright": "Copyright Violation", | ||||
|   "abuseReportTypeImpersonation": "Impersonation", | ||||
|   "abuseReportTypeOffensiveContent": "Offensive Content", | ||||
|   "abuseReportTypePrivacyViolation": "Privacy Violation", | ||||
|   "abuseReportTypeIllegalContent": "Illegal Content", | ||||
|   "abuseReportTypeOther": "Other", | ||||
|   "tags": "Tags", | ||||
|   "tagsHint": "Enter tags, separated by commas", | ||||
|   "categories": "Categories", | ||||
|   "categoriesHint": "Enter categories, separated by commas", | ||||
|   "chatNotJoined": "You have not joined this chat yet.", | ||||
|   "chatUnableJoin": "You can't join this chat due to it's access control settings.", | ||||
|   "chatJoin": "Join the Chat", | ||||
|   "realmJoin": "Join the Realm", | ||||
|   "realmJoinSuccess": "Successfully joined the realm.", | ||||
|   "discoverRealms": "Discover Realms", | ||||
|   "discoverPublishers": "Discover Publishers", | ||||
|   "search": "Search" | ||||
| } | ||||
|   | ||||
							
								
								
									
										2
									
								
								crowdin.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								crowdin.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| bundles: | ||||
|   - 6 | ||||
| @@ -84,7 +84,7 @@ PODS: | ||||
|     - Flutter | ||||
|   - flutter_platform_alert (0.0.1): | ||||
|     - Flutter | ||||
|   - flutter_secure_storage (3.3.1): | ||||
|   - flutter_secure_storage (6.0.0): | ||||
|     - Flutter | ||||
|   - flutter_timezone (0.0.1): | ||||
|     - Flutter | ||||
| @@ -362,7 +362,7 @@ SPEC CHECKSUMS: | ||||
|   flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 | ||||
|   flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf | ||||
|   flutter_platform_alert: bf3b5fcd4ac14bd637e20527e9c471633071afd3 | ||||
|   flutter_secure_storage: 50035aef357c5a8bdd67fd6bc81370d46efc4d16 | ||||
|   flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 | ||||
|   flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 | ||||
|   flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 | ||||
|   flutter_webrtc: fd0d3bdef8766a0736dbbe2e5b7e85f1f3c52117 | ||||
|   | ||||
| @@ -857,7 +857,7 @@ | ||||
| 				INFOPLIST_FILE = SolianShareExtension/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; | ||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 18.5; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
| @@ -900,7 +900,7 @@ | ||||
| 				INFOPLIST_FILE = SolianShareExtension/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; | ||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 18.5; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
| @@ -940,7 +940,7 @@ | ||||
| 				INFOPLIST_FILE = SolianShareExtension/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; | ||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 18.5; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
| @@ -979,7 +979,7 @@ | ||||
| 				INFOPLIST_FILE = SolianNotificationService/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService; | ||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 18.5; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
| @@ -1021,7 +1021,7 @@ | ||||
| 				INFOPLIST_FILE = SolianNotificationService/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService; | ||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 18.5; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
| @@ -1060,7 +1060,7 @@ | ||||
| 				INFOPLIST_FILE = SolianNotificationService/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService; | ||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 18.5; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| 					"@executable_path/Frameworks", | ||||
|   | ||||
| @@ -11,6 +11,21 @@ import UIKit | ||||
|     ) -> Bool { | ||||
|         UNUserNotificationCenter.current().delegate = notifyDelegate | ||||
|          | ||||
|         let replyableMessageCategory = UNNotificationCategory( | ||||
|             identifier: "REPLYABLE_MESSAGE", | ||||
|             actions: [ | ||||
|                 UNTextInputNotificationAction( | ||||
|                     identifier: "reply_action", | ||||
|                     title: "Reply", | ||||
|                     options: [] | ||||
|                 ), | ||||
|             ], | ||||
|             intentIdentifiers: [], | ||||
|             options: [] | ||||
|         ) | ||||
|          | ||||
|         UNUserNotificationCenter.current().setNotificationCategories([replyableMessageCategory]) | ||||
|          | ||||
|         GeneratedPluginRegistrant.register(with: self) | ||||
|         return super.application(application, didFinishLaunchingWithOptions: launchOptions) | ||||
|     } | ||||
|   | ||||
| @@ -10,14 +10,26 @@ import Alamofire | ||||
|  | ||||
| class NotifyDelegate: UIResponder, UNUserNotificationCenterDelegate { | ||||
|     func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { | ||||
|         if let textResponse = response as? UNTextInputNotificationResponse { | ||||
|             let content = response.notification.request.content | ||||
|             guard let metadata = content.userInfo["meta"] as? [AnyHashable: Any] else { | ||||
|         guard let textResponse = response as? UNTextInputNotificationResponse else { | ||||
|             completionHandler() | ||||
|             return | ||||
|         } | ||||
|  | ||||
|             var token: String? = UserDefaults.standard.getFlutterToken() | ||||
|             if token == nil { | ||||
|         let content = response.notification.request.content | ||||
|          | ||||
|         // Only handle replies for new messages | ||||
|         guard let notificationType = content.userInfo["type"] as? String, notificationType == "messages.new" else { | ||||
|             completionHandler() | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         guard let metadata = content.userInfo["meta"] as? [AnyHashable: Any] else { | ||||
|             completionHandler() | ||||
|             return | ||||
|         } | ||||
|          | ||||
|         guard let token = UserDefaults.standard.getFlutterToken() else { | ||||
|             completionHandler() | ||||
|             return | ||||
|         } | ||||
|          | ||||
| @@ -30,7 +42,7 @@ class NotifyDelegate: UIResponder, UNUserNotificationCenterDelegate { | ||||
|         ] | ||||
|          | ||||
|         AF.request(url, method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: HTTPHeaders( | ||||
|                 [HTTPHeader(name: "Authorization", value: "AtField \(token!)")] | ||||
|             [HTTPHeader(name: "Authorization", value: "AtField \(token)")] | ||||
|         )) | ||||
|             .validate() | ||||
|             .responseString { response in | ||||
| @@ -41,9 +53,8 @@ class NotifyDelegate: UIResponder, UNUserNotificationCenterDelegate { | ||||
|                     print("Failed to send chat reply message: \(error)") | ||||
|                     break | ||||
|                 } | ||||
|                 } | ||||
|         } | ||||
|          | ||||
|                 // Call completion handler after network request is finished | ||||
|                 completionHandler() | ||||
|             } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -60,21 +60,7 @@ class NotificationService: UNNotificationServiceExtension { | ||||
|          | ||||
|         let pfpIdentifier = meta["pfp"] as? String | ||||
|          | ||||
|         let replyableMessageCategory = UNNotificationCategory( | ||||
|             identifier: content.categoryIdentifier, | ||||
|             actions: [ | ||||
|                 UNTextInputNotificationAction( | ||||
|                     identifier: "reply_action", | ||||
|                     title: "Reply", | ||||
|                     options: [] | ||||
|                 ), | ||||
|             ], | ||||
|             intentIdentifiers: [], | ||||
|             options: [] | ||||
|         ) | ||||
|          | ||||
|         UNUserNotificationCenter.current().setNotificationCategories([replyableMessageCategory]) | ||||
|         content.categoryIdentifier = replyableMessageCategory.identifier | ||||
|         content.categoryIdentifier = "REPLYABLE_MESSAGE" | ||||
|          | ||||
|         let metaCopy = meta as? [String: Any] ?? [:] | ||||
|         let pfpUrl = pfpIdentifier != nil ? getAttachmentUrl(for: pfpIdentifier!) : nil | ||||
|   | ||||
| @@ -9,6 +9,6 @@ import receive_sharing_intent | ||||
|  | ||||
| class ShareViewController: RSIShareViewController { | ||||
|     override func shouldAutoRedirect() -> Bool { | ||||
|         return false | ||||
|         return true | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -71,25 +71,32 @@ class MessageRepository { | ||||
|     bool synced = false, | ||||
|   }) async { | ||||
|     try { | ||||
|       // For initial load, fetch latest messages in the background to sync. | ||||
|       if (offset == 0 && !synced) { | ||||
|         // Not awaiting this is intentional, for a quicker UI response. | ||||
|         // The UI should rely on a stream from the database to get updates. | ||||
|         _fetchAndCacheMessages(room.id, offset: 0, take: take).catchError((_) { | ||||
|           // Best effort, errors will be handled by later fetches. | ||||
|           return <LocalChatMessage>[]; | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       final localMessages = await _getCachedMessages( | ||||
|         room.id, | ||||
|         offset: offset, | ||||
|         take: take, | ||||
|       ); | ||||
|  | ||||
|       // If it already synced with the remote, skip this | ||||
|       if (offset == 0 && !synced) { | ||||
|         // Fetch latest messages | ||||
|         _fetchAndCacheMessages(room.id, offset: offset, take: take); | ||||
|  | ||||
|       // If local cache has messages, return them. This is the common case for scrolling up. | ||||
|       if (localMessages.isNotEmpty) { | ||||
|         return localMessages; | ||||
|       } | ||||
|       } | ||||
|  | ||||
|       // If local cache is empty, we've probably reached the end of cached history. | ||||
|       // Fetch from remote. This will also be hit on first load if cache is empty. | ||||
|       return await _fetchAndCacheMessages(room.id, offset: offset, take: take); | ||||
|     } catch (e) { | ||||
|       // If API fails but we have local messages, return them | ||||
|       // Final fallback to cache in case of network errors during fetch. | ||||
|       final localMessages = await _getCachedMessages( | ||||
|         room.id, | ||||
|         offset: offset, | ||||
| @@ -117,24 +124,26 @@ class MessageRepository { | ||||
|     final dbLocalMessages = | ||||
|         dbMessages.map(_database.companionToMessage).toList(); | ||||
|  | ||||
|     // Combine with pending messages | ||||
|     // Combine with pending messages for the first page | ||||
|     if (offset == 0) { | ||||
|       final pendingForRoom = | ||||
|           pendingMessages.values.where((msg) => msg.roomId == roomId).toList(); | ||||
|  | ||||
|     // Sort by timestamp descending (newest first) | ||||
|       final allMessages = [...pendingForRoom, ...dbLocalMessages]; | ||||
|       allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); | ||||
|  | ||||
|     // Apply pagination | ||||
|     if (offset >= allMessages.length) { | ||||
|       return []; | ||||
|       // Remove duplicates by ID, preserving the order | ||||
|       final uniqueMessages = <LocalChatMessage>[]; | ||||
|       final seenIds = <String>{}; | ||||
|       for (final message in allMessages) { | ||||
|         if (seenIds.add(message.id)) { | ||||
|           uniqueMessages.add(message); | ||||
|         } | ||||
|       } | ||||
|       return uniqueMessages; | ||||
|     } | ||||
|  | ||||
|     final end = | ||||
|         (offset + take) > allMessages.length | ||||
|             ? allMessages.length | ||||
|             : (offset + take); | ||||
|     return allMessages.sublist(offset, end); | ||||
|     return dbLocalMessages; | ||||
|   } | ||||
|  | ||||
|   Future<List<LocalChatMessage>> _fetchAndCacheMessages( | ||||
|   | ||||
| @@ -18,6 +18,7 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/route.dart'; | ||||
|  | ||||
| import 'package:island/services/notify.dart'; | ||||
| import 'package:island/services/timezone.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| @@ -28,6 +29,12 @@ import 'package:image_picker_platform_interface/image_picker_platform_interface. | ||||
| import 'package:flutter_native_splash/flutter_native_splash.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| @pragma('vm:entry-point') | ||||
| Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { | ||||
|   await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); | ||||
|   log('Handling a background message: ${message.messageId}'); | ||||
| } | ||||
|  | ||||
| void main() async { | ||||
|   final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); | ||||
|   if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { | ||||
| @@ -42,6 +49,7 @@ void main() async { | ||||
|     await Firebase.initializeApp( | ||||
|       options: DefaultFirebaseOptions.currentPlatform, | ||||
|     ); | ||||
|     FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); | ||||
|     log("[SplashScreen] Firebase is ready!"); | ||||
|   } catch (err) { | ||||
|     showErrorAlert(err); | ||||
| @@ -124,7 +132,7 @@ void main() async { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| final appRouter = AppRouter(); | ||||
| // Router will be provided through Riverpod | ||||
|  | ||||
| final globalOverlay = GlobalKey<OverlayState>(); | ||||
|  | ||||
| @@ -140,7 +148,8 @@ class IslandApp extends HookConsumerWidget { | ||||
|         var uri = notification.data['action_uri'] as String; | ||||
|         if (uri.startsWith('/')) { | ||||
|           // In-app routes | ||||
|           appRouter.pushPath(notification.data['action_uri']); | ||||
|           final router = ref.read(routerProvider); | ||||
|           router.go(notification.data['action_uri']); | ||||
|         } else { | ||||
|           // External links | ||||
|           launchUrlString(uri); | ||||
| @@ -149,17 +158,30 @@ class IslandApp extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       Future(() async { | ||||
|         RemoteMessage? initialMessage = | ||||
|             await FirebaseMessaging.instance.getInitialMessage(); | ||||
|         if (initialMessage != null) { | ||||
|           handleMessage(initialMessage); | ||||
|       // When the app is opened from a terminated state. | ||||
|       FirebaseMessaging.instance.getInitialMessage().then((message) { | ||||
|         if (message != null) { | ||||
|           handleMessage(message); | ||||
|         } | ||||
|  | ||||
|         FirebaseMessaging.onMessageOpenedApp.listen(handleMessage); | ||||
|       }); | ||||
|  | ||||
|       return null; | ||||
|       // When the app is in the background and opened. | ||||
|       final onMessageOpenedAppSubscription = FirebaseMessaging | ||||
|           .onMessageOpenedApp | ||||
|           .listen(handleMessage); | ||||
|  | ||||
|       // When the app is in the foreground. | ||||
|       final onMessageSubscription = FirebaseMessaging.onMessage.listen(( | ||||
|         message, | ||||
|       ) { | ||||
|         log('Foreground message received: ${message.messageId}'); | ||||
|         handleMessage(message); | ||||
|       }); | ||||
|  | ||||
|       return () { | ||||
|         onMessageOpenedAppSubscription.cancel(); | ||||
|         onMessageSubscription.cancel(); | ||||
|       }; | ||||
|     }, []); | ||||
|  | ||||
|     useEffect(() { | ||||
| @@ -182,11 +204,13 @@ class IslandApp extends HookConsumerWidget { | ||||
|       return null; | ||||
|     }, []); | ||||
|  | ||||
|     final router = ref.watch(routerProvider); | ||||
|  | ||||
|     return MaterialApp.router( | ||||
|       theme: theme?.light, | ||||
|       darkTheme: theme?.dark, | ||||
|       themeMode: ThemeMode.system, | ||||
|       routerConfig: appRouter.config(), | ||||
|       routerConfig: router, | ||||
|       supportedLocales: context.supportedLocales, | ||||
|       localizationsDelegates: [ | ||||
|         ...context.localizationDelegates, | ||||
| @@ -200,10 +224,8 @@ class IslandApp extends HookConsumerWidget { | ||||
|           initialEntries: [ | ||||
|             OverlayEntry( | ||||
|               builder: | ||||
|                   (_) => WindowScaffold( | ||||
|                     router: appRouter, | ||||
|                     child: child ?? const SizedBox.shrink(), | ||||
|                   ), | ||||
|                   (_) => | ||||
|                       WindowScaffold(child: child ?? const SizedBox.shrink()), | ||||
|             ), | ||||
|           ], | ||||
|         ); | ||||
|   | ||||
| @@ -13,7 +13,8 @@ sealed class SnChatRoom with _$SnChatRoom { | ||||
|     required String? name, | ||||
|     required String? description, | ||||
|     required int type, | ||||
|     required bool isPublic, | ||||
|     @Default(false) bool isPublic, | ||||
|     @Default(false) bool isCommunity, | ||||
|     required SnCloudFile? picture, | ||||
|     required SnCloudFile? background, | ||||
|     required String? realmId, | ||||
|   | ||||
| @@ -16,7 +16,7 @@ T _$identity<T>(T value) => value; | ||||
| /// @nodoc | ||||
| mixin _$SnChatRoom { | ||||
|  | ||||
|  String get id; String? get name; String? get description; int get type; bool get isPublic; SnCloudFile? get picture; SnCloudFile? get background; String? get realmId; SnRealm? get realm; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; List<SnChatMember>? get members; | ||||
|  String get id; String? get name; String? get description; int get type; bool get isPublic; bool get isCommunity; SnCloudFile? get picture; SnCloudFile? get background; String? get realmId; SnRealm? get realm; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; List<SnChatMember>? get members; | ||||
| /// Create a copy of SnChatRoom | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -29,16 +29,16 @@ $SnChatRoomCopyWith<SnChatRoom> get copyWith => _$SnChatRoomCopyWithImpl<SnChatR | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.type, type) || other.type == type)&&(identical(other.isPublic, isPublic) || other.isPublic == isPublic)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.realmId, realmId) || other.realmId == realmId)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&const DeepCollectionEquality().equals(other.members, members)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.type, type) || other.type == type)&&(identical(other.isPublic, isPublic) || other.isPublic == isPublic)&&(identical(other.isCommunity, isCommunity) || other.isCommunity == isCommunity)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.realmId, realmId) || other.realmId == realmId)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&const DeepCollectionEquality().equals(other.members, members)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,name,description,type,isPublic,picture,background,realmId,realm,createdAt,updatedAt,deletedAt,const DeepCollectionEquality().hash(members)); | ||||
| int get hashCode => Object.hash(runtimeType,id,name,description,type,isPublic,isCommunity,picture,background,realmId,realm,createdAt,updatedAt,deletedAt,const DeepCollectionEquality().hash(members)); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnChatRoom(id: $id, name: $name, description: $description, type: $type, isPublic: $isPublic, picture: $picture, background: $background, realmId: $realmId, realm: $realm, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, members: $members)'; | ||||
|   return 'SnChatRoom(id: $id, name: $name, description: $description, type: $type, isPublic: $isPublic, isCommunity: $isCommunity, picture: $picture, background: $background, realmId: $realmId, realm: $realm, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, members: $members)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -49,7 +49,7 @@ abstract mixin class $SnChatRoomCopyWith<$Res>  { | ||||
|   factory $SnChatRoomCopyWith(SnChatRoom value, $Res Function(SnChatRoom) _then) = _$SnChatRoomCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String? name, String? description, int type, bool isPublic, SnCloudFile? picture, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members | ||||
|  String id, String? name, String? description, int type, bool isPublic, bool isCommunity, SnCloudFile? picture, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -66,13 +66,14 @@ class _$SnChatRoomCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnChatRoom | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = freezed,Object? description = freezed,Object? type = null,Object? isPublic = null,Object? picture = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = freezed,Object? description = freezed,Object? type = null,Object? isPublic = null,Object? isCommunity = null,Object? picture = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,name: freezed == 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?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| as int,isPublic: null == isPublic ? _self.isPublic : isPublic // ignore: cast_nullable_to_non_nullable | ||||
| as bool,isCommunity: null == isCommunity ? _self.isCommunity : isCommunity // ignore: cast_nullable_to_non_nullable | ||||
| as bool,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable | ||||
| as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable | ||||
| as SnCloudFile?,realmId: freezed == realmId ? _self.realmId : realmId // ignore: cast_nullable_to_non_nullable | ||||
| @@ -128,14 +129,15 @@ $SnRealmCopyWith<$Res>? get realm { | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnChatRoom implements SnChatRoom { | ||||
|   const _SnChatRoom({required this.id, required this.name, required this.description, required this.type, required this.isPublic, required this.picture, required this.background, required this.realmId, required this.realm, required this.createdAt, required this.updatedAt, required this.deletedAt, required final  List<SnChatMember>? members}): _members = members; | ||||
|   const _SnChatRoom({required this.id, required this.name, required this.description, required this.type, this.isPublic = false, this.isCommunity = false, required this.picture, required this.background, required this.realmId, required this.realm, required this.createdAt, required this.updatedAt, required this.deletedAt, required final  List<SnChatMember>? members}): _members = members; | ||||
|   factory _SnChatRoom.fromJson(Map<String, dynamic> json) => _$SnChatRoomFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @override final  String? name; | ||||
| @override final  String? description; | ||||
| @override final  int type; | ||||
| @override final  bool isPublic; | ||||
| @override@JsonKey() final  bool isPublic; | ||||
| @override@JsonKey() final  bool isCommunity; | ||||
| @override final  SnCloudFile? picture; | ||||
| @override final  SnCloudFile? background; | ||||
| @override final  String? realmId; | ||||
| @@ -166,16 +168,16 @@ Map<String, dynamic> toJson() { | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.type, type) || other.type == type)&&(identical(other.isPublic, isPublic) || other.isPublic == isPublic)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.realmId, realmId) || other.realmId == realmId)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&const DeepCollectionEquality().equals(other._members, _members)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.type, type) || other.type == type)&&(identical(other.isPublic, isPublic) || other.isPublic == isPublic)&&(identical(other.isCommunity, isCommunity) || other.isCommunity == isCommunity)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.realmId, realmId) || other.realmId == realmId)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&const DeepCollectionEquality().equals(other._members, _members)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,name,description,type,isPublic,picture,background,realmId,realm,createdAt,updatedAt,deletedAt,const DeepCollectionEquality().hash(_members)); | ||||
| int get hashCode => Object.hash(runtimeType,id,name,description,type,isPublic,isCommunity,picture,background,realmId,realm,createdAt,updatedAt,deletedAt,const DeepCollectionEquality().hash(_members)); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnChatRoom(id: $id, name: $name, description: $description, type: $type, isPublic: $isPublic, picture: $picture, background: $background, realmId: $realmId, realm: $realm, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, members: $members)'; | ||||
|   return 'SnChatRoom(id: $id, name: $name, description: $description, type: $type, isPublic: $isPublic, isCommunity: $isCommunity, picture: $picture, background: $background, realmId: $realmId, realm: $realm, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, members: $members)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -186,7 +188,7 @@ abstract mixin class _$SnChatRoomCopyWith<$Res> implements $SnChatRoomCopyWith<$ | ||||
|   factory _$SnChatRoomCopyWith(_SnChatRoom value, $Res Function(_SnChatRoom) _then) = __$SnChatRoomCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String? name, String? description, int type, bool isPublic, SnCloudFile? picture, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members | ||||
|  String id, String? name, String? description, int type, bool isPublic, bool isCommunity, SnCloudFile? picture, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -203,13 +205,14 @@ class __$SnChatRoomCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of SnChatRoom | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = freezed,Object? description = freezed,Object? type = null,Object? isPublic = null,Object? picture = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = freezed,Object? description = freezed,Object? type = null,Object? isPublic = null,Object? isCommunity = null,Object? picture = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) { | ||||
|   return _then(_SnChatRoom( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,name: freezed == 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?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| as int,isPublic: null == isPublic ? _self.isPublic : isPublic // ignore: cast_nullable_to_non_nullable | ||||
| as bool,isCommunity: null == isCommunity ? _self.isCommunity : isCommunity // ignore: cast_nullable_to_non_nullable | ||||
| as bool,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable | ||||
| as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable | ||||
| as SnCloudFile?,realmId: freezed == realmId ? _self.realmId : realmId // ignore: cast_nullable_to_non_nullable | ||||
|   | ||||
| @@ -11,7 +11,8 @@ _SnChatRoom _$SnChatRoomFromJson(Map<String, dynamic> json) => _SnChatRoom( | ||||
|   name: json['name'] as String?, | ||||
|   description: json['description'] as String?, | ||||
|   type: (json['type'] as num).toInt(), | ||||
|   isPublic: json['is_public'] as bool, | ||||
|   isPublic: json['is_public'] as bool? ?? false, | ||||
|   isCommunity: json['is_community'] as bool? ?? false, | ||||
|   picture: | ||||
|       json['picture'] == null | ||||
|           ? null | ||||
| @@ -44,6 +45,7 @@ Map<String, dynamic> _$SnChatRoomToJson(_SnChatRoom instance) => | ||||
|       'description': instance.description, | ||||
|       'type': instance.type, | ||||
|       'is_public': instance.isPublic, | ||||
|       'is_community': instance.isCommunity, | ||||
|       'picture': instance.picture?.toJson(), | ||||
|       'background': instance.background?.toJson(), | ||||
|       'realm_id': instance.realmId, | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/models/post_category.dart'; | ||||
| import 'package:island/models/post_tag.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
|  | ||||
| part 'post.freezed.dart'; | ||||
| @@ -33,8 +35,8 @@ sealed class SnPost with _$SnPost { | ||||
|     @Default(SnPublisher()) SnPublisher publisher, | ||||
|     @Default({}) Map<String, int> reactionsCount, | ||||
|     @Default([]) List<dynamic> reactions, | ||||
|     @Default([]) List<dynamic> tags, | ||||
|     @Default([]) List<dynamic> categories, | ||||
|     @Default([]) List<PostTag> tags, | ||||
|     @Default([]) List<PostCategory> categories, | ||||
|     @Default([]) List<dynamic> collections, | ||||
|     @Default(null) DateTime? createdAt, | ||||
|     @Default(null) DateTime? updatedAt, | ||||
|   | ||||
| @@ -16,7 +16,7 @@ T _$identity<T>(T value) => value; | ||||
| /// @nodoc | ||||
| mixin _$SnPost { | ||||
|  | ||||
|  String get id; String? get title; String? get description; String? get language; DateTime? get editedAt; DateTime? get publishedAt; int get visibility; String? get content; int get type; Map<String, dynamic>? get meta; int get viewsUnique; int get viewsTotal; int get upvotes; int get downvotes; int get repliesCount; String? get threadedPostId; SnPost? get threadedPost; String? get repliedPostId; SnPost? get repliedPost; String? get forwardedPostId; SnPost? get forwardedPost; List<SnCloudFile> get attachments; SnPublisher get publisher; Map<String, int> get reactionsCount; List<dynamic> get reactions; List<dynamic> get tags; List<dynamic> get categories; List<dynamic> get collections; DateTime? get createdAt; DateTime? get updatedAt; DateTime? get deletedAt; bool get isTruncated; | ||||
|  String get id; String? get title; String? get description; String? get language; DateTime? get editedAt; DateTime? get publishedAt; int get visibility; String? get content; int get type; Map<String, dynamic>? get meta; int get viewsUnique; int get viewsTotal; int get upvotes; int get downvotes; int get repliesCount; String? get threadedPostId; SnPost? get threadedPost; String? get repliedPostId; SnPost? get repliedPost; String? get forwardedPostId; SnPost? get forwardedPost; List<SnCloudFile> get attachments; SnPublisher get publisher; Map<String, int> get reactionsCount; List<dynamic> get reactions; List<PostTag> get tags; List<PostCategory> get categories; List<dynamic> get collections; DateTime? get createdAt; DateTime? get updatedAt; DateTime? get deletedAt; bool get isTruncated; | ||||
| /// Create a copy of SnPost | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -49,7 +49,7 @@ abstract mixin class $SnPostCopyWith<$Res>  { | ||||
|   factory $SnPostCopyWith(SnPost value, $Res Function(SnPost) _then) = _$SnPostCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String? title, String? description, String? language, DateTime? editedAt, DateTime? publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<dynamic> tags, List<dynamic> categories, List<dynamic> collections, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, bool isTruncated | ||||
|  String id, String? title, String? description, String? language, DateTime? editedAt, DateTime? publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<PostTag> tags, List<PostCategory> categories, List<dynamic> collections, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, bool isTruncated | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -94,8 +94,8 @@ as List<SnCloudFile>,publisher: null == publisher ? _self.publisher : publisher | ||||
| as SnPublisher,reactionsCount: null == reactionsCount ? _self.reactionsCount : reactionsCount // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, int>,reactions: null == reactions ? _self.reactions : reactions // ignore: cast_nullable_to_non_nullable | ||||
| as List<dynamic>,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable | ||||
| as List<dynamic>,categories: null == categories ? _self.categories : categories // ignore: cast_nullable_to_non_nullable | ||||
| as List<dynamic>,collections: null == collections ? _self.collections : collections // ignore: cast_nullable_to_non_nullable | ||||
| as List<PostTag>,categories: null == categories ? _self.categories : categories // ignore: cast_nullable_to_non_nullable | ||||
| as List<PostCategory>,collections: null == collections ? _self.collections : collections // ignore: cast_nullable_to_non_nullable | ||||
| as List<dynamic>,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| @@ -156,7 +156,7 @@ $SnPublisherCopyWith<$Res> get publisher { | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnPost implements SnPost { | ||||
|   const _SnPost({required this.id, this.title, this.description, this.language, this.editedAt, this.publishedAt = null, this.visibility = 0, this.content, this.type = 0, final  Map<String, dynamic>? meta, this.viewsUnique = 0, this.viewsTotal = 0, this.upvotes = 0, this.downvotes = 0, this.repliesCount = 0, this.threadedPostId, this.threadedPost, this.repliedPostId, this.repliedPost, this.forwardedPostId, this.forwardedPost, final  List<SnCloudFile> attachments = const [], this.publisher = const SnPublisher(), final  Map<String, int> reactionsCount = const {}, final  List<dynamic> reactions = const [], final  List<dynamic> tags = const [], final  List<dynamic> categories = const [], final  List<dynamic> collections = const [], this.createdAt = null, this.updatedAt = null, this.deletedAt, this.isTruncated = false}): _meta = meta,_attachments = attachments,_reactionsCount = reactionsCount,_reactions = reactions,_tags = tags,_categories = categories,_collections = collections; | ||||
|   const _SnPost({required this.id, this.title, this.description, this.language, this.editedAt, this.publishedAt = null, this.visibility = 0, this.content, this.type = 0, final  Map<String, dynamic>? meta, this.viewsUnique = 0, this.viewsTotal = 0, this.upvotes = 0, this.downvotes = 0, this.repliesCount = 0, this.threadedPostId, this.threadedPost, this.repliedPostId, this.repliedPost, this.forwardedPostId, this.forwardedPost, final  List<SnCloudFile> attachments = const [], this.publisher = const SnPublisher(), final  Map<String, int> reactionsCount = const {}, final  List<dynamic> reactions = const [], final  List<PostTag> tags = const [], final  List<PostCategory> categories = const [], final  List<dynamic> collections = const [], this.createdAt = null, this.updatedAt = null, this.deletedAt, this.isTruncated = false}): _meta = meta,_attachments = attachments,_reactionsCount = reactionsCount,_reactions = reactions,_tags = tags,_categories = categories,_collections = collections; | ||||
|   factory _SnPost.fromJson(Map<String, dynamic> json) => _$SnPostFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @@ -210,15 +210,15 @@ class _SnPost implements SnPost { | ||||
|   return EqualUnmodifiableListView(_reactions); | ||||
| } | ||||
|  | ||||
|  final  List<dynamic> _tags; | ||||
| @override@JsonKey() List<dynamic> get tags { | ||||
|  final  List<PostTag> _tags; | ||||
| @override@JsonKey() List<PostTag> get tags { | ||||
|   if (_tags is EqualUnmodifiableListView) return _tags; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableListView(_tags); | ||||
| } | ||||
|  | ||||
|  final  List<dynamic> _categories; | ||||
| @override@JsonKey() List<dynamic> get categories { | ||||
|  final  List<PostCategory> _categories; | ||||
| @override@JsonKey() List<PostCategory> get categories { | ||||
|   if (_categories is EqualUnmodifiableListView) return _categories; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableListView(_categories); | ||||
| @@ -269,7 +269,7 @@ abstract mixin class _$SnPostCopyWith<$Res> implements $SnPostCopyWith<$Res> { | ||||
|   factory _$SnPostCopyWith(_SnPost value, $Res Function(_SnPost) _then) = __$SnPostCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String? title, String? description, String? language, DateTime? editedAt, DateTime? publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<dynamic> tags, List<dynamic> categories, List<dynamic> collections, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, bool isTruncated | ||||
|  String id, String? title, String? description, String? language, DateTime? editedAt, DateTime? publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<PostTag> tags, List<PostCategory> categories, List<dynamic> collections, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, bool isTruncated | ||||
| }); | ||||
|  | ||||
|  | ||||
| @@ -314,8 +314,8 @@ as List<SnCloudFile>,publisher: null == publisher ? _self.publisher : publisher | ||||
| as SnPublisher,reactionsCount: null == reactionsCount ? _self._reactionsCount : reactionsCount // ignore: cast_nullable_to_non_nullable | ||||
| as Map<String, int>,reactions: null == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable | ||||
| as List<dynamic>,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable | ||||
| as List<dynamic>,categories: null == categories ? _self._categories : categories // ignore: cast_nullable_to_non_nullable | ||||
| as List<dynamic>,collections: null == collections ? _self._collections : collections // ignore: cast_nullable_to_non_nullable | ||||
| as List<PostTag>,categories: null == categories ? _self._categories : categories // ignore: cast_nullable_to_non_nullable | ||||
| as List<PostCategory>,collections: null == collections ? _self._collections : collections // ignore: cast_nullable_to_non_nullable | ||||
| as List<dynamic>,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
|   | ||||
| @@ -58,8 +58,16 @@ _SnPost _$SnPostFromJson(Map<String, dynamic> json) => _SnPost( | ||||
|       ) ?? | ||||
|       const {}, | ||||
|   reactions: json['reactions'] as List<dynamic>? ?? const [], | ||||
|   tags: json['tags'] as List<dynamic>? ?? const [], | ||||
|   categories: json['categories'] as List<dynamic>? ?? const [], | ||||
|   tags: | ||||
|       (json['tags'] as List<dynamic>?) | ||||
|           ?.map((e) => PostTag.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList() ?? | ||||
|       const [], | ||||
|   categories: | ||||
|       (json['categories'] as List<dynamic>?) | ||||
|           ?.map((e) => PostCategory.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList() ?? | ||||
|       const [], | ||||
|   collections: json['collections'] as List<dynamic>? ?? const [], | ||||
|   createdAt: | ||||
|       json['created_at'] == null | ||||
| @@ -102,8 +110,8 @@ Map<String, dynamic> _$SnPostToJson(_SnPost instance) => <String, dynamic>{ | ||||
|   'publisher': instance.publisher.toJson(), | ||||
|   'reactions_count': instance.reactionsCount, | ||||
|   'reactions': instance.reactions, | ||||
|   'tags': instance.tags, | ||||
|   'categories': instance.categories, | ||||
|   'tags': instance.tags.map((e) => e.toJson()).toList(), | ||||
|   'categories': instance.categories.map((e) => e.toJson()).toList(), | ||||
|   'collections': instance.collections, | ||||
|   'created_at': instance.createdAt?.toIso8601String(), | ||||
|   'updated_at': instance.updatedAt?.toIso8601String(), | ||||
|   | ||||
							
								
								
									
										19
									
								
								lib/models/post_category.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								lib/models/post_category.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
|  | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
|  | ||||
| part 'post_category.freezed.dart'; | ||||
| part 'post_category.g.dart'; | ||||
|  | ||||
| @freezed | ||||
| sealed class PostCategory with _$PostCategory { | ||||
|   const factory PostCategory({ | ||||
|     required String id, | ||||
|     required String slug, | ||||
|     String? name, | ||||
|     @Default([]) List<SnPost> posts, | ||||
|   }) = _PostCategory; | ||||
|  | ||||
|   factory PostCategory.fromJson(Map<String, dynamic> json) => | ||||
|       _$PostCategoryFromJson(json); | ||||
| } | ||||
							
								
								
									
										163
									
								
								lib/models/post_category.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								lib/models/post_category.freezed.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,163 @@ | ||||
| // dart format width=80 | ||||
| // coverage:ignore-file | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark | ||||
|  | ||||
| part of 'post_category.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // FreezedGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| // dart format off | ||||
| T _$identity<T>(T value) => value; | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$PostCategory { | ||||
|  | ||||
|  String get id; String get slug; String? get name; List<SnPost> get posts; | ||||
| /// Create a copy of PostCategory | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $PostCategoryCopyWith<PostCategory> get copyWith => _$PostCategoryCopyWithImpl<PostCategory>(this as PostCategory, _$identity); | ||||
|  | ||||
|   /// Serializes this PostCategory to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
|  | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is PostCategory&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other.posts, posts)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(posts)); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'PostCategory(id: $id, slug: $slug, name: $name, posts: $posts)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class $PostCategoryCopyWith<$Res>  { | ||||
|   factory $PostCategoryCopyWith(PostCategory value, $Res Function(PostCategory) _then) = _$PostCategoryCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String slug, String? name, List<SnPost> posts | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class _$PostCategoryCopyWithImpl<$Res> | ||||
|     implements $PostCategoryCopyWith<$Res> { | ||||
|   _$PostCategoryCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final PostCategory _self; | ||||
|   final $Res Function(PostCategory) _then; | ||||
|  | ||||
| /// Create a copy of PostCategory | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable | ||||
| as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||
| as String?,posts: null == posts ? _self.posts : posts // ignore: cast_nullable_to_non_nullable | ||||
| as List<SnPost>, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _PostCategory implements PostCategory { | ||||
|   const _PostCategory({required this.id, required this.slug, this.name, final  List<SnPost> posts = const []}): _posts = posts; | ||||
|   factory _PostCategory.fromJson(Map<String, dynamic> json) => _$PostCategoryFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @override final  String slug; | ||||
| @override final  String? name; | ||||
|  final  List<SnPost> _posts; | ||||
| @override@JsonKey() List<SnPost> get posts { | ||||
|   if (_posts is EqualUnmodifiableListView) return _posts; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableListView(_posts); | ||||
| } | ||||
|  | ||||
|  | ||||
| /// Create a copy of PostCategory | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$PostCategoryCopyWith<_PostCategory> get copyWith => __$PostCategoryCopyWithImpl<_PostCategory>(this, _$identity); | ||||
|  | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$PostCategoryToJson(this, ); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostCategory&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other._posts, _posts)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(_posts)); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'PostCategory(id: $id, slug: $slug, name: $name, posts: $posts)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class _$PostCategoryCopyWith<$Res> implements $PostCategoryCopyWith<$Res> { | ||||
|   factory _$PostCategoryCopyWith(_PostCategory value, $Res Function(_PostCategory) _then) = __$PostCategoryCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String slug, String? name, List<SnPost> posts | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class __$PostCategoryCopyWithImpl<$Res> | ||||
|     implements _$PostCategoryCopyWith<$Res> { | ||||
|   __$PostCategoryCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final _PostCategory _self; | ||||
|   final $Res Function(_PostCategory) _then; | ||||
|  | ||||
| /// Create a copy of PostCategory | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,}) { | ||||
|   return _then(_PostCategory( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable | ||||
| as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||
| as String?,posts: null == posts ? _self._posts : posts // ignore: cast_nullable_to_non_nullable | ||||
| as List<SnPost>, | ||||
|   )); | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| // dart format on | ||||
							
								
								
									
										27
									
								
								lib/models/post_category.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								lib/models/post_category.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'post_category.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| _PostCategory _$PostCategoryFromJson(Map<String, dynamic> json) => | ||||
|     _PostCategory( | ||||
|       id: json['id'] as String, | ||||
|       slug: json['slug'] as String, | ||||
|       name: json['name'] as String?, | ||||
|       posts: | ||||
|           (json['posts'] as List<dynamic>?) | ||||
|               ?.map((e) => SnPost.fromJson(e as Map<String, dynamic>)) | ||||
|               .toList() ?? | ||||
|           const [], | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$PostCategoryToJson(_PostCategory instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'slug': instance.slug, | ||||
|       'name': instance.name, | ||||
|       'posts': instance.posts.map((e) => e.toJson()).toList(), | ||||
|     }; | ||||
							
								
								
									
										19
									
								
								lib/models/post_tag.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								lib/models/post_tag.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
|  | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
|  | ||||
| part 'post_tag.freezed.dart'; | ||||
| part 'post_tag.g.dart'; | ||||
|  | ||||
| @freezed | ||||
| sealed class PostTag with _$PostTag { | ||||
|   const factory PostTag({ | ||||
|     required String id, | ||||
|     required String slug, | ||||
|     String? name, | ||||
|     @Default([]) List<SnPost> posts, | ||||
|   }) = _PostTag; | ||||
|  | ||||
|   factory PostTag.fromJson(Map<String, dynamic> json) => | ||||
|       _$PostTagFromJson(json); | ||||
| } | ||||
							
								
								
									
										163
									
								
								lib/models/post_tag.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								lib/models/post_tag.freezed.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,163 @@ | ||||
| // dart format width=80 | ||||
| // coverage:ignore-file | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark | ||||
|  | ||||
| part of 'post_tag.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // FreezedGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| // dart format off | ||||
| T _$identity<T>(T value) => value; | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$PostTag { | ||||
|  | ||||
|  String get id; String get slug; String? get name; List<SnPost> get posts; | ||||
| /// Create a copy of PostTag | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $PostTagCopyWith<PostTag> get copyWith => _$PostTagCopyWithImpl<PostTag>(this as PostTag, _$identity); | ||||
|  | ||||
|   /// Serializes this PostTag to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
|  | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is PostTag&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other.posts, posts)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(posts)); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'PostTag(id: $id, slug: $slug, name: $name, posts: $posts)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class $PostTagCopyWith<$Res>  { | ||||
|   factory $PostTagCopyWith(PostTag value, $Res Function(PostTag) _then) = _$PostTagCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String slug, String? name, List<SnPost> posts | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class _$PostTagCopyWithImpl<$Res> | ||||
|     implements $PostTagCopyWith<$Res> { | ||||
|   _$PostTagCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final PostTag _self; | ||||
|   final $Res Function(PostTag) _then; | ||||
|  | ||||
| /// Create a copy of PostTag | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable | ||||
| as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||
| as String?,posts: null == posts ? _self.posts : posts // ignore: cast_nullable_to_non_nullable | ||||
| as List<SnPost>, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _PostTag implements PostTag { | ||||
|   const _PostTag({required this.id, required this.slug, this.name, final  List<SnPost> posts = const []}): _posts = posts; | ||||
|   factory _PostTag.fromJson(Map<String, dynamic> json) => _$PostTagFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @override final  String slug; | ||||
| @override final  String? name; | ||||
|  final  List<SnPost> _posts; | ||||
| @override@JsonKey() List<SnPost> get posts { | ||||
|   if (_posts is EqualUnmodifiableListView) return _posts; | ||||
|   // ignore: implicit_dynamic_type | ||||
|   return EqualUnmodifiableListView(_posts); | ||||
| } | ||||
|  | ||||
|  | ||||
| /// Create a copy of PostTag | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$PostTagCopyWith<_PostTag> get copyWith => __$PostTagCopyWithImpl<_PostTag>(this, _$identity); | ||||
|  | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$PostTagToJson(this, ); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostTag&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other._posts, _posts)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(_posts)); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'PostTag(id: $id, slug: $slug, name: $name, posts: $posts)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class _$PostTagCopyWith<$Res> implements $PostTagCopyWith<$Res> { | ||||
|   factory _$PostTagCopyWith(_PostTag value, $Res Function(_PostTag) _then) = __$PostTagCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String slug, String? name, List<SnPost> posts | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class __$PostTagCopyWithImpl<$Res> | ||||
|     implements _$PostTagCopyWith<$Res> { | ||||
|   __$PostTagCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final _PostTag _self; | ||||
|   final $Res Function(_PostTag) _then; | ||||
|  | ||||
| /// Create a copy of PostTag | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,}) { | ||||
|   return _then(_PostTag( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable | ||||
| as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||
| as String?,posts: null == posts ? _self._posts : posts // ignore: cast_nullable_to_non_nullable | ||||
| as List<SnPost>, | ||||
|   )); | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| // dart format on | ||||
							
								
								
									
										25
									
								
								lib/models/post_tag.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								lib/models/post_tag.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'post_tag.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| _PostTag _$PostTagFromJson(Map<String, dynamic> json) => _PostTag( | ||||
|   id: json['id'] as String, | ||||
|   slug: json['slug'] as String, | ||||
|   name: json['name'] as String?, | ||||
|   posts: | ||||
|       (json['posts'] as List<dynamic>?) | ||||
|           ?.map((e) => SnPost.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList() ?? | ||||
|       const [], | ||||
| ); | ||||
|  | ||||
| Map<String, dynamic> _$PostTagToJson(_PostTag instance) => <String, dynamic>{ | ||||
|   'id': instance.id, | ||||
|   'slug': instance.slug, | ||||
|   'name': instance.name, | ||||
|   'posts': instance.posts.map((e) => e.toJson()).toList(), | ||||
| }; | ||||
| @@ -10,8 +10,8 @@ sealed class SnRealm with _$SnRealm { | ||||
|   const factory SnRealm({ | ||||
|     required String id, | ||||
|     required String slug, | ||||
|     required String name, | ||||
|     required String description, | ||||
|     @Default('') String name, | ||||
|     @Default('') String description, | ||||
|     required String? verifiedAs, | ||||
|     required DateTime? verifiedAt, | ||||
|     required bool isCommunity, | ||||
|   | ||||
| @@ -117,13 +117,13 @@ $SnCloudFileCopyWith<$Res>? get background { | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnRealm implements SnRealm { | ||||
|   const _SnRealm({required this.id, required this.slug, required this.name, required this.description, required this.verifiedAs, required this.verifiedAt, required this.isCommunity, required this.isPublic, required this.picture, required this.background, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}); | ||||
|   const _SnRealm({required this.id, required this.slug, this.name = '', this.description = '', required this.verifiedAs, required this.verifiedAt, required this.isCommunity, required this.isPublic, required this.picture, required this.background, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}); | ||||
|   factory _SnRealm.fromJson(Map<String, dynamic> json) => _$SnRealmFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @override final  String slug; | ||||
| @override final  String name; | ||||
| @override final  String description; | ||||
| @override@JsonKey() final  String name; | ||||
| @override@JsonKey() final  String description; | ||||
| @override final  String? verifiedAs; | ||||
| @override final  DateTime? verifiedAt; | ||||
| @override final  bool isCommunity; | ||||
|   | ||||
| @@ -9,8 +9,8 @@ part of 'realm.dart'; | ||||
| _SnRealm _$SnRealmFromJson(Map<String, dynamic> json) => _SnRealm( | ||||
|   id: json['id'] as String, | ||||
|   slug: json['slug'] as String, | ||||
|   name: json['name'] as String, | ||||
|   description: json['description'] as String, | ||||
|   name: json['name'] as String? ?? '', | ||||
|   description: json['description'] as String? ?? '', | ||||
|   verifiedAs: json['verified_as'] as String?, | ||||
|   verifiedAt: | ||||
|       json['verified_at'] == null | ||||
|   | ||||
| @@ -32,7 +32,6 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> { | ||||
|     state = const AsyncValue.data(null); | ||||
|     final prefs = _ref.read(sharedPreferencesProvider); | ||||
|     await prefs.remove(kTokenPairStoreKey); | ||||
|     _ref.invalidate(userInfoProvider); | ||||
|     _ref.invalidate(tokenProvider); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										390
									
								
								lib/route.dart
									
									
									
									
									
								
							
							
						
						
									
										390
									
								
								lib/route.dart
									
									
									
									
									
								
							| @@ -1,95 +1,327 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/widgets/app_wrapper.dart'; | ||||
| import 'package:island/screens/tabs.dart'; | ||||
|  | ||||
| @AutoRouterConfig(replaceInRouteName: 'Screen|Page,Route') | ||||
| class AppRouter extends RootStackRouter { | ||||
|   @override | ||||
|   RouteType get defaultRouteType => RouteType.adaptive(); | ||||
| import 'package:island/screens/explore.dart'; | ||||
| import 'package:island/screens/account.dart'; | ||||
| import 'package:island/screens/notification.dart'; | ||||
| import 'package:island/screens/wallet.dart'; | ||||
| import 'package:island/screens/account/relationship.dart'; | ||||
| import 'package:island/screens/account/profile.dart'; | ||||
| import 'package:island/screens/account/me/update.dart'; | ||||
| import 'package:island/screens/account/leveling.dart'; | ||||
| import 'package:island/screens/account/me/settings.dart'; | ||||
| import 'package:island/screens/chat/chat.dart'; | ||||
| import 'package:island/screens/chat/room.dart'; | ||||
| import 'package:island/screens/chat/room_detail.dart'; | ||||
| import 'package:island/screens/chat/call.dart'; | ||||
| import 'package:island/screens/creators/hub.dart'; | ||||
| import 'package:island/screens/creators/posts/list.dart'; | ||||
| import 'package:island/screens/creators/stickers/stickers.dart'; | ||||
| import 'package:island/screens/creators/stickers/pack_detail.dart'; | ||||
| import 'package:island/screens/creators/publishers.dart'; | ||||
| import 'package:island/screens/posts/compose.dart'; | ||||
| import 'package:island/screens/posts/detail.dart'; | ||||
| import 'package:island/screens/posts/pub_profile.dart'; | ||||
| import 'package:island/screens/auth/login.dart'; | ||||
| import 'package:island/screens/auth/create_account.dart'; | ||||
| import 'package:island/screens/settings.dart'; | ||||
| import 'package:island/screens/realm/realms.dart'; | ||||
| import 'package:island/screens/realm/detail.dart'; | ||||
| import 'package:island/screens/account/event_calendar.dart'; | ||||
| import 'package:island/screens/discovery/realms.dart'; | ||||
|  | ||||
|   @override | ||||
|   List<AutoRoute> get routes => [ | ||||
|     AutoRoute(path: '/', page: AppWrapper.page, children: _appRoutes), | ||||
|   ]; | ||||
| // Shell route keys for nested navigation | ||||
| final rootNavigatorKey = GlobalKey<NavigatorState>(); | ||||
| final _shellNavigatorKey = GlobalKey<NavigatorState>(); | ||||
| final _tabsShellKey = GlobalKey<NavigatorState>(); | ||||
|  | ||||
|   List<AutoRoute> get _appRoutes => [ | ||||
|     AutoRoute(page: PostComposeRoute.page, path: 'posts/compose'), | ||||
|     AutoRoute(page: PostEditRoute.page, path: 'posts/:id/edit'), | ||||
|     AutoRoute(page: CallRoute.page, path: 'chat/:id/call'), | ||||
|     AutoRoute(page: EventCalanderRoute.page, path: 'account/:name/calendar'), | ||||
|     AutoRoute( | ||||
|       page: TabsRoute.page, | ||||
|       path: '', | ||||
|       children: [ | ||||
|         AutoRoute( | ||||
|           page: ExploreShellRoute.page, | ||||
|           path: '', | ||||
|           children: [ | ||||
|             AutoRoute(page: ExploreRoute.page, path: ''), | ||||
|             AutoRoute(page: PostDetailRoute.page, path: 'posts/:id'), | ||||
|             AutoRoute( | ||||
|               page: PublisherProfileRoute.page, | ||||
|               path: 'publishers/:name', | ||||
| // Provider for the router | ||||
| final routerProvider = Provider<GoRouter>((ref) { | ||||
|   return GoRouter( | ||||
|     navigatorKey: rootNavigatorKey, | ||||
|     initialLocation: '/', | ||||
|     routes: [ | ||||
|       ShellRoute( | ||||
|         navigatorKey: _shellNavigatorKey, | ||||
|         builder: (context, state, child) { | ||||
|           return AppWrapper(child: child); | ||||
|         }, | ||||
|         routes: [ | ||||
|           // Standalone routes without bottom navigation | ||||
|           GoRoute( | ||||
|             path: '/posts/compose', | ||||
|             builder: | ||||
|                 (context, state) => PostComposeScreen( | ||||
|                   initialState: state.extra as PostComposeInitialState?, | ||||
|                 ), | ||||
|           ], | ||||
|           ), | ||||
|         AutoRoute( | ||||
|           page: AccountShellRoute.page, | ||||
|           path: 'account', | ||||
|           children: [ | ||||
|             AutoRoute(page: AccountRoute.page, path: ''), | ||||
|             AutoRoute(page: NotificationRoute.page, path: 'notifications'), | ||||
|             AutoRoute(page: WalletRoute.page, path: 'wallet'), | ||||
|             AutoRoute(page: RelationshipRoute.page, path: 'relationships'), | ||||
|             AutoRoute(page: AccountProfileRoute.page, path: ':name'), | ||||
|             AutoRoute(page: UpdateProfileRoute.page, path: 'me/update'), | ||||
|             AutoRoute(page: LevelingRoute.page, path: 'me/leveling'), | ||||
|             AutoRoute(page: AccountSettingsRoute.page, path: 'settings'), | ||||
|           ], | ||||
|           GoRoute( | ||||
|             path: '/posts/:id/edit', | ||||
|             builder: (context, state) { | ||||
|               final id = state.pathParameters['id']!; | ||||
|               return PostEditScreen(id: id); | ||||
|             }, | ||||
|           ), | ||||
|         AutoRoute(page: RealmListRoute.page, path: 'realms'), | ||||
|         AutoRoute( | ||||
|           page: ChatShellRoute.page, | ||||
|           path: 'chat', | ||||
|           children: [ | ||||
|             AutoRoute(page: ChatListRoute.page, path: ''), | ||||
|             AutoRoute(page: ChatRoomRoute.page, path: ':id'), | ||||
|             AutoRoute(page: NewChatRoute.page, path: 'new'), | ||||
|             AutoRoute(page: EditChatRoute.page, path: ':id/edit'), | ||||
|             AutoRoute(page: ChatDetailRoute.page, path: ':id/detail'), | ||||
|           ], | ||||
|           GoRoute( | ||||
|             path: '/chat/:id/call', | ||||
|             builder: (context, state) { | ||||
|               final id = state.pathParameters['id']!; | ||||
|               return CallScreen(roomId: id); | ||||
|             }, | ||||
|           ), | ||||
|       ], | ||||
|           GoRoute( | ||||
|             path: '/account/:name/calendar', | ||||
|             builder: (context, state) { | ||||
|               final name = state.pathParameters['name']!; | ||||
|               return EventCalanderScreen(name: name); | ||||
|             }, | ||||
|           ), | ||||
|     AutoRoute( | ||||
|       page: CreatorHubShellRoute.page, | ||||
|       path: 'creators', | ||||
|       children: [ | ||||
|         AutoRoute(page: CreatorHubRoute.page, path: ''), | ||||
|         AutoRoute(page: CreatorPostListRoute.page, path: ':name/posts'), | ||||
|         AutoRoute(page: StickersRoute.page, path: ':name/stickers'), | ||||
|         AutoRoute(page: NewStickerPacksRoute.page, path: ':name/stickers/new'), | ||||
|         AutoRoute( | ||||
|           page: EditStickerPacksRoute.page, | ||||
|           GoRoute( | ||||
|             path: '/creators', | ||||
|             builder: (context, state) => const CreatorHubScreen(), | ||||
|             routes: [ | ||||
|               GoRoute( | ||||
|                 path: ':name/posts', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   return CreatorPostListScreen(pubName: name); | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: ':name/stickers', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   return StickersScreen(pubName: name); | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: ':name/stickers/new', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   return NewStickerPacksScreen(pubName: name); | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: ':name/stickers/:packId/edit', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   final packId = state.pathParameters['packId']!; | ||||
|                   return EditStickerPacksScreen(pubName: name, packId: packId); | ||||
|                 }, | ||||
|               ), | ||||
|         AutoRoute( | ||||
|           page: StickerPackDetailRoute.page, | ||||
|               GoRoute( | ||||
|                 path: ':name/stickers/:packId', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   final packId = state.pathParameters['packId']!; | ||||
|                   return StickerPackDetailScreen(pubName: name, id: packId); | ||||
|                 }, | ||||
|               ), | ||||
|         AutoRoute(page: NewStickersRoute.page, path: ':name/stickers/new'), | ||||
|         AutoRoute( | ||||
|           page: EditStickersRoute.page, | ||||
|           path: ':name/stickers/:id/edit', | ||||
|               GoRoute( | ||||
|                 path: ':name/stickers/:packId/new', | ||||
|                 builder: (context, state) { | ||||
|                   final packId = state.pathParameters['packId']!; | ||||
|                   return NewStickersScreen(packId: packId); | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: ':name/stickers/:packId/:id/edit', | ||||
|                 builder: (context, state) { | ||||
|                   final packId = state.pathParameters['packId']!; | ||||
|                   final id = state.pathParameters['id']!; | ||||
|                   return EditStickersScreen(id: id, packId: packId); | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: 'new', | ||||
|                 builder: (context, state) => const NewPublisherScreen(), | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: ':name/edit', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   return EditPublisherScreen(name: name); | ||||
|                 }, | ||||
|               ), | ||||
|         AutoRoute(page: NewPublisherRoute.page, path: 'new'), | ||||
|         AutoRoute(page: EditPublisherRoute.page, path: ':name/edit'), | ||||
|             ], | ||||
|           ), | ||||
|     AutoRoute(page: LoginRoute.page, path: 'auth/login'), | ||||
|     AutoRoute(page: CreateAccountRoute.page, path: 'auth/create-account'), | ||||
|     AutoRoute(page: SettingsRoute.page, path: 'settings'), | ||||
|     AutoRoute(page: NewRealmRoute.page, path: 'realms/new'), | ||||
|     AutoRoute(page: RealmDetailRoute.page, path: 'realms/:slug'), | ||||
|     AutoRoute(page: EditRealmRoute.page, path: 'realms/:slug/edit'), | ||||
|   ]; | ||||
|  | ||||
|           // Auth routes | ||||
|           GoRoute( | ||||
|             path: '/auth/login', | ||||
|             builder: (context, state) => const LoginScreen(), | ||||
|           ), | ||||
|           GoRoute( | ||||
|             path: '/auth/create-account', | ||||
|             builder: (context, state) => const CreateAccountScreen(), | ||||
|           ), | ||||
|  | ||||
|           // Other routes | ||||
|           GoRoute( | ||||
|             path: '/settings', | ||||
|             builder: (context, state) => const SettingsScreen(), | ||||
|           ), | ||||
|  | ||||
|           // Main tabs with TabsScreen shell | ||||
|           ShellRoute( | ||||
|             navigatorKey: _tabsShellKey, | ||||
|             builder: (context, state, child) { | ||||
|               return TabsScreen(child: child); | ||||
|             }, | ||||
|             routes: [ | ||||
|               // Explore tab | ||||
|               GoRoute( | ||||
|                 path: '/', | ||||
|                 builder: (context, state) => const ExploreScreen(), | ||||
|                 routes: [ | ||||
|                   GoRoute( | ||||
|                     path: 'posts/:id', | ||||
|                     builder: (context, state) { | ||||
|                       final id = state.pathParameters['id']!; | ||||
|                       return PostDetailScreen(id: id); | ||||
|                     }, | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: 'publishers/:name', | ||||
|                     builder: (context, state) { | ||||
|                       final name = state.pathParameters['name']!; | ||||
|                       return PublisherProfileScreen(name: name); | ||||
|                     }, | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: 'discovery/realms', | ||||
|                     builder: (context, state) => const DiscoveryRealmsScreen(), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|  | ||||
|               // Chat tab | ||||
|               GoRoute( | ||||
|                 path: '/chat', | ||||
|                 builder: (context, state) => const ChatListScreen(), | ||||
|                 routes: [ | ||||
|                   GoRoute( | ||||
|                     path: 'new', | ||||
|                     builder: (context, state) => const NewChatScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: ':id', | ||||
|                     builder: (context, state) { | ||||
|                       final id = state.pathParameters['id']!; | ||||
|                       return ChatRoomScreen(id: id); | ||||
|                     }, | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: ':id/edit', | ||||
|                     builder: (context, state) { | ||||
|                       final id = state.pathParameters['id']!; | ||||
|                       return EditChatScreen(id: id); | ||||
|                     }, | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: ':id/detail', | ||||
|                     builder: (context, state) { | ||||
|                       final id = state.pathParameters['id']!; | ||||
|                       return ChatDetailScreen(id: id); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|  | ||||
|               // Realms tab | ||||
|               GoRoute( | ||||
|                 path: '/realms', | ||||
|                 builder: (context, state) => const RealmListScreen(), | ||||
|                 routes: [ | ||||
|                   GoRoute( | ||||
|                     path: 'new', | ||||
|                     builder: (context, state) => const NewRealmScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: ':slug', | ||||
|                     builder: (context, state) { | ||||
|                       final slug = state.pathParameters['slug']!; | ||||
|                       return RealmDetailScreen(slug: slug); | ||||
|                     }, | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: ':slug/edit', | ||||
|                     builder: (context, state) { | ||||
|                       final slug = state.pathParameters['slug']!; | ||||
|                       return EditRealmScreen(slug: slug); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|  | ||||
|               // Account tab | ||||
|               GoRoute( | ||||
|                 path: '/account', | ||||
|                 builder: (context, state) => const AccountScreen(), | ||||
|                 routes: [ | ||||
|                   GoRoute( | ||||
|                     path: 'notifications', | ||||
|                     builder: (context, state) => const NotificationScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: 'wallet', | ||||
|                     builder: (context, state) => const WalletScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: 'relationships', | ||||
|                     builder: (context, state) => const RelationshipScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: ':name', | ||||
|                     builder: (context, state) { | ||||
|                       final name = state.pathParameters['name']!; | ||||
|                       return AccountProfileScreen(name: name); | ||||
|                     }, | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: 'me/update', | ||||
|                     builder: (context, state) => const UpdateProfileScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: 'me/leveling', | ||||
|                     builder: (context, state) => const LevelingScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: 'settings', | ||||
|                     builder: (context, state) => const AccountSettingsScreen(), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ], | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| // Navigation helper functions | ||||
| class AppRouter { | ||||
|   static GoRouter of(BuildContext context) { | ||||
|     return GoRouter.of(context); | ||||
|   } | ||||
|  | ||||
|   static void go(BuildContext context, String path) { | ||||
|     context.go(path); | ||||
|   } | ||||
|  | ||||
|   static void push(BuildContext context, String path) { | ||||
|     context.push(path); | ||||
|   } | ||||
|  | ||||
|   static void pop(BuildContext context) { | ||||
|     context.pop(); | ||||
|   } | ||||
|  | ||||
|   static bool canPop(BuildContext context) { | ||||
|     return context.canPop(); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										1773
									
								
								lib/route.gr.dart
									
									
									
									
									
								
							
							
						
						
									
										1773
									
								
								lib/route.gr.dart
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,14 +1,13 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/pods/message.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/screens/notification.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/account/account_name.dart'; | ||||
| @@ -19,9 +18,9 @@ import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class AccountShellScreen extends HookConsumerWidget { | ||||
|   const AccountShellScreen({super.key}); | ||||
|   final Widget child; | ||||
|   const AccountShellScreen({super.key, required this.child}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -34,17 +33,16 @@ class AccountShellScreen extends HookConsumerWidget { | ||||
|           children: [ | ||||
|             Flexible(flex: 2, child: AccountScreen(isAside: true)), | ||||
|             VerticalDivider(width: 1), | ||||
|             Flexible(flex: 3, child: AutoRouter()), | ||||
|             Flexible(flex: 3, child: child), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return AppBackground(isRoot: true, child: AutoRouter()); | ||||
|     return AppBackground(isRoot: true, child: child); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class AccountScreen extends HookConsumerWidget { | ||||
|   final bool isAside; | ||||
|   const AccountScreen({super.key, this.isAside = false}); | ||||
| @@ -100,9 +98,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                           radius: 24, | ||||
|                         ), | ||||
|                         onTap: () { | ||||
|                           context.router.push( | ||||
|                             AccountProfileRoute(name: user.value!.name), | ||||
|                           ); | ||||
|                           context.push('/account/${user.value!.name}'); | ||||
|                         }, | ||||
|                       ), | ||||
|                       Expanded( | ||||
| @@ -147,7 +143,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                 progress: user.value!.profile.levelingProgress, | ||||
|               ), | ||||
|               onTap: () { | ||||
|                 context.router.push(LevelingRoute()); | ||||
|                 context.push('/account/leveling'); | ||||
|               }, | ||||
|             ).padding(horizontal: 12), | ||||
|             Row( | ||||
| @@ -165,7 +161,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                         ], | ||||
|                       ).padding(horizontal: 16, vertical: 12), | ||||
|                       onTap: () { | ||||
|                         context.router.push(CreatorHubShellRoute()); | ||||
|                         context.push('/creators'); | ||||
|                       }, | ||||
|                     ), | ||||
|                   ).height(140), | ||||
| @@ -204,7 +200,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                 ], | ||||
|               ), | ||||
|               onTap: () { | ||||
|                 context.router.push(NotificationRoute()); | ||||
|                 context.push('/account/notifications'); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
| @@ -214,7 +210,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('wallet').tr(), | ||||
|               onTap: () { | ||||
|                 context.router.push(WalletRoute()); | ||||
|                 context.push('/wallet'); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
| @@ -224,7 +220,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('relationships').tr(), | ||||
|               onTap: () { | ||||
|                 context.router.push(RelationshipRoute()); | ||||
|                 context.push('/account/relationship'); | ||||
|               }, | ||||
|             ), | ||||
|             const Divider(height: 1).padding(vertical: 8), | ||||
| @@ -235,7 +231,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('appSettings').tr(), | ||||
|               onTap: () { | ||||
|                 context.router.push(SettingsRoute()); | ||||
|                 context.push('/settings'); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
| @@ -245,7 +241,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('updateYourProfile').tr(), | ||||
|               onTap: () { | ||||
|                 context.router.push(UpdateProfileRoute()); | ||||
|                 context.push('/account/me/update'); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
| @@ -255,7 +251,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('accountSettings').tr(), | ||||
|               onTap: () { | ||||
|                 context.router.push(AccountSettingsRoute()); | ||||
|                 context.push('/account/me/settings'); | ||||
|               }, | ||||
|             ), | ||||
|             if (kDebugMode) const Divider(height: 1).padding(vertical: 8), | ||||
| @@ -320,7 +316,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget { | ||||
|                   child: Card( | ||||
|                     child: InkWell( | ||||
|                       onTap: () { | ||||
|                         context.router.push(CreateAccountRoute()); | ||||
|                         context.push('/auth/create'); | ||||
|                       }, | ||||
|                       child: Padding( | ||||
|                         padding: const EdgeInsets.all(16), | ||||
| @@ -342,7 +338,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget { | ||||
|                   child: Card( | ||||
|                     child: InkWell( | ||||
|                       onTap: () { | ||||
|                         context.router.push(LoginRoute()); | ||||
|                         context.push('/auth/login'); | ||||
|                       }, | ||||
|                       child: Padding( | ||||
|                         padding: const EdgeInsets.all(16), | ||||
| @@ -361,7 +357,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget { | ||||
|                 const Gap(8), | ||||
|                 TextButton( | ||||
|                   onPressed: () { | ||||
|                     context.router.push(SettingsRoute()); | ||||
|                     context.push('/settings'); | ||||
|                   }, | ||||
|                   child: Text('appSettings').tr(), | ||||
|                 ).center(), | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| @@ -12,10 +11,9 @@ import 'package:island/widgets/account/event_calendar.dart'; | ||||
| import 'package:island/widgets/account/fortune_graph.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class EventCalanderScreen extends HookConsumerWidget { | ||||
|   final String name; | ||||
|   const EventCalanderScreen({super.key, @PathParam("name") required this.name}); | ||||
|   const EventCalanderScreen({super.key, required this.name}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| @@ -31,7 +30,6 @@ Future<SnWalletSubscription?> accountStellarSubscription(Ref ref) async { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class LevelingScreen extends HookConsumerWidget { | ||||
|   const LevelingScreen({super.key}); | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:auto_route/annotations.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| @@ -51,7 +50,6 @@ Future<List<SnAccountConnection>> accountConnections(Ref ref) async { | ||||
|       .toList(); | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class AccountSettingsScreen extends HookConsumerWidget { | ||||
|   const AccountSettingsScreen({super.key}); | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:croppy/croppy.dart' hide cropImage; | ||||
| import 'package:dropdown_button2/dropdown_button2.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| @@ -20,7 +19,6 @@ import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| const kServerSupportedLanguages = {'en-US': 'en-us', 'zh-CN': 'zh-hans'}; | ||||
|  | ||||
| @RoutePage() | ||||
| class UpdateProfileScreen extends HookConsumerWidget { | ||||
|   const UpdateProfileScreen({super.key}); | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/chat.dart'; | ||||
| @@ -96,13 +96,9 @@ Future<SnRelationship?> accountRelationship(Ref ref, String uname) async { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class AccountProfileScreen extends HookConsumerWidget { | ||||
|   final String name; | ||||
|   const AccountProfileScreen({ | ||||
|     super.key, | ||||
|     @PathParam("name") required this.name, | ||||
|   }); | ||||
|   const AccountProfileScreen({super.key, required this.name}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -142,7 +138,7 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|     Future<void> directMessageAction() async { | ||||
|       if (!account.hasValue) return; | ||||
|       if (accountChat.value != null) { | ||||
|         context.router.pushPath('/chat/${accountChat.value!.id}'); | ||||
|         context.push('/chat/${accountChat.value!.id}'); | ||||
|         return; | ||||
|       } | ||||
|       showLoadingModal(context); | ||||
| @@ -153,7 +149,7 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|           data: {'related_user_id': account.value!.id}, | ||||
|         ); | ||||
|         final chat = SnChatRoom.fromJson(resp.data); | ||||
|         if (context.mounted) context.router.pushPath('/chat/${chat.id}'); | ||||
|         if (context.mounted) context.push('/chat/${chat.id}'); | ||||
|         ref.invalidate(accountDirectChatProvider(name)); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| @@ -204,7 +203,6 @@ class RelationshipListTile extends StatelessWidget { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class RelationshipScreen extends HookConsumerWidget { | ||||
|   const RelationshipScreen({super.key}); | ||||
|  | ||||
| @@ -217,6 +215,7 @@ class RelationshipScreen extends HookConsumerWidget { | ||||
|     Future<void> addFriend() async { | ||||
|       final result = await showModalBottomSheet( | ||||
|         context: context, | ||||
|         useRootNavigator: true, | ||||
|         builder: (context) => AccountPickerSheet(), | ||||
|       ); | ||||
|       if (result == null) return; | ||||
|   | ||||
| @@ -1,12 +1,11 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:email_validator/email_validator.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/screens/account/me/update.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| @@ -16,7 +15,6 @@ import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| import 'captcha.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class CreateAccountScreen extends HookConsumerWidget { | ||||
|   const CreateAccountScreen({super.key}); | ||||
|  | ||||
| @@ -307,7 +305,7 @@ class _PostCreateModal extends HookConsumerWidget { | ||||
|             TextButton( | ||||
|               onPressed: () { | ||||
|                 Navigator.pop(context); | ||||
|                 context.router.replace(LoginRoute()); | ||||
|                 context.pushReplacement('/auth/login'); | ||||
|               }, | ||||
|               child: Text('login'.tr()), | ||||
|             ), | ||||
|   | ||||
| @@ -3,7 +3,6 @@ import 'dart:io'; | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:animations/animations.dart'; | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| @@ -43,7 +42,6 @@ final Map<int, (String, String, IconData)> kFactorTypes = { | ||||
|   4: ('authFactorPin', 'authFactorPinDescription', Symbols.nest_secure_alarm), | ||||
| }; | ||||
|  | ||||
| @RoutePage() | ||||
| class LoginScreen extends HookConsumerWidget { | ||||
|   const LoginScreen({super.key}); | ||||
|  | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import 'package:gap/gap.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/services/udid.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @@ -204,12 +205,7 @@ class _OidcScreenState extends ConsumerState<OidcScreen> { | ||||
|                       onPressed: () { | ||||
|                         if (currentUrl != null) { | ||||
|                           Clipboard.setData(ClipboardData(text: currentUrl!)); | ||||
|                           ScaffoldMessenger.of(context).showSnackBar( | ||||
|                             SnackBar( | ||||
|                               content: Text('copyToClipboard').tr(), | ||||
|                               duration: const Duration(seconds: 1), | ||||
|                             ), | ||||
|                           ); | ||||
|                           showSnackBar('copyToClipboard'); | ||||
|                         } | ||||
|                       }, | ||||
|                     ), | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import 'package:auto_route/annotations.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| @@ -14,10 +13,9 @@ import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class CallScreen extends HookConsumerWidget { | ||||
|   final String roomId; | ||||
|   const CallScreen({super.key, @PathParam('id') required this.roomId}); | ||||
|   const CallScreen({super.key, required this.roomId}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:croppy/croppy.dart' hide cropImage; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| @@ -15,7 +15,6 @@ import 'package:island/pods/call.dart'; | ||||
| import 'package:island/pods/chat_summary.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/screens/realm/realms.dart'; | ||||
| import 'package:island/services/file.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| @@ -173,9 +172,9 @@ Future<List<SnChatRoom>> chatroomsJoined(Ref ref) async { | ||||
|       .toList(); | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class ChatShellScreen extends HookConsumerWidget { | ||||
|   const ChatShellScreen({super.key}); | ||||
|   final Widget child; | ||||
|   const ChatShellScreen({super.key, required this.child}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -187,18 +186,17 @@ class ChatShellScreen extends HookConsumerWidget { | ||||
|         child: Row( | ||||
|           children: [ | ||||
|             Flexible(flex: 2, child: ChatListScreen(isAside: true)), | ||||
|             VerticalDivider(width: 1), | ||||
|             Flexible(flex: 4, child: AutoRouter()), | ||||
|             const VerticalDivider(width: 1), | ||||
|             Flexible(flex: 4, child: child), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return AppBackground(isRoot: true, child: AutoRouter()); | ||||
|     return AppBackground(isRoot: true, child: child); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class ChatListScreen extends HookConsumerWidget { | ||||
|   final bool isAside; | ||||
|   const ChatListScreen({super.key, this.isAside = false}); | ||||
| @@ -229,7 +227,8 @@ class ChatListScreen extends HookConsumerWidget { | ||||
|     Future<void> createDirectMessage() async { | ||||
|       final result = await showModalBottomSheet( | ||||
|         context: context, | ||||
|         builder: (context) => AccountPickerSheet(), | ||||
|         useRootNavigator: true, | ||||
|         builder: (context) => const AccountPickerSheet(), | ||||
|       ); | ||||
|       if (result == null) return; | ||||
|       final client = ref.read(apiClientProvider); | ||||
| @@ -244,7 +243,7 @@ class ChatListScreen extends HookConsumerWidget { | ||||
|     return AppScaffold( | ||||
|       extendBody: false, // Prevent conflicts with tabs navigation | ||||
|       appBar: AppBar( | ||||
|         title: Text('chat').tr(), | ||||
|         title: const Text('chat').tr(), | ||||
|         bottom: TabBar( | ||||
|           controller: tabController, | ||||
|           tabs: [ | ||||
| @@ -298,7 +297,7 @@ class ChatListScreen extends HookConsumerWidget { | ||||
|               showModalBottomSheet( | ||||
|                 isScrollControlled: true, | ||||
|                 context: context, | ||||
|                 builder: (context) => _ChatInvitesSheet(), | ||||
|                 builder: (context) => const _ChatInvitesSheet(), | ||||
|               ); | ||||
|             }, | ||||
|           ), | ||||
| @@ -309,17 +308,18 @@ class ChatListScreen extends HookConsumerWidget { | ||||
|         onPressed: () { | ||||
|           showModalBottomSheet( | ||||
|             context: context, | ||||
|             useRootNavigator: true, | ||||
|             builder: | ||||
|                 (context) => Column( | ||||
|                   mainAxisSize: MainAxisSize.min, | ||||
|                   crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                   children: [ | ||||
|                     ListTile( | ||||
|                       title: Text('createChatRoom').tr(), | ||||
|                       title: const Text('createChatRoom').tr(), | ||||
|                       leading: const Icon(Symbols.add), | ||||
|                       onTap: () { | ||||
|                         Navigator.pop(context); | ||||
|                         context.pushRoute(NewChatRoute()).then((value) { | ||||
|                         context.push('/chat/new').then((value) { | ||||
|                           if (value != null) { | ||||
|                             ref.invalidate(chatroomsJoinedProvider); | ||||
|                           } | ||||
| @@ -327,7 +327,7 @@ class ChatListScreen extends HookConsumerWidget { | ||||
|                       }, | ||||
|                     ), | ||||
|                     ListTile( | ||||
|                       title: Text('createDirectMessage').tr(), | ||||
|                       title: const Text('createDirectMessage').tr(), | ||||
|                       leading: const Icon(Symbols.person), | ||||
|                       onTap: () { | ||||
|                         Navigator.pop(context); | ||||
| @@ -400,16 +400,7 @@ class ChatListScreen extends HookConsumerWidget { | ||||
|                               room: item, | ||||
|                               isDirect: item.type == 1, | ||||
|                               onTap: () { | ||||
|                                 if (context.router.topRoute.name == | ||||
|                                     ChatRoomRoute.name) { | ||||
|                                   context.router.replace( | ||||
|                                     ChatRoomRoute(id: item.id), | ||||
|                                   ); | ||||
|                                 } else { | ||||
|                                   context.router.push( | ||||
|                                     ChatRoomRoute(id: item.id), | ||||
|                                   ); | ||||
|                                 } | ||||
|                                 context.push('/chat/${item.id}'); | ||||
|                               }, | ||||
|                             ); | ||||
|                           }, | ||||
| @@ -443,33 +434,45 @@ class ChatListScreen extends HookConsumerWidget { | ||||
| @riverpod | ||||
| Future<SnChatRoom?> chatroom(Ref ref, String? identifier) async { | ||||
|   if (identifier == null) return null; | ||||
|   try { | ||||
|     final client = ref.watch(apiClientProvider); | ||||
|     final resp = await client.get('/chat/$identifier'); | ||||
|     return SnChatRoom.fromJson(resp.data); | ||||
|   } catch (err) { | ||||
|     if (err is DioException && err.response?.statusCode == 404) { | ||||
|       return null; // Chat room not found | ||||
|     } | ||||
|     rethrow; // Rethrow other errors | ||||
|   } | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
| Future<SnChatMember?> chatroomIdentity(Ref ref, String? identifier) async { | ||||
|   if (identifier == null) return null; | ||||
|   try { | ||||
|     final client = ref.watch(apiClientProvider); | ||||
|     final resp = await client.get('/chat/$identifier/members/me'); | ||||
|     return SnChatMember.fromJson(resp.data); | ||||
|   } catch (err) { | ||||
|     if (err is DioException && err.response?.statusCode == 404) { | ||||
|       return null; // Chat member not found | ||||
|     } | ||||
|     rethrow; // Rethrow other errors | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class NewChatScreen extends StatelessWidget { | ||||
|   const NewChatScreen({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return EditChatScreen(); | ||||
|     return const EditChatScreen(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class EditChatScreen extends HookConsumerWidget { | ||||
|   final String? id; | ||||
|   const EditChatScreen({super.key, @PathParam("id") this.id}); | ||||
|   const EditChatScreen({super.key, this.id}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -481,6 +484,8 @@ class EditChatScreen extends HookConsumerWidget { | ||||
|     final descriptionController = useTextEditingController(); | ||||
|     final picture = useState<SnCloudFile?>(null); | ||||
|     final background = useState<SnCloudFile?>(null); | ||||
|     final isPublic = useState(true); | ||||
|     final isCommunity = useState(false); | ||||
|  | ||||
|     final chat = ref.watch(chatroomProvider(id)); | ||||
|  | ||||
| @@ -493,12 +498,14 @@ class EditChatScreen extends HookConsumerWidget { | ||||
|         descriptionController.text = chat.value!.description ?? ''; | ||||
|         picture.value = chat.value!.picture; | ||||
|         background.value = chat.value!.background; | ||||
|         isPublic.value = chat.value!.isPublic; | ||||
|         isCommunity.value = chat.value!.isCommunity; | ||||
|         currentRealm.value = joinedRealms.value?.firstWhereOrNull( | ||||
|           (realm) => realm.id == chat.value!.realmId, | ||||
|         ); | ||||
|       } | ||||
|       return; | ||||
|     }, [chat]); | ||||
|     }, [chat, joinedRealms]); | ||||
|  | ||||
|     void setPicture(String position) async { | ||||
|       showLoadingModal(context); | ||||
| @@ -516,9 +523,9 @@ class EditChatScreen extends HookConsumerWidget { | ||||
|         image: result, | ||||
|         allowedAspectRatios: [ | ||||
|           if (position == 'background') | ||||
|             CropAspectRatio(height: 7, width: 16) | ||||
|             const CropAspectRatio(height: 7, width: 16) | ||||
|           else | ||||
|             CropAspectRatio(height: 1, width: 1), | ||||
|             const CropAspectRatio(height: 1, width: 1), | ||||
|         ], | ||||
|       ); | ||||
|       if (result == null) { | ||||
| @@ -575,11 +582,13 @@ class EditChatScreen extends HookConsumerWidget { | ||||
|             'background_id': background.value?.id, | ||||
|             'picture_id': picture.value?.id, | ||||
|             'realm_id': currentRealm.value?.id, | ||||
|             'is_public': isPublic.value, | ||||
|             'is_community': isCommunity.value, | ||||
|           }, | ||||
|           options: Options(method: id == null ? 'POST' : 'PATCH'), | ||||
|         ); | ||||
|         if (context.mounted) { | ||||
|           context.maybePop(SnChatRoom.fromJson(resp.data)); | ||||
|           context.pop(SnChatRoom.fromJson(resp.data)); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
| @@ -667,6 +676,19 @@ class EditChatScreen extends HookConsumerWidget { | ||||
|                       (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ), | ||||
|                 const SizedBox(height: 16), | ||||
|                 CheckboxListTile( | ||||
|                   title: const Text('isPublic').tr(), | ||||
|                   subtitle: const Text('isPublicHint').tr(), | ||||
|                   value: isPublic.value, | ||||
|                   onChanged: (value) => isPublic.value = value ?? false, | ||||
|                 ), | ||||
|                 CheckboxListTile( | ||||
|                   title: const Text('isCommunity').tr(), | ||||
|                   subtitle: const Text('isCommunityHint').tr(), | ||||
|                   value: isCommunity.value, | ||||
|                   onChanged: (value) => isCommunity.value = value ?? false, | ||||
|                 ), | ||||
|                 const SizedBox(height: 16), | ||||
|                 Align( | ||||
|                   alignment: Alignment.centerRight, | ||||
|                   child: TextButton.icon( | ||||
| @@ -767,7 +789,7 @@ class _ChatInvitesSheet extends HookConsumerWidget { | ||||
|                               ), | ||||
|                               if (invite.chatRoom!.type == 1) | ||||
|                                 Badge( | ||||
|                                   label: Text('directMessage').tr(), | ||||
|                                   label: const Text('directMessage').tr(), | ||||
|                                   backgroundColor: | ||||
|                                       Theme.of(context).colorScheme.primary, | ||||
|                                   textColor: | ||||
|   | ||||
| @@ -25,7 +25,7 @@ final chatroomsJoinedProvider = | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| typedef ChatroomsJoinedRef = AutoDisposeFutureProviderRef<List<SnChatRoom>>; | ||||
| String _$chatroomHash() => r'dce3c0fc407f178bb7c306a08b9fa545795a9205'; | ||||
| String _$chatroomHash() => r'8dac7aaac50932e6dd213039102d43c1cf5f1d4e'; | ||||
|  | ||||
| /// Copied from Dart SDK | ||||
| class _SystemHash { | ||||
| @@ -164,7 +164,7 @@ class _ChatroomProviderElement | ||||
|   String? get identifier => (origin as ChatroomProvider).identifier; | ||||
| } | ||||
|  | ||||
| String _$chatroomIdentityHash() => r'4c349ea4265df7b0498cf26c82dbaabe3d868727'; | ||||
| String _$chatroomIdentityHash() => r'ad6ad09b6fc4cf7c4abe146ea97f8e364a3d4fd0'; | ||||
|  | ||||
| /// See also [chatroomIdentity]. | ||||
| @ProviderFor(chatroomIdentity) | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
| import 'dart:io'; | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| @@ -18,7 +18,6 @@ import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/database.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| @@ -288,15 +287,76 @@ class MessagesNotifier extends _$MessagesNotifier { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class ChatRoomScreen extends HookConsumerWidget { | ||||
|   final String id; | ||||
|   const ChatRoomScreen({super.key, @PathParam("id") required this.id}); | ||||
|   const ChatRoomScreen({super.key, required this.id}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final chatRoom = ref.watch(chatroomProvider(id)); | ||||
|     final chatIdentity = ref.watch(chatroomIdentityProvider(id)); | ||||
|  | ||||
|     if (chatIdentity.isLoading || chatRoom.isLoading) { | ||||
|       return AppScaffold( | ||||
|         appBar: AppBar(leading: const PageBackButton()), | ||||
|         body: CircularProgressIndicator().center(), | ||||
|       ); | ||||
|     } else if (chatIdentity.value == null) { | ||||
|       // Identity was not found, user was not joined | ||||
|       return AppScaffold( | ||||
|         appBar: AppBar(leading: const PageBackButton()), | ||||
|         body: Center( | ||||
|           child: | ||||
|               ConstrainedBox( | ||||
|                 constraints: const BoxConstraints(maxWidth: 280), | ||||
|                 child: Column( | ||||
|                   crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                   mainAxisAlignment: MainAxisAlignment.center, | ||||
|                   children: [ | ||||
|                     Icon( | ||||
|                       chatRoom.value?.isCommunity == true | ||||
|                           ? Symbols.person_add | ||||
|                           : Symbols.person_remove, | ||||
|                       size: 36, | ||||
|                       fill: 1, | ||||
|                     ).padding(bottom: 4), | ||||
|                     Text('chatNotJoined').tr(), | ||||
|                     if (chatRoom.value?.isCommunity != true) | ||||
|                       Text( | ||||
|                         'chatUnableJoin', | ||||
|                         textAlign: TextAlign.center, | ||||
|                       ).tr().bold() | ||||
|                     else | ||||
|                       FilledButton.tonalIcon( | ||||
|                         onPressed: () async { | ||||
|                           try { | ||||
|                             showLoadingModal(context); | ||||
|                             final apiClient = ref.read(apiClientProvider); | ||||
|                             if (chatRoom.value == null) { | ||||
|                               hideLoadingModal(context); | ||||
|                               return; | ||||
|                             } | ||||
|  | ||||
|                             await apiClient.post( | ||||
|                               '/chat/${chatRoom.value!.id}/members/me', | ||||
|                             ); | ||||
|                             ref.invalidate(chatroomIdentityProvider(id)); | ||||
|                           } catch (err) { | ||||
|                             showErrorAlert(err); | ||||
|                           } finally { | ||||
|                             if (context.mounted) hideLoadingModal(context); | ||||
|                           } | ||||
|                         }, | ||||
|                         label: Text('chatJoin').tr(), | ||||
|                         icon: const Icon(Icons.add), | ||||
|                       ).padding(top: 8), | ||||
|                   ], | ||||
|                 ), | ||||
|               ).center(), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     final messages = ref.watch(messagesNotifierProvider(id)); | ||||
|     final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier); | ||||
|     final ws = ref.watch(websocketProvider); | ||||
| @@ -431,6 +491,28 @@ class ChatRoomScreen extends HookConsumerWidget { | ||||
|       return () => subscription.cancel(); | ||||
|     }, [ws, chatRoom]); | ||||
|  | ||||
|     useEffect(() { | ||||
|       final wsState = ref.read(websocketStateProvider.notifier); | ||||
|       wsState.sendMessage( | ||||
|         jsonEncode( | ||||
|           WebSocketPacket( | ||||
|             type: 'messages.subscribe', | ||||
|             data: {'chat_room_id': id}, | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|       return () { | ||||
|         wsState.sendMessage( | ||||
|           jsonEncode( | ||||
|             WebSocketPacket( | ||||
|               type: 'messages.unsubscribe', | ||||
|               data: {'chat_room_id': id}, | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       }; | ||||
|     }, [id]); | ||||
|  | ||||
|     Future<void> pickPhotoMedia() async { | ||||
|       final result = await ref | ||||
|           .watch(imagePickerProvider) | ||||
| @@ -605,7 +687,7 @@ class ChatRoomScreen extends HookConsumerWidget { | ||||
|           IconButton( | ||||
|             icon: const Icon(Icons.more_vert), | ||||
|             onPressed: () { | ||||
|               context.router.push(ChatDetailRoute(id: id)); | ||||
|               context.push('/chat/$id/detail'); | ||||
|             }, | ||||
|           ), | ||||
|           const Gap(8), | ||||
|   | ||||
| @@ -1,14 +1,13 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/chat.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/screens/chat/chat.dart'; | ||||
| import 'package:island/widgets/account/account_picker.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| @@ -23,10 +22,9 @@ import 'package:styled_widget/styled_widget.dart'; | ||||
| part 'room_detail.freezed.dart'; | ||||
| part 'room_detail.g.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class ChatDetailScreen extends HookConsumerWidget { | ||||
|   final String id; | ||||
|   const ChatDetailScreen({super.key, @PathParam("id") required this.id}); | ||||
|   const ChatDetailScreen({super.key, required this.id}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -391,7 +389,7 @@ class _ChatRoomActionMenu extends HookConsumerWidget { | ||||
|             if ((chatIdentity.value?.role ?? 0) >= 50) | ||||
|               PopupMenuItem( | ||||
|                 onTap: () { | ||||
|                   context.router.replace(EditChatRoute(id: id)); | ||||
|                   context.pushReplacement('/chat/$id/edit'); | ||||
|                 }, | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
| @@ -426,9 +424,7 @@ class _ChatRoomActionMenu extends HookConsumerWidget { | ||||
|                       client.delete('/chat/$id'); | ||||
|                       ref.invalidate(chatroomsJoinedProvider); | ||||
|                       if (context.mounted) { | ||||
|                         context.router.popUntil( | ||||
|                           (route) => route is ChatRoomRoute, | ||||
|                         ); | ||||
|                         context.pop(); | ||||
|                       } | ||||
|                     } | ||||
|                   }); | ||||
| @@ -461,9 +457,7 @@ class _ChatRoomActionMenu extends HookConsumerWidget { | ||||
|                       client.delete('/chat/$id/members/me'); | ||||
|                       ref.invalidate(chatroomsJoinedProvider); | ||||
|                       if (context.mounted) { | ||||
|                         context.router.popUntil( | ||||
|                           (route) => route is ChatRoomRoute, | ||||
|                         ); | ||||
|                         context.pop(); | ||||
|                       } | ||||
|                     } | ||||
|                   }); | ||||
| @@ -590,8 +584,8 @@ class _ChatMemberListSheet extends HookConsumerWidget { | ||||
|  | ||||
|     Future<void> invitePerson() async { | ||||
|       final result = await showModalBottomSheet( | ||||
|         isScrollControlled: true, | ||||
|         context: context, | ||||
|         useRootNavigator: true, | ||||
|         builder: (context) => const AccountPickerSheet(), | ||||
|       ); | ||||
|       if (result == null) return; | ||||
|   | ||||
| @@ -1,13 +1,12 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:dropdown_button2/dropdown_button2.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/screens/creators/publishers.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| @@ -27,9 +26,9 @@ Future<SnPublisherStats?> publisherStats(Ref ref, String? uname) async { | ||||
|   return SnPublisherStats.fromJson(resp.data); | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class CreatorHubShellScreen extends StatelessWidget { | ||||
|   const CreatorHubShellScreen({super.key}); | ||||
|   final Widget child; | ||||
|   const CreatorHubShellScreen({super.key, required this.child}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
| @@ -39,15 +38,14 @@ class CreatorHubShellScreen extends StatelessWidget { | ||||
|         children: [ | ||||
|           SizedBox(width: 360, child: const CreatorHubScreen(isAside: true)), | ||||
|           const VerticalDivider(width: 1), | ||||
|           Expanded(child: AutoRouter()), | ||||
|           Expanded(child: child), | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|     return AutoRouter(); | ||||
|     return child; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class CreatorHubScreen extends HookConsumerWidget { | ||||
|   final bool isAside; | ||||
|   const CreatorHubScreen({super.key, this.isAside = false}); | ||||
| @@ -65,8 +63,8 @@ class CreatorHubScreen extends HookConsumerWidget { | ||||
|     ); | ||||
|  | ||||
|     void updatePublisher() { | ||||
|       context.router | ||||
|           .push(EditPublisherRoute(name: currentPublisher.value!.name)) | ||||
|       context | ||||
|           .push('/creators/${currentPublisher.value!.name}/edit') | ||||
|           .then((value) async { | ||||
|             if (value == null) return; | ||||
|             final data = await ref.refresh(publishersManagedProvider.future); | ||||
| @@ -223,7 +221,7 @@ class CreatorHubScreen extends HookConsumerWidget { | ||||
|                             subtitle: Text('createPublisherHint').tr(), | ||||
|                             trailing: const Icon(Symbols.chevron_right), | ||||
|                             onTap: () { | ||||
|                               context.router.push(NewPublisherRoute()).then(( | ||||
|                               context.push('/creators/publishers/new').then(( | ||||
|                                 value, | ||||
|                               ) { | ||||
|                                 if (value != null) { | ||||
| @@ -249,10 +247,8 @@ class CreatorHubScreen extends HookConsumerWidget { | ||||
|                               horizontal: 24, | ||||
|                             ), | ||||
|                             onTap: () { | ||||
|                               context.router.push( | ||||
|                                 StickersRoute( | ||||
|                                   pubName: currentPublisher.value!.name, | ||||
|                                 ), | ||||
|                               context.push( | ||||
|                                 '/creators/${currentPublisher.value!.name}/stickers', | ||||
|                               ); | ||||
|                             }, | ||||
|                           ), | ||||
| @@ -265,10 +261,8 @@ class CreatorHubScreen extends HookConsumerWidget { | ||||
|                               horizontal: 24, | ||||
|                             ), | ||||
|                             onTap: () { | ||||
|                               context.router.push( | ||||
|                                 CreatorPostListRoute( | ||||
|                                   pubName: currentPublisher.value!.name, | ||||
|                                 ), | ||||
|                               context.push( | ||||
|                                 '/creators/${currentPublisher.value!.name}/posts', | ||||
|                               ); | ||||
|                             }, | ||||
|                           ), | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| @@ -8,13 +8,9 @@ import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:island/widgets/post/post_list.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class CreatorPostListScreen extends HookConsumerWidget { | ||||
|   final String pubName; | ||||
|   const CreatorPostListScreen({ | ||||
|     super.key, | ||||
|     @PathParam('name') required this.pubName, | ||||
|   }); | ||||
|   const CreatorPostListScreen({super.key, required this.pubName}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -34,7 +30,7 @@ class CreatorPostListScreen extends HookConsumerWidget { | ||||
|                     subtitle: Text('Create a regular post'), | ||||
|                     onTap: () async { | ||||
|                       Navigator.pop(context); | ||||
|                       final result = await context.router.pushPath( | ||||
|                       final result = await context.push( | ||||
|                         '/posts/compose?type=0', | ||||
|                       ); | ||||
|                       if (result == true) { | ||||
| @@ -48,7 +44,7 @@ class CreatorPostListScreen extends HookConsumerWidget { | ||||
|                     subtitle: Text('Create a detailed article'), | ||||
|                     onTap: () async { | ||||
|                       Navigator.pop(context); | ||||
|                       final result = await context.router.pushPath( | ||||
|                       final result = await context.push( | ||||
|                         '/posts/compose?type=1', | ||||
|                       ); | ||||
|                       if (result == true) { | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:croppy/croppy.dart' hide cropImage; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:image_picker/image_picker.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| @@ -44,7 +44,6 @@ Future<SnPublisher?> publisher(Ref ref, String? identifier) async { | ||||
|   return SnPublisher.fromJson(resp.data); | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class NewPublisherScreen extends StatelessWidget { | ||||
|   const NewPublisherScreen({super.key}); | ||||
|  | ||||
| @@ -54,10 +53,9 @@ class NewPublisherScreen extends StatelessWidget { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class EditPublisherScreen extends HookConsumerWidget { | ||||
|   final String? name; | ||||
|   const EditPublisherScreen({super.key, @PathParam('id') this.name}); | ||||
|   const EditPublisherScreen({super.key, this.name}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -177,7 +175,7 @@ class EditPublisherScreen extends HookConsumerWidget { | ||||
|           options: Options(method: name == null ? 'POST' : 'PATCH'), | ||||
|         ); | ||||
|         if (context.mounted) { | ||||
|           context.maybePop(SnPublisher.fromJson(resp.data)); | ||||
|           context.pop(SnPublisher.fromJson(resp.data)); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| @@ -10,7 +10,6 @@ import 'package:google_fonts/google_fonts.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/sticker.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/screens/creators/stickers/stickers.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| @@ -34,14 +33,13 @@ Future<List<SnSticker>> stickerPackContent(Ref ref, String packId) async { | ||||
|       .toList(); | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class StickerPackDetailScreen extends HookConsumerWidget { | ||||
|   final String id; | ||||
|   final String pubName; | ||||
|   const StickerPackDetailScreen({ | ||||
|     super.key, | ||||
|     @PathParam('name') required this.pubName, | ||||
|     @PathParam('packId') required this.id, | ||||
|     required this.pubName, | ||||
|     required this.id, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
| @@ -76,7 +74,7 @@ class StickerPackDetailScreen extends HookConsumerWidget { | ||||
|           IconButton( | ||||
|             icon: const Icon(Symbols.add_circle), | ||||
|             onPressed: () { | ||||
|               AutoRouter.of(context).push(NewStickersRoute(packId: id)).then(( | ||||
|               context.push('/creators/stickers/$id/new').then(( | ||||
|                 value, | ||||
|               ) { | ||||
|                 if (value != null) { | ||||
| @@ -175,12 +173,9 @@ class StickerPackDetailScreen extends HookConsumerWidget { | ||||
|                                         title: 'edit'.tr(), | ||||
|                                         image: MenuImage.icon(Symbols.edit), | ||||
|                                         callback: () { | ||||
|                                           context.router | ||||
|                                           context | ||||
|                                               .push( | ||||
|                                                 EditStickersRoute( | ||||
|                                                   packId: id, | ||||
|                                                   id: sticker.id, | ||||
|                                                 ), | ||||
|                                                 '/creators/stickers/$id/edit/${sticker.id}', | ||||
|                                               ) | ||||
|                                               .then((value) { | ||||
|                                                 if (value != null) { | ||||
| @@ -264,8 +259,8 @@ class _StickerPackActionMenu extends HookConsumerWidget { | ||||
|           (context) => [ | ||||
|             PopupMenuItem( | ||||
|               onTap: () { | ||||
|                 context.router.push( | ||||
|                   EditStickerPacksRoute(pubName: pubName, packId: packId), | ||||
|                 context.push( | ||||
|                   '/creators/$pubName/stickers/$packId/edit', | ||||
|                 ); | ||||
|               }, | ||||
|               child: Row( | ||||
| @@ -299,7 +294,7 @@ class _StickerPackActionMenu extends HookConsumerWidget { | ||||
|                     final client = ref.watch(apiClientProvider); | ||||
|                     client.delete('/stickers/$packId'); | ||||
|                     ref.invalidate(stickerPacksNotifierProvider); | ||||
|                     if (context.mounted) context.router.maybePop(true); | ||||
|                     if (context.mounted) context.pop(true); | ||||
|                   } | ||||
|                 }); | ||||
|               }, | ||||
| @@ -331,13 +326,9 @@ Future<SnSticker?> stickerPackSticker( | ||||
|   return SnSticker.fromJson(resp.data); | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class NewStickersScreen extends StatelessWidget { | ||||
|   final String packId; | ||||
|   const NewStickersScreen({ | ||||
|     super.key, | ||||
|     @PathParam('packId') required this.packId, | ||||
|   }); | ||||
|   const NewStickersScreen({super.key, required this.packId}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
| @@ -345,15 +336,10 @@ class NewStickersScreen extends StatelessWidget { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class EditStickersScreen extends HookConsumerWidget { | ||||
|   final String packId; | ||||
|   final String? id; | ||||
|   const EditStickersScreen({ | ||||
|     super.key, | ||||
|     @PathParam("packId") required this.packId, | ||||
|     @PathParam("id") required this.id, | ||||
|   }); | ||||
|   const EditStickersScreen({super.key, required this.packId, required this.id}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|   | ||||
| @@ -1,13 +1,12 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/sticker.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| @@ -17,10 +16,9 @@ import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||
|  | ||||
| part 'stickers.g.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class StickersScreen extends HookConsumerWidget { | ||||
|   final String pubName; | ||||
|   const StickersScreen({super.key, @PathParam("name") required this.pubName}); | ||||
|   const StickersScreen({super.key, required this.pubName}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -30,7 +28,7 @@ class StickersScreen extends HookConsumerWidget { | ||||
|         actions: [ | ||||
|           IconButton( | ||||
|             onPressed: () { | ||||
|               context.router.push(NewStickerPacksRoute(pubName: pubName)).then(( | ||||
|               context.push('/creators/stickers/new?pubName=pubName').then(( | ||||
|                 value, | ||||
|               ) { | ||||
|                 if (value != null) { | ||||
| @@ -73,8 +71,8 @@ class SliverStickerPacksList extends HookConsumerWidget { | ||||
|                 subtitle: Text(sticker.description), | ||||
|                 trailing: const Icon(Symbols.chevron_right), | ||||
|                 onTap: () { | ||||
|                   context.router.push( | ||||
|                     StickerPackDetailRoute(pubName: pubName, id: sticker.id), | ||||
|                   context.push( | ||||
|                     '/creators/$pubName/stickers/${sticker.id}', | ||||
|                   ); | ||||
|                 }, | ||||
|               ); | ||||
| @@ -137,13 +135,9 @@ Future<SnStickerPack?> stickerPack(Ref ref, String? packId) async { | ||||
|   return SnStickerPack.fromJson(resp.data); | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class NewStickerPacksScreen extends HookConsumerWidget { | ||||
|   final String pubName; | ||||
|   const NewStickerPacksScreen({ | ||||
|     super.key, | ||||
|     @PathParam("name") required this.pubName, | ||||
|   }); | ||||
|   const NewStickerPacksScreen({super.key, required this.pubName}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -151,15 +145,10 @@ class NewStickerPacksScreen extends HookConsumerWidget { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class EditStickerPacksScreen extends HookConsumerWidget { | ||||
|   final String pubName; | ||||
|   final String? packId; | ||||
|   const EditStickerPacksScreen({ | ||||
|     super.key, | ||||
|     @PathParam("name") required this.pubName, | ||||
|     @PathParam("packId") this.packId, | ||||
|   }); | ||||
|   const EditStickerPacksScreen({super.key, required this.pubName, this.packId}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -200,7 +189,7 @@ class EditStickerPacksScreen extends HookConsumerWidget { | ||||
|           ), | ||||
|         ); | ||||
|         if (!context.mounted) return; | ||||
|         context.router.maybePop(SnStickerPack.fromJson(resp.data)); | ||||
|         context.pop(SnStickerPack.fromJson(resp.data)); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|   | ||||
							
								
								
									
										64
									
								
								lib/screens/discovery/realms.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								lib/screens/discovery/realms.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| 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/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/realm/realm_list.dart'; | ||||
| import 'dart:async'; | ||||
|  | ||||
| class DiscoveryRealmsScreen extends HookConsumerWidget { | ||||
|   const DiscoveryRealmsScreen({super.key}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     Timer? debounceTimer; | ||||
|     final searchController = useTextEditingController(); | ||||
|     final currentQuery = useState<String?>(null); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar(title: Text('discoverRealms'.tr())), | ||||
|       body: Stack( | ||||
|         children: [ | ||||
|           CustomScrollView( | ||||
|             slivers: [ | ||||
|               SliverGap(80), | ||||
|               SliverRealmList( | ||||
|                 query: currentQuery.value, | ||||
|                 key: ValueKey(currentQuery.value), | ||||
|               ), | ||||
|               SliverGap(MediaQuery.of(context).padding.bottom + 16), | ||||
|             ], | ||||
|           ), | ||||
|           Positioned( | ||||
|             top: 0, | ||||
|             left: 0, | ||||
|             right: 0, | ||||
|             child: Padding( | ||||
|               padding: const EdgeInsets.all(16), | ||||
|               child: SearchBar( | ||||
|                 elevation: WidgetStateProperty.all(4), | ||||
|                 controller: searchController, | ||||
|                 hintText: 'search'.tr(), | ||||
|                 leading: const Icon(Icons.search), | ||||
|                 padding: WidgetStateProperty.all( | ||||
|                   const EdgeInsets.symmetric(horizontal: 24), | ||||
|                 ), | ||||
|                 onChanged: (value) { | ||||
|                   if (debounceTimer?.isActive ?? false) { | ||||
|                     debounceTimer?.cancel(); | ||||
|                   } | ||||
|                   debounceTimer = Timer(const Duration(milliseconds: 300), () { | ||||
|                     if (currentQuery.value != value) { | ||||
|                       currentQuery.value = value; | ||||
|                     } | ||||
|                   }); | ||||
|                 }, | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,35 +1,36 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/activity.dart'; | ||||
| import 'package:island/models/realm.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/widgets/check_in.dart'; | ||||
| import 'package:island/widgets/post/post_item.dart'; | ||||
| import 'package:island/widgets/tour/tour.dart'; | ||||
| import 'package:island/screens/tabs.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:island/pods/network.dart'; | ||||
| import 'package:island/widgets/realm/realm_card.dart'; | ||||
| import 'package:island/widgets/publisher/publisher_card.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| part 'explore.g.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class ExploreShellScreen extends ConsumerWidget { | ||||
|   const ExploreShellScreen({super.key}); | ||||
| class ExploreShellScreen extends HookConsumerWidget { | ||||
|   final Widget child; | ||||
|   const ExploreShellScreen({super.key, required this.child}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final isWide = isWideScreen(context); | ||||
|     final isWide = MediaQuery.of(context).size.width > 640; | ||||
|  | ||||
|     if (isWide) { | ||||
|       return AppBackground( | ||||
| @@ -38,17 +39,16 @@ class ExploreShellScreen extends ConsumerWidget { | ||||
|           children: [ | ||||
|             Flexible(flex: 2, child: ExploreScreen(isAside: true)), | ||||
|             VerticalDivider(width: 1), | ||||
|             Flexible(flex: 3, child: AutoRouter()), | ||||
|             Flexible(flex: 3, child: child), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return AppBackground(isRoot: true, child: AutoRouter()); | ||||
|     return AppBackground(isRoot: true, child: child); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class ExploreScreen extends HookConsumerWidget { | ||||
|   final bool isAside; | ||||
|   const ExploreScreen({super.key, this.isAside = false}); | ||||
| @@ -76,7 +76,6 @@ class ExploreScreen extends HookConsumerWidget { | ||||
|             currentFilter.value = 'friends'; | ||||
|             break; | ||||
|         } | ||||
|         showSnackBar('Browsing ${currentFilter.value}'); | ||||
|       } | ||||
|  | ||||
|       tabController.addListener(listener); | ||||
| @@ -87,8 +86,7 @@ class ExploreScreen extends HookConsumerWidget { | ||||
|       activityListNotifierProvider(currentFilter.value).notifier, | ||||
|     ); | ||||
|  | ||||
|     return TourTriggerWidget( | ||||
|       child: AppScaffold( | ||||
|     return AppScaffold( | ||||
|       extendBody: false, // Prevent conflicts with tabs navigation | ||||
|       appBar: AppBar( | ||||
|         toolbarHeight: 0, | ||||
| @@ -128,7 +126,7 @@ class ExploreScreen extends HookConsumerWidget { | ||||
|       floatingActionButton: FloatingActionButton( | ||||
|         heroTag: Key("explore-page-fab"), | ||||
|         onPressed: () { | ||||
|             context.router.push(PostComposeRoute()).then((value) { | ||||
|           context.push('/posts/compose').then((value) { | ||||
|             if (value != null) { | ||||
|               activitiesNotifier.forceRefresh(); | ||||
|             } | ||||
| @@ -139,13 +137,13 @@ class ExploreScreen extends HookConsumerWidget { | ||||
|       floatingActionButtonLocation: TabbedFabLocation(context), | ||||
|       body: TabBarView( | ||||
|         controller: tabController, | ||||
|         physics: const NeverScrollableScrollPhysics(), | ||||
|         children: [ | ||||
|           _buildActivityList(ref, null), | ||||
|           _buildActivityList(ref, 'subscriptions'), | ||||
|           _buildActivityList(ref, 'friends'), | ||||
|         ], | ||||
|       ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -175,6 +173,64 @@ class ExploreScreen extends HookConsumerWidget { | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _DiscoveryActivityItem extends StatelessWidget { | ||||
|   final Map<String, dynamic> data; | ||||
|  | ||||
|   const _DiscoveryActivityItem({required this.data}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final items = data['items'] as List; | ||||
|     final type = items.firstOrNull?['type'] ?? 'unknown'; | ||||
|  | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         Row( | ||||
|           crossAxisAlignment: CrossAxisAlignment.center, | ||||
|           children: [ | ||||
|             const Icon(Symbols.explore, size: 19), | ||||
|             const Gap(8), | ||||
|             Text( | ||||
|               (switch (type) { | ||||
|                 'realm' => 'discoverRealms', | ||||
|                 'publisher' => 'discoverPublishers', | ||||
|                 _ => 'unknown', | ||||
|               }).tr(), | ||||
|               style: Theme.of(context).textTheme.titleMedium, | ||||
|             ).padding(top: 1), | ||||
|           ], | ||||
|         ).padding(horizontal: 20, top: 8, bottom: 4), | ||||
|         SizedBox( | ||||
|           height: 180, | ||||
|           child: ListView.builder( | ||||
|             scrollDirection: Axis.horizontal, | ||||
|             itemCount: items.length, | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 8), | ||||
|             itemBuilder: (context, index) { | ||||
|               final item = items[index]; | ||||
|               switch (type) { | ||||
|                 case 'realm': | ||||
|                   return RealmCard( | ||||
|                     realm: SnRealm.fromJson(item['data']), | ||||
|                     maxWidth: 280, | ||||
|                   ); | ||||
|                 case 'publisher': | ||||
|                   return PublisherCard( | ||||
|                     publisher: SnPublisher.fromJson(item['data']), | ||||
|                     maxWidth: 280, | ||||
|                   ); | ||||
|                 default: | ||||
|                   return Placeholder(); | ||||
|               } | ||||
|             }, | ||||
|           ), | ||||
|         ).padding(bottom: 4), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _ActivityListView extends HookConsumerWidget { | ||||
|   final CursorPagingData<SnActivity> data; | ||||
|   final int widgetCount; | ||||
| @@ -218,10 +274,14 @@ class _ActivityListView extends HookConsumerWidget { | ||||
|                 itemWidget = PostItem( | ||||
|                   backgroundColor: | ||||
|                       isWideScreen(context) ? Colors.transparent : null, | ||||
|                   item: SnPost.fromJson(item.data), | ||||
|                   item: SnPost.fromJson(item.data!), | ||||
|                   padding: | ||||
|                       isReply | ||||
|                           ? EdgeInsets.only(left: 16, right: 16, bottom: 16) | ||||
|                           ? const EdgeInsets.only( | ||||
|                             left: 16, | ||||
|                             right: 16, | ||||
|                             bottom: 16, | ||||
|                           ) | ||||
|                           : null, | ||||
|                   onRefresh: (_) { | ||||
|                     activitiesNotifier.forceRefresh(); | ||||
| @@ -249,6 +309,9 @@ class _ActivityListView extends HookConsumerWidget { | ||||
|                   ); | ||||
|                 } | ||||
|                 break; | ||||
|               case 'discovery': | ||||
|                 itemWidget = _DiscoveryActivityItem(data: item.data!); | ||||
|                 break; | ||||
|               default: | ||||
|                 itemWidget = const Placeholder(); | ||||
|             } | ||||
| @@ -278,6 +341,7 @@ class ActivityListNotifier extends _$ActivityListNotifier | ||||
|       if (cursor != null) 'cursor': cursor, | ||||
|       'take': take, | ||||
|       if (filter != null) 'filter': filter, | ||||
|       if (kDebugMode) 'debugInclude': 'realms,publishers', | ||||
|     }; | ||||
|  | ||||
|     final response = await client.get( | ||||
|   | ||||
| @@ -7,7 +7,7 @@ part of 'explore.dart'; | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$activityListNotifierHash() => | ||||
|     r'14ec2f211c86e1e64a9a34b142d0e8f78ff6361a'; | ||||
|     r'57e9dcec944a9f88f8508b69fc91342592f5b349'; | ||||
|  | ||||
| /// Copied from Dart SDK | ||||
| class _SystemHash { | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| @@ -107,7 +107,6 @@ class NotificationListNotifier extends _$NotificationListNotifier | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class NotificationScreen extends HookConsumerWidget { | ||||
|   const NotificationScreen({super.key}); | ||||
|  | ||||
| @@ -198,7 +197,7 @@ class NotificationScreen extends HookConsumerWidget { | ||||
|                         return; | ||||
|                       } | ||||
|                       if (uri.scheme == 'solian') { | ||||
|                         context.router.pushPath( | ||||
|                         context.push( | ||||
|                           ['', uri.host, ...uri.pathSegments].join('/'), | ||||
|                         ); | ||||
|                         return; | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| @@ -40,10 +39,9 @@ sealed class PostComposeInitialState with _$PostComposeInitialState { | ||||
|       _$PostComposeInitialStateFromJson(json); | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class PostEditScreen extends HookConsumerWidget { | ||||
|   final String id; | ||||
|   const PostEditScreen({super.key, @PathParam('id') required this.id}); | ||||
|   const PostEditScreen({super.key, required this.id}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -66,7 +64,6 @@ class PostEditScreen extends HookConsumerWidget { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class PostComposeScreen extends HookConsumerWidget { | ||||
|   final SnPost? originalPost; | ||||
|   final SnPost? repliedPost; | ||||
| @@ -78,7 +75,7 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|     this.originalPost, | ||||
|     this.repliedPost, | ||||
|     this.forwardedPost, | ||||
|     @QueryParam('type') this.type, | ||||
|     this.type, | ||||
|     this.initialState, | ||||
|   }); | ||||
|  | ||||
| @@ -106,15 +103,32 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|         originalPost: originalPost, | ||||
|         forwardedPost: effectiveForwardedPost, | ||||
|         repliedPost: effectiveRepliedPost, | ||||
|         postType: 0, // Regular post type | ||||
|       ), | ||||
|       [originalPost, effectiveForwardedPost, effectiveRepliedPost], | ||||
|     ); | ||||
|  | ||||
|     // Add a listener to the entire state to trigger rebuilds | ||||
|     final stateNotifier = useMemoized( | ||||
|       () => Listenable.merge([ | ||||
|         state.titleController, | ||||
|         state.descriptionController, | ||||
|         state.contentController, | ||||
|         state.visibility, | ||||
|         state.attachments, | ||||
|         state.attachmentProgress, | ||||
|         state.currentPublisher, | ||||
|         state.submitting, | ||||
|       ]), | ||||
|       [state], | ||||
|     ); | ||||
|     useListenable(stateNotifier); | ||||
|  | ||||
|     // Start auto-save when component mounts | ||||
|     useEffect(() { | ||||
|       if (originalPost == null) { | ||||
|         // Only auto-save for new posts, not edits | ||||
|         state.startAutoSave(ref, postType: 0); | ||||
|         state.startAutoSave(ref); | ||||
|       } | ||||
|       return () => state.stopAutoSave(); | ||||
|     }, [state]); | ||||
| @@ -153,13 +167,18 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|         final drafts = ref.read(composeStorageNotifierProvider); | ||||
|         if (drafts.isNotEmpty) { | ||||
|           final mostRecentDraft = drafts.values.reduce( | ||||
|             (a, b) => (a.updatedAt ?? DateTime(0)).isAfter(b.updatedAt ?? DateTime(0)) ? a : b, | ||||
|             (a, b) => | ||||
|                 (a.updatedAt ?? DateTime(0)).isAfter(b.updatedAt ?? DateTime(0)) | ||||
|                     ? a | ||||
|                     : b, | ||||
|           ); | ||||
|  | ||||
|           // Only load if the draft has meaningful content | ||||
|           if (mostRecentDraft.content?.isNotEmpty == true || mostRecentDraft.title?.isNotEmpty == true) { | ||||
|           if (mostRecentDraft.content?.isNotEmpty == true || | ||||
|               mostRecentDraft.title?.isNotEmpty == true) { | ||||
|             state.titleController.text = mostRecentDraft.title ?? ''; | ||||
|             state.descriptionController.text = mostRecentDraft.description ?? ''; | ||||
|             state.descriptionController.text = | ||||
|                 mostRecentDraft.description ?? ''; | ||||
|             state.contentController.text = mostRecentDraft.content ?? ''; | ||||
|             state.visibility.value = mostRecentDraft.visibility; | ||||
|           } | ||||
| @@ -187,6 +206,8 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|               titleController: state.titleController, | ||||
|               descriptionController: state.descriptionController, | ||||
|               visibility: state.visibility, | ||||
|               tagsController: state.tagsController, | ||||
|               categoriesController: state.categoriesController, | ||||
|               onVisibilityChanged: () { | ||||
|                 // Trigger rebuild if needed | ||||
|               }, | ||||
| @@ -206,9 +227,7 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|         ), | ||||
|         itemCount: state.attachments.value.length, | ||||
|         itemBuilder: (context, idx) { | ||||
|           return ValueListenableBuilder<Map<int, double>>( | ||||
|             valueListenable: state.attachmentProgress, | ||||
|             builder: (context, progressMap, _) { | ||||
|           final progressMap = state.attachmentProgress.value; | ||||
|           return AttachmentPreview( | ||||
|             item: state.attachments.value[idx], | ||||
|             progress: progressMap[idx], | ||||
| @@ -225,8 +244,6 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|           ); | ||||
|         }, | ||||
|       ); | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Widget buildNarrowAttachmentList() { | ||||
| @@ -235,9 +252,8 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|           for (var idx = 0; idx < state.attachments.value.length; idx++) | ||||
|             Container( | ||||
|               margin: const EdgeInsets.only(bottom: 8), | ||||
|               child: ValueListenableBuilder<Map<int, double>>( | ||||
|                 valueListenable: state.attachmentProgress, | ||||
|                 builder: (context, progressMap, _) { | ||||
|               child: () { | ||||
|                 final progressMap = state.attachmentProgress.value; | ||||
|                 return AttachmentPreview( | ||||
|                   item: state.attachments.value[idx], | ||||
|                   progress: progressMap[idx], | ||||
| @@ -253,8 +269,7 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                     ); | ||||
|                   }, | ||||
|                 ); | ||||
|                 }, | ||||
|               ), | ||||
|               }(), | ||||
|             ), | ||||
|         ], | ||||
|       ); | ||||
| @@ -290,7 +305,8 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                               state.titleController.text = draft.title ?? ''; | ||||
|                               state.descriptionController.text = | ||||
|                                   draft.description ?? ''; | ||||
|                               state.contentController.text = draft.content ?? ''; | ||||
|                               state.contentController.text = | ||||
|                                   draft.content ?? ''; | ||||
|                               state.visibility.value = draft.visibility; | ||||
|                             } | ||||
|                           }, | ||||
| @@ -309,12 +325,9 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|               onPressed: showSettingsSheet, | ||||
|               tooltip: 'postSettings'.tr(), | ||||
|             ), | ||||
|             ValueListenableBuilder<bool>( | ||||
|               valueListenable: state.submitting, | ||||
|               builder: (context, submitting, _) { | ||||
|                 return IconButton( | ||||
|             IconButton( | ||||
|               onPressed: | ||||
|                       submitting | ||||
|                   state.submitting.value | ||||
|                       ? null | ||||
|                       : () => ComposeLogic.performAction( | ||||
|                         ref, | ||||
| @@ -323,10 +336,9 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                         originalPost: originalPost, | ||||
|                         repliedPost: repliedPost, | ||||
|                         forwardedPost: forwardedPost, | ||||
|                             postType: 0, // Regular post type | ||||
|                       ), | ||||
|               icon: | ||||
|                       submitting | ||||
|                   state.submitting.value | ||||
|                       ? SizedBox( | ||||
|                         width: 28, | ||||
|                         height: 28, | ||||
| @@ -336,12 +348,8 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                         ), | ||||
|                       ).center() | ||||
|                       : Icon( | ||||
|                             originalPost != null | ||||
|                                 ? Symbols.edit | ||||
|                                 : Symbols.upload, | ||||
|                         originalPost != null ? Symbols.edit : Symbols.upload, | ||||
|                       ), | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|             const Gap(8), | ||||
|           ], | ||||
| @@ -402,7 +410,6 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                                     originalPost: originalPost, | ||||
|                                     repliedPost: repliedPost, | ||||
|                                     forwardedPost: forwardedPost, | ||||
|                                     postType: 0, // Regular post type | ||||
|                                   ), | ||||
|                               child: TextField( | ||||
|                                 controller: state.contentController, | ||||
| @@ -423,22 +430,17 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                             const Gap(8), | ||||
|  | ||||
|                             // Attachments preview | ||||
|                             ValueListenableBuilder<List<UniversalFile>>( | ||||
|                               valueListenable: state.attachments, | ||||
|                               builder: (context, attachments, _) { | ||||
|                                 if (attachments.isEmpty) { | ||||
|                                   return const SizedBox.shrink(); | ||||
|                                 } | ||||
|                                 return LayoutBuilder( | ||||
|                             if (state.attachments.value.isNotEmpty) | ||||
|                               LayoutBuilder( | ||||
|                                 builder: (context, constraints) { | ||||
|                                   final isWide = isWideScreen(context); | ||||
|                                   return isWide | ||||
|                                       ? buildWideAttachmentGrid() | ||||
|                                       : buildNarrowAttachmentList(); | ||||
|                                 }, | ||||
|                                 ); | ||||
|                               }, | ||||
|                             ), | ||||
|                               ) | ||||
|                             else | ||||
|                               const SizedBox.shrink(), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ), | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| @@ -26,10 +25,9 @@ import 'package:island/widgets/post/draft_manager.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class ArticleEditScreen extends HookConsumerWidget { | ||||
|   final String id; | ||||
|   const ArticleEditScreen({super.key, @PathParam('id') required this.id}); | ||||
|   const ArticleEditScreen({super.key, required this.id}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -50,7 +48,6 @@ class ArticleEditScreen extends HookConsumerWidget { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class ArticleComposeScreen extends HookConsumerWidget { | ||||
|   final SnPost? originalPost; | ||||
|  | ||||
| @@ -63,7 +60,10 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|  | ||||
|     final publishers = ref.watch(publishersManagedProvider); | ||||
|     final state = useMemoized( | ||||
|       () => ComposeLogic.createState(originalPost: originalPost), | ||||
|       () => ComposeLogic.createState( | ||||
|         originalPost: originalPost, | ||||
|         postType: 1, // Article type | ||||
|       ), | ||||
|       [originalPost], | ||||
|     ); | ||||
|  | ||||
| @@ -73,7 +73,7 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|       if (originalPost == null) { | ||||
|         // Only auto-save for new articles, not edits | ||||
|         autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) { | ||||
|           ComposeLogic.saveDraftWithoutUpload(ref, state, postType: 1); | ||||
|           ComposeLogic.saveDraftWithoutUpload(ref, state); | ||||
|         }); | ||||
|       } | ||||
|       return () { | ||||
| @@ -81,7 +81,7 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|         state.stopAutoSave(); | ||||
|         // Save final draft before disposing | ||||
|         if (originalPost == null) { | ||||
|           ComposeLogic.saveDraftWithoutUpload(ref, state, postType: 1); | ||||
|           ComposeLogic.saveDraftWithoutUpload(ref, state); | ||||
|         } | ||||
|         ComposeLogic.dispose(state); | ||||
|         autoSaveTimer?.cancel(); | ||||
| @@ -143,6 +143,8 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|               titleController: state.titleController, | ||||
|               descriptionController: state.descriptionController, | ||||
|               visibility: state.visibility, | ||||
|               tagsController: state.tagsController, | ||||
|               categoriesController: state.categoriesController, | ||||
|               onVisibilityChanged: () { | ||||
|                 // Trigger rebuild if needed | ||||
|               }, | ||||
| @@ -363,7 +365,7 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|     return PopScope( | ||||
|       onPopInvoked: (_) { | ||||
|         if (originalPost == null) { | ||||
|           ComposeLogic.saveDraftWithoutUpload(ref, state, postType: 1); | ||||
|           ComposeLogic.saveDraftWithoutUpload(ref, state); | ||||
|         } | ||||
|       }, | ||||
|       child: AppScaffold( | ||||
| @@ -411,7 +413,7 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|               ), | ||||
|             IconButton( | ||||
|               icon: const Icon(Symbols.save), | ||||
|               onPressed: () => ComposeLogic.saveDraft(ref, state, postType: 1), | ||||
|               onPressed: () => ComposeLogic.saveDraft(ref, state), | ||||
|               tooltip: 'saveDraft'.tr(), | ||||
|             ), | ||||
|             IconButton( | ||||
| @@ -438,7 +440,6 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|                             state, | ||||
|                             context, | ||||
|                             originalPost: originalPost, | ||||
|                             postType: 1, // Article type | ||||
|                           ), | ||||
|                   icon: | ||||
|                       submitting | ||||
| @@ -531,18 +532,17 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|     if (isPaste && isModifierPressed) { | ||||
|       ComposeLogic.handlePaste(state); | ||||
|     } else if (isSave && isModifierPressed) { | ||||
|       ComposeLogic.saveDraft(ref, state, postType: 1); | ||||
|       ComposeLogic.saveDraft(ref, state); | ||||
|       ComposeLogic.saveDraft(ref, state); | ||||
|     } else if (isSubmit && isModifierPressed && !state.submitting.value) { | ||||
|       ComposeLogic.performAction( | ||||
|         ref, | ||||
|         state, | ||||
|         context, | ||||
|         originalPost: originalPost, | ||||
|         postType: 1, // Article type | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Helper method to save article draft | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| @@ -22,10 +21,9 @@ Future<SnPost?> post(Ref ref, String id) async { | ||||
|   return SnPost.fromJson(resp.data); | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class PostDetailScreen extends HookConsumerWidget { | ||||
|   final String id; | ||||
|   const PostDetailScreen({super.key, @PathParam('id') required this.id}); | ||||
|   const PostDetailScreen({super.key, required this.id}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| @@ -54,6 +54,7 @@ Future<SnSubscriptionStatus> publisherSubscriptionStatus( | ||||
|  | ||||
| @riverpod | ||||
| Future<Color?> publisherAppbarForcegroundColor(Ref ref, String pubName) async { | ||||
|   try { | ||||
|     final publisher = await ref.watch(publisherProvider(pubName).future); | ||||
|     if (publisher.background == null) return null; | ||||
|     final palette = await PaletteGenerator.fromImageProvider( | ||||
| @@ -65,15 +66,14 @@ Future<Color?> publisherAppbarForcegroundColor(Ref ref, String pubName) async { | ||||
|     final dominantColor = palette.dominantColor?.color; | ||||
|     if (dominantColor == null) return null; | ||||
|     return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; | ||||
|   } catch (_) { | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class PublisherProfileScreen extends HookConsumerWidget { | ||||
|   final String name; | ||||
|   const PublisherProfileScreen({ | ||||
|     super.key, | ||||
|     @PathParam("name") required this.name, | ||||
|   }); | ||||
|   const PublisherProfileScreen({super.key, required this.name}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -186,7 +186,7 @@ class PublisherProfileScreen extends HookConsumerWidget { | ||||
|                         ), | ||||
|                         onTap: () { | ||||
|                           Navigator.pop(context, true); | ||||
|                           context.router.pushPath('/account/${data.name}'); | ||||
|                           context.push('/account/${data.name}'); | ||||
|                         }, | ||||
|                       ), | ||||
|                       Expanded( | ||||
|   | ||||
| @@ -1,13 +1,17 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:island/screens/chat/chat.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:island/models/chat.dart'; | ||||
| import 'package:island/services/color.dart'; | ||||
| import 'package:palette_generator/palette_generator.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/realm.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/screens/realm/realms.dart'; | ||||
| import 'package:island/widgets/account/account_picker.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| @@ -20,24 +24,54 @@ import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| part 'detail.g.dart'; | ||||
|  | ||||
| @riverpod | ||||
| Future<Color?> realmAppbarForegroundColor(Ref ref, String realmSlug) async { | ||||
|   final realm = await ref.watch(realmProvider(realmSlug).future); | ||||
|   if (realm?.background == null) return null; | ||||
|   final palette = await PaletteGenerator.fromImageProvider( | ||||
|     CloudImageWidget.provider( | ||||
|       fileId: realm!.background!.id, | ||||
|       serverUrl: ref.watch(serverUrlProvider), | ||||
|     ), | ||||
|   ); | ||||
|   final dominantColor = palette.dominantColor?.color; | ||||
|   if (dominantColor == null) return null; | ||||
|   return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
| Future<SnRealmMember?> realmIdentity(Ref ref, String realmSlug) async { | ||||
|   try { | ||||
|     final apiClient = ref.watch(apiClientProvider); | ||||
|     final response = await apiClient.get('/realms/$realmSlug/members/me'); | ||||
|     return SnRealmMember.fromJson(response.data); | ||||
|   } catch (err) { | ||||
|     if (err is DioException && err.response?.statusCode == 404) { | ||||
|       return null; // No identity found, user is not a member | ||||
|     } | ||||
|     rethrow; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
| Future<List<SnChatRoom>> realmChatRooms(Ref ref, String realmSlug) async { | ||||
|   final apiClient = ref.watch(apiClientProvider); | ||||
|   final response = await apiClient.get('/realms/$realmSlug/chat'); | ||||
|   return (response.data as List).map((e) => SnChatRoom.fromJson(e)).toList(); | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class RealmDetailScreen extends HookConsumerWidget { | ||||
|   final String slug; | ||||
|   const RealmDetailScreen({super.key, @PathParam("slug") required this.slug}); | ||||
|  | ||||
|   const RealmDetailScreen({super.key, required this.slug}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final realmState = ref.watch(realmProvider(slug)); | ||||
|     final appbarColor = ref.watch(realmAppbarForegroundColorProvider(slug)); | ||||
|  | ||||
|     const iconShadow = Shadow( | ||||
|       color: Colors.black54, | ||||
|     final iconShadow = Shadow( | ||||
|       color: appbarColor.value?.invert ?? Colors.black54, | ||||
|       blurRadius: 5.0, | ||||
|       offset: Offset(1.0, 1.0), | ||||
|     ); | ||||
| @@ -52,7 +86,11 @@ class RealmDetailScreen extends HookConsumerWidget { | ||||
|                 SliverAppBar( | ||||
|                   expandedHeight: 180, | ||||
|                   pinned: true, | ||||
|                   leading: PageBackButton(shadows: [iconShadow]), | ||||
|                   foregroundColor: appbarColor.value, | ||||
|                   leading: PageBackButton( | ||||
|                     color: appbarColor.value, | ||||
|                     shadows: [iconShadow], | ||||
|                   ), | ||||
|                   flexibleSpace: FlexibleSpaceBar( | ||||
|                     background: | ||||
|                         realm!.background?.id != null | ||||
| @@ -64,14 +102,16 @@ class RealmDetailScreen extends HookConsumerWidget { | ||||
|                     title: Text( | ||||
|                       realm.name, | ||||
|                       style: TextStyle( | ||||
|                         color: Theme.of(context).appBarTheme.foregroundColor, | ||||
|                         color: | ||||
|                             appbarColor.value ?? | ||||
|                             Theme.of(context).appBarTheme.foregroundColor, | ||||
|                         shadows: [iconShadow], | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                   actions: [ | ||||
|                     IconButton( | ||||
|                       icon: const Icon(Icons.people, shadows: [iconShadow]), | ||||
|                       icon: Icon(Icons.people, shadows: [iconShadow]), | ||||
|                       onPressed: () { | ||||
|                         showModalBottomSheet( | ||||
|                           isScrollControlled: true, | ||||
| @@ -87,19 +127,98 @@ class RealmDetailScreen extends HookConsumerWidget { | ||||
|                   ], | ||||
|                 ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: Padding( | ||||
|                     padding: const EdgeInsets.all(16.0), | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                   child: ref | ||||
|                       .watch(realmIdentityProvider(slug)) | ||||
|                       .when( | ||||
|                         loading: () => const SizedBox.shrink(), | ||||
|                         error: (_, _) => const SizedBox.shrink(), | ||||
|                         data: | ||||
|                             (identity) => Column( | ||||
|                               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                               children: [ | ||||
|                                 ExpansionTile( | ||||
|                                   title: const Text('description').tr(), | ||||
|                                   initiallyExpanded: identity == null, | ||||
|                                   tilePadding: EdgeInsets.symmetric( | ||||
|                                     horizontal: 20, | ||||
|                                   ), | ||||
|                                   expandedCrossAxisAlignment: | ||||
|                                       CrossAxisAlignment.stretch, | ||||
|                                   children: [ | ||||
|                                     Text( | ||||
|                                       realm.description, | ||||
|                                       style: const TextStyle(fontSize: 16), | ||||
|                                     ).padding( | ||||
|                                       horizontal: 20, | ||||
|                                       bottom: 16, | ||||
|                                       top: 8, | ||||
|                                     ), | ||||
|                                   ], | ||||
|                                 ), | ||||
|                                 if (identity == null && realm.isCommunity) | ||||
|                                   FilledButton.tonalIcon( | ||||
|                                     onPressed: () async { | ||||
|                                       try { | ||||
|                                         final apiClient = ref.read( | ||||
|                                           apiClientProvider, | ||||
|                                         ); | ||||
|                                         await apiClient.post( | ||||
|                                           '/realms/$slug/members/me', | ||||
|                                         ); | ||||
|                                         ref.invalidate( | ||||
|                                           realmIdentityProvider(slug), | ||||
|                                         ); | ||||
|                                         ref.invalidate(realmsJoinedProvider); | ||||
|                                         showSnackBar('realmJoinSuccess'.tr()); | ||||
|                                       } catch (err) { | ||||
|                                         showErrorAlert(err); | ||||
|                                       } | ||||
|                                     }, | ||||
|                                     icon: const Icon(Symbols.add), | ||||
|                                     label: const Text('realmJoin').tr(), | ||||
|                                   ).padding(horizontal: 16, vertical: 16) | ||||
|                                 else | ||||
|                                   const SizedBox.shrink(), | ||||
|                               ], | ||||
|                             ), | ||||
|                       ), | ||||
|                 ), | ||||
|                 const SliverToBoxAdapter(child: Divider(height: 1)), | ||||
|                 Consumer( | ||||
|                   builder: (context, ref, _) { | ||||
|                     final chatRooms = ref.watch(realmChatRoomsProvider(slug)); | ||||
|                     return chatRooms.when( | ||||
|                       loading: | ||||
|                           () => const SliverToBoxAdapter( | ||||
|                             child: Center(child: CircularProgressIndicator()), | ||||
|                           ), | ||||
|                       error: | ||||
|                           (error, _) => SliverToBoxAdapter( | ||||
|                             child: Center(child: Text('Error: $error')), | ||||
|                           ), | ||||
|                       data: (rooms) { | ||||
|                         if (rooms.isEmpty) { | ||||
|                           return const SliverToBoxAdapter( | ||||
|                             child: SizedBox.shrink(), | ||||
|                           ); | ||||
|                         } | ||||
|                         return SliverList( | ||||
|                           delegate: SliverChildBuilderDelegate(( | ||||
|                             context, | ||||
|                             index, | ||||
|                           ) { | ||||
|                             return ChatRoomListTile( | ||||
|                               room: rooms[index], | ||||
|                               onTap: () { | ||||
|                                 context.push('/chat/${rooms[index].id}'); | ||||
|                               }, | ||||
|                             ); | ||||
|                           }, childCount: rooms.length), | ||||
|                         ); | ||||
|                       }, | ||||
|                     ); | ||||
|                   }, | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|       ), | ||||
| @@ -115,8 +234,8 @@ class _RealmActionMenu extends HookConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final realmIdentityAsync = ref.watch(realmIdentityProvider(realmSlug)); | ||||
|     final isModerator = realmIdentityAsync.when( | ||||
|     final realmIdentity = ref.watch(realmIdentityProvider(realmSlug)); | ||||
|     final isModerator = realmIdentity.when( | ||||
|       data: (identity) => (identity?.role ?? 0) >= 50, | ||||
|       loading: () => false, | ||||
|       error: (_, _) => false, | ||||
| @@ -129,7 +248,7 @@ class _RealmActionMenu extends HookConsumerWidget { | ||||
|             if (isModerator) | ||||
|               PopupMenuItem( | ||||
|                 onTap: () { | ||||
|                   context.router.replace(EditRealmRoute(slug: realmSlug)); | ||||
|                   context.pushReplacement('/realms/$realmSlug/edit'); | ||||
|                 }, | ||||
|                 child: Row( | ||||
|                   children: [ | ||||
| @@ -142,7 +261,7 @@ class _RealmActionMenu extends HookConsumerWidget { | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             realmIdentityAsync.when( | ||||
|             realmIdentity.when( | ||||
|               data: | ||||
|                   (identity) => | ||||
|                       (identity?.role ?? 0) >= 100 | ||||
| @@ -167,7 +286,7 @@ class _RealmActionMenu extends HookConsumerWidget { | ||||
|                                   client.delete('/realms/$realmSlug'); | ||||
|                                   ref.invalidate(realmsJoinedProvider); | ||||
|                                   if (context.mounted) { | ||||
|                                     context.router.maybePop(true); | ||||
|                                     context.pop(true); | ||||
|                                   } | ||||
|                                 } | ||||
|                               }); | ||||
| @@ -201,7 +320,7 @@ class _RealmActionMenu extends HookConsumerWidget { | ||||
|                                   ); | ||||
|                                   ref.invalidate(realmsJoinedProvider); | ||||
|                                   if (context.mounted) { | ||||
|                                     context.router.maybePop(true); | ||||
|                                     context.pop(true); | ||||
|                                   } | ||||
|                                 } | ||||
|                               }); | ||||
| @@ -239,7 +358,7 @@ class _RealmActionMenu extends HookConsumerWidget { | ||||
|                           client.delete('/realms/$realmSlug/members/me'); | ||||
|                           ref.invalidate(realmsJoinedProvider); | ||||
|                           if (context.mounted) { | ||||
|                             context.router.maybePop(true); | ||||
|                             context.pop(true); | ||||
|                           } | ||||
|                         } | ||||
|                       }); | ||||
|   | ||||
| @@ -6,7 +6,8 @@ part of 'detail.dart'; | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$realmIdentityHash() => r'eac6e829b5b46bcfadbf201ab6f918d78c894b9f'; | ||||
| String _$realmAppbarForegroundColorHash() => | ||||
|     r'14b5563d861996ea182d0d2db7aa5c2bb3bbaf48'; | ||||
|  | ||||
| /// Copied from Dart SDK | ||||
| class _SystemHash { | ||||
| @@ -29,6 +30,133 @@ class _SystemHash { | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// See also [realmAppbarForegroundColor]. | ||||
| @ProviderFor(realmAppbarForegroundColor) | ||||
| const realmAppbarForegroundColorProvider = RealmAppbarForegroundColorFamily(); | ||||
|  | ||||
| /// See also [realmAppbarForegroundColor]. | ||||
| class RealmAppbarForegroundColorFamily extends Family<AsyncValue<Color?>> { | ||||
|   /// See also [realmAppbarForegroundColor]. | ||||
|   const RealmAppbarForegroundColorFamily(); | ||||
|  | ||||
|   /// See also [realmAppbarForegroundColor]. | ||||
|   RealmAppbarForegroundColorProvider call(String realmSlug) { | ||||
|     return RealmAppbarForegroundColorProvider(realmSlug); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   RealmAppbarForegroundColorProvider getProviderOverride( | ||||
|     covariant RealmAppbarForegroundColorProvider provider, | ||||
|   ) { | ||||
|     return call(provider.realmSlug); | ||||
|   } | ||||
|  | ||||
|   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'realmAppbarForegroundColorProvider'; | ||||
| } | ||||
|  | ||||
| /// See also [realmAppbarForegroundColor]. | ||||
| class RealmAppbarForegroundColorProvider | ||||
|     extends AutoDisposeFutureProvider<Color?> { | ||||
|   /// See also [realmAppbarForegroundColor]. | ||||
|   RealmAppbarForegroundColorProvider(String realmSlug) | ||||
|     : this._internal( | ||||
|         (ref) => realmAppbarForegroundColor( | ||||
|           ref as RealmAppbarForegroundColorRef, | ||||
|           realmSlug, | ||||
|         ), | ||||
|         from: realmAppbarForegroundColorProvider, | ||||
|         name: r'realmAppbarForegroundColorProvider', | ||||
|         debugGetCreateSourceHash: | ||||
|             const bool.fromEnvironment('dart.vm.product') | ||||
|                 ? null | ||||
|                 : _$realmAppbarForegroundColorHash, | ||||
|         dependencies: RealmAppbarForegroundColorFamily._dependencies, | ||||
|         allTransitiveDependencies: | ||||
|             RealmAppbarForegroundColorFamily._allTransitiveDependencies, | ||||
|         realmSlug: realmSlug, | ||||
|       ); | ||||
|  | ||||
|   RealmAppbarForegroundColorProvider._internal( | ||||
|     super._createNotifier, { | ||||
|     required super.name, | ||||
|     required super.dependencies, | ||||
|     required super.allTransitiveDependencies, | ||||
|     required super.debugGetCreateSourceHash, | ||||
|     required super.from, | ||||
|     required this.realmSlug, | ||||
|   }) : super.internal(); | ||||
|  | ||||
|   final String realmSlug; | ||||
|  | ||||
|   @override | ||||
|   Override overrideWith( | ||||
|     FutureOr<Color?> Function(RealmAppbarForegroundColorRef provider) create, | ||||
|   ) { | ||||
|     return ProviderOverride( | ||||
|       origin: this, | ||||
|       override: RealmAppbarForegroundColorProvider._internal( | ||||
|         (ref) => create(ref as RealmAppbarForegroundColorRef), | ||||
|         from: from, | ||||
|         name: null, | ||||
|         dependencies: null, | ||||
|         allTransitiveDependencies: null, | ||||
|         debugGetCreateSourceHash: null, | ||||
|         realmSlug: realmSlug, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AutoDisposeFutureProviderElement<Color?> createElement() { | ||||
|     return _RealmAppbarForegroundColorProviderElement(this); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return other is RealmAppbarForegroundColorProvider && | ||||
|         other.realmSlug == realmSlug; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||
|     hash = _SystemHash.combine(hash, realmSlug.hashCode); | ||||
|  | ||||
|     return _SystemHash.finish(hash); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| mixin RealmAppbarForegroundColorRef on AutoDisposeFutureProviderRef<Color?> { | ||||
|   /// The parameter `realmSlug` of this provider. | ||||
|   String get realmSlug; | ||||
| } | ||||
|  | ||||
| class _RealmAppbarForegroundColorProviderElement | ||||
|     extends AutoDisposeFutureProviderElement<Color?> | ||||
|     with RealmAppbarForegroundColorRef { | ||||
|   _RealmAppbarForegroundColorProviderElement(super.provider); | ||||
|  | ||||
|   @override | ||||
|   String get realmSlug => | ||||
|       (origin as RealmAppbarForegroundColorProvider).realmSlug; | ||||
| } | ||||
|  | ||||
| String _$realmIdentityHash() => r'308d43eef8a6145c762d27bdf7e12e27149524db'; | ||||
|  | ||||
| /// See also [realmIdentity]. | ||||
| @ProviderFor(realmIdentity) | ||||
| const realmIdentityProvider = RealmIdentityFamily(); | ||||
| @@ -148,6 +276,128 @@ class _RealmIdentityProviderElement | ||||
|   String get realmSlug => (origin as RealmIdentityProvider).realmSlug; | ||||
| } | ||||
|  | ||||
| String _$realmChatRoomsHash() => r'8207c1e6f0922323967f208efeed027e943039cc'; | ||||
|  | ||||
| /// See also [realmChatRooms]. | ||||
| @ProviderFor(realmChatRooms) | ||||
| const realmChatRoomsProvider = RealmChatRoomsFamily(); | ||||
|  | ||||
| /// See also [realmChatRooms]. | ||||
| class RealmChatRoomsFamily extends Family<AsyncValue<List<SnChatRoom>>> { | ||||
|   /// See also [realmChatRooms]. | ||||
|   const RealmChatRoomsFamily(); | ||||
|  | ||||
|   /// See also [realmChatRooms]. | ||||
|   RealmChatRoomsProvider call(String realmSlug) { | ||||
|     return RealmChatRoomsProvider(realmSlug); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   RealmChatRoomsProvider getProviderOverride( | ||||
|     covariant RealmChatRoomsProvider provider, | ||||
|   ) { | ||||
|     return call(provider.realmSlug); | ||||
|   } | ||||
|  | ||||
|   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'realmChatRoomsProvider'; | ||||
| } | ||||
|  | ||||
| /// See also [realmChatRooms]. | ||||
| class RealmChatRoomsProvider | ||||
|     extends AutoDisposeFutureProvider<List<SnChatRoom>> { | ||||
|   /// See also [realmChatRooms]. | ||||
|   RealmChatRoomsProvider(String realmSlug) | ||||
|     : this._internal( | ||||
|         (ref) => realmChatRooms(ref as RealmChatRoomsRef, realmSlug), | ||||
|         from: realmChatRoomsProvider, | ||||
|         name: r'realmChatRoomsProvider', | ||||
|         debugGetCreateSourceHash: | ||||
|             const bool.fromEnvironment('dart.vm.product') | ||||
|                 ? null | ||||
|                 : _$realmChatRoomsHash, | ||||
|         dependencies: RealmChatRoomsFamily._dependencies, | ||||
|         allTransitiveDependencies: | ||||
|             RealmChatRoomsFamily._allTransitiveDependencies, | ||||
|         realmSlug: realmSlug, | ||||
|       ); | ||||
|  | ||||
|   RealmChatRoomsProvider._internal( | ||||
|     super._createNotifier, { | ||||
|     required super.name, | ||||
|     required super.dependencies, | ||||
|     required super.allTransitiveDependencies, | ||||
|     required super.debugGetCreateSourceHash, | ||||
|     required super.from, | ||||
|     required this.realmSlug, | ||||
|   }) : super.internal(); | ||||
|  | ||||
|   final String realmSlug; | ||||
|  | ||||
|   @override | ||||
|   Override overrideWith( | ||||
|     FutureOr<List<SnChatRoom>> Function(RealmChatRoomsRef provider) create, | ||||
|   ) { | ||||
|     return ProviderOverride( | ||||
|       origin: this, | ||||
|       override: RealmChatRoomsProvider._internal( | ||||
|         (ref) => create(ref as RealmChatRoomsRef), | ||||
|         from: from, | ||||
|         name: null, | ||||
|         dependencies: null, | ||||
|         allTransitiveDependencies: null, | ||||
|         debugGetCreateSourceHash: null, | ||||
|         realmSlug: realmSlug, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AutoDisposeFutureProviderElement<List<SnChatRoom>> createElement() { | ||||
|     return _RealmChatRoomsProviderElement(this); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return other is RealmChatRoomsProvider && other.realmSlug == realmSlug; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||
|     hash = _SystemHash.combine(hash, realmSlug.hashCode); | ||||
|  | ||||
|     return _SystemHash.finish(hash); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| mixin RealmChatRoomsRef on AutoDisposeFutureProviderRef<List<SnChatRoom>> { | ||||
|   /// The parameter `realmSlug` of this provider. | ||||
|   String get realmSlug; | ||||
| } | ||||
|  | ||||
| class _RealmChatRoomsProviderElement | ||||
|     extends AutoDisposeFutureProviderElement<List<SnChatRoom>> | ||||
|     with RealmChatRoomsRef { | ||||
|   _RealmChatRoomsProviderElement(super.provider); | ||||
|  | ||||
|   @override | ||||
|   String get realmSlug => (origin as RealmChatRoomsProvider).realmSlug; | ||||
| } | ||||
|  | ||||
| String _$realmMemberListNotifierHash() => | ||||
|     r'b2e3eefc62a597f45df9470b2058fdda62f8853f'; | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:croppy/croppy.dart' show CropAspectRatio; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| @@ -11,7 +11,6 @@ import 'package:island/models/file.dart'; | ||||
| import 'package:island/models/realm.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/services/file.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| @@ -33,7 +32,6 @@ Future<List<SnRealm>> realmsJoined(Ref ref) async { | ||||
|   return resp.data.map((e) => SnRealm.fromJson(e)).cast<SnRealm>().toList(); | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class RealmListScreen extends HookConsumerWidget { | ||||
|   const RealmListScreen({super.key}); | ||||
|  | ||||
| @@ -48,6 +46,10 @@ class RealmListScreen extends HookConsumerWidget { | ||||
|       appBar: AppBar( | ||||
|         title: const Text('realms').tr(), | ||||
|         actions: [ | ||||
|           IconButton( | ||||
|             icon: const Icon(Symbols.travel_explore), | ||||
|             onPressed: () => context.push('/discovery/realms'), | ||||
|           ), | ||||
|           IconButton( | ||||
|             icon: Badge( | ||||
|               label: Text( | ||||
| @@ -68,7 +70,7 @@ class RealmListScreen extends HookConsumerWidget { | ||||
|               showModalBottomSheet( | ||||
|                 context: context, | ||||
|                 isScrollControlled: true, | ||||
|                 builder: (_) => _RealmInviteSheet(), | ||||
|                 builder: (_) => const _RealmInviteSheet(), | ||||
|               ); | ||||
|             }, | ||||
|           ), | ||||
| @@ -76,10 +78,10 @@ class RealmListScreen extends HookConsumerWidget { | ||||
|         ], | ||||
|       ), | ||||
|       floatingActionButton: FloatingActionButton( | ||||
|         heroTag: Key("realms-page-fab"), | ||||
|         heroTag: const Key("realms-page-fab"), | ||||
|         child: const Icon(Symbols.add), | ||||
|         onPressed: () { | ||||
|           context.router.push(NewRealmRoute()).then((value) { | ||||
|           context.push('/realms/new').then((value) { | ||||
|             if (value != null) { | ||||
|               ref.invalidate(realmsJoinedProvider); | ||||
|             } | ||||
| @@ -106,11 +108,9 @@ class RealmListScreen extends HookConsumerWidget { | ||||
|                           title: Text(value[item].name), | ||||
|                           subtitle: Text(value[item].description), | ||||
|                           onTap: () { | ||||
|                             context.router.push( | ||||
|                               RealmDetailRoute(slug: value[item].slug), | ||||
|                             ); | ||||
|                             context.push('/realms/${value[item].slug}'); | ||||
|                           }, | ||||
|                           contentPadding: EdgeInsets.only( | ||||
|                           contentPadding: const EdgeInsets.only( | ||||
|                             left: 16, | ||||
|                             right: 14, | ||||
|                             top: 8, | ||||
| @@ -143,7 +143,6 @@ Future<SnRealm?> realm(Ref ref, String? identifier) async { | ||||
|   return SnRealm.fromJson(resp.data); | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class NewRealmScreen extends StatelessWidget { | ||||
|   const NewRealmScreen({super.key}); | ||||
|  | ||||
| @@ -153,10 +152,9 @@ class NewRealmScreen extends StatelessWidget { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class EditRealmScreen extends HookConsumerWidget { | ||||
|   final String? slug; | ||||
|   const EditRealmScreen({super.key, @PathParam('slug') this.slug}); | ||||
|   const EditRealmScreen({super.key, this.slug}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -164,6 +162,8 @@ class EditRealmScreen extends HookConsumerWidget { | ||||
|  | ||||
|     final picture = useState<SnCloudFile?>(null); | ||||
|     final background = useState<SnCloudFile?>(null); | ||||
|     final isPublic = useState(true); | ||||
|     final isCommunity = useState(false); | ||||
|  | ||||
|     final slugController = useTextEditingController(); | ||||
|     final nameController = useTextEditingController(); | ||||
| @@ -180,6 +180,8 @@ class EditRealmScreen extends HookConsumerWidget { | ||||
|         slugController.text = realm.value!.slug; | ||||
|         nameController.text = realm.value!.name; | ||||
|         descriptionController.text = realm.value!.description; | ||||
|         isPublic.value = realm.value!.isPublic; | ||||
|         isCommunity.value = realm.value!.isCommunity; | ||||
|       } | ||||
|       return null; | ||||
|     }, [realm]); | ||||
| @@ -200,9 +202,9 @@ class EditRealmScreen extends HookConsumerWidget { | ||||
|         image: result, | ||||
|         allowedAspectRatios: [ | ||||
|           if (position == 'background') | ||||
|             CropAspectRatio(height: 7, width: 16) | ||||
|             const CropAspectRatio(height: 7, width: 16) | ||||
|           else | ||||
|             CropAspectRatio(height: 1, width: 1), | ||||
|             const CropAspectRatio(height: 1, width: 1), | ||||
|         ], | ||||
|       ); | ||||
|       if (result == null) { | ||||
| @@ -258,11 +260,13 @@ class EditRealmScreen extends HookConsumerWidget { | ||||
|             'description': descriptionController.text, | ||||
|             'background_id': background.value?.id, | ||||
|             'picture_id': picture.value?.id, | ||||
|             'is_public': isPublic.value, | ||||
|             'is_community': isCommunity.value, | ||||
|           }, | ||||
|           options: Options(method: slug == null ? 'POST' : 'PATCH'), | ||||
|         ); | ||||
|         if (context.mounted) { | ||||
|           context.maybePop(SnRealm.fromJson(resp.data)); | ||||
|           context.pop(SnRealm.fromJson(resp.data)); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
| @@ -320,7 +324,6 @@ class EditRealmScreen extends HookConsumerWidget { | ||||
|             key: formKey, | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               spacing: 16, | ||||
|               children: [ | ||||
|                 TextFormField( | ||||
|                   controller: slugController, | ||||
| @@ -331,12 +334,14 @@ class EditRealmScreen extends HookConsumerWidget { | ||||
|                   onTapOutside: | ||||
|                       (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ), | ||||
|                 const SizedBox(height: 16), | ||||
|                 TextFormField( | ||||
|                   controller: nameController, | ||||
|                   decoration: InputDecoration(labelText: 'name'.tr()), | ||||
|                   onTapOutside: | ||||
|                       (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ), | ||||
|                 const SizedBox(height: 16), | ||||
|                 TextFormField( | ||||
|                   controller: descriptionController, | ||||
|                   decoration: InputDecoration(labelText: 'description'.tr()), | ||||
| @@ -345,6 +350,20 @@ class EditRealmScreen extends HookConsumerWidget { | ||||
|                   onTapOutside: | ||||
|                       (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ), | ||||
|                 const SizedBox(height: 16), | ||||
|                 CheckboxListTile( | ||||
|                   title: const Text('isPublic').tr(), | ||||
|                   subtitle: const Text('isPublicHint').tr(), | ||||
|                   value: isPublic.value, | ||||
|                   onChanged: (value) => isPublic.value = value ?? false, | ||||
|                 ), | ||||
|                 CheckboxListTile( | ||||
|                   title: const Text('isCommunity').tr(), | ||||
|                   subtitle: const Text('isCommunityHint').tr(), | ||||
|                   value: isCommunity.value, | ||||
|                   onChanged: (value) => isCommunity.value = value ?? false, | ||||
|                 ), | ||||
|                 const SizedBox(height: 16), | ||||
|                 Align( | ||||
|                   alignment: Alignment.centerRight, | ||||
|                   child: TextButton.icon( | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:dropdown_button2/dropdown_button2.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_colorpicker/flutter_colorpicker.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| @@ -21,7 +21,6 @@ import 'package:path_provider/path_provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class SettingsScreen extends HookConsumerWidget { | ||||
|   const SettingsScreen({super.key}); | ||||
|  | ||||
| @@ -590,7 +589,7 @@ class SettingsScreen extends HookConsumerWidget { | ||||
|           if (isDesktop && | ||||
|               event is KeyDownEvent && | ||||
|               event.logicalKey == LogicalKeyboardKey.escape) { | ||||
|             context.router.pop(); | ||||
|             context.pop(); | ||||
|             return KeyEventResult.handled; | ||||
|           } | ||||
|           return KeyEventResult.ignored; | ||||
|   | ||||
| @@ -1,20 +1,32 @@ | ||||
| import 'dart:ui'; | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:island/screens/notification.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/navigation/conditional_bottom_nav.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| final currentRouteProvider = StateProvider<String?>((ref) => null); | ||||
|  | ||||
| class TabsScreen extends HookConsumerWidget { | ||||
|   const TabsScreen({super.key}); | ||||
|   final Widget? child; | ||||
|   const TabsScreen({super.key, this.child}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final useHorizontalLayout = isWideScreen(context); | ||||
|     // final useHorizontalLayout = isWideScreen(context); | ||||
|     final currentLocation = GoRouterState.of(context).uri.toString(); | ||||
|      | ||||
|     // Update the current route provider whenever the location changes | ||||
|     useEffect(() { | ||||
|       WidgetsBinding.instance.addPostFrameCallback((_) { | ||||
|         ref.read(currentRouteProvider.notifier).state = currentLocation; | ||||
|       }); | ||||
|       return null; | ||||
|     }, [currentLocation]); | ||||
|  | ||||
|     final notificationUnreadCount = ref.watch( | ||||
|       notificationUnreadCountNotifierProvider, | ||||
| @@ -40,19 +52,25 @@ class TabsScreen extends HookConsumerWidget { | ||||
|       ), | ||||
|     ]; | ||||
|  | ||||
|     final routes = <PageRouteInfo>[ | ||||
|       ExploreRoute(), | ||||
|       ChatListRoute(), | ||||
|       RealmListRoute(), | ||||
|       AccountRoute(), | ||||
|     final routes = [ | ||||
|       '/', | ||||
|       '/chat', | ||||
|       '/realms', | ||||
|       '/account', | ||||
|     ]; | ||||
|  | ||||
|     return AutoTabsRouter.tabBar( | ||||
|       routes: routes, | ||||
|       scrollDirection: useHorizontalLayout ? Axis.vertical : Axis.horizontal, | ||||
|       physics: const NeverScrollableScrollPhysics(), | ||||
|       builder: (context, child, _) { | ||||
|         final tabsRouter = AutoTabsRouter.of(context); | ||||
|     int getCurrentIndex() { | ||||
|       if (currentLocation.startsWith('/chat')) return 1; | ||||
|       if (currentLocation.startsWith('/realms')) return 2; | ||||
|       if (currentLocation.startsWith('/account')) return 3; | ||||
|       return 0; // Default to explore | ||||
|     } | ||||
|  | ||||
|     void onDestinationSelected(int index) { | ||||
|       context.go(routes[index]); | ||||
|     } | ||||
|  | ||||
|     final currentIndex = getCurrentIndex(); | ||||
|  | ||||
|     if (isWideScreen(context)) { | ||||
|       return Row( | ||||
| @@ -67,22 +85,23 @@ class TabsScreen extends HookConsumerWidget { | ||||
|                       ), | ||||
|                     ) | ||||
|                     .toList(), | ||||
|                 selectedIndex: tabsRouter.activeIndex, | ||||
|                 onDestinationSelected: tabsRouter.setActiveIndex, | ||||
|             selectedIndex: currentIndex, | ||||
|             onDestinationSelected: onDestinationSelected, | ||||
|           ), | ||||
|           const VerticalDivider(width: 1), | ||||
|               Expanded(child: child), | ||||
|           Expanded(child: child ?? const SizedBox.shrink()), | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Stack( | ||||
|       children: [ | ||||
|             Positioned.fill(child: child), | ||||
|         Positioned.fill(child: child ?? const SizedBox.shrink()), | ||||
|         Positioned( | ||||
|           left: 0, | ||||
|           right: 0, | ||||
|           bottom: 0, | ||||
|           child: ConditionalBottomNav( | ||||
|             child: ClipRRect( | ||||
|               child: BackdropFilter( | ||||
|                 filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), | ||||
| @@ -98,15 +117,15 @@ class TabsScreen extends HookConsumerWidget { | ||||
|                     child: NavigationBar( | ||||
|                       backgroundColor: Colors.transparent, | ||||
|                       shadowColor: Colors.transparent, | ||||
|                         overlayColor: WidgetStatePropertyAll( | ||||
|                       overlayColor: const WidgetStatePropertyAll( | ||||
|                         Colors.transparent, | ||||
|                       ), | ||||
|                       surfaceTintColor: Colors.transparent, | ||||
|                       height: 56, | ||||
|                       labelBehavior: | ||||
|                           NavigationDestinationLabelBehavior.alwaysHide, | ||||
|                         selectedIndex: tabsRouter.activeIndex, | ||||
|                         onDestinationSelected: tabsRouter.setActiveIndex, | ||||
|                       selectedIndex: currentIndex, | ||||
|                       onDestinationSelected: onDestinationSelected, | ||||
|                       destinations: destinations, | ||||
|                     ), | ||||
|                   ), | ||||
| @@ -114,10 +133,9 @@ class TabsScreen extends HookConsumerWidget { | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import 'package:auto_route/annotations.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| @@ -72,7 +71,6 @@ class TransactionListNotifier extends _$TransactionListNotifier | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| class WalletScreen extends HookConsumerWidget { | ||||
|   const WalletScreen({super.key}); | ||||
|  | ||||
|   | ||||
| @@ -185,7 +185,6 @@ Completer<SnCloudFile?> _processUpload( | ||||
|         onProgress: (double progress, Duration estimate) { | ||||
|           onProgress?.call(progress, estimate); | ||||
|         }, | ||||
|         measureUploadSpeed: true, | ||||
|       ) | ||||
|       .catchError(completer.completeError); | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,67 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:developer'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:firebase_messaging/firebase_messaging.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:island/main.dart'; | ||||
| import 'package:island/route.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/widgets/app_notification.dart'; | ||||
| import 'package:top_snackbar_flutter/top_snack_bar.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| StreamSubscription<WebSocketPacket> setupNotificationListener( | ||||
|   BuildContext context, | ||||
|   WidgetRef ref, | ||||
| ) { | ||||
|   final ws = ref.watch(websocketProvider); | ||||
|   return ws.dataStream.listen((pkt) { | ||||
|     if (pkt.type == "notifications.new") { | ||||
|       final notification = SnNotification.fromJson(pkt.data!); | ||||
|       showTopSnackBar( | ||||
|         globalOverlay.currentState!, | ||||
|         NotificationCard(notification: notification), | ||||
|         onTap: () { | ||||
|           if (notification.meta['action_uri'] != null) { | ||||
|             var uri = notification.meta['action_uri'] as String; | ||||
|             if (uri.startsWith('/')) { | ||||
|               // In-app routes | ||||
|               rootNavigatorKey.currentContext?.push( | ||||
|                 notification.meta['action_uri'], | ||||
|               ); | ||||
|             } else { | ||||
|               // External URLs | ||||
|               launchUrlString(uri); | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         onDismissed: () {}, | ||||
|         dismissType: DismissType.onSwipe, | ||||
|         displayDuration: const Duration(seconds: 5), | ||||
|         snackBarPosition: SnackBarPosition.top, | ||||
|         padding: EdgeInsets.only( | ||||
|           left: 16, | ||||
|           right: 16, | ||||
|           top: | ||||
|               (!kIsWeb && | ||||
|                       (Platform.isMacOS || | ||||
|                           Platform.isWindows || | ||||
|                           Platform.isLinux)) | ||||
|                   ? 24 | ||||
|                   // ignore: use_build_context_synchronously | ||||
|                   : MediaQuery.of(context).padding.top + 8, | ||||
|           bottom: 16, | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  | ||||
| Future<void> subscribePushNotification(Dio apiClient) async { | ||||
|   await FirebaseMessaging.instance.requestPermission( | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:io'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:receive_sharing_intent/receive_sharing_intent.dart'; | ||||
| import 'package:island/widgets/share/share_sheet.dart'; | ||||
| @@ -15,6 +17,7 @@ class SharingIntentService { | ||||
|  | ||||
|   /// Initialize the sharing intent service | ||||
|   void initialize(BuildContext context) { | ||||
|     if (kIsWeb || !(Platform.isIOS || Platform.isAndroid)) return; | ||||
|     debugPrint("SharingIntentService: Initializing with context"); | ||||
|     _context = context; | ||||
|     _setupSharingListeners(); | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter_popup_card/flutter_popup_card.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| @@ -105,7 +105,7 @@ class AccountProfileCard extends HookConsumerWidget { | ||||
|                       FilledButton.tonalIcon( | ||||
|                         onPressed: () { | ||||
|                           Navigator.pop(context); | ||||
|                           context.router.pushPath('/account/${data.name}'); | ||||
|                           context.push('/account/${data.name}'); | ||||
|                         }, | ||||
|                         icon: const Icon(Symbols.launch), | ||||
|                         label: Text('accountProfileView').tr(), | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:fl_chart/fl_chart.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/activity.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| @@ -66,7 +66,7 @@ class FortuneGraphWidget extends HookConsumerWidget { | ||||
|                 padding: EdgeInsets.zero, | ||||
|                 constraints: const BoxConstraints(), | ||||
|                 onPressed: () { | ||||
|                   context.router.pushNamed( | ||||
|                   context.pushNamed( | ||||
|                     '/account/$eventCalanderUser/calendar', | ||||
|                   ); | ||||
|                 }, | ||||
|   | ||||
| @@ -11,7 +11,7 @@ export 'content/alert.native.dart' | ||||
| void showSnackBar(String message, {SnackBarAction? action}) { | ||||
|   showTopSnackBar( | ||||
|     globalOverlay.currentState!, | ||||
|     Card(child: Text(message).padding(horizontal: 24, vertical: 16)), | ||||
|     Card(child: Text(message).padding(horizontal: 20, vertical: 16)), | ||||
|     snackBarPosition: SnackBarPosition.bottom, | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -1,235 +1,18 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:developer'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:collection/collection.dart'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/main.dart'; | ||||
| import 'package:island/models/user.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| part 'app_notification.freezed.dart'; | ||||
| part 'app_notification.g.dart'; | ||||
| class NotificationCard extends HookConsumerWidget { | ||||
|   final SnNotification notification; | ||||
|  | ||||
| class AppNotificationToast extends HookConsumerWidget { | ||||
|   const AppNotificationToast({super.key}); | ||||
|   const NotificationCard({super.key, required this.notification}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final notifications = ref.watch(appNotificationsProvider); | ||||
|  | ||||
|     // Create a global key for AnimatedList | ||||
|     final listKey = useMemoized(() => GlobalKey<AnimatedListState>()); | ||||
|  | ||||
|     // Track visual notification count (including those being animated out) | ||||
|     final visualCount = useState(notifications.length); | ||||
|  | ||||
|     // Track notifications being removed to manage visual count | ||||
|     final animatingOutIds = useState<Set<String>>({}); | ||||
|  | ||||
|     // Track previous notifications to detect changes | ||||
|     final previousNotifications = usePrevious(notifications) ?? []; | ||||
|  | ||||
|     // Handle notification changes | ||||
|     useEffect(() { | ||||
|       final currentIds = notifications.map((n) => n.data.id).toSet(); | ||||
|       final previousIds = previousNotifications.map((n) => n.data.id).toSet(); | ||||
|  | ||||
|       // Find new notifications (added) | ||||
|       final newIds = currentIds.difference(previousIds); | ||||
|  | ||||
|       // Update visual count for new notifications | ||||
|       if (newIds.isNotEmpty) { | ||||
|         visualCount.value += newIds.length; | ||||
|       } | ||||
|  | ||||
|       // Insert new notifications with animation | ||||
|       for (final id in newIds) { | ||||
|         final index = notifications.indexWhere((n) => n.data.id == id); | ||||
|         if (index != -1 && | ||||
|             listKey.currentState != null && | ||||
|             index >= 0 && | ||||
|             index <= notifications.length) { | ||||
|           try { | ||||
|             listKey.currentState!.insertItem( | ||||
|               index, | ||||
|               duration: const Duration(milliseconds: 150), | ||||
|             ); | ||||
|           } catch (e) { | ||||
|             // Log error but don't crash the app | ||||
|             debugPrint('Error inserting notification: $e'); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       return null; | ||||
|     }, [notifications]); | ||||
|  | ||||
|     return Positioned( | ||||
|       top: MediaQuery.of(context).padding.top + 50, | ||||
|       left: 16, | ||||
|       right: 16, | ||||
|       child: SizedBox( | ||||
|         // Use visualCount instead of notifications.length for height calculation | ||||
|         height: visualCount.value * 80, | ||||
|         child: AnimatedList( | ||||
|           physics: NeverScrollableScrollPhysics(), | ||||
|           padding: EdgeInsets.zero, | ||||
|           key: listKey, | ||||
|           initialItemCount: notifications.length, | ||||
|           itemBuilder: (context, index, animation) { | ||||
|             // Safely access notifications with bounds check | ||||
|             if (index >= notifications.length) { | ||||
|               return const SizedBox.shrink(); // Return empty widget if out of bounds | ||||
|             } | ||||
|  | ||||
|             final notification = notifications[index]; | ||||
|             final now = DateTime.now(); | ||||
|             final createdAt = notification.createdAt ?? now; | ||||
|             final duration = | ||||
|                 notification.duration ?? const Duration(seconds: 5); | ||||
|             final elapsedTime = now.difference(createdAt); | ||||
|             final remainingTime = duration - elapsedTime; | ||||
|             final progress = | ||||
|                 1.0 - | ||||
|                 (remainingTime.inMilliseconds / duration.inMilliseconds).clamp( | ||||
|                   0.0, | ||||
|                   1.0, | ||||
|                 ); // Ensure progress is clamped | ||||
|  | ||||
|             return SizeTransition( | ||||
|               sizeFactor: animation.drive( | ||||
|                 CurveTween(curve: Curves.fastLinearToSlowEaseIn), | ||||
|               ), | ||||
|               child: _NotificationCard( | ||||
|                 notification: notification, | ||||
|                 progress: progress.clamp(0.0, 1.0), | ||||
|                 onDismiss: () { | ||||
|                   // Find the current index before removal | ||||
|                   final currentIndex = notifications.indexWhere( | ||||
|                     (n) => n.data.id == notification.data.id, | ||||
|                   ); | ||||
|  | ||||
|                   // Add to animating out set | ||||
|                   final notificationId = notification.data.id; | ||||
|                   if (!animatingOutIds.value.contains(notificationId)) { | ||||
|                     animatingOutIds.value = { | ||||
|                       ...animatingOutIds.value, | ||||
|                       notificationId, | ||||
|                     }; | ||||
|                   } | ||||
|  | ||||
|                   if (currentIndex != -1 && | ||||
|                       listKey.currentState != null && | ||||
|                       currentIndex >= 0 && | ||||
|                       currentIndex < notifications.length) { | ||||
|                     try { | ||||
|                       // Remove the item with animation | ||||
|                       listKey.currentState!.removeItem( | ||||
|                         currentIndex, | ||||
|                         (context, animation) => SizeTransition( | ||||
|                           sizeFactor: animation.drive( | ||||
|                             CurveTween(curve: Curves.fastLinearToSlowEaseIn), | ||||
|                           ), | ||||
|                           child: _NotificationCard( | ||||
|                             notification: notification, | ||||
|                             progress: progress.clamp(0.0, 1.0), | ||||
|                             onDismiss: | ||||
|                                 () {}, // Empty because it's being removed | ||||
|                           ), | ||||
|                         ), | ||||
|                         duration: const Duration(milliseconds: 150), | ||||
|                         // When animation completes, update the visual count | ||||
|                       ); | ||||
|  | ||||
|                       // Schedule decrementing the visual count after animation completes | ||||
|                       Future.delayed(const Duration(milliseconds: 150), () { | ||||
|                         if (animatingOutIds.value.contains(notificationId)) { | ||||
|                           visualCount.value = | ||||
|                               visualCount.value > 0 ? visualCount.value - 1 : 0; | ||||
|                           animatingOutIds.value = | ||||
|                               animatingOutIds.value | ||||
|                                   .where((id) => id != notificationId) | ||||
|                                   .toSet(); | ||||
|                         } | ||||
|                       }); | ||||
|                     } catch (e) { | ||||
|                       // Log error but don't crash the app | ||||
|                       log('[Notification] Error removing notification: $e'); | ||||
|                       // Still update visual count in case of error | ||||
|                       visualCount.value = | ||||
|                           visualCount.value > 0 ? visualCount.value - 1 : 0; | ||||
|                       animatingOutIds.value = | ||||
|                           animatingOutIds.value | ||||
|                               .where((id) => id != notificationId) | ||||
|                               .toSet(); | ||||
|                     } | ||||
|                   } | ||||
|  | ||||
|                   // Actually remove from state | ||||
|                   ref | ||||
|                       .read(appNotificationsProvider.notifier) | ||||
|                       .removeNotification(notification); | ||||
|                 }, | ||||
|               ), | ||||
|             ); | ||||
|           }, | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _NotificationCard extends HookConsumerWidget { | ||||
|   final AppNotification notification; | ||||
|   final double progress; | ||||
|   final VoidCallback onDismiss; | ||||
|  | ||||
|   const _NotificationCard({ | ||||
|     required this.notification, | ||||
|     required this.progress, | ||||
|     required this.onDismiss, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     // Use state to track the current progress for smooth animation | ||||
|     final progressState = useState(progress); | ||||
|  | ||||
|     // Use effect to update progress smoothly | ||||
|     useEffect(() { | ||||
|       if (progress < 1.0) { | ||||
|         // Update progress every 16ms (roughly 60fps) for smooth animation | ||||
|         final timer = Timer.periodic(const Duration(milliseconds: 16), (_) { | ||||
|           final now = DateTime.now(); | ||||
|           final createdAt = notification.createdAt ?? now; | ||||
|           final duration = notification.duration ?? const Duration(seconds: 5); | ||||
|           final elapsedTime = now.difference(createdAt); | ||||
|           final remainingTime = duration - elapsedTime; | ||||
|           final newProgress = (1.0 - | ||||
|                   (remainingTime.inMilliseconds / duration.inMilliseconds)) | ||||
|               .clamp(0.0, 1.0); | ||||
|  | ||||
|           progressState.value = newProgress; | ||||
|  | ||||
|           // Auto-dismiss when complete | ||||
|           if (newProgress >= 1.0) { | ||||
|             onDismiss(); | ||||
|           } | ||||
|         }); | ||||
|  | ||||
|         return timer.cancel; | ||||
|       } | ||||
|       return null; | ||||
|     }, [notification.createdAt, notification.duration]); | ||||
|     final icon = Symbols.info; | ||||
|  | ||||
|     return Card( | ||||
|       elevation: 4, | ||||
| @@ -237,56 +20,23 @@ class _NotificationCard extends HookConsumerWidget { | ||||
|       shape: RoundedRectangleBorder( | ||||
|         borderRadius: BorderRadius.vertical(bottom: Radius.circular(8)), | ||||
|       ), | ||||
|       child: InkWell( | ||||
|         borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|         onTap: () { | ||||
|           if (notification.data.meta['action_uri'] != null) { | ||||
|             var uri = notification.data.meta['action_uri'] as String; | ||||
|             if (uri.startsWith('/')) { | ||||
|               // In-app routes | ||||
|               appRouter.pushPath(notification.data.meta['action_uri']); | ||||
|             } else { | ||||
|               // External URLs | ||||
|               launchUrlString(uri); | ||||
|             } | ||||
|             onDismiss(); | ||||
|           } | ||||
|         }, | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         mainAxisSize: MainAxisSize.min, | ||||
|         children: [ | ||||
|             // Progress indicator | ||||
|             if (progressState.value > 0 && progressState.value < 1.0) | ||||
|               AnimatedBuilder( | ||||
|                 animation: progressState, | ||||
|                 builder: (context, _) { | ||||
|                   return LinearProgressIndicator( | ||||
|                     borderRadius: BorderRadius.vertical( | ||||
|                       top: Radius.circular(16), | ||||
|                     ), | ||||
|                     value: 1.0 - progressState.value, | ||||
|                     backgroundColor: Colors.transparent, | ||||
|                     color: Theme.of(context).colorScheme.tertiary, | ||||
|                     minHeight: 3, | ||||
|                     stopIndicatorColor: Colors.transparent, | ||||
|                     stopIndicatorRadius: 0, | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.all(12), | ||||
|             child: Row( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                   if (notification.data.meta['avatar'] != null) | ||||
|                 if (notification.meta['pfp'] != null) | ||||
|                   ProfilePictureWidget( | ||||
|                       fileId: notification.data.meta['avatar'], | ||||
|                     fileId: notification.meta['pfp'], | ||||
|                     radius: 12, | ||||
|                   ).padding(right: 12, top: 2) | ||||
|                   else if (notification.icon != null) | ||||
|                 else | ||||
|                   Icon( | ||||
|                       notification.icon, | ||||
|                     icon, | ||||
|                     color: Theme.of(context).colorScheme.primary, | ||||
|                     size: 24, | ||||
|                   ).padding(right: 12), | ||||
| @@ -295,168 +45,28 @@ class _NotificationCard extends HookConsumerWidget { | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       Text( | ||||
|                           notification.data.title, | ||||
|                         notification.title, | ||||
|                         style: Theme.of(context).textTheme.titleMedium | ||||
|                             ?.copyWith(fontWeight: FontWeight.bold), | ||||
|                       ), | ||||
|                         if (notification.data.content.isNotEmpty) | ||||
|                       if (notification.content.isNotEmpty) | ||||
|                         Text( | ||||
|                             notification.data.content, | ||||
|                           notification.content, | ||||
|                           style: Theme.of(context).textTheme.bodyMedium, | ||||
|                         ), | ||||
|                         if (notification.data.subtitle.isNotEmpty) | ||||
|                       if (notification.subtitle.isNotEmpty) | ||||
|                         Text( | ||||
|                             notification.data.subtitle, | ||||
|                           notification.subtitle, | ||||
|                           style: Theme.of(context).textTheme.bodySmall, | ||||
|                         ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|                   IconButton( | ||||
|                     icon: const Icon(Symbols.close, size: 18), | ||||
|                     onPressed: onDismiss, | ||||
|                     padding: EdgeInsets.zero, | ||||
|                     constraints: const BoxConstraints(), | ||||
|                   ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| sealed class AppNotification with _$AppNotification { | ||||
|   const factory AppNotification({ | ||||
|     required SnNotification data, | ||||
|     @JsonKey(ignore: true) IconData? icon, | ||||
|     @JsonKey(ignore: true) Duration? duration, | ||||
|     @Default(null) DateTime? createdAt, | ||||
|     @Default(false) @JsonKey(ignore: true) bool isAnimatingOut, | ||||
|   }) = _AppNotification; | ||||
|  | ||||
|   factory AppNotification.fromJson(Map<String, dynamic> json) => | ||||
|       _$AppNotificationFromJson(json); | ||||
| } | ||||
|  | ||||
| // Using riverpod_generator for cleaner provider code | ||||
| @riverpod | ||||
| class AppNotifications extends _$AppNotifications { | ||||
|   StreamSubscription? _subscription; | ||||
|  | ||||
|   @override | ||||
|   List<AppNotification> build() { | ||||
|     ref.onDispose(() { | ||||
|       _subscription?.cancel(); | ||||
|     }); | ||||
|  | ||||
|     _initWebSocketListener(); | ||||
|     return []; | ||||
|   } | ||||
|  | ||||
|   void _initWebSocketListener() { | ||||
|     final service = ref.read(websocketProvider); | ||||
|     _subscription = service.dataStream.listen((packet) { | ||||
|       // Handle notification packets | ||||
|       if (packet.type == 'notifications.new') { | ||||
|         try { | ||||
|           final data = SnNotification.fromJson(packet.data!); | ||||
|  | ||||
|           IconData? icon; | ||||
|           switch (data.topic) { | ||||
|             case 'general': | ||||
|             default: | ||||
|               icon = Symbols.info; | ||||
|               break; | ||||
|           } | ||||
|  | ||||
|           addNotification( | ||||
|             AppNotification( | ||||
|               data: data, | ||||
|               icon: icon, | ||||
|               createdAt: data.createdAt.toLocal(), | ||||
|               duration: const Duration(seconds: 5), | ||||
|             ), | ||||
|           ); | ||||
|         } catch (e) { | ||||
|           log('[Notification] Error processing notification: $e'); | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   void addNotification(AppNotification notification) { | ||||
|     // Create a new notification with createdAt if not provided | ||||
|     final newNotification = | ||||
|         notification.createdAt == null | ||||
|             ? notification.copyWith(createdAt: DateTime.now()) | ||||
|             : notification; | ||||
|  | ||||
|     // Add to state | ||||
|     state = [...state, newNotification]; | ||||
|  | ||||
|     // Auto-remove notification after duration | ||||
|     final duration = newNotification.duration ?? const Duration(seconds: 5); | ||||
|     Future.delayed(duration, () { | ||||
|       // Find the notification in the current state | ||||
|       final notificationToRemove = state.firstWhereOrNull( | ||||
|         (n) => n.data.id == newNotification.data.id, | ||||
|       ); | ||||
|  | ||||
|       // Only proceed if the notification still exists in state | ||||
|       if (notificationToRemove != null) { | ||||
|         // Call removeNotification which will handle the animation | ||||
|         removeNotification(notificationToRemove); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // Map to track notifications that are being animated out | ||||
|   final Map<String, bool> _animatingNotifications = {}; | ||||
|  | ||||
|   // Map to track which notifications should animate out | ||||
|   final Map<String, bool> _animatingOutNotifications = {}; | ||||
|  | ||||
|   void removeNotification(AppNotification notification) { | ||||
|     final notificationId = notification.data.id; | ||||
|  | ||||
|     // If this notification is already being removed, don't do anything | ||||
|     if (_animatingNotifications[notificationId] == true) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Mark this notification as being removed | ||||
|     _animatingNotifications[notificationId] = true; | ||||
|  | ||||
|     // Remove from state immediately - AnimatedList handles the animation | ||||
|     state = state.where((n) => n.data.id != notificationId).toList(); | ||||
|  | ||||
|     // Clean up tracking | ||||
|     _animatingNotifications.remove(notificationId); | ||||
|     _animatingOutNotifications.remove(notificationId); | ||||
|   } | ||||
|  | ||||
|   // Helper method to check if a notification should animate out | ||||
|   bool isAnimatingOut(String notificationId) { | ||||
|     return _animatingOutNotifications[notificationId] == true; | ||||
|   } | ||||
|  | ||||
|   // Helper method to manually add a notification for testing | ||||
|   void showNotification({ | ||||
|     required SnNotification data, | ||||
|     IconData? icon, | ||||
|     Duration? duration, | ||||
|   }) { | ||||
|     addNotification( | ||||
|       AppNotification( | ||||
|         data: data, | ||||
|         icon: icon, | ||||
|         duration: duration, | ||||
|         createdAt: data.createdAt, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,190 +0,0 @@ | ||||
| // dart format width=80 | ||||
| // coverage:ignore-file | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark | ||||
|  | ||||
| part of 'app_notification.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // FreezedGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| // dart format off | ||||
| T _$identity<T>(T value) => value; | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$AppNotification implements DiagnosticableTreeMixin { | ||||
|  | ||||
|  SnNotification get data;@JsonKey(ignore: true) IconData? get icon;@JsonKey(ignore: true) Duration? get duration; DateTime? get createdAt;@JsonKey(ignore: true) bool get isAnimatingOut; | ||||
| /// Create a copy of AppNotification | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $AppNotificationCopyWith<AppNotification> get copyWith => _$AppNotificationCopyWithImpl<AppNotification>(this as AppNotification, _$identity); | ||||
|  | ||||
|   /// Serializes this AppNotification to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
|  | ||||
| @override | ||||
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||
|   properties | ||||
|     ..add(DiagnosticsProperty('type', 'AppNotification')) | ||||
|     ..add(DiagnosticsProperty('data', data))..add(DiagnosticsProperty('icon', icon))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('createdAt', createdAt))..add(DiagnosticsProperty('isAnimatingOut', isAnimatingOut)); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is AppNotification&&(identical(other.data, data) || other.data == data)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isAnimatingOut, isAnimatingOut) || other.isAnimatingOut == isAnimatingOut)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,data,icon,duration,createdAt,isAnimatingOut); | ||||
|  | ||||
| @override | ||||
| String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||
|   return 'AppNotification(data: $data, icon: $icon, duration: $duration, createdAt: $createdAt, isAnimatingOut: $isAnimatingOut)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class $AppNotificationCopyWith<$Res>  { | ||||
|   factory $AppNotificationCopyWith(AppNotification value, $Res Function(AppNotification) _then) = _$AppNotificationCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  SnNotification data,@JsonKey(ignore: true) IconData? icon,@JsonKey(ignore: true) Duration? duration, DateTime? createdAt,@JsonKey(ignore: true) bool isAnimatingOut | ||||
| }); | ||||
|  | ||||
|  | ||||
| $SnNotificationCopyWith<$Res> get data; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class _$AppNotificationCopyWithImpl<$Res> | ||||
|     implements $AppNotificationCopyWith<$Res> { | ||||
|   _$AppNotificationCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final AppNotification _self; | ||||
|   final $Res Function(AppNotification) _then; | ||||
|  | ||||
| /// Create a copy of AppNotification | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? data = null,Object? icon = freezed,Object? duration = freezed,Object? createdAt = freezed,Object? isAnimatingOut = null,}) { | ||||
|   return _then(_self.copyWith( | ||||
| data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable | ||||
| as SnNotification,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable | ||||
| as IconData?,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable | ||||
| as Duration?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,isAnimatingOut: null == isAnimatingOut ? _self.isAnimatingOut : isAnimatingOut // ignore: cast_nullable_to_non_nullable | ||||
| as bool, | ||||
|   )); | ||||
| } | ||||
| /// Create a copy of AppNotification | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnNotificationCopyWith<$Res> get data { | ||||
|    | ||||
|   return $SnNotificationCopyWith<$Res>(_self.data, (value) { | ||||
|     return _then(_self.copyWith(data: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _AppNotification with DiagnosticableTreeMixin implements AppNotification { | ||||
|   const _AppNotification({required this.data, @JsonKey(ignore: true) this.icon, @JsonKey(ignore: true) this.duration, this.createdAt = null, @JsonKey(ignore: true) this.isAnimatingOut = false}); | ||||
|   factory _AppNotification.fromJson(Map<String, dynamic> json) => _$AppNotificationFromJson(json); | ||||
|  | ||||
| @override final  SnNotification data; | ||||
| @override@JsonKey(ignore: true) final  IconData? icon; | ||||
| @override@JsonKey(ignore: true) final  Duration? duration; | ||||
| @override@JsonKey() final  DateTime? createdAt; | ||||
| @override@JsonKey(ignore: true) final  bool isAnimatingOut; | ||||
|  | ||||
| /// Create a copy of AppNotification | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$AppNotificationCopyWith<_AppNotification> get copyWith => __$AppNotificationCopyWithImpl<_AppNotification>(this, _$identity); | ||||
|  | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$AppNotificationToJson(this, ); | ||||
| } | ||||
| @override | ||||
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | ||||
|   properties | ||||
|     ..add(DiagnosticsProperty('type', 'AppNotification')) | ||||
|     ..add(DiagnosticsProperty('data', data))..add(DiagnosticsProperty('icon', icon))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('createdAt', createdAt))..add(DiagnosticsProperty('isAnimatingOut', isAnimatingOut)); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppNotification&&(identical(other.data, data) || other.data == data)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isAnimatingOut, isAnimatingOut) || other.isAnimatingOut == isAnimatingOut)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,data,icon,duration,createdAt,isAnimatingOut); | ||||
|  | ||||
| @override | ||||
| String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { | ||||
|   return 'AppNotification(data: $data, icon: $icon, duration: $duration, createdAt: $createdAt, isAnimatingOut: $isAnimatingOut)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class _$AppNotificationCopyWith<$Res> implements $AppNotificationCopyWith<$Res> { | ||||
|   factory _$AppNotificationCopyWith(_AppNotification value, $Res Function(_AppNotification) _then) = __$AppNotificationCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  SnNotification data,@JsonKey(ignore: true) IconData? icon,@JsonKey(ignore: true) Duration? duration, DateTime? createdAt,@JsonKey(ignore: true) bool isAnimatingOut | ||||
| }); | ||||
|  | ||||
|  | ||||
| @override $SnNotificationCopyWith<$Res> get data; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class __$AppNotificationCopyWithImpl<$Res> | ||||
|     implements _$AppNotificationCopyWith<$Res> { | ||||
|   __$AppNotificationCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final _AppNotification _self; | ||||
|   final $Res Function(_AppNotification) _then; | ||||
|  | ||||
| /// Create a copy of AppNotification | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? data = null,Object? icon = freezed,Object? duration = freezed,Object? createdAt = freezed,Object? isAnimatingOut = null,}) { | ||||
|   return _then(_AppNotification( | ||||
| data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable | ||||
| as SnNotification,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable | ||||
| as IconData?,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable | ||||
| as Duration?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,isAnimatingOut: null == isAnimatingOut ? _self.isAnimatingOut : isAnimatingOut // ignore: cast_nullable_to_non_nullable | ||||
| as bool, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| /// Create a copy of AppNotification | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnNotificationCopyWith<$Res> get data { | ||||
|    | ||||
|   return $SnNotificationCopyWith<$Res>(_self.data, (value) { | ||||
|     return _then(_self.copyWith(data: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
| // dart format on | ||||
| @@ -1,48 +0,0 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'app_notification.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| _AppNotification _$AppNotificationFromJson(Map<String, dynamic> json) => | ||||
|     _AppNotification( | ||||
|       data: SnNotification.fromJson(json['data'] as Map<String, dynamic>), | ||||
|       createdAt: | ||||
|           json['created_at'] == null | ||||
|               ? null | ||||
|               : DateTime.parse(json['created_at'] as String), | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$AppNotificationToJson(_AppNotification instance) => | ||||
|     <String, dynamic>{ | ||||
|       'data': instance.data.toJson(), | ||||
|       'created_at': instance.createdAt?.toIso8601String(), | ||||
|     }; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$appNotificationsHash() => r'a7e7e1d1533e329b000d4b294e455b8420ec3c4d'; | ||||
|  | ||||
| /// See also [AppNotifications]. | ||||
| @ProviderFor(AppNotifications) | ||||
| final appNotificationsProvider = AutoDisposeNotifierProvider< | ||||
|   AppNotifications, | ||||
|   List<AppNotification> | ||||
| >.internal( | ||||
|   AppNotifications.new, | ||||
|   name: r'appNotificationsProvider', | ||||
|   debugGetCreateSourceHash: | ||||
|       const bool.fromEnvironment('dart.vm.product') | ||||
|           ? null | ||||
|           : _$appNotificationsHash, | ||||
|   dependencies: null, | ||||
|   allTransitiveDependencies: null, | ||||
| ); | ||||
|  | ||||
| typedef _$AppNotifications = AutoDisposeNotifier<List<AppNotification>>; | ||||
| // ignore_for_file: type=lint | ||||
| // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package | ||||
| @@ -1,44 +1,47 @@ | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:bitsdojo_window/bitsdojo_window.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/route.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/widgets/app_notification.dart'; | ||||
| import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class WindowScaffold extends HookConsumerWidget { | ||||
|   final Widget child; | ||||
|   final AppRouter router; | ||||
|   const WindowScaffold({super.key, required this.child, required this.router}); | ||||
|   const WindowScaffold({super.key, required this.child}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     // Add window resize listener for desktop platforms | ||||
|     useEffect(() { | ||||
|       if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { | ||||
|       if (!kIsWeb && | ||||
|           (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { | ||||
|         void saveWindowSize() { | ||||
|           final size = appWindow.size; | ||||
|           final settingsNotifier = ref.read(appSettingsNotifierProvider.notifier); | ||||
|           final settingsNotifier = ref.read( | ||||
|             appSettingsNotifierProvider.notifier, | ||||
|           ); | ||||
|           settingsNotifier.setWindowSize(size); | ||||
|         } | ||||
|  | ||||
|         // Save window size when app is about to close | ||||
|         WidgetsBinding.instance.addObserver(_WindowSizeObserver(saveWindowSize)); | ||||
|         WidgetsBinding.instance.addObserver( | ||||
|           _WindowSizeObserver(saveWindowSize), | ||||
|         ); | ||||
|  | ||||
|         return () { | ||||
|           // Cleanup observer when widget is disposed | ||||
|           WidgetsBinding.instance.removeObserver(_WindowSizeObserver(saveWindowSize)); | ||||
|           WidgetsBinding.instance.removeObserver( | ||||
|             _WindowSizeObserver(saveWindowSize), | ||||
|           ); | ||||
|         }; | ||||
|       } | ||||
|       return null; | ||||
| @@ -106,7 +109,6 @@ class WindowScaffold extends HookConsumerWidget { | ||||
|               ], | ||||
|             ), | ||||
|             _WebSocketIndicator(), | ||||
|             AppNotificationToast(), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
| @@ -114,11 +116,7 @@ class WindowScaffold extends HookConsumerWidget { | ||||
|  | ||||
|     return Stack( | ||||
|       fit: StackFit.expand, | ||||
|       children: [ | ||||
|         Positioned.fill(child: child), | ||||
|         _WebSocketIndicator(), | ||||
|         AppNotificationToast(), | ||||
|       ], | ||||
|       children: [Positioned.fill(child: child), _WebSocketIndicator()], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -136,7 +134,8 @@ class _WindowSizeObserver extends WidgetsBindingObserver { | ||||
|     if (state == AppLifecycleState.paused || | ||||
|         state == AppLifecycleState.detached || | ||||
|         state == AppLifecycleState.hidden) { | ||||
|       if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { | ||||
|       if (!kIsWeb && | ||||
|           (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { | ||||
|         onSaveWindowSize(); | ||||
|       } | ||||
|     } | ||||
| @@ -144,7 +143,8 @@ class _WindowSizeObserver extends WidgetsBindingObserver { | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return other is _WindowSizeObserver && other.onSaveWindowSize == onSaveWindowSize; | ||||
|     return other is _WindowSizeObserver && | ||||
|         other.onSaveWindowSize == onSaveWindowSize; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -235,7 +235,7 @@ class PageBackButton extends StatelessWidget { | ||||
|     return IconButton( | ||||
|       onPressed: () { | ||||
|         onWillPop?.call(); | ||||
|         context.router.maybePop(); | ||||
|         context.pop(); | ||||
|       }, | ||||
|       icon: Icon( | ||||
|         color: color, | ||||
|   | ||||
| @@ -1,23 +1,30 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'dart:async'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/services/notify.dart'; | ||||
| import 'package:island/services/sharing_intent.dart'; | ||||
| import 'package:island/widgets/tour/tour.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class AppWrapper extends HookConsumerWidget { | ||||
|   const AppWrapper({super.key}); | ||||
|   final Widget child; | ||||
|   const AppWrapper({super.key, required this.child}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     useEffect(() { | ||||
|       StreamSubscription? ntySubs; | ||||
|       Future(() { | ||||
|         if (context.mounted) ntySubs = setupNotificationListener(context, ref); | ||||
|       }); | ||||
|       final sharingService = SharingIntentService(); | ||||
|       sharingService.initialize(context); | ||||
|       return () { | ||||
|         sharingService.dispose(); | ||||
|         ntySubs?.cancel(); | ||||
|       }; | ||||
|     }, const []); | ||||
|  | ||||
|     return AutoRouter(); | ||||
|     return TourTriggerWidget(child: child); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,11 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/chat.dart'; | ||||
| import 'package:island/pods/call.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
|  | ||||
| @@ -45,7 +44,7 @@ class AudioCallButton extends HookConsumerWidget { | ||||
|       try { | ||||
|         await apiClient.post('/chat/realtime/$roomId'); | ||||
|         if (context.mounted) { | ||||
|           context.router.push(CallRoute(roomId: roomId)); | ||||
|           context.push('/chat/call/roomId'); | ||||
|         } | ||||
|       } catch (e) { | ||||
|         showErrorAlert(e); | ||||
| @@ -97,7 +96,7 @@ class AudioCallButton extends HookConsumerWidget { | ||||
|         tooltip: 'Join Ongoing Call', | ||||
|         onPressed: () { | ||||
|           if (context.mounted) { | ||||
|             context.router.push(CallRoute(roomId: roomId)); | ||||
|             context.push('/chat/call/roomId'); | ||||
|           } | ||||
|         }, | ||||
|       ); | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/pods/call.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/chat/call_participant_tile.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| @@ -175,14 +175,7 @@ class CallControlsBar extends HookConsumerWidget { | ||||
|         }, | ||||
|       ); | ||||
|     } catch (e) { | ||||
|       if (context.mounted) { | ||||
|         ScaffoldMessenger.of(context).showSnackBar( | ||||
|           SnackBar( | ||||
|             content: Text('${'failedToEnumerateDevices'.tr()}: $e'), | ||||
|             backgroundColor: Colors.red, | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|       showErrorAlert(e); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -367,7 +360,7 @@ class CallOverlayBar extends HookConsumerWidget { | ||||
|         ).padding(all: 16), | ||||
|       ), | ||||
|       onTap: () { | ||||
|         context.router.push(CallRoute(roomId: callNotifier.roomId!)); | ||||
|         context.push('/chat/call/callNotifier.roomId!'); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -233,16 +233,27 @@ class MessageItem extends HookConsumerWidget { | ||||
|                           if (remoteMessage.meta['embeds'] != null) | ||||
|                             ...((remoteMessage.meta['embeds'] as List<dynamic>) | ||||
|                                 .where((embed) => embed['Type'] == 'link') | ||||
|                                 .map((embed) => SnEmbedLink.fromJson(embed as Map<String, dynamic>)) | ||||
|                                 .map((link) => LayoutBuilder( | ||||
|                                 .map( | ||||
|                                   (embed) => SnEmbedLink.fromJson( | ||||
|                                     embed as Map<String, dynamic>, | ||||
|                                   ), | ||||
|                                 ) | ||||
|                                 .map( | ||||
|                                   (link) => LayoutBuilder( | ||||
|                                     builder: (context, constraints) { | ||||
|                                       return EmbedLinkWidget( | ||||
|                                         link: link, | ||||
|                                           maxWidth: math.min(constraints.maxWidth, 480), | ||||
|                                           margin: const EdgeInsets.symmetric(vertical: 4), | ||||
|                                         maxWidth: math.min( | ||||
|                                           constraints.maxWidth, | ||||
|                                           480, | ||||
|                                         ), | ||||
|                                         margin: const EdgeInsets.symmetric( | ||||
|                                           vertical: 4, | ||||
|                                         ), | ||||
|                                       ); | ||||
|                                     }, | ||||
|                                     )) | ||||
|                                   ), | ||||
|                                 ) | ||||
|                                 .toList()), | ||||
|                           if (progress != null && progress!.isNotEmpty) | ||||
|                             Column( | ||||
| @@ -482,7 +493,11 @@ class _MessageItemContent extends StatelessWidget { | ||||
|         ); | ||||
|       case 'text': | ||||
|       default: | ||||
|         return MarkdownTextContent(content: item.content!, isSelectable: true); | ||||
|         return MarkdownTextContent( | ||||
|           content: item.content!, | ||||
|           isSelectable: true, | ||||
|           linesMargin: EdgeInsets.zero, | ||||
|         ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,15 +1,14 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/activity.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/screens/auth/captcha.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| @@ -137,7 +136,7 @@ class CheckInWidget extends HookConsumerWidget { | ||||
|               if (todayResult.valueOrNull == null) { | ||||
|                 checkIn(); | ||||
|               } else { | ||||
|                 context.router.push(EventCalanderRoute(name: 'me')); | ||||
|                 context.push('/account/me/calendar'); | ||||
|               } | ||||
|             }, | ||||
|             icon: AnimatedSwitcher( | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| 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:path/path.dart' show extension; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| @@ -185,13 +186,7 @@ class CloudFileZoomIn extends HookConsumerWidget { | ||||
|     Future<void> saveToGallery() async { | ||||
|       try { | ||||
|         // Show loading indicator | ||||
|         final scaffold = ScaffoldMessenger.of(context); | ||||
|         scaffold.showSnackBar( | ||||
|           const SnackBar( | ||||
|             content: Text('Saving image to gallery...'), | ||||
|             duration: Duration(seconds: 1), | ||||
|           ), | ||||
|         ); | ||||
|         showSnackBar('Saving image to gallery...'); | ||||
|  | ||||
|         // Get the image URL | ||||
|         final client = ref.watch(apiClientProvider); | ||||
| @@ -208,21 +203,9 @@ class CloudFileZoomIn extends HookConsumerWidget { | ||||
|         await Gal.putImage(filePath, album: 'Solar Network'); | ||||
|  | ||||
|         // Show success message | ||||
|         scaffold.showSnackBar( | ||||
|           const SnackBar( | ||||
|             content: Text('Image saved to gallery'), | ||||
|             duration: Duration(seconds: 2), | ||||
|           ), | ||||
|         ); | ||||
|         showSnackBar('Image saved to gallery'); | ||||
|       } catch (e) { | ||||
|         // Show error message | ||||
|         if (!context.mounted) return; | ||||
|         ScaffoldMessenger.of(context).showSnackBar( | ||||
|           SnackBar( | ||||
|             content: Text('Failed to save image: $e'), | ||||
|             duration: const Duration(seconds: 2), | ||||
|           ), | ||||
|         ); | ||||
|         showErrorAlert(e); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_highlight/themes/a11y-dark.dart'; | ||||
| import 'package:flutter_highlight/themes/a11y-light.dart'; | ||||
| @@ -74,7 +74,7 @@ class MarkdownTextContent extends HookConsumerWidget { | ||||
|               final url = Uri.tryParse(href); | ||||
|               if (url != null) { | ||||
|                 if (url.scheme == 'solian') { | ||||
|                   context.router.pushPath( | ||||
|                   context.push( | ||||
|                     ['', url.host, ...url.pathSegments].join('/'), | ||||
|                   ); | ||||
|                   return; | ||||
|   | ||||
							
								
								
									
										27
									
								
								lib/widgets/navigation/conditional_bottom_nav.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								lib/widgets/navigation/conditional_bottom_nav.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
|  | ||||
| class ConditionalBottomNav extends HookConsumerWidget { | ||||
|   final Widget child; | ||||
|   const ConditionalBottomNav({super.key, required this.child}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final currentLocation = GoRouterState.of(context).uri.toString(); | ||||
|  | ||||
|     // Force rebuild when route changes | ||||
|     useEffect(() { | ||||
|       // This effect will run whenever currentLocation changes | ||||
|       return null; | ||||
|     }, [currentLocation]); | ||||
|  | ||||
|     // Use the same route logic as TabsScreen for consistency | ||||
|     const mainTabRoutes = ['/', '/chat', '/realms', '/account']; | ||||
|  | ||||
|     final shouldShowBottomNav = mainTabRoutes.contains(currentLocation); | ||||
|  | ||||
|     return shouldShowBottomNav ? child : const SizedBox.shrink(); | ||||
|   } | ||||
| } | ||||
| @@ -106,7 +106,9 @@ class _PaymentContent extends ConsumerStatefulWidget { | ||||
|  | ||||
| class _PaymentContentState extends ConsumerState<_PaymentContent> { | ||||
|   static const String _pinStorageKey = 'app_pin_code'; | ||||
|   static final _secureStorage = FlutterSecureStorage(); | ||||
|   static final _secureStorage = FlutterSecureStorage( | ||||
|     aOptions: AndroidOptions(encryptedSharedPreferences: true), | ||||
|   ); | ||||
|  | ||||
|   final LocalAuthentication _localAuth = LocalAuthentication(); | ||||
|  | ||||
|   | ||||
| @@ -4,12 +4,107 @@ import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:textfield_tags/textfield_tags.dart'; | ||||
|  | ||||
| /// A reusable widget for tag input fields with chip display | ||||
| class ChipTagInputField extends StatelessWidget { | ||||
|   final InputFieldValues inputFieldValues; | ||||
|   final String labelText; | ||||
|   final String hintText; | ||||
|  | ||||
|   const ChipTagInputField({ | ||||
|     super.key, | ||||
|     required this.inputFieldValues, | ||||
|     required this.labelText, | ||||
|     required this.hintText, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return TextField( | ||||
|       controller: inputFieldValues.textEditingController, | ||||
|       focusNode: inputFieldValues.focusNode, | ||||
|       decoration: InputDecoration( | ||||
|         label: Text(labelText).tr(), | ||||
|         border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), | ||||
|         contentPadding: const EdgeInsets.all(16), | ||||
|         hintText: inputFieldValues.tags.isNotEmpty ? '' : hintText.tr(), | ||||
|         errorText: inputFieldValues.error, | ||||
|         prefixIconConstraints: BoxConstraints( | ||||
|           maxWidth: MediaQuery.of(context).size.width * 0.8, | ||||
|         ), | ||||
|         prefixIcon: | ||||
|             inputFieldValues.tags.isNotEmpty | ||||
|                 ? SingleChildScrollView( | ||||
|                   controller: inputFieldValues.tagScrollController, | ||||
|                   scrollDirection: Axis.vertical, | ||||
|                   child: Padding( | ||||
|                     padding: const EdgeInsets.only(top: 8, bottom: 8, left: 8), | ||||
|                     child: Wrap( | ||||
|                       runSpacing: 4.0, | ||||
|                       spacing: 4.0, | ||||
|                       children: | ||||
|                           inputFieldValues.tags.map<Widget>((dynamic tag) { | ||||
|                             return Container( | ||||
|                               decoration: BoxDecoration( | ||||
|                                 borderRadius: BorderRadius.all( | ||||
|                                   Radius.circular(20.0), | ||||
|                                 ), | ||||
|                                 color: Theme.of(context).colorScheme.primary, | ||||
|                               ), | ||||
|                               margin: const EdgeInsets.only(left: 5), | ||||
|                               padding: const EdgeInsets.symmetric( | ||||
|                                 horizontal: 10.0, | ||||
|                                 vertical: 5.0, | ||||
|                               ), | ||||
|                               child: Row( | ||||
|                                 mainAxisAlignment: MainAxisAlignment.start, | ||||
|                                 mainAxisSize: MainAxisSize.min, | ||||
|                                 children: [ | ||||
|                                   InkWell( | ||||
|                                     child: Text( | ||||
|                                       '#$tag', | ||||
|                                       style: TextStyle( | ||||
|                                         color: | ||||
|                                             Theme.of( | ||||
|                                               context, | ||||
|                                             ).colorScheme.onPrimary, | ||||
|                                       ), | ||||
|                                     ), | ||||
|                                   ), | ||||
|                                   const Gap(4), | ||||
|                                   InkWell( | ||||
|                                     child: const Icon( | ||||
|                                       Icons.cancel, | ||||
|                                       size: 14.0, | ||||
|                                       color: Color.fromARGB(255, 233, 233, 233), | ||||
|                                     ), | ||||
|                                     onTap: () { | ||||
|                                       inputFieldValues.onTagRemoved(tag); | ||||
|                                     }, | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ), | ||||
|                             ); | ||||
|                           }).toList(), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ) | ||||
|                 : null, | ||||
|       ), | ||||
|       onChanged: inputFieldValues.onTagChanged, | ||||
|       onSubmitted: inputFieldValues.onTagSubmitted, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class ComposeSettingsSheet extends HookWidget { | ||||
|   final TextEditingController titleController; | ||||
|   final TextEditingController descriptionController; | ||||
|   final ValueNotifier<int> visibility; | ||||
|   final VoidCallback? onVisibilityChanged; | ||||
|   final StringTagController tagsController; | ||||
|   final StringTagController categoriesController; | ||||
|  | ||||
|   const ComposeSettingsSheet({ | ||||
|     super.key, | ||||
| @@ -17,6 +112,8 @@ class ComposeSettingsSheet extends HookWidget { | ||||
|     required this.descriptionController, | ||||
|     required this.visibility, | ||||
|     this.onVisibilityChanged, | ||||
|     required this.tagsController, | ||||
|     required this.categoriesController, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
| @@ -117,6 +214,7 @@ class ComposeSettingsSheet extends HookWidget { | ||||
|         padding: const EdgeInsets.all(16), | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           spacing: 16, | ||||
|           children: [ | ||||
|             // Title field | ||||
|             TextField( | ||||
| @@ -133,7 +231,6 @@ class ComposeSettingsSheet extends HookWidget { | ||||
|               onTapOutside: | ||||
|                   (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|             ), | ||||
|             const Gap(16), | ||||
|  | ||||
|             // Description field | ||||
|             TextField( | ||||
| @@ -151,7 +248,45 @@ class ComposeSettingsSheet extends HookWidget { | ||||
|               onTapOutside: | ||||
|                   (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|             ), | ||||
|             const Gap(16), | ||||
|  | ||||
|             // Tags field | ||||
|             TextFieldTags( | ||||
|               textfieldTagsController: tagsController, | ||||
|               textSeparators: const [' ', ','], | ||||
|               letterCase: LetterCase.normal, | ||||
|               validator: (String tag) { | ||||
|                 if (tag.isEmpty) { | ||||
|                   return 'No, cannot be empty'; | ||||
|                 } | ||||
|                 return null; | ||||
|               }, | ||||
|               inputFieldBuilder: (context, inputFieldValues) { | ||||
|                 return ChipTagInputField( | ||||
|                   inputFieldValues: inputFieldValues, | ||||
|                   labelText: 'tags', | ||||
|                   hintText: 'tagsHint', | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|  | ||||
|             // Categories field | ||||
|             TextFieldTags( | ||||
|               textfieldTagsController: categoriesController, | ||||
|               textSeparators: const [' ', ','], | ||||
|               letterCase: LetterCase.small, | ||||
|               validator: (String tag) { | ||||
|                 if (tag.isEmpty) return 'No, cannot be empty'; | ||||
|                 if (tag.contains(' ')) return 'Tags should be URL-safe'; | ||||
|                 return null; | ||||
|               }, | ||||
|               inputFieldBuilder: (context, inputFieldValues) { | ||||
|                 return ChipTagInputField( | ||||
|                   inputFieldValues: inputFieldValues, | ||||
|                   labelText: 'categories', | ||||
|                   hintText: 'categoriesHint', | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|  | ||||
|             // Visibility setting | ||||
|             Container( | ||||
|   | ||||
| @@ -16,34 +16,42 @@ import 'package:pasteboard/pasteboard.dart'; | ||||
| import 'dart:async'; | ||||
| import 'dart:developer'; | ||||
|  | ||||
| import 'package:textfield_tags/textfield_tags.dart'; | ||||
|  | ||||
| class ComposeState { | ||||
|   final ValueNotifier<List<UniversalFile>> attachments; | ||||
|   final TextEditingController titleController; | ||||
|   final TextEditingController descriptionController; | ||||
|   final TextEditingController contentController; | ||||
|   final ValueNotifier<int> visibility; | ||||
|   final ValueNotifier<bool> submitting; | ||||
|   final ValueNotifier<List<UniversalFile>> attachments; | ||||
|   final ValueNotifier<Map<int, double>> attachmentProgress; | ||||
|   final ValueNotifier<SnPublisher?> currentPublisher; | ||||
|   final ValueNotifier<bool> submitting; | ||||
|   StringTagController tagsController; | ||||
|   StringTagController categoriesController; | ||||
|   final String draftId; | ||||
|   int postType; | ||||
|   Timer? _autoSaveTimer; | ||||
|  | ||||
|   ComposeState({ | ||||
|     required this.attachments, | ||||
|     required this.titleController, | ||||
|     required this.descriptionController, | ||||
|     required this.contentController, | ||||
|     required this.visibility, | ||||
|     required this.submitting, | ||||
|     required this.attachments, | ||||
|     required this.attachmentProgress, | ||||
|     required this.currentPublisher, | ||||
|     required this.submitting, | ||||
|     required this.tagsController, | ||||
|     required this.categoriesController, | ||||
|     required this.draftId, | ||||
|     this.postType = 0, | ||||
|   }); | ||||
|  | ||||
|   void startAutoSave(WidgetRef ref, {int postType = 0}) { | ||||
|   void startAutoSave(WidgetRef ref) { | ||||
|     _autoSaveTimer?.cancel(); | ||||
|     _autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) { | ||||
|       ComposeLogic.saveDraftWithoutUpload(ref, this, postType: postType); | ||||
|       ComposeLogic.saveDraftWithoutUpload(ref, this); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
| @@ -59,9 +67,15 @@ class ComposeLogic { | ||||
|     SnPost? forwardedPost, | ||||
|     SnPost? repliedPost, | ||||
|     String? draftId, | ||||
|     int postType = 0, | ||||
|   }) { | ||||
|     final id = draftId ?? DateTime.now().millisecondsSinceEpoch.toString(); | ||||
|  | ||||
|     final tagsController = StringTagController(); | ||||
|     final categoriesController = StringTagController(); | ||||
|     originalPost?.tags.forEach((x) => tagsController.addTag(x.slug)); | ||||
|     originalPost?.categories.forEach( | ||||
|       (x) => categoriesController.addTag(x.slug), | ||||
|     ); | ||||
|     return ComposeState( | ||||
|       attachments: ValueNotifier<List<UniversalFile>>( | ||||
|         originalPost?.attachments | ||||
| @@ -86,17 +100,32 @@ class ComposeLogic { | ||||
|       contentController: TextEditingController( | ||||
|         text: | ||||
|             originalPost?.content ?? | ||||
|             (forwardedPost != null ? '> ${forwardedPost.content}\n\n' : null), | ||||
|             (forwardedPost != null | ||||
|                 ? '''> ${forwardedPost.content} | ||||
|  | ||||
| ''' | ||||
|                 : null), | ||||
|       ), | ||||
|       visibility: ValueNotifier<int>(originalPost?.visibility ?? 0), | ||||
|       submitting: ValueNotifier<bool>(false), | ||||
|       attachmentProgress: ValueNotifier<Map<int, double>>({}), | ||||
|       currentPublisher: ValueNotifier<SnPublisher?>(null), | ||||
|       tagsController: tagsController, | ||||
|       categoriesController: categoriesController, | ||||
|       draftId: id, | ||||
|       postType: postType, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   static ComposeState createStateFromDraft(SnPost draft) { | ||||
|   static ComposeState createStateFromDraft(SnPost draft, {int postType = 0}) { | ||||
|     final tagsController = StringTagController(); | ||||
|     final categoriesController = StringTagController(); | ||||
|     for (var x in draft.tags) { | ||||
|       tagsController.addTag(x.slug); | ||||
|     } | ||||
|     for (var x in draft.categories) { | ||||
|       categoriesController.addTag(x.slug); | ||||
|     } | ||||
|     return ComposeState( | ||||
|       attachments: ValueNotifier<List<UniversalFile>>( | ||||
|         draft.attachments.map((e) => UniversalFile.fromAttachment(e)).toList(), | ||||
| @@ -108,15 +137,14 @@ class ComposeLogic { | ||||
|       submitting: ValueNotifier<bool>(false), | ||||
|       attachmentProgress: ValueNotifier<Map<int, double>>({}), | ||||
|       currentPublisher: ValueNotifier<SnPublisher?>(null), | ||||
|       tagsController: tagsController, | ||||
|       categoriesController: categoriesController, | ||||
|       draftId: draft.id, | ||||
|       postType: postType, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   static Future<void> saveDraft( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, { | ||||
|     int postType = 0, | ||||
|   }) async { | ||||
|   static Future<void> saveDraft(WidgetRef ref, ComposeState state) async { | ||||
|     final hasContent = | ||||
|         state.titleController.text.trim().isNotEmpty || | ||||
|         state.descriptionController.text.trim().isNotEmpty || | ||||
| @@ -148,7 +176,7 @@ class ComposeLogic { | ||||
|                   baseUrl: baseUrl, | ||||
|                   filename: | ||||
|                       attachment.data.name ?? | ||||
|                       (postType == 1 ? 'Article media' : 'Post media'), | ||||
|                       (state.postType == 1 ? 'Article media' : 'Post media'), | ||||
|                   mimetype: | ||||
|                       attachment.data.mimeType ?? | ||||
|                       ComposeLogic.getMimeTypeFromFileType(attachment.type), | ||||
| @@ -175,7 +203,7 @@ class ComposeLogic { | ||||
|         publishedAt: DateTime.now(), | ||||
|         visibility: state.visibility.value, | ||||
|         content: state.contentController.text, | ||||
|         type: postType, | ||||
|         type: state.postType, | ||||
|         meta: null, | ||||
|         viewsUnique: 0, | ||||
|         viewsTotal: 0, | ||||
| @@ -225,9 +253,8 @@ class ComposeLogic { | ||||
|  | ||||
|   static Future<void> saveDraftWithoutUpload( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, { | ||||
|     int postType = 0, | ||||
|   }) async { | ||||
|     ComposeState state, | ||||
|   ) async { | ||||
|     final hasContent = | ||||
|         state.titleController.text.trim().isNotEmpty || | ||||
|         state.descriptionController.text.trim().isNotEmpty || | ||||
| @@ -252,7 +279,7 @@ class ComposeLogic { | ||||
|         publishedAt: DateTime.now(), | ||||
|         visibility: state.visibility.value, | ||||
|         content: state.contentController.text, | ||||
|         type: postType, | ||||
|         type: state.postType, | ||||
|         meta: null, | ||||
|         viewsUnique: 0, | ||||
|         viewsTotal: 0, | ||||
| @@ -306,54 +333,7 @@ class ComposeLogic { | ||||
|     BuildContext context, | ||||
|   ) async { | ||||
|     try { | ||||
|       final draft = SnPost( | ||||
|         id: state.draftId, | ||||
|         title: state.titleController.text, | ||||
|         description: state.descriptionController.text, | ||||
|         language: null, | ||||
|         editedAt: null, | ||||
|         publishedAt: DateTime.now(), | ||||
|         visibility: state.visibility.value, | ||||
|         content: state.contentController.text, | ||||
|         type: 0, | ||||
|         meta: null, | ||||
|         viewsUnique: 0, | ||||
|         viewsTotal: 0, | ||||
|         upvotes: 0, | ||||
|         downvotes: 0, | ||||
|         repliesCount: 0, | ||||
|         threadedPostId: null, | ||||
|         threadedPost: null, | ||||
|         repliedPostId: null, | ||||
|         repliedPost: null, | ||||
|         forwardedPostId: null, | ||||
|         forwardedPost: null, | ||||
|         attachments: [], // TODO: Handle attachments | ||||
|         publisher: SnPublisher( | ||||
|           id: '', | ||||
|           type: 0, | ||||
|           name: '', | ||||
|           nick: '', | ||||
|           picture: null, | ||||
|           background: null, | ||||
|           account: null, | ||||
|           accountId: null, | ||||
|           createdAt: DateTime.now(), | ||||
|           updatedAt: DateTime.now(), | ||||
|           deletedAt: null, | ||||
|           realmId: null, | ||||
|           verification: null, | ||||
|         ), | ||||
|         reactions: [], | ||||
|         tags: [], | ||||
|         categories: [], | ||||
|         collections: [], | ||||
|         createdAt: DateTime.now(), | ||||
|         updatedAt: DateTime.now(), | ||||
|         deletedAt: null, | ||||
|       ); | ||||
|  | ||||
|       await ref.read(composeStorageNotifierProvider.notifier).saveDraft(draft); | ||||
|       await saveDraft(ref, state); | ||||
|  | ||||
|       if (context.mounted) { | ||||
|         showSnackBar('draftSaved'.tr()); | ||||
| @@ -508,7 +488,6 @@ class ComposeLogic { | ||||
|     SnPost? originalPost, | ||||
|     SnPost? repliedPost, | ||||
|     SnPost? forwardedPost, | ||||
|     int? postType, // 0 for regular post, 1 for article | ||||
|   }) async { | ||||
|     if (state.submitting.value) return; | ||||
|  | ||||
| @@ -554,9 +533,11 @@ class ComposeLogic { | ||||
|                 .where((e) => e.isOnCloud) | ||||
|                 .map((e) => e.data.id) | ||||
|                 .toList(), | ||||
|         if (postType != null) 'type': postType, | ||||
|         'type': state.postType, | ||||
|         if (repliedPost != null) 'replied_post_id': repliedPost.id, | ||||
|         if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id, | ||||
|         'tags': state.tagsController.getTags, | ||||
|         'categories': state.categoriesController.getTags, | ||||
|       }; | ||||
|  | ||||
|       // Send request | ||||
| @@ -570,7 +551,7 @@ class ComposeLogic { | ||||
|       ); | ||||
|  | ||||
|       // Delete draft after successful submission | ||||
|       if (postType == 1) { | ||||
|       if (state.postType == 1) { | ||||
|         // Delete article draft | ||||
|         await ref | ||||
|             .read(composeStorageNotifierProvider.notifier) | ||||
| @@ -613,7 +594,6 @@ class ComposeLogic { | ||||
|     SnPost? originalPost, | ||||
|     SnPost? repliedPost, | ||||
|     SnPost? forwardedPost, | ||||
|     int? postType, | ||||
|   }) { | ||||
|     if (event is! RawKeyDownEvent) return; | ||||
|  | ||||
| @@ -634,7 +614,6 @@ class ComposeLogic { | ||||
|         originalPost: originalPost, | ||||
|         repliedPost: repliedPost, | ||||
|         forwardedPost: forwardedPost, | ||||
|         postType: postType, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| @@ -649,5 +628,7 @@ class ComposeLogic { | ||||
|     state.submitting.dispose(); | ||||
|     state.attachmentProgress.dispose(); | ||||
|     state.currentPublisher.dispose(); | ||||
|     state.tagsController.dispose(); | ||||
|     state.categoriesController.dispose(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/services/compose_storage_db.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
|  | ||||
| class DraftManagerSheet extends HookConsumerWidget { | ||||
| @@ -43,9 +44,9 @@ class DraftManagerSheet extends HookConsumerWidget { | ||||
|       ], | ||||
|     ); | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: AppBar(title: Text('drafts'.tr())), | ||||
|       body: | ||||
|     return SheetScaffold( | ||||
|       titleText: 'drafts'.tr(), | ||||
|       child: | ||||
|           isLoading.value | ||||
|               ? const Center(child: CircularProgressIndicator()) | ||||
|               : Column( | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| @@ -8,9 +8,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'dart:math' as math; | ||||
| import 'package:island/models/embed.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/widgets/account/account_name.dart'; | ||||
| @@ -20,6 +20,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/safety/abuse_report_helper.dart'; | ||||
| import 'package:island/widgets/post/post_replies_sheet.dart'; | ||||
| import 'package:island/widgets/share/share_sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| @@ -70,7 +71,7 @@ class PostItem extends HookConsumerWidget { | ||||
|                 title: 'edit'.tr(), | ||||
|                 image: MenuImage.icon(Symbols.edit), | ||||
|                 callback: () { | ||||
|                   context.router.push(PostEditRoute(id: item.id)).then((value) { | ||||
|                   context.push('/posts/${item.id}/edit').then((value) { | ||||
|                     if (value != null) { | ||||
|                       onRefresh?.call(); | ||||
|                     } | ||||
| @@ -115,28 +116,39 @@ class PostItem extends HookConsumerWidget { | ||||
|               title: 'reply'.tr(), | ||||
|               image: MenuImage.icon(Symbols.reply), | ||||
|               callback: () { | ||||
|                 context.router.push(PostComposeRoute(repliedPost: item)); | ||||
|                 context.push('/posts/compose', extra: {'repliedPost': item}); | ||||
|               }, | ||||
|             ), | ||||
|             MenuAction( | ||||
|               title: 'forward'.tr(), | ||||
|               image: MenuImage.icon(Symbols.forward), | ||||
|               callback: () { | ||||
|                 context.router.push(PostComposeRoute(forwardedPost: item)); | ||||
|                 context.push('/posts/compose', extra: {'forwardedPost': item}); | ||||
|               }, | ||||
|             ), | ||||
|             MenuSeparator(), | ||||
|             MenuAction( | ||||
|               title: 'share'.tr(), | ||||
|               image: MenuImage.icon(Symbols.share), | ||||
|               callback: () { | ||||
|                 showShareSheetLink( | ||||
|                   context: context, | ||||
|                   link: 'https://solsynth.dev/posts/${item.id}', | ||||
|                   link: '${ref.read(serverUrlProvider)}/posts/${item.id}', | ||||
|                   title: 'sharePost'.tr(), | ||||
|                   toSystem: true, | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|             MenuAction( | ||||
|               title: 'abuseReport'.tr(), | ||||
|               image: MenuImage.icon(Symbols.flag), | ||||
|               callback: () { | ||||
|                 showAbuseReportSheet( | ||||
|                   context, | ||||
|                   resourceIdentifier: 'posts:${item.id}', | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|           ], | ||||
|         ); | ||||
|       }, | ||||
| @@ -155,9 +167,7 @@ class PostItem extends HookConsumerWidget { | ||||
|                   GestureDetector( | ||||
|                     child: ProfilePictureWidget(file: item.publisher.picture), | ||||
|                     onTap: () { | ||||
|                       context.router.push( | ||||
|                         PublisherProfileRoute(name: item.publisher.name), | ||||
|                       ); | ||||
|                       context.push('/publishers/${item.publisher.name}'); | ||||
|                     }, | ||||
|                   ), | ||||
|                   Expanded( | ||||
| @@ -232,6 +242,57 @@ class PostItem extends HookConsumerWidget { | ||||
|                                       ? EdgeInsets.only(bottom: 8) | ||||
|                                       : null, | ||||
|                             ), | ||||
|                           // Render tags and categories if they exist | ||||
|                           if (item.tags.isNotEmpty || | ||||
|                               item.categories.isNotEmpty) | ||||
|                             Column( | ||||
|                               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                               children: [ | ||||
|                                 if (item.tags.isNotEmpty) | ||||
|                                   Wrap( | ||||
|                                     children: [ | ||||
|                                       for (final tag in item.tags) | ||||
|                                         InkWell( | ||||
|                                           child: Row( | ||||
|                                             spacing: 4, | ||||
|                                             children: [ | ||||
|                                               const Icon( | ||||
|                                                 Symbols.label, | ||||
|                                                 size: 13, | ||||
|                                               ), | ||||
|                                               Text( | ||||
|                                                 tag.name ?? '#${tag.slug}', | ||||
|                                               ).fontSize(13), | ||||
|                                             ], | ||||
|                                           ), | ||||
|                                           onTap: () {}, | ||||
|                                         ), | ||||
|                                     ], | ||||
|                                   ), | ||||
|                                 if (item.categories.isNotEmpty) | ||||
|                                   Wrap( | ||||
|                                     children: [ | ||||
|                                       for (final category in item.categories) | ||||
|                                         InkWell( | ||||
|                                           child: Row( | ||||
|                                             spacing: 4, | ||||
|                                             children: [ | ||||
|                                               const Icon( | ||||
|                                                 Symbols.category, | ||||
|                                                 size: 13, | ||||
|                                               ), | ||||
|                                               Text( | ||||
|                                                 category.name ?? | ||||
|                                                     '#${category.slug}', | ||||
|                                               ).fontSize(13), | ||||
|                                             ], | ||||
|                                           ), | ||||
|                                           onTap: () {}, | ||||
|                                         ), | ||||
|                                     ], | ||||
|                                   ), | ||||
|                               ], | ||||
|                             ), | ||||
|                           // Show truncation hint if post is truncated | ||||
|                           if (item.isTruncated && !isFullPost) | ||||
|                             _PostTruncateHint().padding( | ||||
| @@ -273,7 +334,7 @@ class PostItem extends HookConsumerWidget { | ||||
|                       ), | ||||
|                       onTap: () { | ||||
|                         if (isOpenable) { | ||||
|                           context.router.push(PostDetailRoute(id: item.id)); | ||||
|                           context.push('/posts/${item.id}'); | ||||
|                         } | ||||
|                       }, | ||||
|                     ), | ||||
| @@ -474,9 +535,7 @@ Widget _buildReferencePost(BuildContext context, SnPost item) { | ||||
|         ), | ||||
|       ], | ||||
|     ), | ||||
|   ).gestures( | ||||
|     onTap: () => context.router.push(PostDetailRoute(id: referencePost.id)), | ||||
|   ); | ||||
|   ).gestures(onTap: () => context.push('/posts/referencePost.id')); | ||||
| } | ||||
|  | ||||
| class PostReactionList extends HookConsumerWidget { | ||||
| @@ -720,13 +779,17 @@ class _PostTruncateHint extends StatelessWidget { | ||||
|             color: Theme.of(context).colorScheme.secondary, | ||||
|           ), | ||||
|           SizedBox(width: isCompact ? 4 : 6), | ||||
|           Text( | ||||
|           Flexible( | ||||
|             child: Text( | ||||
|               'postTruncated'.tr(), | ||||
|               style: TextStyle( | ||||
|                 fontSize: isCompact ? 10 : 12, | ||||
|                 color: Theme.of(context).colorScheme.secondary, | ||||
|                 fontStyle: FontStyle.italic, | ||||
|               ), | ||||
|               maxLines: 1, | ||||
|               overflow: TextOverflow.ellipsis, | ||||
|             ), | ||||
|           ), | ||||
|           SizedBox(width: isCompact ? 3 : 4), | ||||
|           Icon( | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/cloud_file_collection.dart'; | ||||
| @@ -46,7 +45,7 @@ class PostItemCreator extends HookConsumerWidget { | ||||
|               title: 'edit'.tr(), | ||||
|               image: MenuImage.icon(Symbols.edit), | ||||
|               callback: () { | ||||
|                 context.router.push(PostEditRoute(id: item.id)).then((value) { | ||||
|                 context.push('/posts/${item.id}/edit').then((value) { | ||||
|                   if (value != null) { | ||||
|                     onRefresh?.call(); | ||||
|                   } | ||||
| @@ -81,7 +80,7 @@ class PostItemCreator extends HookConsumerWidget { | ||||
|               image: MenuImage.icon(Symbols.link), | ||||
|               callback: () { | ||||
|                 // Copy post link to clipboard | ||||
|                 context.router.push(PostDetailRoute(id: item.id)); | ||||
|                 context.push('/posts/${item.id}'); | ||||
|               }, | ||||
|             ), | ||||
|           ], | ||||
| @@ -95,7 +94,7 @@ class PostItemCreator extends HookConsumerWidget { | ||||
|           borderRadius: BorderRadius.circular(12), | ||||
|           onTap: () { | ||||
|             if (isOpenable) { | ||||
|               context.router.pushPath('/posts/${item.id}'); | ||||
|               context.push('/posts/${item.id}'); | ||||
|             } | ||||
|           }, | ||||
|           child: Padding( | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/route.gr.dart'; | ||||
| import 'package:island/screens/creators/publishers.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| @@ -44,8 +43,7 @@ class PublisherModal extends HookConsumerWidget { | ||||
|                                     const Gap(12), | ||||
|                                     ElevatedButton( | ||||
|                                       onPressed: () { | ||||
|                                         context.router | ||||
|                                             .push(NewPublisherRoute()) | ||||
|                                         context.push('/creators/publishers/new') | ||||
|                                             .then((value) { | ||||
|                                               if (value != null) { | ||||
|                                                 ref.invalidate( | ||||
|   | ||||
							
								
								
									
										100
									
								
								lib/widgets/publisher/publisher_card.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								lib/widgets/publisher/publisher_card.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | ||||
| 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/post.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
|  | ||||
| class PublisherCard extends ConsumerWidget { | ||||
|   final SnPublisher publisher; | ||||
|   final double? maxWidth; | ||||
|  | ||||
|   const PublisherCard({super.key, required this.publisher, this.maxWidth}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     Widget imageWidget; | ||||
|     if (publisher.picture != null) { | ||||
|       imageWidget = CloudImageWidget( | ||||
|         file: publisher.background, | ||||
|         fit: BoxFit.cover, | ||||
|       ); | ||||
|     } else { | ||||
|       imageWidget = ColoredBox( | ||||
|         color: Theme.of(context).colorScheme.secondaryContainer, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Widget card = Card( | ||||
|       clipBehavior: Clip.antiAlias, | ||||
|       child: InkWell( | ||||
|         onTap: () { | ||||
|           context.push('/publishers/${publisher.name}'); | ||||
|         }, | ||||
|         child: AspectRatio( | ||||
|           aspectRatio: 16 / 7, | ||||
|           child: Stack( | ||||
|             fit: StackFit.expand, | ||||
|             children: [ | ||||
|               imageWidget, | ||||
|               Positioned( | ||||
|                 bottom: 0, | ||||
|                 left: 0, | ||||
|                 right: 0, | ||||
|                 child: Container( | ||||
|                   decoration: BoxDecoration( | ||||
|                     gradient: LinearGradient( | ||||
|                       begin: Alignment.bottomCenter, | ||||
|                       end: Alignment.topCenter, | ||||
|                       colors: [ | ||||
|                         Colors.black.withOpacity(0.7), | ||||
|                         Colors.transparent, | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                   padding: const EdgeInsets.all(8), | ||||
|                   child: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       Container( | ||||
|                         decoration: BoxDecoration( | ||||
|                           shape: BoxShape.circle, | ||||
|                           boxShadow: [ | ||||
|                             BoxShadow( | ||||
|                               color: Colors.black.withOpacity(0.5), | ||||
|                               blurRadius: 4, | ||||
|                               offset: const Offset(0, 2), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                         child: ProfilePictureWidget( | ||||
|                           file: publisher.picture, | ||||
|                           radius: 12, | ||||
|                         ), | ||||
|                       ), | ||||
|                       const Gap(2), | ||||
|                       Text( | ||||
|                         publisher.nick, | ||||
|                         style: Theme.of(context).textTheme.titleSmall?.copyWith( | ||||
|                           color: Colors.white, | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                         ), | ||||
|                         maxLines: 2, | ||||
|                         overflow: TextOverflow.ellipsis, | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     return ConstrainedBox( | ||||
|       constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), | ||||
|       child: card, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										103
									
								
								lib/widgets/realm/realm_card.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								lib/widgets/realm/realm_card.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | ||||
| 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/realm.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
|  | ||||
| class RealmCard extends ConsumerWidget { | ||||
|   final SnRealm realm; | ||||
|   final double? maxWidth; | ||||
|  | ||||
|   const RealmCard({super.key, required this.realm, this.maxWidth}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     Widget imageWidget; | ||||
|     if (realm.picture != null) { | ||||
|       imageWidget = | ||||
|           imageWidget = CloudImageWidget( | ||||
|             file: realm.background, | ||||
|             fit: BoxFit.cover, | ||||
|           ); | ||||
|     } else { | ||||
|       imageWidget = ColoredBox( | ||||
|         color: Theme.of(context).colorScheme.secondaryContainer, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Widget card = Card( | ||||
|       clipBehavior: Clip.antiAlias, | ||||
|       child: InkWell( | ||||
|         onTap: () { | ||||
|           context.push('/realms/${realm.slug}'); | ||||
|         }, | ||||
|         child: AspectRatio( | ||||
|           aspectRatio: 16 / 7, | ||||
|           child: Stack( | ||||
|             fit: StackFit.expand, | ||||
|             children: [ | ||||
|               imageWidget, | ||||
|               Positioned( | ||||
|                 bottom: 0, | ||||
|                 left: 0, | ||||
|                 right: 0, | ||||
|                 child: Container( | ||||
|                   decoration: BoxDecoration( | ||||
|                     gradient: LinearGradient( | ||||
|                       begin: Alignment.bottomCenter, | ||||
|                       end: Alignment.topCenter, | ||||
|                       colors: [ | ||||
|                         Colors.black.withOpacity(0.7), | ||||
|                         Colors.transparent, | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                   padding: const EdgeInsets.all(8), | ||||
|                   child: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       Container( | ||||
|                         decoration: BoxDecoration( | ||||
|                           shape: BoxShape.circle, | ||||
|                           boxShadow: [ | ||||
|                             BoxShadow( | ||||
|                               color: Colors.black.withOpacity(0.5), | ||||
|                               blurRadius: 4, | ||||
|                               offset: const Offset(0, 2), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                         child: ProfilePictureWidget( | ||||
|                           file: realm.picture, | ||||
|                           fallbackIcon: Symbols.group, | ||||
|                           radius: 12, | ||||
|                         ), | ||||
|                       ), | ||||
|                       const Gap(2), | ||||
|                       Text( | ||||
|                         realm.name, | ||||
|                         style: Theme.of(context).textTheme.titleSmall?.copyWith( | ||||
|                           color: Colors.white, | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                         ), | ||||
|                         maxLines: 2, | ||||
|                         overflow: TextOverflow.ellipsis, | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     return ConstrainedBox( | ||||
|       constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), | ||||
|       child: card, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										88
									
								
								lib/widgets/realm/realm_list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								lib/widgets/realm/realm_list.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/realm.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/realm/realm_card.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||
|  | ||||
| part 'realm_list.g.dart'; | ||||
|  | ||||
| @riverpod | ||||
| class RealmListNotifier extends _$RealmListNotifier | ||||
|     with CursorPagingNotifierMixin<SnRealm> { | ||||
|   static const int _pageSize = 20; | ||||
|  | ||||
|   @override | ||||
|   Future<CursorPagingData<SnRealm>> build(String? query) { | ||||
|     return fetch(cursor: null, query: query); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<CursorPagingData<SnRealm>> fetch({ | ||||
|     required String? cursor, | ||||
|     String? query, | ||||
|   }) async { | ||||
|     final client = ref.read(apiClientProvider); | ||||
|     final offset = cursor == null ? 0 : int.parse(cursor); | ||||
|  | ||||
|     final queryParams = { | ||||
|       'offset': offset, | ||||
|       'take': _pageSize, | ||||
|       if (query != null && query.isNotEmpty) 'query': query, | ||||
|     }; | ||||
|  | ||||
|     final response = await client.get( | ||||
|       '/discovery/realms', | ||||
|       queryParameters: queryParams, | ||||
|     ); | ||||
|     final total = int.parse(response.headers.value('X-Total') ?? '0'); | ||||
|     final List<dynamic> data = response.data; | ||||
|     final realms = data.map((json) => SnRealm.fromJson(json)).toList(); | ||||
|  | ||||
|     final hasMore = offset + realms.length < total; | ||||
|     final nextCursor = hasMore ? (offset + realms.length).toString() : null; | ||||
|  | ||||
|     return CursorPagingData( | ||||
|       items: realms, | ||||
|       hasMore: hasMore, | ||||
|       nextCursor: nextCursor, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class SliverRealmList extends HookConsumerWidget { | ||||
|   const SliverRealmList({super.key, this.query}); | ||||
|  | ||||
|   final String? query; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     return PagingHelperSliverView( | ||||
|       provider: realmListNotifierProvider(query), | ||||
|       futureRefreshable: realmListNotifierProvider(query).future, | ||||
|       notifierRefreshable: realmListNotifierProvider(query).notifier, | ||||
|       contentBuilder: | ||||
|           (data, widgetCount, endItemView) => SliverList.separated( | ||||
|             itemCount: widgetCount, | ||||
|             itemBuilder: (context, index) { | ||||
|               if (index == widgetCount - 1) { | ||||
|                 return endItemView; | ||||
|               } | ||||
|  | ||||
|               final realm = data.items[index]; | ||||
|  | ||||
|               return Padding( | ||||
|                 padding: const EdgeInsets.symmetric( | ||||
|                   horizontal: 16, | ||||
|                   vertical: 8, | ||||
|                 ), | ||||
|                 child: RealmCard(realm: realm), | ||||
|               ); | ||||
|             }, | ||||
|             separatorBuilder: (_, _) => const Gap(8), | ||||
|           ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										179
									
								
								lib/widgets/realm/realm_list.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								lib/widgets/realm/realm_list.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'realm_list.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$realmListNotifierHash() => r'02dee373a5609a5617b04ffec395d09dea7ae070'; | ||||
|  | ||||
| /// 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 _$RealmListNotifier | ||||
|     extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnRealm>> { | ||||
|   late final String? query; | ||||
|  | ||||
|   FutureOr<CursorPagingData<SnRealm>> build(String? query); | ||||
| } | ||||
|  | ||||
| /// See also [RealmListNotifier]. | ||||
| @ProviderFor(RealmListNotifier) | ||||
| const realmListNotifierProvider = RealmListNotifierFamily(); | ||||
|  | ||||
| /// See also [RealmListNotifier]. | ||||
| class RealmListNotifierFamily | ||||
|     extends Family<AsyncValue<CursorPagingData<SnRealm>>> { | ||||
|   /// See also [RealmListNotifier]. | ||||
|   const RealmListNotifierFamily(); | ||||
|  | ||||
|   /// See also [RealmListNotifier]. | ||||
|   RealmListNotifierProvider call(String? query) { | ||||
|     return RealmListNotifierProvider(query); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   RealmListNotifierProvider getProviderOverride( | ||||
|     covariant RealmListNotifierProvider provider, | ||||
|   ) { | ||||
|     return call(provider.query); | ||||
|   } | ||||
|  | ||||
|   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'realmListNotifierProvider'; | ||||
| } | ||||
|  | ||||
| /// See also [RealmListNotifier]. | ||||
| class RealmListNotifierProvider | ||||
|     extends | ||||
|         AutoDisposeAsyncNotifierProviderImpl< | ||||
|           RealmListNotifier, | ||||
|           CursorPagingData<SnRealm> | ||||
|         > { | ||||
|   /// See also [RealmListNotifier]. | ||||
|   RealmListNotifierProvider(String? query) | ||||
|     : this._internal( | ||||
|         () => RealmListNotifier()..query = query, | ||||
|         from: realmListNotifierProvider, | ||||
|         name: r'realmListNotifierProvider', | ||||
|         debugGetCreateSourceHash: | ||||
|             const bool.fromEnvironment('dart.vm.product') | ||||
|                 ? null | ||||
|                 : _$realmListNotifierHash, | ||||
|         dependencies: RealmListNotifierFamily._dependencies, | ||||
|         allTransitiveDependencies: | ||||
|             RealmListNotifierFamily._allTransitiveDependencies, | ||||
|         query: query, | ||||
|       ); | ||||
|  | ||||
|   RealmListNotifierProvider._internal( | ||||
|     super._createNotifier, { | ||||
|     required super.name, | ||||
|     required super.dependencies, | ||||
|     required super.allTransitiveDependencies, | ||||
|     required super.debugGetCreateSourceHash, | ||||
|     required super.from, | ||||
|     required this.query, | ||||
|   }) : super.internal(); | ||||
|  | ||||
|   final String? query; | ||||
|  | ||||
|   @override | ||||
|   FutureOr<CursorPagingData<SnRealm>> runNotifierBuild( | ||||
|     covariant RealmListNotifier notifier, | ||||
|   ) { | ||||
|     return notifier.build(query); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Override overrideWith(RealmListNotifier Function() create) { | ||||
|     return ProviderOverride( | ||||
|       origin: this, | ||||
|       override: RealmListNotifierProvider._internal( | ||||
|         () => create()..query = query, | ||||
|         from: from, | ||||
|         name: null, | ||||
|         dependencies: null, | ||||
|         allTransitiveDependencies: null, | ||||
|         debugGetCreateSourceHash: null, | ||||
|         query: query, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AutoDisposeAsyncNotifierProviderElement< | ||||
|     RealmListNotifier, | ||||
|     CursorPagingData<SnRealm> | ||||
|   > | ||||
|   createElement() { | ||||
|     return _RealmListNotifierProviderElement(this); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return other is RealmListNotifierProvider && other.query == query; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||
|     hash = _SystemHash.combine(hash, query.hashCode); | ||||
|  | ||||
|     return _SystemHash.finish(hash); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||
| // ignore: unused_element | ||||
| mixin RealmListNotifierRef | ||||
|     on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnRealm>> { | ||||
|   /// The parameter `query` of this provider. | ||||
|   String? get query; | ||||
| } | ||||
|  | ||||
| class _RealmListNotifierProviderElement | ||||
|     extends | ||||
|         AutoDisposeAsyncNotifierProviderElement< | ||||
|           RealmListNotifier, | ||||
|           CursorPagingData<SnRealm> | ||||
|         > | ||||
|     with RealmListNotifierRef { | ||||
|   _RealmListNotifierProviderElement(super.provider); | ||||
|  | ||||
|   @override | ||||
|   String? get query => (origin as RealmListNotifierProvider).query; | ||||
| } | ||||
|  | ||||
| // 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 | ||||
							
								
								
									
										20
									
								
								lib/widgets/realm/realm_tile.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								lib/widgets/realm/realm_tile.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/realm.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
|  | ||||
| class RealmTile extends HookConsumerWidget { | ||||
|   final SnRealm realm; | ||||
|   const RealmTile({super.key, required this.realm}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     return ListTile( | ||||
|       leading: ProfilePictureWidget(file: realm.picture), | ||||
|       title: Text(realm.name), | ||||
|       subtitle: Text(realm.description), | ||||
|       onTap: () => context.push('/realms/${realm.slug}'), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										24
									
								
								lib/widgets/safety/abuse_report_helper.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								lib/widgets/safety/abuse_report_helper.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:island/widgets/safety/abuse_report_sheet.dart'; | ||||
|  | ||||
| /// Helper function to show the safety report sheet | ||||
| /// | ||||
| /// [context] - The build context | ||||
| /// [resourceIdentifier] - The identifier of the resource being reported (e.g., post ID, user ID, etc.) | ||||
| /// [initialReason] - Optional initial reason text to pre-fill the form | ||||
| Future<void> showAbuseReportSheet( | ||||
|   BuildContext context, { | ||||
|   required String resourceIdentifier, | ||||
|   String? initialReason, | ||||
| }) { | ||||
|   return showModalBottomSheet<void>( | ||||
|     context: context, | ||||
|     isScrollControlled: true, | ||||
|     useRootNavigator: true, | ||||
|     builder: | ||||
|         (context) => AbuseReportSheet( | ||||
|           resourceIdentifier: resourceIdentifier, | ||||
|           initialReason: initialReason, | ||||
|         ), | ||||
|   ); | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user