Compare commits
	
		
			8 Commits
		
	
	
		
			5939a1dc5b
			...
			3.0.0+109
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| fe8640a6db | |||
| ff475d43dd | |||
| 9e8f6d57df | |||
| 79227a12e2 | |||
| a23dcfe702 | |||
| 243ecb3f71 | |||
| b8dec9f798 | |||
| 536375729f | 
							
								
								
									
										5
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
								
							| @@ -3,7 +3,7 @@ name: Build Release | ||||
| on: | ||||
|   push: | ||||
|     tags: | ||||
|       - '*' | ||||
|       - "*" | ||||
|   workflow_dispatch: | ||||
|  | ||||
| jobs: | ||||
| @@ -59,6 +59,7 @@ jobs: | ||||
|           sudo apt-get install -y libnotify-dev | ||||
|           sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev | ||||
|           sudo apt-get install -y gstreamer-1.0 | ||||
|           sudo apt-get install -y libsecret-1 | ||||
|       - run: flutter pub get | ||||
|       - run: flutter build linux | ||||
|       - name: Archive production artifacts | ||||
| @@ -80,4 +81,4 @@ jobs: | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: build-output-linux-appimage | ||||
|           path: './*.AppImage*' | ||||
|           path: "./*.AppImage*" | ||||
|   | ||||
| @@ -57,6 +57,9 @@ android { | ||||
|  | ||||
| dependencies { | ||||
|     implementation("com.google.android.material:material:1.12.0") | ||||
|     implementation("com.github.bumptech.glide:glide:4.16.0") | ||||
|     implementation("com.squareup.okhttp3:okhttp:4.12.0") | ||||
|     implementation("com.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="true" /> | ||||
|  | ||||
|         <service | ||||
|             android:name=".service.MessagingService" | ||||
|             android:exported="false"> | ||||
|             <intent-filter> | ||||
|                 <action android:name="com.google.firebase.MESSAGING_EVENT" /> | ||||
|             </intent-filter> | ||||
|         </service> | ||||
|  | ||||
|         <provider | ||||
|             android:name="androidx.core.content.FileProvider" | ||||
|             android:authorities="dev.solsynth.solian.provider" | ||||
|   | ||||
| @@ -1,14 +0,0 @@ | ||||
| package dev.solsynth.solian | ||||
|  | ||||
| import io.flutter.embedding.android.FlutterActivity | ||||
| import io.flutter.embedding.engine.FlutterEngine | ||||
| import io.flutter.plugins.sharedpreferences.LegacySharedPreferencesPlugin | ||||
|  | ||||
| class MainActivity : FlutterActivity() | ||||
| { | ||||
|     override fun configureFlutterEngine(flutterEngine: FlutterEngine) { | ||||
|         super.configureFlutterEngine(flutterEngine) | ||||
|         // https://github.com/flutter/flutter/issues/153075#issuecomment-2693189362 | ||||
|         flutterEngine.plugins.add(LegacySharedPreferencesPlugin()) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,39 @@ | ||||
| package dev.solsynth.solian | ||||
|  | ||||
| import android.content.Intent | ||||
| import io.flutter.embedding.android.FlutterActivity | ||||
| import io.flutter.embedding.engine.FlutterEngine | ||||
| import io.flutter.plugin.common.MethodChannel | ||||
| import io.flutter.plugins.sharedpreferences.LegacySharedPreferencesPlugin | ||||
|  | ||||
| class MainActivity : FlutterActivity() | ||||
| { | ||||
|     private val CHANNEL = "dev.solsynth.solian/notifications" | ||||
|  | ||||
|     override fun configureFlutterEngine(flutterEngine: FlutterEngine) { | ||||
|         super.configureFlutterEngine(flutterEngine) | ||||
|         // https://github.com/flutter/flutter/issues/153075#issuecomment-2693189362 | ||||
|         flutterEngine.plugins.add(LegacySharedPreferencesPlugin()) | ||||
|  | ||||
|         MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> | ||||
|             if (call.method == "initialLink") { | ||||
|                 val roomId = intent.getStringExtra("room_id") | ||||
|                 if (roomId != null) { | ||||
|                     result.success("/rooms/$roomId") | ||||
|                 } else { | ||||
|                     result.success(null) | ||||
|                 } | ||||
|             } else { | ||||
|                 result.notImplemented() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onNewIntent(intent: Intent) { | ||||
|         super.onNewIntent(intent) | ||||
|         val roomId = intent.getStringExtra("room_id") | ||||
|         if (roomId != null) { | ||||
|             MethodChannel(flutterEngine!!.dartExecutor.binaryMessenger, CHANNEL).invokeMethod("newLink", "/rooms/$roomId") | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,47 @@ | ||||
| package dev.solsynth.solian.network | ||||
|  | ||||
| import android.content.Context | ||||
| import android.content.SharedPreferences | ||||
| import okhttp3.Call | ||||
| import okhttp3.Callback | ||||
| import okhttp3.MediaType.Companion.toMediaType | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| import okhttp3.RequestBody.Companion.toRequestBody | ||||
| import okhttp3.Response | ||||
| import org.json.JSONObject | ||||
| import java.io.IOException | ||||
|  | ||||
| class ApiClient(private val context: Context) { | ||||
|     private val client = OkHttpClient() | ||||
|     private val sharedPreferences: SharedPreferences = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) | ||||
|  | ||||
|     fun sendMessage(roomId: String, message: String, replyTo: String, callback: (Boolean) -> Unit) { | ||||
|         val token = sharedPreferences.getString("flutter.token", null) | ||||
|         if (token == null) { | ||||
|             callback(false) | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         val json = JSONObject().apply { | ||||
|             put("content", message) | ||||
|             put("reply_to", replyTo) | ||||
|         } | ||||
|         val body = json.toString().toRequestBody("application/json; charset=utf-8".toMediaType()) | ||||
|         val request = Request.Builder() | ||||
|             .url("https://solian.dev/api/rooms/$roomId/messages") | ||||
|             .header("Authorization", "Bearer $token") | ||||
|             .post(body) | ||||
|             .build() | ||||
|  | ||||
|         client.newCall(request).enqueue(object : Callback { | ||||
|             override fun onFailure(call: Call, e: IOException) { | ||||
|                 callback(false) | ||||
|             } | ||||
|  | ||||
|             override fun onResponse(call: Call, response: Response) { | ||||
|                 callback(response.isSuccessful) | ||||
|             } | ||||
|         }) | ||||
|     } | ||||
| } | ||||
| @@ -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,102 @@ | ||||
| package dev.solsynth.solian.service | ||||
|  | ||||
| import android.app.PendingIntent | ||||
| import android.content.Intent | ||||
| import android.graphics.Bitmap | ||||
| import android.graphics.drawable.Drawable | ||||
| import android.os.Build | ||||
| import androidx.core.app.NotificationCompat | ||||
| import androidx.core.app.NotificationManagerCompat | ||||
| import androidx.core.app.RemoteInput | ||||
| import com.bumptech.glide.Glide | ||||
| import com.bumptech.glide.request.target.CustomTarget | ||||
| import com.bumptech.glide.request.transition.Transition | ||||
| import com.google.firebase.messaging.FirebaseMessagingService | ||||
| import com.google.firebase.messaging.RemoteMessage | ||||
| import dev.solsynth.solian.MainActivity | ||||
| import dev.solsynth.solian.receiver.ReplyReceiver | ||||
| import org.json.JSONObject | ||||
|  | ||||
| class MessagingService: FirebaseMessagingService() { | ||||
|     override fun onMessageReceived(remoteMessage: RemoteMessage) { | ||||
|         val type = remoteMessage.data["type"] | ||||
|         if (type == "messages.new") { | ||||
|             handleMessageNotification(remoteMessage) | ||||
|         } else { | ||||
|             // Handle other notification types | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun handleMessageNotification(remoteMessage: RemoteMessage) { | ||||
|         val data = remoteMessage.data | ||||
|         val metaString = data["meta"] ?: return | ||||
|         val meta = JSONObject(metaString) | ||||
|  | ||||
|         val pfp = meta.optString("pfp", null) | ||||
|         val roomId = meta.optString("room_id", null) | ||||
|         val messageId = meta.optString("message_id", null) | ||||
|  | ||||
|         val notificationId = System.currentTimeMillis().toInt() | ||||
|  | ||||
|         val replyLabel = "Reply" | ||||
|         val remoteInput = RemoteInput.Builder("key_text_reply") | ||||
|             .setLabel(replyLabel) | ||||
|             .build() | ||||
|  | ||||
|         val replyIntent = Intent(this, ReplyReceiver::class.java).apply { | ||||
|             putExtra("room_id", roomId) | ||||
|             putExtra("message_id", messageId) | ||||
|             putExtra("notification_id", notificationId) | ||||
|         } | ||||
|  | ||||
|         val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { | ||||
|             PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE | ||||
|         } else { | ||||
|             PendingIntent.FLAG_UPDATE_CURRENT | ||||
|         } | ||||
|  | ||||
|         val replyPendingIntent = PendingIntent.getBroadcast( | ||||
|             applicationContext, | ||||
|             notificationId, | ||||
|             replyIntent, | ||||
|             pendingIntentFlags | ||||
|         ) | ||||
|  | ||||
|         val action = NotificationCompat.Action.Builder( | ||||
|             android.R.drawable.ic_menu_send, | ||||
|             replyLabel, | ||||
|             replyPendingIntent | ||||
|         ) | ||||
|             .addRemoteInput(remoteInput) | ||||
|             .build() | ||||
|  | ||||
|         val intent = Intent(this, MainActivity::class.java) | ||||
|         intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) | ||||
|         intent.putExtra("room_id", roomId) | ||||
|         val pendingIntent = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags) | ||||
|  | ||||
|         val notificationBuilder = NotificationCompat.Builder(this, "messages") | ||||
|             .setSmallIcon(android.R.drawable.ic_dialog_info) | ||||
|             .setContentTitle(remoteMessage.notification?.title) | ||||
|             .setContentText(remoteMessage.notification?.body) | ||||
|             .setPriority(NotificationCompat.PRIORITY_HIGH) | ||||
|             .setContentIntent(pendingIntent) | ||||
|             .addAction(action) | ||||
|  | ||||
|         if (pfp != null) { | ||||
|             Glide.with(applicationContext) | ||||
|                 .asBitmap() | ||||
|                 .load(pfp) | ||||
|                 .into(object : CustomTarget<Bitmap>() { | ||||
|                     override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { | ||||
|                         notificationBuilder.setLargeIcon(resource) | ||||
|                         NotificationManagerCompat.from(applicationContext).notify(notificationId, notificationBuilder.build()) | ||||
|                     } | ||||
|  | ||||
|                     override fun onLoadCleared(placeholder: Drawable?) {} | ||||
|                 }) | ||||
|         } else { | ||||
|             NotificationManagerCompat.from(this).notify(notificationId, notificationBuilder.build()) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -89,14 +89,32 @@ | ||||
|   "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", | ||||
|   "joinRealm": "Join Realm", | ||||
|   "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", | ||||
| @@ -231,6 +249,7 @@ | ||||
|   "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", | ||||
| @@ -286,6 +305,10 @@ | ||||
|   "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 @{}", | ||||
| @@ -293,6 +316,10 @@ | ||||
|   "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", | ||||
| @@ -304,6 +331,12 @@ | ||||
|   "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", | ||||
| @@ -346,6 +379,7 @@ | ||||
|   "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.", | ||||
| @@ -373,6 +407,10 @@ | ||||
|   "lastActiveAt": "Last active at {}", | ||||
|   "authDeviceLogout": "Logout", | ||||
|   "authDeviceLogoutHint": "Are you sure you want to logout this device? This will also disable the push notification to this device.", | ||||
|   "typingHint": { | ||||
|     "one": "{} is typing...", | ||||
|     "other": "{} are typing..." | ||||
|   }, | ||||
|   "authDeviceEditLabel": "Edit Label", | ||||
|   "authDeviceLabelTitle": "Edit Device Label", | ||||
|   "authDeviceLabelHint": "Enter a name for this device", | ||||
| @@ -439,6 +477,21 @@ | ||||
|   "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", | ||||
| @@ -520,17 +573,29 @@ | ||||
|   "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!", | ||||
|   "shareSuccess": "Shared successfully!", | ||||
|   "shareToSpecificChatSuccess": "Shared to {} successfully!", | ||||
|   "wouldYouLikeToGoToChat": "Would you like to go to the chat?", | ||||
|   "no": "No", | ||||
|   "yes": "Yes", | ||||
|   "navigateToChat": "Navigate to Chat", | ||||
|   "wouldYouLikeToNavigateToChat": "Would you like to navigate to the chat?", | ||||
|   "abuseReport": "Report", | ||||
|   "abuseReportTitle": "Report Content", | ||||
|   "abuseReportDescription": "Help us keep the community safe by reporting inappropriate content or behavior.", | ||||
| @@ -556,5 +621,13 @@ | ||||
|   "tags": "Tags", | ||||
|   "tagsHint": "Enter tags, separated by commas", | ||||
|   "categories": "Categories", | ||||
|   "categoriesHint": "Enter categories, separated by commas" | ||||
|   "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" | ||||
| } | ||||
|   | ||||
| @@ -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,40 +10,51 @@ 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 { | ||||
|                 return | ||||
|             } | ||||
|              | ||||
|             var token: String? = UserDefaults.standard.getFlutterToken() | ||||
|             if token == nil { | ||||
|                 return | ||||
|             } | ||||
|              | ||||
|             let serverUrl = UserDefaults.standard.getServerUrl() | ||||
|             let url = "\(serverUrl)/chat/\(metadata["room_id"] ?? "")/messages" | ||||
|              | ||||
|             let parameters: [String: Any?] = [ | ||||
|                 "content": textResponse.userText, | ||||
|                 "replied_message_id": metadata["message_id"] | ||||
|             ] | ||||
|              | ||||
|             AF.request(url, method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: HTTPHeaders( | ||||
|                 [HTTPHeader(name: "Authorization", value: "AtField \(token!)")] | ||||
|             )) | ||||
|                 .validate() | ||||
|                 .responseString { response in | ||||
|                     switch response.result { | ||||
|                     case .success(_): | ||||
|                         break | ||||
|                     case .failure(let error): | ||||
|                         print("Failed to send chat reply message: \(error)") | ||||
|                         break | ||||
|                     } | ||||
|                 } | ||||
|         guard let textResponse = response as? UNTextInputNotificationResponse else { | ||||
|             completionHandler() | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         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 | ||||
|         } | ||||
|          | ||||
|         completionHandler() | ||||
|         guard let token = UserDefaults.standard.getFlutterToken() else { | ||||
|             completionHandler() | ||||
|             return | ||||
|         } | ||||
|          | ||||
|         let serverUrl = UserDefaults.standard.getServerUrl() | ||||
|         let url = "\(serverUrl)/chat/\(metadata["room_id"] ?? "")/messages" | ||||
|          | ||||
|         let parameters: [String: Any?] = [ | ||||
|             "content": textResponse.userText, | ||||
|             "replied_message_id": metadata["message_id"] | ||||
|         ] | ||||
|          | ||||
|         AF.request(url, method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: HTTPHeaders( | ||||
|             [HTTPHeader(name: "Authorization", value: "AtField \(token)")] | ||||
|         )) | ||||
|             .validate() | ||||
|             .responseString { response in | ||||
|                 switch response.result { | ||||
|                 case .success(_): | ||||
|                     break | ||||
|                 case .failure(let error): | ||||
|                     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 | ||||
|   | ||||
| @@ -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 (localMessages.isNotEmpty) { | ||||
|           return localMessages; | ||||
|         } | ||||
|       // 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 | ||||
|     final pendingForRoom = | ||||
|         pendingMessages.values.where((msg) => msg.roomId == roomId).toList(); | ||||
|     // 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)); | ||||
|       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( | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import 'package:firebase_core/firebase_core.dart'; | ||||
| import 'package:firebase_messaging/firebase_messaging.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:image_picker_android/image_picker_android.dart'; | ||||
| @@ -158,6 +159,28 @@ class IslandApp extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       const channel = MethodChannel('dev.solsynth.solian/notifications'); | ||||
|  | ||||
|       Future<void> handleInitialLink() async { | ||||
|         final String? link = await channel.invokeMethod('initialLink'); | ||||
|         if (link != null) { | ||||
|           final router = ref.read(routerProvider); | ||||
|           router.go(link); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { | ||||
|         handleInitialLink(); | ||||
|       } | ||||
|  | ||||
|       channel.setMethodCallHandler((call) async { | ||||
|         if (call.method == 'newLink') { | ||||
|           final String link = call.arguments; | ||||
|           final router = ref.read(routerProvider); | ||||
|           router.go(link); | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       // When the app is opened from a terminated state. | ||||
|       FirebaseMessaging.instance.getInitialMessage().then((message) { | ||||
|         if (message != null) { | ||||
|   | ||||
| @@ -13,8 +13,8 @@ sealed class SnChatRoom with _$SnChatRoom { | ||||
|     required String? name, | ||||
|     required String? description, | ||||
|     required int type, | ||||
|     required bool isPublic, | ||||
|     required bool isCommunity, | ||||
|     @Default(false) bool isPublic, | ||||
|     @Default(false) bool isCommunity, | ||||
|     required SnCloudFile? picture, | ||||
|     required SnCloudFile? background, | ||||
|     required String? realmId, | ||||
|   | ||||
| @@ -129,15 +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.isCommunity, 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 final  bool isCommunity; | ||||
| @override@JsonKey() final  bool isPublic; | ||||
| @override@JsonKey() final  bool isCommunity; | ||||
| @override final  SnCloudFile? picture; | ||||
| @override final  SnCloudFile? background; | ||||
| @override final  String? realmId; | ||||
|   | ||||
| @@ -11,8 +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, | ||||
|   isCommunity: json['is_community'] as bool, | ||||
|   isPublic: json['is_public'] as bool? ?? false, | ||||
|   isCommunity: json['is_community'] as bool? ?? false, | ||||
|   picture: | ||||
|       json['picture'] == null | ||||
|           ? null | ||||
|   | ||||
| @@ -10,7 +10,7 @@ sealed class SnRealm with _$SnRealm { | ||||
|   const factory SnRealm({ | ||||
|     required String id, | ||||
|     required String slug, | ||||
|     required String name, | ||||
|     @Default('') String name, | ||||
|     @Default('') String description, | ||||
|     required String? verifiedAs, | ||||
|     required DateTime? verifiedAt, | ||||
|   | ||||
| @@ -117,12 +117,12 @@ $SnCloudFileCopyWith<$Res>? get background { | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnRealm implements SnRealm { | ||||
|   const _SnRealm({required this.id, required this.slug, required 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}); | ||||
|   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@JsonKey() final  String name; | ||||
| @override@JsonKey() final  String description; | ||||
| @override final  String? verifiedAs; | ||||
| @override final  DateTime? verifiedAt; | ||||
|   | ||||
| @@ -9,7 +9,7 @@ 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, | ||||
|   name: json['name'] as String? ?? '', | ||||
|   description: json['description'] as String? ?? '', | ||||
|   verifiedAs: json['verified_as'] as String?, | ||||
|   verifiedAt: | ||||
|   | ||||
| @@ -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); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -53,7 +53,10 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|           // Standalone routes without bottom navigation | ||||
|           GoRoute( | ||||
|             path: '/posts/compose', | ||||
|             builder: (context, state) => const PostComposeScreen(), | ||||
|             builder: | ||||
|                 (context, state) => PostComposeScreen( | ||||
|                   initialState: state.extra as PostComposeInitialState?, | ||||
|                 ), | ||||
|           ), | ||||
|           GoRoute( | ||||
|             path: '/posts/:id/edit', | ||||
| @@ -76,33 +79,37 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|               return EventCalanderScreen(name: name); | ||||
|             }, | ||||
|           ), | ||||
|           GoRoute( | ||||
|             path: '/creators', | ||||
|             builder: (context, state) => const CreatorHubScreen(), | ||||
|           ShellRoute( | ||||
|             builder: | ||||
|                 (context, state, child) => CreatorHubShellScreen(child: child), | ||||
|             routes: [ | ||||
|               GoRoute( | ||||
|                 path: ':name/posts', | ||||
|                 path: '/creators', | ||||
|                 builder: (context, state) => const CreatorHubScreen(), | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: '/creators/:name/posts', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   return CreatorPostListScreen(pubName: name); | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: ':name/stickers', | ||||
|                 path: '/creators/:name/stickers', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   return StickersScreen(pubName: name); | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: ':name/stickers/new', | ||||
|                 path: '/creators/:name/stickers/new', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   return NewStickerPacksScreen(pubName: name); | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: ':name/stickers/:packId/edit', | ||||
|                 path: '/creators/:name/stickers/:packId/edit', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   final packId = state.pathParameters['packId']!; | ||||
| @@ -110,7 +117,7 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: ':name/stickers/:packId', | ||||
|                 path: '/creators/:name/stickers/:packId', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   final packId = state.pathParameters['packId']!; | ||||
| @@ -118,14 +125,14 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: ':name/stickers/:packId/new', | ||||
|                 path: '/creators/:name/stickers/:packId/new', | ||||
|                 builder: (context, state) { | ||||
|                   final packId = state.pathParameters['packId']!; | ||||
|                   return NewStickersScreen(packId: packId); | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: ':name/stickers/:packId/:id/edit', | ||||
|                 path: '/creators/:name/stickers/:packId/:id/edit', | ||||
|                 builder: (context, state) { | ||||
|                   final packId = state.pathParameters['packId']!; | ||||
|                   final id = state.pathParameters['id']!; | ||||
| @@ -133,11 +140,11 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: 'new', | ||||
|                 path: '/creators/new', | ||||
|                 builder: (context, state) => const NewPublisherScreen(), | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 path: ':name/edit', | ||||
|                 path: '/creators/:name/edit', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   return EditPublisherScreen(name: name); | ||||
| @@ -170,56 +177,64 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|             }, | ||||
|             routes: [ | ||||
|               // Explore tab | ||||
|               GoRoute( | ||||
|                 path: '/', | ||||
|                 builder: (context, state) => const ExploreScreen(), | ||||
|               ShellRoute( | ||||
|                 builder: | ||||
|                     (context, state, child) => ExploreShellScreen(child: child), | ||||
|                 routes: [ | ||||
|                   GoRoute( | ||||
|                     path: 'posts/:id', | ||||
|                     path: '/', | ||||
|                     builder: (context, state) => const ExploreScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: '/posts/:id', | ||||
|                     builder: (context, state) { | ||||
|                       final id = state.pathParameters['id']!; | ||||
|                       return PostDetailScreen(id: id); | ||||
|                     }, | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: 'publishers/:name', | ||||
|                     path: '/publishers/:name', | ||||
|                     builder: (context, state) { | ||||
|                       final name = state.pathParameters['name']!; | ||||
|                       return PublisherProfileScreen(name: name); | ||||
|                     }, | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: 'discovery/realms', | ||||
|                     path: '/discovery/realms', | ||||
|                     builder: (context, state) => const DiscoveryRealmsScreen(), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|  | ||||
|               // Chat tab | ||||
|               GoRoute( | ||||
|                 path: '/chat', | ||||
|                 builder: (context, state) => const ChatListScreen(), | ||||
|               ShellRoute( | ||||
|                 builder: | ||||
|                     (context, state, child) => ChatShellScreen(child: child), | ||||
|                 routes: [ | ||||
|                   GoRoute( | ||||
|                     path: 'new', | ||||
|                     path: '/chat', | ||||
|                     builder: (context, state) => const ChatListScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: '/chat/new', | ||||
|                     builder: (context, state) => const NewChatScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: ':id', | ||||
|                     path: '/chat/:id', | ||||
|                     builder: (context, state) { | ||||
|                       final id = state.pathParameters['id']!; | ||||
|                       return ChatRoomScreen(id: id); | ||||
|                     }, | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: ':id/edit', | ||||
|                     path: '/chat/:id/edit', | ||||
|                     builder: (context, state) { | ||||
|                       final id = state.pathParameters['id']!; | ||||
|                       return EditChatScreen(id: id); | ||||
|                     }, | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: ':id/detail', | ||||
|                     path: '/chat/:id/detail', | ||||
|                     builder: (context, state) { | ||||
|                       final id = state.pathParameters['id']!; | ||||
|                       return ChatDetailScreen(id: id); | ||||
| @@ -255,39 +270,43 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|               ), | ||||
|  | ||||
|               // Account tab | ||||
|               GoRoute( | ||||
|                 path: '/account', | ||||
|                 builder: (context, state) => const AccountScreen(), | ||||
|               ShellRoute( | ||||
|                 builder: | ||||
|                     (context, state, child) => AccountShellScreen(child: child), | ||||
|                 routes: [ | ||||
|                   GoRoute( | ||||
|                     path: 'notifications', | ||||
|                     path: '/account', | ||||
|                     builder: (context, state) => const AccountScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: '/account/notifications', | ||||
|                     builder: (context, state) => const NotificationScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: 'wallet', | ||||
|                     path: '/account/wallet', | ||||
|                     builder: (context, state) => const WalletScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: 'relationships', | ||||
|                     path: '/account/relationships', | ||||
|                     builder: (context, state) => const RelationshipScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: ':name', | ||||
|                     path: '/account/:name', | ||||
|                     builder: (context, state) { | ||||
|                       final name = state.pathParameters['name']!; | ||||
|                       return AccountProfileScreen(name: name); | ||||
|                     }, | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: 'me/update', | ||||
|                     path: '/account/me/update', | ||||
|                     builder: (context, state) => const UpdateProfileScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: 'me/leveling', | ||||
|                     path: '/account/me/leveling', | ||||
|                     builder: (context, state) => const LevelingScreen(), | ||||
|                   ), | ||||
|                   GoRoute( | ||||
|                     path: 'settings', | ||||
|                     path: '/account/settings', | ||||
|                     builder: (context, state) => const AccountSettingsScreen(), | ||||
|                   ), | ||||
|                 ], | ||||
|   | ||||
| @@ -143,7 +143,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|                 progress: user.value!.profile.levelingProgress, | ||||
|               ), | ||||
|               onTap: () { | ||||
|                 context.push('/account/leveling'); | ||||
|                 context.push('/account/me/leveling'); | ||||
|               }, | ||||
|             ).padding(horizontal: 12), | ||||
|             Row( | ||||
| @@ -210,7 +210,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('wallet').tr(), | ||||
|               onTap: () { | ||||
|                 context.push('/wallet'); | ||||
|                 context.push('/account/wallet'); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
|   | ||||
| @@ -53,17 +53,21 @@ Future<List<SnAccountBadge>> accountBadges(Ref ref, String uname) async { | ||||
|  | ||||
| @riverpod | ||||
| Future<Color?> accountAppbarForcegroundColor(Ref ref, String uname) async { | ||||
|   final account = await ref.watch(accountProvider(uname).future); | ||||
|   if (account.profile.background == null) return null; | ||||
|   final palette = await PaletteGenerator.fromImageProvider( | ||||
|     CloudImageWidget.provider( | ||||
|       fileId: account.profile.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; | ||||
|   try { | ||||
|     final account = await ref.watch(accountProvider(uname).future); | ||||
|     if (account.profile.background == null) return null; | ||||
|     final palette = await PaletteGenerator.fromImageProvider( | ||||
|       CloudImageWidget.provider( | ||||
|         fileId: account.profile.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; | ||||
|   } catch (_) { | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @riverpod | ||||
|   | ||||
| @@ -434,17 +434,31 @@ class ChatListScreen extends HookConsumerWidget { | ||||
| @riverpod | ||||
| Future<SnChatRoom?> chatroom(Ref ref, String? identifier) async { | ||||
|   if (identifier == null) return null; | ||||
|   final client = ref.watch(apiClientProvider); | ||||
|   final resp = await client.get('/chat/$identifier'); | ||||
|   return SnChatRoom.fromJson(resp.data); | ||||
|   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; | ||||
|   final client = ref.watch(apiClientProvider); | ||||
|   final resp = await client.get('/chat/$identifier/members/me'); | ||||
|   return SnChatMember.fromJson(resp.data); | ||||
|   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 | ||||
|   } | ||||
| } | ||||
|  | ||||
| class NewChatScreen extends StatelessWidget { | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -305,7 +305,55 @@ class ChatRoomScreen extends HookConsumerWidget { | ||||
|       // Identity was not found, user was not joined | ||||
|       return AppScaffold( | ||||
|         appBar: AppBar(leading: const PageBackButton()), | ||||
|         body: Center(child: Text('You are not a member of this chat room')), | ||||
|         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(), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
| @@ -443,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) | ||||
| @@ -617,7 +687,7 @@ class ChatRoomScreen extends HookConsumerWidget { | ||||
|           IconButton( | ||||
|             icon: const Icon(Icons.more_vert), | ||||
|             onPressed: () { | ||||
|               context.push('/chat/id/detail'); | ||||
|               context.push('/chat/$id/detail'); | ||||
|             }, | ||||
|           ), | ||||
|           const Gap(8), | ||||
|   | ||||
| @@ -1,22 +1,62 @@ | ||||
| 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: CustomScrollView( | ||||
|         slivers: [ | ||||
|           SliverGap(16), | ||||
|           SliverRealmList(), | ||||
|           SliverGap(MediaQuery.of(context).padding.bottom + 16), | ||||
|       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,4 +1,5 @@ | ||||
| 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'; | ||||
| @@ -12,13 +13,13 @@ 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'; | ||||
| @@ -85,65 +86,64 @@ class ExploreScreen extends HookConsumerWidget { | ||||
|       activityListNotifierProvider(currentFilter.value).notifier, | ||||
|     ); | ||||
|  | ||||
|     return TourTriggerWidget( | ||||
|       child: AppScaffold( | ||||
|         extendBody: false, // Prevent conflicts with tabs navigation | ||||
|         appBar: AppBar( | ||||
|           toolbarHeight: 0, | ||||
|           bottom: TabBar( | ||||
|             controller: tabController, | ||||
|             tabs: [ | ||||
|               Tab( | ||||
|                 child: Text( | ||||
|                   'explore'.tr(), | ||||
|                   textAlign: TextAlign.center, | ||||
|                   style: TextStyle( | ||||
|                     color: Theme.of(context).appBarTheme.foregroundColor!, | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|               Tab( | ||||
|                 child: Text( | ||||
|                   'exploreFilterSubscriptions'.tr(), | ||||
|                   textAlign: TextAlign.center, | ||||
|                   style: TextStyle( | ||||
|                     color: Theme.of(context).appBarTheme.foregroundColor!, | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|               Tab( | ||||
|                 child: Text( | ||||
|                   'exploreFilterFriends'.tr(), | ||||
|                   textAlign: TextAlign.center, | ||||
|                   style: TextStyle( | ||||
|                     color: Theme.of(context).appBarTheme.foregroundColor!, | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|         floatingActionButton: FloatingActionButton( | ||||
|           heroTag: Key("explore-page-fab"), | ||||
|           onPressed: () { | ||||
|             context.push('/posts/compose').then((value) { | ||||
|               if (value != null) { | ||||
|                 activitiesNotifier.forceRefresh(); | ||||
|               } | ||||
|             }); | ||||
|           }, | ||||
|           child: const Icon(Symbols.edit), | ||||
|         ), | ||||
|         floatingActionButtonLocation: TabbedFabLocation(context), | ||||
|         body: TabBarView( | ||||
|     return AppScaffold( | ||||
|       extendBody: false, // Prevent conflicts with tabs navigation | ||||
|       appBar: AppBar( | ||||
|         toolbarHeight: 0, | ||||
|         bottom: TabBar( | ||||
|           controller: tabController, | ||||
|           children: [ | ||||
|             _buildActivityList(ref, null), | ||||
|             _buildActivityList(ref, 'subscriptions'), | ||||
|             _buildActivityList(ref, 'friends'), | ||||
|           tabs: [ | ||||
|             Tab( | ||||
|               child: Text( | ||||
|                 'explore'.tr(), | ||||
|                 textAlign: TextAlign.center, | ||||
|                 style: TextStyle( | ||||
|                   color: Theme.of(context).appBarTheme.foregroundColor!, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             Tab( | ||||
|               child: Text( | ||||
|                 'exploreFilterSubscriptions'.tr(), | ||||
|                 textAlign: TextAlign.center, | ||||
|                 style: TextStyle( | ||||
|                   color: Theme.of(context).appBarTheme.foregroundColor!, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             Tab( | ||||
|               child: Text( | ||||
|                 'exploreFilterFriends'.tr(), | ||||
|                 textAlign: TextAlign.center, | ||||
|                 style: TextStyle( | ||||
|                   color: Theme.of(context).appBarTheme.foregroundColor!, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|       floatingActionButton: FloatingActionButton( | ||||
|         heroTag: Key("explore-page-fab"), | ||||
|         onPressed: () { | ||||
|           context.push('/posts/compose').then((value) { | ||||
|             if (value != null) { | ||||
|               activitiesNotifier.forceRefresh(); | ||||
|             } | ||||
|           }); | ||||
|         }, | ||||
|         child: const Icon(Symbols.edit), | ||||
|       ), | ||||
|       floatingActionButtonLocation: TabbedFabLocation(context), | ||||
|       body: TabBarView( | ||||
|         controller: tabController, | ||||
|         physics: const NeverScrollableScrollPhysics(), | ||||
|         children: [ | ||||
|           _buildActivityList(ref, null), | ||||
|           _buildActivityList(ref, 'subscriptions'), | ||||
|           _buildActivityList(ref, 'friends'), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -180,10 +180,8 @@ class _DiscoveryActivityItem extends StatelessWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final items = | ||||
|         (data['items'] as List) | ||||
|             .map((e) => SnRealm.fromJson(e['data'] as Map<String, dynamic>)) | ||||
|             .toList(); | ||||
|     final items = data['items'] as List; | ||||
|     final type = items.firstOrNull?['type'] ?? 'unknown'; | ||||
|  | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
| @@ -194,7 +192,11 @@ class _DiscoveryActivityItem extends StatelessWidget { | ||||
|             const Icon(Symbols.explore, size: 19), | ||||
|             const Gap(8), | ||||
|             Text( | ||||
|               'discoverCommunities'.tr(), | ||||
|               (switch (type) { | ||||
|                 'realm' => 'discoverRealms', | ||||
|                 'publisher' => 'discoverPublishers', | ||||
|                 _ => 'unknown', | ||||
|               }).tr(), | ||||
|               style: Theme.of(context).textTheme.titleMedium, | ||||
|             ).padding(top: 1), | ||||
|           ], | ||||
| @@ -204,13 +206,26 @@ class _DiscoveryActivityItem extends StatelessWidget { | ||||
|           child: ListView.builder( | ||||
|             scrollDirection: Axis.horizontal, | ||||
|             itemCount: items.length, | ||||
|             padding: const EdgeInsets.only(right: 8), | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 8), | ||||
|             itemBuilder: (context, index) { | ||||
|               final realm = items[index]; | ||||
|               return RealmCard(realm: realm); | ||||
|               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), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| @@ -326,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 { | ||||
|   | ||||
| @@ -4,19 +4,18 @@ import 'dart:math' as math; | ||||
| 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'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/route.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:relative_time/relative_time.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:url_launcher/url_launcher.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| part 'notification.g.dart'; | ||||
|  | ||||
| @@ -180,36 +179,17 @@ class NotificationScreen extends HookConsumerWidget { | ||||
|                             ), | ||||
|                           ), | ||||
|                   onTap: () { | ||||
|                     if (notification.meta['link'] is String) { | ||||
|                       final href = notification.meta['link']; | ||||
|                       final uri = Uri.tryParse(href); | ||||
|                       if (uri == null) { | ||||
|                         showSnackBar( | ||||
|                           'brokenLink'.tr(args: []), | ||||
|                           action: SnackBarAction( | ||||
|                             label: 'copyToClipboard'.tr(), | ||||
|                             onPressed: () { | ||||
|                               Clipboard.setData(ClipboardData(text: href)); | ||||
|                               clearSnackBar(context); | ||||
|                             }, | ||||
|                           ), | ||||
|                     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'], | ||||
|                         ); | ||||
|                         return; | ||||
|                       } else { | ||||
|                         // External URLs | ||||
|                         launchUrlString(uri); | ||||
|                       } | ||||
|                       if (uri.scheme == 'solian') { | ||||
|                         context.push( | ||||
|                           ['', uri.host, ...uri.pathSegments].join('/'), | ||||
|                         ); | ||||
|                         return; | ||||
|                       } | ||||
|                       showConfirmAlert( | ||||
|                         'openLinkConfirmDescription'.tr(args: [href]), | ||||
|                         'openLinkConfirm'.tr(), | ||||
|                       ).then((value) { | ||||
|                         if (value) { | ||||
|                           launchUrl(uri, mode: LaunchMode.externalApplication); | ||||
|                         } | ||||
|                       }); | ||||
|                     } | ||||
|                   }, | ||||
|                 ); | ||||
|   | ||||
| @@ -54,25 +54,26 @@ Future<SnSubscriptionStatus> publisherSubscriptionStatus( | ||||
|  | ||||
| @riverpod | ||||
| Future<Color?> publisherAppbarForcegroundColor(Ref ref, String pubName) async { | ||||
|   final publisher = await ref.watch(publisherProvider(pubName).future); | ||||
|   if (publisher.background == null) return null; | ||||
|   final palette = await PaletteGenerator.fromImageProvider( | ||||
|     CloudImageWidget.provider( | ||||
|       fileId: publisher.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; | ||||
|   try { | ||||
|     final publisher = await ref.watch(publisherProvider(pubName).future); | ||||
|     if (publisher.background == null) return null; | ||||
|     final palette = await PaletteGenerator.fromImageProvider( | ||||
|       CloudImageWidget.provider( | ||||
|         fileId: publisher.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; | ||||
|   } catch (_) { | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
|  | ||||
| class PublisherProfileScreen extends HookConsumerWidget { | ||||
|   final String name; | ||||
|   const PublisherProfileScreen({ | ||||
|     super.key, | ||||
|     required this.name, | ||||
|   }); | ||||
|   const PublisherProfileScreen({super.key, required this.name}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|   | ||||
| @@ -155,7 +155,7 @@ class RealmDetailScreen extends HookConsumerWidget { | ||||
|                                     ), | ||||
|                                   ], | ||||
|                                 ), | ||||
|                                 if (identity == null && realm.isPublic) | ||||
|                                 if (identity == null && realm.isCommunity) | ||||
|                                   FilledButton.tonalIcon( | ||||
|                                     onPressed: () async { | ||||
|                                       try { | ||||
| @@ -169,14 +169,14 @@ class RealmDetailScreen extends HookConsumerWidget { | ||||
|                                           realmIdentityProvider(slug), | ||||
|                                         ); | ||||
|                                         ref.invalidate(realmsJoinedProvider); | ||||
|                                         showSnackBar('joinRealmSuccess'.tr()); | ||||
|                                         showSnackBar('realmJoinSuccess'.tr()); | ||||
|                                       } catch (err) { | ||||
|                                         showErrorAlert(err); | ||||
|                                       } | ||||
|                                     }, | ||||
|                                     icon: const Icon(Symbols.add), | ||||
|                                     label: const Text('joinRealm').tr(), | ||||
|                                   ).padding(horizontal: 16, vertical: 8) | ||||
|                                     label: const Text('realmJoin').tr(), | ||||
|                                   ).padding(horizontal: 16, vertical: 16) | ||||
|                                 else | ||||
|                                   const SizedBox.shrink(), | ||||
|                               ], | ||||
|   | ||||
| @@ -32,7 +32,9 @@ StreamSubscription<WebSocketPacket> setupNotificationListener( | ||||
|             var uri = notification.meta['action_uri'] as String; | ||||
|             if (uri.startsWith('/')) { | ||||
|               // In-app routes | ||||
|               rootNavigatorKey.currentContext?.push(notification.meta['action_uri']); | ||||
|               rootNavigatorKey.currentContext?.push( | ||||
|                 notification.meta['action_uri'], | ||||
|               ); | ||||
|             } else { | ||||
|               // External URLs | ||||
|               launchUrlString(uri); | ||||
| @@ -46,8 +48,14 @@ StreamSubscription<WebSocketPacket> setupNotificationListener( | ||||
|         padding: EdgeInsets.only( | ||||
|           left: 16, | ||||
|           right: 16, | ||||
|           // ignore: use_build_context_synchronously | ||||
|           top: MediaQuery.of(context).padding.top + 24, | ||||
|           top: | ||||
|               (!kIsWeb && | ||||
|                       (Platform.isMacOS || | ||||
|                           Platform.isWindows || | ||||
|                           Platform.isLinux)) | ||||
|                   ? 24 | ||||
|                   // ignore: use_build_context_synchronously | ||||
|                   : MediaQuery.of(context).padding.top + 8, | ||||
|           bottom: 16, | ||||
|         ), | ||||
|       ); | ||||
|   | ||||
| @@ -4,6 +4,7 @@ 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'; | ||||
|  | ||||
| class AppWrapper extends HookConsumerWidget { | ||||
|   final Widget child; | ||||
| @@ -24,6 +25,6 @@ class AppWrapper extends HookConsumerWidget { | ||||
|       }; | ||||
|     }, const []); | ||||
|  | ||||
|     return child; | ||||
|     return TourTriggerWidget(child: child); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -186,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); | ||||
| @@ -209,12 +203,7 @@ 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) { | ||||
|         showErrorAlert(e); | ||||
|       } | ||||
|   | ||||
							
								
								
									
										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, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,41 +1,33 @@ | ||||
| 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/pods/network.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}); | ||||
|   const RealmCard({super.key, required this.realm, this.maxWidth}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final client = ref.watch(apiClientProvider); | ||||
|  | ||||
|     Widget imageWidget; | ||||
|     if (realm.picture != null) { | ||||
|       final imageUrl = '${client.options.baseUrl}/files/${realm.picture!.id}'; | ||||
|       imageWidget = Image.network( | ||||
|         imageUrl, | ||||
|         fit: BoxFit.cover, | ||||
|         width: double.infinity, | ||||
|         height: double.infinity, | ||||
|       ); | ||||
|       imageWidget = | ||||
|           imageWidget = CloudImageWidget( | ||||
|             file: realm.background, | ||||
|             fit: BoxFit.cover, | ||||
|           ); | ||||
|     } else { | ||||
|       imageWidget = Container( | ||||
|       imageWidget = ColoredBox( | ||||
|         color: Theme.of(context).colorScheme.secondaryContainer, | ||||
|         child: Center( | ||||
|           child: Icon( | ||||
|             Symbols.photo_camera, | ||||
|             color: Theme.of(context).colorScheme.onSecondaryContainer, | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Card( | ||||
|     Widget card = Card( | ||||
|       clipBehavior: Clip.antiAlias, | ||||
|       child: InkWell( | ||||
|         onTap: () { | ||||
| @@ -44,6 +36,7 @@ class RealmCard extends ConsumerWidget { | ||||
|         child: AspectRatio( | ||||
|           aspectRatio: 16 / 7, | ||||
|           child: Stack( | ||||
|             fit: StackFit.expand, | ||||
|             children: [ | ||||
|               imageWidget, | ||||
|               Positioned( | ||||
| @@ -62,14 +55,37 @@ class RealmCard extends ConsumerWidget { | ||||
|                     ), | ||||
|                   ), | ||||
|                   padding: const EdgeInsets.all(8), | ||||
|                   child: Text( | ||||
|                     realm.name, | ||||
|                     style: Theme.of(context).textTheme.titleSmall?.copyWith( | ||||
|                   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, | ||||
|                         maxLines: 2, | ||||
|                         overflow: TextOverflow.ellipsis, | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
| @@ -78,5 +94,10 @@ class RealmCard extends ConsumerWidget { | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     return ConstrainedBox( | ||||
|       constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), | ||||
|       child: card, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| 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'; | ||||
| @@ -14,16 +15,23 @@ class RealmListNotifier extends _$RealmListNotifier | ||||
|   static const int _pageSize = 20; | ||||
|  | ||||
|   @override | ||||
|   Future<CursorPagingData<SnRealm>> build() { | ||||
|     return fetch(cursor: null); | ||||
|   Future<CursorPagingData<SnRealm>> build(String? query) { | ||||
|     return fetch(cursor: null, query: query); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<CursorPagingData<SnRealm>> fetch({required String? cursor}) async { | ||||
|   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}; | ||||
|     final queryParams = { | ||||
|       'offset': offset, | ||||
|       'take': _pageSize, | ||||
|       if (query != null && query.isNotEmpty) 'query': query, | ||||
|     }; | ||||
|  | ||||
|     final response = await client.get( | ||||
|       '/discovery/realms', | ||||
| @@ -45,16 +53,18 @@ class RealmListNotifier extends _$RealmListNotifier | ||||
| } | ||||
|  | ||||
| class SliverRealmList extends HookConsumerWidget { | ||||
|   const SliverRealmList({super.key}); | ||||
|   const SliverRealmList({super.key, this.query}); | ||||
|  | ||||
|   final String? query; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     return PagingHelperSliverView( | ||||
|       provider: realmListNotifierProvider, | ||||
|       futureRefreshable: realmListNotifierProvider.future, | ||||
|       notifierRefreshable: realmListNotifierProvider.notifier, | ||||
|       provider: realmListNotifierProvider(query), | ||||
|       futureRefreshable: realmListNotifierProvider(query).future, | ||||
|       notifierRefreshable: realmListNotifierProvider(query).notifier, | ||||
|       contentBuilder: | ||||
|           (data, widgetCount, endItemView) => SliverList.builder( | ||||
|           (data, widgetCount, endItemView) => SliverList.separated( | ||||
|             itemCount: widgetCount, | ||||
|             itemBuilder: (context, index) { | ||||
|               if (index == widgetCount - 1) { | ||||
| @@ -71,6 +81,7 @@ class SliverRealmList extends HookConsumerWidget { | ||||
|                 child: RealmCard(realm: realm), | ||||
|               ); | ||||
|             }, | ||||
|             separatorBuilder: (_, _) => const Gap(8), | ||||
|           ), | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -6,25 +6,174 @@ part of 'realm_list.dart'; | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| String _$realmListNotifierHash() => r'440eb8c61db2059699191b904b6518a0b01ccd25'; | ||||
| 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) | ||||
| final realmListNotifierProvider = AutoDisposeAsyncNotifierProvider< | ||||
|   RealmListNotifier, | ||||
|   CursorPagingData<SnRealm> | ||||
| >.internal( | ||||
|   RealmListNotifier.new, | ||||
|   name: r'realmListNotifierProvider', | ||||
|   debugGetCreateSourceHash: | ||||
|       const bool.fromEnvironment('dart.vm.product') | ||||
|           ? null | ||||
|           : _$realmListNotifierHash, | ||||
|   dependencies: null, | ||||
|   allTransitiveDependencies: null, | ||||
| ); | ||||
| 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; | ||||
| } | ||||
|  | ||||
| typedef _$RealmListNotifier = | ||||
|     AutoDisposeAsyncNotifier<CursorPagingData<SnRealm>>; | ||||
| // 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 | ||||
|   | ||||
| @@ -13,6 +13,7 @@ import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/services/file.dart'; | ||||
| import 'package:mime/mime.dart'; | ||||
|  | ||||
| import 'dart:io'; | ||||
| import 'package:path/path.dart' as path; | ||||
| @@ -149,9 +150,9 @@ class _ShareSheetState extends ConsumerState<ShareSheet> { | ||||
|         case ShareContentType.file: | ||||
|           if (widget.content.files != null) { | ||||
|             // Convert XFiles to UniversalFiles | ||||
|             for (final xFile in widget.content.files!) { | ||||
|               final file = File(xFile.path); | ||||
|               final mimeType = xFile.mimeType; | ||||
|             for (final file in widget.content.files!) { | ||||
|               var mimeType = file.mimeType; | ||||
|               mimeType ??= lookupMimeType(file.path); | ||||
|  | ||||
|               UniversalFileType fileType; | ||||
|               if (mimeType?.startsWith('image/') == true) { | ||||
|   | ||||
| @@ -49,6 +49,8 @@ PODS: | ||||
|     - OrderedSet (~> 6.0.3) | ||||
|   - flutter_platform_alert (0.0.1): | ||||
|     - FlutterMacOS | ||||
|   - flutter_secure_storage_macos (6.1.3): | ||||
|     - FlutterMacOS | ||||
|   - flutter_timezone (0.1.0): | ||||
|     - FlutterMacOS | ||||
|   - flutter_udid (0.0.1): | ||||
| @@ -171,6 +173,7 @@ DEPENDENCIES: | ||||
|   - firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`) | ||||
|   - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) | ||||
|   - flutter_platform_alert (from `Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos`) | ||||
|   - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) | ||||
|   - flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`) | ||||
|   - flutter_udid (from `Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos`) | ||||
|   - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`) | ||||
| @@ -232,6 +235,8 @@ EXTERNAL SOURCES: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos | ||||
|   flutter_platform_alert: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos | ||||
|   flutter_secure_storage_macos: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos | ||||
|   flutter_timezone: | ||||
|     :path: Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos | ||||
|   flutter_udid: | ||||
| @@ -295,6 +300,7 @@ SPEC CHECKSUMS: | ||||
|   FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4 | ||||
|   flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d | ||||
|   flutter_platform_alert: 8fa7a7c21f95b26d08b4a3891936ca27e375f284 | ||||
|   flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 | ||||
|   flutter_timezone: d59eea86178cbd7943cd2431cc2eaa9850f935d8 | ||||
|   flutter_udid: d26e455e8c06174e6aff476e147defc6cae38495 | ||||
|   flutter_webrtc: a7eeb54859e672228c28f4b48b1fb61561976ea3 | ||||
|   | ||||
							
								
								
									
										10
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -1470,7 +1470,7 @@ packages: | ||||
|     source: hosted | ||||
|     version: "1.16.0" | ||||
|   mime: | ||||
|     dependency: transitive | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: mime | ||||
|       sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" | ||||
| @@ -1785,10 +1785,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: record_linux | ||||
|       sha256: "29e7735b05c1944bb6c9b72a36c08d4a1b24117e712d6a9523c003bde12bf484" | ||||
|       sha256: "0626678a092c75ce6af1e32fe7fd1dea709b92d308bc8e3b6d6348e2430beb95" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.1.0" | ||||
|     version: "1.1.1" | ||||
|   record_macos: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2254,10 +2254,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: synchronized | ||||
|       sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" | ||||
|       sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.3.1" | ||||
|     version: "3.4.0" | ||||
|   table_calendar: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|   | ||||
| @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev | ||||
| # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||
| # In Windows, build-name is used as the major, minor, and patch parts | ||||
| # of the product and file versions while build-number is used as the build suffix. | ||||
| version: 3.0.0+107 | ||||
| version: 3.0.0+109 | ||||
|  | ||||
| environment: | ||||
|   sdk: ^3.7.2 | ||||
| @@ -123,9 +123,10 @@ dependencies: | ||||
|   receive_sharing_intent: ^1.8.1 | ||||
|   top_snackbar_flutter: ^3.3.0 | ||||
|   textfield_tags: | ||||
|    git: | ||||
|     git: | ||||
|       url: https://github.com/lionelmennig/textfield_tags.git | ||||
|       ref: fixes/allow-controller-re-registration | ||||
|   mime: ^2.0.0 | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user